diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f6cd35fd2c..c4724663db 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -311,7 +311,7 @@ "nonTriviaTokens": 92 }, "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 9, + "importDeclarations": 8, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -337,11 +337,11 @@ "@maka/core/session-name": 1, "@maka/ui": 1 }, - "importSpecifiers": 16, + "importSpecifiers": 14, "nonTriviaTokens": 4086 }, "src/renderer/app-shell-chrome-actions.tsx": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -357,11 +357,11 @@ "@maka/ui": 1, "@maka/ui/icons": 1 }, - "importSpecifiers": 8, + "importSpecifiers": 7, "nonTriviaTokens": 408 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 7, + "importDeclarations": 5, "bridgePaths": { "window.maka.connections.setDefault": 1, "window.maka.connections.test": 1, @@ -388,11 +388,11 @@ "./locales/shell-copy.js": 1, "react": 1 }, - "importSpecifiers": 11, + "importSpecifiers": 9, "nonTriviaTokens": 2307 }, "src/renderer/app-shell-context-compaction.ts": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -402,11 +402,11 @@ "dependencyPaths": { "./locales/shell-copy.js": 1 }, - "importSpecifiers": 1, + "importSpecifiers": 0, "nonTriviaTokens": 610 }, "src/renderer/app-shell-copy.ts": { - "importDeclarations": 2, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -417,7 +417,7 @@ "./locales/shell-copy.js": 1, "@maka/core/redaction": 1 }, - "importSpecifiers": 2, + "importSpecifiers": 1, "nonTriviaTokens": 504 }, "src/renderer/app-shell-detail-panel.tsx": { @@ -453,7 +453,7 @@ "nonTriviaTokens": 666 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 12, + "importDeclarations": 11, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -502,11 +502,11 @@ "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 20, + "importSpecifiers": 19, "nonTriviaTokens": 3816 }, "src/renderer/app-shell-overlays.tsx": { - "importDeclarations": 8, + "importDeclarations": 7, "bridgePaths": {}, "environmentCapabilities": { "window": 2, @@ -534,11 +534,11 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 12, + "importSpecifiers": 11, "nonTriviaTokens": 977 }, "src/renderer/app-shell-project-actions.ts": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": { "window.maka.app.openPath": 4, "window.maka.app.resolveProjectGitInfo": 1, @@ -564,11 +564,11 @@ "./open-path": 1, "./session-workspace-errors": 1 }, - "importSpecifiers": 9, + "importSpecifiers": 7, "nonTriviaTokens": 2284 }, "src/renderer/app-shell-revision-actions.ts": { - "importDeclarations": 6, + "importDeclarations": 4, "bridgePaths": { "window.maka.sessions.abandonSessionCopy": 2, "window.maka.sessions.reviseBeforeTurn": 1 @@ -588,11 +588,11 @@ "./session-workspace-errors.js": 1, "@maka/core/session": 1 }, - "importSpecifiers": 10, + "importSpecifiers": 8, "nonTriviaTokens": 2316 }, "src/renderer/app-shell-session-events.ts": { - "importDeclarations": 3, + "importDeclarations": 2, "bridgePaths": {}, "environmentCapabilities": { "requestAnimationFrame": 1, @@ -610,11 +610,11 @@ "./model-connection-errors.js": 1, "@maka/ui": 1 }, - "importSpecifiers": 12, + "importSpecifiers": 11, "nonTriviaTokens": 2974 }, "src/renderer/app-shell-session-start-actions.ts": { - "importDeclarations": 3, + "importDeclarations": 2, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.onboarding.setMilestone": 1 @@ -631,7 +631,7 @@ "./model-connection-errors.js": 1, "./session-workspace-errors.js": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 5, "nonTriviaTokens": 650 }, "src/renderer/app-shell-session-ui-state.ts": { @@ -649,7 +649,7 @@ "nonTriviaTokens": 7 }, "src/renderer/app-shell-stop-action.ts": { - "importDeclarations": 2, + "importDeclarations": 0, "bridgePaths": { "window.maka.sessions.stop": 1 }, @@ -664,11 +664,11 @@ "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1 }, - "importSpecifiers": 2, + "importSpecifiers": 0, "nonTriviaTokens": 302 }, "src/renderer/app-shell-turn-actions.ts": { - "importDeclarations": 4, + "importDeclarations": 2, "bridgePaths": { "window.maka.sessions.branchFromTurn": 1, "window.maka.sessions.regenerateTurn": 1 @@ -686,7 +686,7 @@ "./session-copy-attempt.js": 1, "./session-workspace-errors.js": 1 }, - "importSpecifiers": 5, + "importSpecifiers": 3, "nonTriviaTokens": 650 }, "src/renderer/app-shell-turn-view-model.ts": { @@ -713,7 +713,7 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 92, + "importDeclarations": 81, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -895,7 +895,7 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, + "importSpecifiers": 124, "nonTriviaTokens": 15588 }, "src/renderer/use-app-shell-composer-quotes.ts": { @@ -916,7 +916,7 @@ "nonTriviaTokens": 360 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 10, + "importDeclarations": 8, "bridgePaths": { "window.maka.sessions.list": 1 }, @@ -941,7 +941,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 13, + "importSpecifiers": 11, "nonTriviaTokens": 581 }, "src/renderer/use-app-shell-session-ui-reads.ts": { diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 606952781f..e522c6901c 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -1249,6 +1249,8 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { const moduleReexports = []; let importDeclarations = 0; let importSpecifiers = 0; + const importDeclarationsBySource = {}; + const importSpecifiersBySource = {}; let unresolvedDependencies = 0; function recordBridgePath(path) { @@ -1261,9 +1263,14 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { function visit(node, parent) { if (node.type === 'ImportDeclaration' && !typeOnlySourceDependency(node)) { + const specifierCount = node.specifiers.filter((specifier) => specifier.importKind !== 'type').length; importDeclarations += 1; - importSpecifiers += node.specifiers.filter((specifier) => specifier.importKind !== 'type').length; + importSpecifiers += specifierCount; const source = staticString(node.source); + if (source !== undefined) { + importDeclarationsBySource[source] = (importDeclarationsBySource[source] ?? 0) + 1; + importSpecifiersBySource[source] = (importSpecifiersBySource[source] ?? 0) + specifierCount; + } if (source !== undefined && node.importKind !== 'type') { for (const specifier of node.specifiers) { if (specifier.importKind === 'type') continue; @@ -1544,7 +1551,9 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { environmentCapabilities: sortedObject(environmentCapabilities), hookCalls: sortedObject(hookCalls), importDeclarations, + importDeclarationsBySource: sortedObject(importDeclarationsBySource), importSpecifiers, + importSpecifiersBySource: sortedObject(importSpecifiersBySource), lifecycleMethods: sortedObject(lifecycleMethods), moduleImports: moduleImports.map((entry, index) => ({ ...entry, @@ -1651,11 +1660,20 @@ function capabilityDebtMetrics(analysis) { }; } -function debtMetrics(analysis) { +// Imports from a sanctioned target (a validated copy catalog, or for AppShell +// files a shell / public application / public feature module) are the edges +// the migration wants a legacy file to take on; they cost no import debt, so +// a legacy file is never pushed to inline a helper it could import. +function debtMetrics(analysis, isSanctionedSource) { + const unsanctioned = (bySource) => + Object.entries(bySource).reduce( + (total, [source, count]) => (isSanctionedSource(source) ? total : total + count), + 0, + ); return { - importDeclarations: analysis.importDeclarations, + importDeclarations: unsanctioned(analysis.importDeclarationsBySource), ...capabilityDebtMetrics(analysis), - importSpecifiers: analysis.importSpecifiers, + importSpecifiers: unsanctioned(analysis.importSpecifiersBySource), nonTriviaTokens: analysis.nonTriviaTokens, }; } @@ -2247,15 +2265,15 @@ function validateMetric(path, metric, actual, expected, violations) { } } -function validateDebtFile(desktopRoot, path, expected, violations, metrics = ROOT_DEBT_METRICS) { - const absolutePath = resolve(desktopRoot, path); - if (!existsSync(absolutePath)) { +function validateDebtFile(desktopRoot, path, expected, violations, section) { + if (!existsSync(resolve(desktopRoot, path))) { violations.push(`${path}: debt ledger entry points to a missing file`); return; } - const analysis = analyzeRendererSource(readFileSync(absolutePath, 'utf8'), path); - for (const metric of metrics) { - validateMetric(path, metric, analysis[metric], expected[metric], violations); + const rootSection = section === 'legacyAppShell' || section === 'rootDebt'; + const actual = rootSection ? debtForPath(desktopRoot, path, section) : capabilityDebtForPath(desktopRoot, path); + for (const metric of rootSection ? ROOT_DEBT_METRICS : CAPABILITY_DEBT_METRICS) { + validateMetric(path, metric, actual[metric], expected[metric], violations); } } @@ -2282,7 +2300,7 @@ function validateLegacyLedger(desktopRoot, config, violations) { } for (const [path, expected] of Object.entries(config.legacyAppShell.files)) { - validateDebtFile(desktopRoot, path, expected, violations); + validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShell'); } const actualClosure = collectRootDependencyClosure(desktopRoot, expectedFiles, violations, 'AppShell'); const expectedClosure = Object.keys(config.legacyAppShell.closure).sort(); @@ -2299,10 +2317,10 @@ function validateLegacyLedger(desktopRoot, config, violations) { if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { violations.push(`${path}: AppShell closure debt must point to a non-owner Desktop source`); } - validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShellClosure'); } for (const [path, expected] of Object.entries(config.rootDebt)) { - validateDebtFile(desktopRoot, path, expected, violations); + validateDebtFile(desktopRoot, path, expected, violations, 'rootDebt'); } const appShellDebtPaths = new Set([...expectedFiles, ...expectedClosure]); const rootDebtPaths = Object.keys(config.rootDebt).sort(); @@ -2322,7 +2340,7 @@ function validateLegacyLedger(desktopRoot, config, violations) { if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { violations.push(`${path}: renderer root closure debt must point to a non-owner Desktop source`); } - validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + validateDebtFile(desktopRoot, path, expected, violations, 'rootDebtClosure'); } const ownedPaths = new Map(); @@ -2981,7 +2999,7 @@ function validateCopyCatalog(desktopRoot, relativePath) { if (metricTotal(value) > 0) return `catalog carries ${metric} (${describeMetric(value)})`; } const forbidden = inspection.runtimeDependencies.find( - (dependency) => dependency.startsWith('.') || dependency.startsWith(DESKTOP_SELF_PREFIX), + (dependency) => !isBarePackageSpecifier(dependency), ); if (forbidden !== undefined) { return `runtime import ${forbidden} is not a bare package specifier`; @@ -3008,9 +3026,17 @@ function validateCopyCatalogFiles(desktopRoot, violations) { } } +function isBarePackageSpecifier(dependency) { + return !dependency.startsWith('.') && !dependency.startsWith(DESKTOP_SELF_PREFIX); +} + function withoutSanctionedDependencies(desktopRoot, section, importerPath, dependencyPaths) { + // A validated catalog is already restricted to bare package runtime imports; + // pricing them again would push copy helpers back inline into the catalog. + const importerIsCatalog = isValidatedCopyCatalog(desktopRoot, importerPath); const filtered = {}; for (const [dependency, count] of Object.entries(dependencyPaths)) { + if (importerIsCatalog && isBarePackageSpecifier(dependency)) continue; if (isSanctionedDependencyTarget(desktopRoot, section, importerPath, dependency)) continue; filtered[dependency] = count; } @@ -3043,8 +3069,11 @@ function allowsMigrationDependency({ base, current, dependency, desktopRoot, pat return swapZones.includes(zoneFor(normalizePath(relative(desktopRoot, target))).kind); } -function debtForPath(desktopRoot, path) { - return debtMetrics(analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path)); +function debtForPath(desktopRoot, path, section) { + return debtMetrics( + analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path), + (source) => isSanctionedDependencyTarget(desktopRoot, section, path, source), + ); } function capabilityDebtForPath(desktopRoot, path) { @@ -3091,7 +3120,7 @@ export function generateArchitectureConfig(desktopRoot, config) { const imports = collectLegacyImportEdges(desktopRoot); const rootDebt = {}; for (const path of Object.keys(config.rootDebt ?? {}).sort()) { - if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path); + if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path, 'rootDebt'); } const appShellDebtPaths = new Set([...appShellFiles, ...closureFiles]); const rootDebtClosureFiles = collectRootDependencyClosure( @@ -3111,7 +3140,7 @@ export function generateArchitectureConfig(desktopRoot, config) { legacyPlatformImports: imports.platform, controllerOwners: controllerOwnersOf(config), legacyAppShell: { - files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path)])), + files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path, 'legacyAppShell')])), closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])), }, rootDebt, diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 46d6e81b7c..db2c11d8cf 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -2229,9 +2229,15 @@ describe('renderer architecture checker fixtures', () => { return ; } `; - const currentDebt = debtForSource(appShellSource, appShellPath); + const currentDebt = { + ...debtForSource(appShellSource, appShellPath), + importDeclarations: 0, + importSpecifiers: 0, + }; const baseDebt = { ...currentDebt, + importDeclarations: 2, + importSpecifiers: 2, dependencyPaths: { './legacy-alpha-owner.js': 1, './legacy-session-owner.js': 1, @@ -2271,6 +2277,37 @@ describe('renderer architecture checker fixtures', () => { ); }); + for (const [targetPath, pricing] of [ + ['src/renderer/application/contracts/fixture-diagnostics.ts', 'free'], + ['src/renderer/application/sessions/fixture-service.ts', 'priced'], + ]) { + it(`${pricing === 'free' ? 'exempts' : 'prices'} AppShell import specifiers from ${targetPath}`, async () => { + const specifier = `./${targetPath.slice('src/renderer/'.length).replace(/\.ts$/u, '.js')}`; + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { reportFixture } from '${specifier}'; + export const AppShell = reportFixture('shell'); + `, + [targetPath]: `export function reportFixture(scope: string): string { return scope; }`, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, transitiveAppShellSeedConfig()); + const baseConfig = structuredClone(currentConfig); + Object.assign(baseConfig.legacyAppShell.files[TRANSITIVE_APP_SHELL_PATH], { + importDeclarations: 0, + importSpecifiers: 0, + dependencyPaths: {}, + }); + const priced = violationsFor(desktopRoot, currentConfig, baseConfig).some((violation) => + violation.startsWith(`${TRANSITIVE_APP_SHELL_PATH}: importSpecifiers debt increased`), + ); + assert.equal(priced, pricing === 'priced'); + }, + ); + }); + } + it('rejects replacing legacy AppShell debt with a feature private import', async () => { const appShellPath = 'src/renderer/app-shell.tsx'; const appShellSource = ` @@ -2831,6 +2868,29 @@ describe('validated copy catalog dependencies', () => { ); }); + it('does not price a catalog\'s own bare package runtime import', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY.en.notice; + `, + { + [CATALOG_PATH]: catalogSource(` + import { lookupCopy } from '@maka/core/ui-locale'; + export const noticeFor = (code: string) => lookupCopy(FIXTURE_COPY.en, code); + `), + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + const baseConfig = structuredClone(currentConfig); + baseConfig.legacyAppShell.closure[CATALOG_PATH].dependencyPaths = {}; + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + const INVALID_CATALOGS = [ ['a hook call', catalogSource(` import { useState } from 'react';