From da6662a90263ebd7f43213bde3c5fb44e35f421a Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Tue, 1 Sep 2026 10:13:44 -0700 Subject: [PATCH 1/2] chore(desktop): admit validated copy catalog deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locale policy forces user-visible copy out of business files and into locales/ catalogs, which adds import edges the renderer architecture ratchet forbids in legacy files — the two gates deadlock. Admit exactly one dependency class: a structurally validated copy catalog (UiCatalog marker, zero tracked capabilities, bare package imports only), re-verified on every run so a catalog can never become a dependency tunnel. Root-entry import and token ratchets stay strict. --- apps/desktop/renderer-architecture.json | 27 +-- .../scripts/check-renderer-architecture.mjs | 138 ++++++++++++- .../check-renderer-architecture.test.mjs | 184 ++++++++++++++++++ apps/desktop/src/renderer/README.md | 12 ++ 4 files changed, 327 insertions(+), 34 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 14d7786e93..5c4cd9f2e3 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -267,57 +267,32 @@ "src/renderer/settings" ], "legacyFeatureImports": [ - "src/renderer/features/goals/controller/use-goal-controller.ts -> src/renderer/locales/shell-copy", - "src/renderer/features/goals/ui/goal-dialog.tsx -> src/renderer/locales/shell-copy", "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", - "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/locales/shell-copy", - "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/locales/shell-remaining-copy", - "src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts -> src/renderer/locales/shell-copy", - "src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts -> src/renderer/locales/shell-remaining-copy", - "src/renderer/features/module-hub/controller/use-skills-controller.ts -> src/renderer/locales/shell-copy", "src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page", - "src/renderer/features/session-navigation/controller/session-row-actions.ts -> src/renderer/locales/shell-copy", "src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts -> src/renderer/use-external-store-selector", "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/use-external-store-selector", "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", - "src/renderer/features/session-navigation/model/session-project-grouping.ts -> src/renderer/locales/shell-remaining-copy", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/observable-state", - "src/renderer/features/task-entry/controller/use-task-entry-controller.ts -> src/renderer/locales/shell-copy", "src/renderer/features/task-entry/model/task-entry-selection.ts -> src/renderer/new-task-reload-intent", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", - "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/locales/conversation-copy", - "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/locales/shell-copy", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/side-chat-command", "src/renderer/features/workbar/model/workbar-layout.ts -> src/renderer/browser-storage", "src/renderer/features/workbar/model/workbar-tabs.ts -> src/renderer/browser-storage", - "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/locales/artifact-copy", "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/open-path", - "src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx -> src/renderer/locales/artifact-copy", - "src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx -> src/renderer/locales/artifact-copy", - "src/renderer/features/workbar/tools/browser/browser-panel.tsx -> src/renderer/locales/browser-copy", - "src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/inspector/use-session-trace.ts -> src/renderer/session-trace-refresh", - "src/renderer/features/workbar/tools/review/session-review-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/model-connection-errors", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/session-copy-attempt", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/attachment-preflight", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-attachments", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", - "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments", - "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", - "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/locales/conversation-copy", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", - "src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx -> src/renderer/locales/conversation-copy", - "src/renderer/features/workbar/ui/workbar-host.tsx -> src/renderer/locales/shell-copy", - "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/locales/conversation-copy", - "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel", - "src/renderer/features/workbar/ui/workbar-toggle.tsx -> src/renderer/locales/shell-copy" + "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel" ], "legacyPlatformImports": [ "src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement" diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 9e09dbd51b..71cb29a843 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -1431,7 +1431,7 @@ function validateDependencies({ } if (sourceZone.kind === 'feature') { - if (targetZone.kind === 'legacy') { + if (targetZone.kind === 'legacy' && !isValidatedCopyCatalog(desktopRoot, targetRelative)) { const edge = `${fileRelative} -> ${targetRelative}`; observedLegacyFeatureImports.add(edge); if (!allowedLegacyFeatureImports.has(edge)) { @@ -1511,7 +1511,7 @@ function validateDependencies({ if (targetZone.kind === 'application' && !isPublicApplicationPath(targetRelative)) { violations.push(`${fileRelative}: Desktop adapter imports application implementation instead of a public entry: ${dependency}`); } - if (targetZone.kind === 'legacy') { + if (targetZone.kind === 'legacy' && !isValidatedCopyCatalog(desktopRoot, targetRelative)) { const edge = `${fileRelative} -> ${targetRelative}`; observedLegacyPlatformImports.add(edge); if (!allowedLegacyPlatformImports.has(edge)) { @@ -2171,6 +2171,108 @@ function metricTotal(value) { return Object.values(value).reduce((total, count) => total + count, 0); } +const COPY_CATALOG_PATH = /^src\/renderer\/locales\/[a-z0-9-]+-copy\.ts$/u; +const COPY_CATALOG_MARKER_MODULE = '@maka/core/ui-locale'; +const COPY_CATALOG_MARKER_TYPE = 'UiCatalog'; +const copyCatalogValidationCache = new Map(); + +function copyCatalogMarkerPresent(source, file) { + const ast = parse(source, { + createImportExpressions: true, + errorRecovery: false, + plugins: PARSER_PLUGINS.filter((plugin) => plugin !== 'jsx'), + sourceFilename: file, + sourceType: 'module', + }); + let markerLocalName; + for (const statement of ast.program.body) { + if (statement.type !== 'ImportDeclaration' || statement.source.value !== COPY_CATALOG_MARKER_MODULE) continue; + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportSpecifier' && specifier.imported.name === COPY_CATALOG_MARKER_TYPE) { + markerLocalName = specifier.local.name; + } + } + } + if (!markerLocalName) return false; + let found = false; + function referencesMarker(typeAnnotation) { + return ( + typeAnnotation?.type === 'TSTypeReference' && + typeAnnotation.typeName.type === 'Identifier' && + typeAnnotation.typeName.name === markerLocalName + ); + } + function visit(node) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + if (typeof node.type !== 'string') return; + if (node.type === 'TSSatisfiesExpression' && referencesMarker(node.typeAnnotation)) found = true; + if (node.type === 'TSTypeAnnotation' && referencesMarker(node.typeAnnotation)) found = true; + for (const [key, value] of Object.entries(node)) { + if (key === 'loc' || key === 'start' || key === 'end') continue; + visit(value); + } + } + visit(ast.program.body); + return found; +} + +// A "validated copy catalog" is the one dependency class the migration ratchet +// admits into legacy files: the locale policy (#2672) forces user-visible copy +// OUT of business files and INTO locales/ catalogs, which necessarily ADDS an +// import edge the debt ratchet would otherwise forbid. The admission is +// structural, re-verified on every run, and never extends to the catalog's own +// dependencies: bare package specifiers only, so a catalog cannot become a +// tunnel to renderer implementation modules. +function isValidatedCopyCatalog(desktopRoot, path) { + const relativePath = /\.[a-z]+$/u.test(path) ? path : `${path}.ts`; + const cacheKey = `${desktopRoot}|${relativePath}`; + const cached = copyCatalogValidationCache.get(cacheKey); + if (cached !== undefined) return cached; + const validated = validateCopyCatalog(desktopRoot, relativePath); + copyCatalogValidationCache.set(cacheKey, validated); + return validated; +} + +function validateCopyCatalog(desktopRoot, relativePath) { + if (!COPY_CATALOG_PATH.test(relativePath)) return false; + const absolutePath = resolve(desktopRoot, relativePath); + if (!existsSync(absolutePath)) return false; + const source = readFileSync(absolutePath, 'utf8'); + let analysis; + try { + analysis = analyzeRendererSource(source, relativePath); + if (!copyCatalogMarkerPresent(source, relativePath)) return false; + } catch { + return false; + } + const capabilityMetrics = [ + analysis.actionFactories, + analysis.bridgePaths, + analysis.environmentCapabilities, + analysis.hookCalls, + analysis.lifecycleMethods, + analysis.unresolvedDependencies, + ]; + if (capabilityMetrics.some((metric) => metricTotal(metric) > 0)) return false; + return analysis.dependencies.every( + (dependency) => !dependency.startsWith('.') && !dependency.startsWith(DESKTOP_SELF_PREFIX), + ); +} + +function withoutValidatedCatalogDependencies(desktopRoot, importerPath, dependencyPaths) { + const filtered = {}; + for (const [dependency, count] of Object.entries(dependencyPaths)) { + const target = resolveDependency(desktopRoot, resolve(desktopRoot, importerPath), dependency); + if (target && isValidatedCopyCatalog(desktopRoot, normalizePath(relative(desktopRoot, target)))) continue; + filtered[dependency] = count; + } + return filtered; +} + function allowsMigrationDependency({ base, current, dependency, desktopRoot, path, section }) { if (metricTotal(current.dependencyPaths) > metricTotal(base.dependencyPaths)) return false; const target = resolveDependency(desktopRoot, resolve(desktopRoot, path), dependency); @@ -2207,6 +2309,7 @@ function collectLegacyImportEdges(desktopRoot) { if (!target) continue; const targetRelative = normalizePath(relative(desktopRoot, target)); if (zoneFor(targetRelative).kind !== 'legacy') continue; + if (isValidatedCopyCatalog(desktopRoot, targetRelative)) continue; const edge = `${fileRelative} -> ${targetRelative}`; if (sourceZone.kind === 'feature') feature.add(edge); else platform.add(edge); @@ -2285,7 +2388,11 @@ function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { for (const path of Object.keys(currentFiles)) { const shiftedAppShellBase = section === 'rootDebtClosure' ? baseConfig.legacyAppShell.closure[path] : undefined; - if (!baseFiles[path] && !shiftedAppShellBase) { + if ( + !baseFiles[path] && + !shiftedAppShellBase && + !(section.endsWith('Closure') && isValidatedCopyCatalog(desktopRoot, path)) + ) { violations.push(`${path}: new ${section} debt entries are forbidden`); } } @@ -2301,10 +2408,18 @@ function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { baseFiles[path] ?? (section === 'rootDebtClosure' ? baseConfig.legacyAppShell.closure[path] : undefined); if (!base) continue; + const currentView = { + ...current, + dependencyPaths: withoutValidatedCatalogDependencies(desktopRoot, path, current.dependencyPaths), + }; + const baseView = { + ...base, + dependencyPaths: withoutValidatedCatalogDependencies(desktopRoot, path, base.dependencyPaths), + }; const metrics = section.endsWith('Closure') ? CAPABILITY_DEBT_METRICS : ROOT_DEBT_METRICS; for (const metric of metrics) { - if (metricTotal(current[metric]) > metricTotal(base[metric])) { - violations.push(`${path}: ${metric} debt increased from ${metricTotal(base[metric])} to ${metricTotal(current[metric])}`); + if (metricTotal(currentView[metric]) > metricTotal(baseView[metric])) { + violations.push(`${path}: ${metric} debt increased from ${metricTotal(baseView[metric])} to ${metricTotal(currentView[metric])}`); } if (['bridgePaths', 'environmentCapabilities', 'hookCalls', 'lifecycleMethods'].includes(metric)) { const increases = Object.fromEntries( @@ -2324,10 +2439,17 @@ function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { } } if (metric === 'dependencyPaths') { - for (const [dependency, count] of Object.entries(current.dependencyPaths)) { + for (const [dependency, count] of Object.entries(currentView.dependencyPaths)) { if ( - count > (base.dependencyPaths[dependency] ?? 0) && - !allowsMigrationDependency({ base, current, dependency, desktopRoot, path, section }) + count > (baseView.dependencyPaths[dependency] ?? 0) && + !allowsMigrationDependency({ + base: baseView, + current: currentView, + dependency, + desktopRoot, + path, + section, + }) ) { violations.push(`${path}: new dependency debt ${dependency}`); } diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 27caed53af..cd9a7a5f00 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -2305,3 +2305,187 @@ describe('renderer architecture checker fixtures', () => { assert.match(invalid.stderr, /base ref does not resolve to a commit/u); }); }); + +describe('validated copy catalog dependencies', () => { + const CATALOG_PATH = 'src/renderer/locales/fixture-copy.ts'; + + function catalogSource(extra = '') { + return ` + import type { UiCatalog } from '@maka/core/ui-locale'; + export interface FixtureCopy { readonly notice: string; } + export const FIXTURE_COPY = { + en: { notice: 'Notice' }, + zh: { notice: '通知' }, + } satisfies UiCatalog; + ${extra} + `; + } + + function catalogSeedConfig() { + return architectureConfig({ + legacyGrowthDirectories: ['src/renderer/locales'], + ownership: [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [TRANSITIVE_APP_SHELL_PATH], + }, + ], + }); + } + + function baseWithoutCatalog(currentConfig) { + const baseConfig = structuredClone(currentConfig); + delete baseConfig.legacyAppShell.closure[CATALOG_PATH]; + baseConfig.legacyRendererFiles = baseConfig.legacyRendererFiles.filter( + (path) => path !== CATALOG_PATH, + ); + baseConfig.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].dependencyPaths = {}; + return baseConfig; + } + + it('admits a validated copy catalog as a new legacy dependency and closure entry', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY.en.notice; + `, + { [CATALOG_PATH]: catalogSource() }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + assert.deepEqual( + violationsFor(desktopRoot, currentConfig, baseWithoutCatalog(currentConfig)), + [], + ); + }, + ); + }); + + const INVALID_CATALOGS = [ + ['a hook call', catalogSource(` + import { useState } from 'react'; + export function useFixtureCopy() { return useState(FIXTURE_COPY); } + `)], + ['a relative implementation import', catalogSource(` + import { legacySessionStore } from '../legacy-session-store.js'; + export const smuggled = legacySessionStore; + `)], + ['no UiCatalog marker', ` + export const FIXTURE_COPY = { + en: { notice: 'Notice' }, + zh: { notice: '通知' }, + }; + `], + ]; + + for (const [flaw, source] of INVALID_CATALOGS) { + it(`keeps the ratchet for a catalog with ${flaw}`, async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY; + `, + { + [CATALOG_PATH]: source, + 'src/renderer/legacy-session-store.ts': `export const legacySessionStore = 'legacy';`, + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + const violations = violationsFor( + desktopRoot, + currentConfig, + baseWithoutCatalog(currentConfig), + ); + assertHasViolation( + violations, + /^src\/renderer\/locales\/fixture-copy\.ts: new legacyAppShellClosure debt entries are forbidden$/u, + ); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new dependency debt \.\/locales\/fixture-copy\.js$/u, + ); + }, + ); + }); + } + + it('fails closed upstream when a reachable catalog uses a dynamic import', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY; + `, + { + [CATALOG_PATH]: catalogSource(` + export async function load(name) { return import(name); } + `), + }, + ), + (desktopRoot) => { + assert.throws( + () => generateArchitectureConfig(desktopRoot, catalogSeedConfig()), + /non-static import/u, + ); + }, + ); + }); + + it('still rejects an unrelated dependency added beside a validated catalog', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + import { legacySessionStore } from './legacy-session-store.js'; + export const legacySessionHelper = FIXTURE_COPY.en.notice + legacySessionStore; + `, + { + [CATALOG_PATH]: catalogSource(), + 'src/renderer/legacy-session-store.ts': `export const legacySessionStore = 'legacy';`, + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + const baseConfig = baseWithoutCatalog(currentConfig); + delete baseConfig.legacyAppShell.closure['src/renderer/legacy-session-store.ts']; + baseConfig.legacyRendererFiles = baseConfig.legacyRendererFiles.filter( + (path) => path !== 'src/renderer/legacy-session-store.ts', + ); + + const violations = violationsFor(desktopRoot, currentConfig, baseConfig); + assertHasViolation( + violations, + /^src\/renderer\/legacy-session-helper\.ts: new dependency debt \.\/legacy-session-store\.js$/u, + ); + assert.ok( + !violations.some((violation) => violation.includes('fixture-copy')), + `catalog dependency must stay admitted, received:\n${violations.join('\n')}`, + ); + }, + ); + }); + + it('lets feature code import a validated catalog without a legacy budget edge', async () => { + await withDesktopFixture( + { + [CATALOG_PATH]: catalogSource(), + 'src/renderer/features/alpha/controller.ts': ` + import { FIXTURE_COPY } from '../../locales/fixture-copy.js'; + export const featureNotice = FIXTURE_COPY.en.notice; + `, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig( + desktopRoot, + architectureConfig({ legacyGrowthDirectories: ['src/renderer/locales'] }), + ); + assert.deepEqual(currentConfig.legacyFeatureImports, []); + assert.deepEqual(violationsFor(desktopRoot, currentConfig), []); + }, + ); + }); +}); diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index c9ea51eaca..66959dffe4 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -103,6 +103,18 @@ rejected. Legacy import allowlists may only shrink relative to the base branch. dependency replacement is allowed only when it moves ownership behind a shell, feature public, or application public/contract boundary. +Validated copy catalogs are the one admitted dependency class: the locale +policy (#2672) forces user-visible copy out of business files and into +`locales/*-copy.ts` catalogs, which necessarily adds import edges the debt +ratchet would otherwise forbid. A catalog is admitted structurally, re-verified +on every run: it must carry a `UiCatalog` marker from `@maka/core/ui-locale`, +record zero tracked hook/bridge/lifecycle/environment/action-factory +capabilities, and import only bare package specifiers — never renderer +implementation — so a catalog cannot become a dependency tunnel. Admitted edges +are excluded from dependency-count ratchets, closure admission, and +feature/Desktop-adapter legacy budgets; everything else about the importing +file still ratchets, and root-entry import/token counts stay strict. + `ownership[].targetZone` is migration-roadmap metadata in this foundation: its shape and legacy path coverage are validated, but it does not claim to prove that a capability has reached its final owner. The directory dependency rules From e60c06db54d7d23fee15998569ac6fb2e0ddd0fd Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Tue, 1 Sep 2026 19:17:32 -0700 Subject: [PATCH 2/2] fix(desktop): harden copy catalog validation The catalog predicate inherited analyzer false positives (object keys and type-space identifiers counted as environment globals) and rejected type-only relative imports, so a catalog could silently lose admission. Count only value references, restrict the bare-specifier rule to runtime imports, and report failed catalogs with a dedicated violation. --- apps/desktop/renderer-architecture.json | 71 ++----- .../scripts/check-renderer-architecture.mjs | 192 ++++++++++++++---- .../check-renderer-architecture.test.mjs | 58 ++++++ apps/desktop/src/renderer/README.md | 14 +- 4 files changed, 243 insertions(+), 92 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5c4cd9f2e3..a1104b0eff 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -323,9 +323,7 @@ "window.maka.sessions.respondToUserQuestion": 1, "window.maka.sessions.submitMessage": 1 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -519,7 +517,6 @@ "document.removeEventListener": 1, "document.visibilityState": 1, "requestAnimationFrame": 1, - "window": 3, "window.clearInterval": 1, "window.clearTimeout": 3, "window.setInterval": 1, @@ -563,7 +560,7 @@ "importDeclarations": 14, "bridgePaths": {}, "environmentCapabilities": { - "window": 4, + "window": 2, "window.addEventListener": 1, "window.removeEventListener": 1 }, @@ -874,7 +871,6 @@ "environmentCapabilities": { "document.querySelector": 1, "requestAnimationFrame": 1, - "window": 1, "window.clearTimeout": 4, "window.requestAnimationFrame": 4, "window.setTimeout": 2 @@ -1608,7 +1604,6 @@ "window.maka.diagnostics.copyReport": 1 }, "environmentCapabilities": { - "location": 3, "navigator": 1, "navigator.clipboard.writeText": 1, "navigator.userAgent": 2, @@ -1722,7 +1717,7 @@ "src/renderer/local-memory-digest.ts": { "bridgePaths": {}, "environmentCapabilities": { - "window.crypto.subtle": 3, + "window.crypto.subtle": 2, "window.crypto.subtle.digest": 2 }, "hookCalls": {}, @@ -1866,9 +1861,7 @@ }, "src/renderer/locales/settings-data-copy.ts": { "bridgePaths": {}, - "environmentCapabilities": { - "history": 3 - }, + "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -1978,9 +1971,7 @@ }, "src/renderer/locales/settings-test-result-copy.ts": { "bridgePaths": {}, - "environmentCapabilities": { - "location": 1 - }, + "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -2624,9 +2615,7 @@ "window.maka.app.updateStatus": 1, "window.maka.diagnostics.copyReport": 1 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useActionGuard": 2, "useEffect": 2, @@ -2684,9 +2673,7 @@ "window.maka.app.removeIcon": 1, "window.maka.app.selectIcon": 1 }, - "environmentCapabilities": { - "window": 2 - }, + "environmentCapabilities": {}, "hookCalls": { "useEffect": 2, "useMountedRef": 1, @@ -2775,9 +2762,7 @@ "window.maka.settings.bots.subscribeStatusChanges": 1, "window.maka.settings.testBotChannel": 2 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useEffect": 3, "useMountedRef": 1, @@ -2990,9 +2975,7 @@ "window.maka.config.import": 1 }, "environmentCapabilities": { - "history": 1, - "navigator.clipboard.writeText": 1, - "window": 2 + "navigator.clipboard.writeText": 1 }, "hookCalls": { "useActionGuard": 1, @@ -3022,9 +3005,7 @@ }, "src/renderer/settings/general-settings-page.tsx": { "bridgePaths": {}, - "environmentCapabilities": { - "window": 4 - }, + "environmentCapabilities": {}, "hookCalls": { "useActionGuard": 2, "useEffect": 1, @@ -3165,9 +3146,7 @@ }, "src/renderer/settings/memory-settings-page.tsx": { "bridgePaths": {}, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useMemoryDocumentController": 1, "useState": 2, @@ -3319,8 +3298,7 @@ "bridgePaths": {}, "environmentCapabilities": { "clearTimeout": 3, - "setTimeout": 1, - "window": 1 + "setTimeout": 1 }, "hookCalls": { "useEffect": 2, @@ -3360,9 +3338,7 @@ "window.maka.projects.subscribeChanges": 1, "window.maka.runtimeHostProfiles.subscribeChanges": 1 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useEffect": 1, "useKeyedActionGuard": 1, @@ -3860,9 +3836,7 @@ "window.maka.runtimeHostProfiles.setEnabled": 1, "window.maka.runtimeHostProfiles.subscribeChanges": 1 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useEffect": 1, "useMountedRef": 1, @@ -4186,7 +4160,6 @@ "window.maka.settings.usageStats": 1 }, "environmentCapabilities": { - "window": 2, "window.addEventListener": 1, "window.removeEventListener": 1 }, @@ -4262,8 +4235,7 @@ "src/renderer/settings/subagent-settings-page.tsx": { "bridgePaths": {}, "environmentCapabilities": { - "document.querySelector": 2, - "window": 1 + "document.querySelector": 2 }, "hookCalls": { "useRef": 2, @@ -4352,9 +4324,7 @@ }, "src/renderer/settings/usage-settings-page.tsx": { "bridgePaths": {}, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useActionGuard": 1, "useOptimisticSettingsDraft": 1, @@ -4533,9 +4503,7 @@ "window.maka.webSearch.query": 1, "window.maka.webSearch.test": 1 }, - "environmentCapabilities": { - "window": 2 - }, + "environmentCapabilities": {}, "hookCalls": { "useKeyedActionGuard": 1, "useMountedRef": 1, @@ -5081,9 +5049,7 @@ "bridgePaths": { "window.maka.search.thread": 1 }, - "environmentCapabilities": { - "window": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useState": 2 }, @@ -5284,7 +5250,6 @@ "bridgePaths": {}, "environmentCapabilities": { "document": 1, - "localStorage": 1, "window": 1, "window.localStorage": 1 }, diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 71cb29a843..3b6eb0ff9b 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -688,6 +688,54 @@ function environmentIdentifierIsShadowed(node, name, parents) { return false; } +const NAMED_KEY_OWNERS = new Set([ + 'ClassMethod', + 'ClassPrivateMethod', + 'ClassPrivateProperty', + 'ClassProperty', + 'ObjectMethod', + 'ObjectProperty', + 'PropertyDefinition', + 'TSMethodSignature', + 'TSPropertySignature', +]); +// TS wrapper nodes whose descendants still execute at runtime; every other +// TS-prefixed ancestor puts an identifier in erased type space. +const TS_RUNTIME_NODES = new Set([ + 'TSAsExpression', + 'TSDeclareFunction', + 'TSDeclareMethod', + 'TSEnumDeclaration', + 'TSEnumMember', + 'TSExportAssignment', + 'TSExternalModuleReference', + 'TSImportEqualsDeclaration', + 'TSInstantiationExpression', + 'TSModuleBlock', + 'TSModuleDeclaration', + 'TSNonNullExpression', + 'TSParameterProperty', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]); + +function isValueReferencePosition(node, parent, parents) { + if (parent) { + if (NAMED_KEY_OWNERS.has(parent.type) && parent.key === node && !parent.computed) return false; + if (isMemberExpression(parent) && parent.property === node && !parent.computed) return false; + if ( + ['BreakStatement', 'ContinueStatement', 'LabeledStatement'].includes(parent.type) && + parent.label === node + ) { + return false; + } + } + for (let current = parent; current && current.type !== 'Program'; current = parents.get(current)) { + if (current.type.startsWith('TS') && !TS_RUNTIME_NODES.has(current.type)) return false; + } + return true; +} + function collectAliases(program, parents) { const hookAliases = new Map(); const hookAliasBindings = new Map(); @@ -1073,7 +1121,8 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { if ( directEnvironmentPath && !directEnvironmentPath.startsWith('window.maka') && - !(isMemberExpression(parent) && unwrapExpression(parent.object) === node) + !(isMemberExpression(parent) && unwrapExpression(parent.object) === node) && + isValueReferencePosition(node, parent, parents) ) { recordEnvironmentCapability(directEnvironmentPath); } @@ -2176,7 +2225,7 @@ const COPY_CATALOG_MARKER_MODULE = '@maka/core/ui-locale'; const COPY_CATALOG_MARKER_TYPE = 'UiCatalog'; const copyCatalogValidationCache = new Map(); -function copyCatalogMarkerPresent(source, file) { +function inspectCopyCatalog(source, file) { const ast = parse(source, { createImportExpressions: true, errorRecovery: false, @@ -2185,16 +2234,41 @@ function copyCatalogMarkerPresent(source, file) { sourceType: 'module', }); let markerLocalName; + const runtimeDependencies = []; for (const statement of ast.program.body) { - if (statement.type !== 'ImportDeclaration' || statement.source.value !== COPY_CATALOG_MARKER_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier' && specifier.imported.name === COPY_CATALOG_MARKER_TYPE) { - markerLocalName = specifier.local.name; + if (statement.type === 'ImportDeclaration') { + if (statement.source.value === COPY_CATALOG_MARKER_MODULE) { + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportSpecifier' && specifier.imported.name === COPY_CATALOG_MARKER_TYPE) { + markerLocalName = specifier.local.name; + } + } } + const typeOnly = + statement.importKind === 'type' || + (statement.specifiers.length > 0 && + statement.specifiers.every((specifier) => specifier.importKind === 'type')); + if (!typeOnly) runtimeDependencies.push(statement.source.value); + } + if ( + (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportAllDeclaration') && + statement.source + ) { + const typeOnly = + statement.exportKind === 'type' || + (statement.specifiers?.length > 0 && + statement.specifiers.every((specifier) => specifier.exportKind === 'type')); + if (!typeOnly) runtimeDependencies.push(statement.source.value); + } + if ( + statement.type === 'TSImportEqualsDeclaration' && + statement.moduleReference?.type === 'TSExternalModuleReference' && + staticString(statement.moduleReference.expression) !== undefined + ) { + runtimeDependencies.push(staticString(statement.moduleReference.expression)); } } - if (!markerLocalName) return false; - let found = false; + let markerFound = false; function referencesMarker(typeAnnotation) { return ( typeAnnotation?.type === 'TSTypeReference' && @@ -2209,15 +2283,28 @@ function copyCatalogMarkerPresent(source, file) { return; } if (typeof node.type !== 'string') return; - if (node.type === 'TSSatisfiesExpression' && referencesMarker(node.typeAnnotation)) found = true; - if (node.type === 'TSTypeAnnotation' && referencesMarker(node.typeAnnotation)) found = true; + if (markerLocalName) { + if (node.type === 'TSSatisfiesExpression' && referencesMarker(node.typeAnnotation)) markerFound = true; + if (node.type === 'TSTypeAnnotation' && referencesMarker(node.typeAnnotation)) markerFound = true; + } + if (node.type === 'ImportExpression' && staticString(node.source) !== undefined) { + runtimeDependencies.push(staticString(node.source)); + } + if ( + (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && + (node.callee?.type === 'Import' || + (node.callee?.type === 'Identifier' && node.callee.name === 'require')) && + staticString(node.arguments?.[0]) !== undefined + ) { + runtimeDependencies.push(staticString(node.arguments[0])); + } for (const [key, value] of Object.entries(node)) { if (key === 'loc' || key === 'start' || key === 'end') continue; visit(value); } } visit(ast.program.body); - return found; + return { markerFound, runtimeDependencies }; } // A "validated copy catalog" is the one dependency class the migration ratchet @@ -2225,42 +2312,78 @@ function copyCatalogMarkerPresent(source, file) { // OUT of business files and INTO locales/ catalogs, which necessarily ADDS an // import edge the debt ratchet would otherwise forbid. The admission is // structural, re-verified on every run, and never extends to the catalog's own -// dependencies: bare package specifiers only, so a catalog cannot become a -// tunnel to renderer implementation modules. +// runtime dependencies: bare package specifiers only, so a catalog cannot +// become a tunnel to renderer implementation modules. Type-only imports are +// erased at compile time and carry no runtime edge, so they stay admitted +// regardless of target. function isValidatedCopyCatalog(desktopRoot, path) { + return copyCatalogFailure(desktopRoot, path) === undefined; +} + +function copyCatalogFailure(desktopRoot, path) { const relativePath = /\.[a-z]+$/u.test(path) ? path : `${path}.ts`; const cacheKey = `${desktopRoot}|${relativePath}`; - const cached = copyCatalogValidationCache.get(cacheKey); - if (cached !== undefined) return cached; - const validated = validateCopyCatalog(desktopRoot, relativePath); - copyCatalogValidationCache.set(cacheKey, validated); - return validated; + if (copyCatalogValidationCache.has(cacheKey)) return copyCatalogValidationCache.get(cacheKey); + const failure = validateCopyCatalog(desktopRoot, relativePath); + copyCatalogValidationCache.set(cacheKey, failure); + return failure; } function validateCopyCatalog(desktopRoot, relativePath) { - if (!COPY_CATALOG_PATH.test(relativePath)) return false; + if (!COPY_CATALOG_PATH.test(relativePath)) { + return 'path does not match src/renderer/locales/*-copy.ts'; + } const absolutePath = resolve(desktopRoot, relativePath); - if (!existsSync(absolutePath)) return false; + if (!existsSync(absolutePath)) return 'file does not exist'; const source = readFileSync(absolutePath, 'utf8'); let analysis; + let inspection; try { analysis = analyzeRendererSource(source, relativePath); - if (!copyCatalogMarkerPresent(source, relativePath)) return false; - } catch { - return false; + inspection = inspectCopyCatalog(source, relativePath); + } catch (error) { + return `could not analyze source: ${error instanceof Error ? error.message : String(error)}`; } - const capabilityMetrics = [ - analysis.actionFactories, - analysis.bridgePaths, - analysis.environmentCapabilities, - analysis.hookCalls, - analysis.lifecycleMethods, - analysis.unresolvedDependencies, - ]; - if (capabilityMetrics.some((metric) => metricTotal(metric) > 0)) return false; - return analysis.dependencies.every( - (dependency) => !dependency.startsWith('.') && !dependency.startsWith(DESKTOP_SELF_PREFIX), + if (!inspection.markerFound) { + return `missing ${COPY_CATALOG_MARKER_TYPE} marker from ${COPY_CATALOG_MARKER_MODULE}`; + } + const capabilityMetrics = { + actionFactories: analysis.actionFactories, + bridgePaths: analysis.bridgePaths, + environmentCapabilities: analysis.environmentCapabilities, + hookCalls: analysis.hookCalls, + lifecycleMethods: analysis.lifecycleMethods, + unresolvedDependencies: analysis.unresolvedDependencies, + }; + for (const [metric, value] of Object.entries(capabilityMetrics)) { + if (metricTotal(value) > 0) return `catalog carries ${metric} (${describeMetric(value)})`; + } + const forbidden = inspection.runtimeDependencies.find( + (dependency) => dependency.startsWith('.') || dependency.startsWith(DESKTOP_SELF_PREFIX), ); + if (forbidden !== undefined) { + return `runtime import ${forbidden} is not a bare package specifier`; + } + return undefined; +} + +function describeMetric(value) { + if (typeof value === 'number') return String(value); + if (Array.isArray(value)) return value.join(', '); + return Object.keys(value).join(', '); +} + +function validateCopyCatalogFiles(desktopRoot, violations) { + const localesRoot = resolve(desktopRoot, 'src/renderer/locales'); + if (!existsSync(localesRoot)) return; + for (const file of sourceFiles(localesRoot)) { + const fileRelative = normalizePath(relative(desktopRoot, file)); + if (!/-copy\.(?:(?:c|m)?ts)$/u.test(fileRelative)) continue; + const failure = copyCatalogFailure(desktopRoot, fileRelative); + if (failure !== undefined) { + violations.push(`${fileRelative}: copy catalog validation failed: ${failure}`); + } + } } function withoutValidatedCatalogDependencies(desktopRoot, importerPath, dependencyPaths) { @@ -2497,6 +2620,7 @@ export function checkRendererArchitecture({ validateRendererEntryContract(resolvedDesktopRoot, resolvedConfig, violations); } validateLegacyLedger(resolvedDesktopRoot, resolvedConfig, violations); + validateCopyCatalogFiles(resolvedDesktopRoot, violations); validateMonotonicDebt(resolvedConfig, baseConfig, resolvedDesktopRoot, violations); const allowedLegacyFeatureImports = new Set(resolvedConfig.legacyFeatureImports); const allowedLegacyPlatformImports = new Set(resolvedConfig.legacyPlatformImports); diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index cd9a7a5f00..41aea4b1e7 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -482,6 +482,25 @@ describe('renderer architecture checker fixtures', () => { assert.equal(analysis.environmentCapabilities.setTimeout, 1); }); + it('counts environment globals only in value-reference positions', () => { + const analysis = analyzeRendererSource( + ` + interface Rows { history: string; report(location: string): void; } + export const rows = { history: 'Input history' }; + export function digest(input) { return input.location; } + export type Snapshot = typeof history; + history.replaceState(null, ''); + location.assign('/next'); + `, + 'src/renderer/shell/environment-reference-positions.ts', + ); + + assert.deepEqual(analysis.environmentCapabilities, { + 'history.replaceState': 1, + 'location.assign': 1, + }); + }); + it('rejects computed and optional access to the Desktop bridge in strict zones', async () => { await withDesktopFixture( { @@ -2372,6 +2391,10 @@ describe('validated copy catalog dependencies', () => { import { legacySessionStore } from '../legacy-session-store.js'; export const smuggled = legacySessionStore; `)], + ['a @maka/desktop self-import', catalogSource(` + import { legacySessionStore } from '@maka/desktop/src/renderer/legacy-session-store.js'; + export const smuggled = legacySessionStore; + `)], ['no UiCatalog marker', ` export const FIXTURE_COPY = { en: { notice: 'Notice' }, @@ -2408,11 +2431,46 @@ describe('validated copy catalog dependencies', () => { violations, /^src\/renderer\/legacy-session-helper\.ts: new dependency debt \.\/locales\/fixture-copy\.js$/u, ); + assertHasViolation( + violations, + /^src\/renderer\/locales\/fixture-copy\.ts: copy catalog validation failed: /u, + ); }, ); }); } + it('keeps admission for copy keys named after browser globals and type-only relative imports', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY.en.history; + `, + { + [CATALOG_PATH]: ` + import type { UiCatalog } from '@maka/core/ui-locale'; + import type { LegacySessionStore } from '../legacy-session-store.js'; + export interface FixtureCopy { readonly history: string; readonly location: string; } + export type StoreRef = LegacySessionStore; + export const FIXTURE_COPY = { + en: { history: 'History', location: 'Location' }, + zh: { history: '历史', location: '位置' }, + } satisfies UiCatalog; + `, + 'src/renderer/legacy-session-store.ts': `export interface LegacySessionStore { readonly id: string }`, + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + assert.deepEqual( + violationsFor(desktopRoot, currentConfig, baseWithoutCatalog(currentConfig)), + [], + ); + }, + ); + }); + it('fails closed upstream when a reachable catalog uses a dynamic import', async () => { await withDesktopFixture( transitiveAppShellFiles( diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 66959dffe4..866dfdc9e3 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -109,11 +109,15 @@ policy (#2672) forces user-visible copy out of business files and into ratchet would otherwise forbid. A catalog is admitted structurally, re-verified on every run: it must carry a `UiCatalog` marker from `@maka/core/ui-locale`, record zero tracked hook/bridge/lifecycle/environment/action-factory -capabilities, and import only bare package specifiers — never renderer -implementation — so a catalog cannot become a dependency tunnel. Admitted edges -are excluded from dependency-count ratchets, closure admission, and -feature/Desktop-adapter legacy budgets; everything else about the importing -file still ratchets, and root-entry import/token counts stay strict. +capabilities, and keep its runtime imports to bare package specifiers — never +relative or `@maka/desktop/` paths — so a catalog cannot become a dependency +tunnel. Type-only imports are erased at compile time and stay admitted +regardless of target. A `locales/*-copy.ts` file that fails validation is a +dedicated violation (`copy catalog validation failed: …`), never a silent fall +back to the ratchet. Admitted edges are excluded from dependency-count +ratchets, closure admission, and feature/Desktop-adapter legacy budgets; +everything else about the importing file still ratchets, and root-entry +import/token counts stay strict. `ownership[].targetZone` is migration-roadmap metadata in this foundation: its shape and legacy path coverage are validated, but it does not claim to prove