From 641982ed9eac5792cf0d7065b55dca4b33865919 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:18:06 +0800 Subject: [PATCH 1/2] chore(desktop): check R2 root symbol uses and retained root hooks The renderer architecture checker gains the rules #4582 needs to close R2 by numbers. rootSymbolUses (M0): a generated record, per feature public entry, of the runtime symbols each root zone takes from it: appShell (the AppShell family), composition, and bootstrap (bootstrap/ plus the guarded main.tsx and app.tsx). Uses are attributed through named and default imports, static namespace members including JSX members, and re-exports followed through any module until a feature public entry. Namespace escapes, wildcard or namespace re-exports over an entry, and runtime import() or require of an entry are violations. Against the base the record may only shrink, except for an export the same change adds to the entry, measured on the materialized base tree, or a use moving one way out of appShell into composition or bootstrap. The CLI lists each admitted use. Retained root hooks (M5): the renderer README now has one row per call site the AppShell hook gate allows, naming its consumer, owner, allowed capability and either the root reason or the removal module. The checker reads the gate's ALLOWED literal without running or editing it and fails when an entry has no row, when the row count differs from the gate count, or when a row names a hook the gate no longer lists. --report prints the M3/M5 completion measures: AppShell-family bridge references and action factories, the Conversation transitional rows, hook-gate entries without a row, and root symbol uses per zone. Seven feature exports that only tests or Storybook read move from public entries to testing.ts (or, for the WorkHub coordination lifecycle, to its application contract). The README lists the remaining non-assembly root exports outside Conversation with their consumer and removal module. Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 189 +++++ .../scripts/check-renderer-architecture.mjs | 681 +++++++++++++++++- .../check-renderer-architecture.test.mjs | 457 +++++++++++- .../__tests__/agent-graph-panel-model.test.ts | 2 +- .../provider-endpoint-presentation.test.ts | 2 +- .../workhub-coordination-lifecycle.test.ts | 2 +- .../__tests__/workhub-linked-work.test.ts | 5 +- apps/desktop/src/renderer/README.md | 113 +++ .../features/connection-settings/index.ts | 2 +- .../features/connection-settings/testing.ts | 20 + .../src/renderer/features/overlays/index.ts | 5 - .../src/renderer/features/overlays/testing.ts | 5 + .../features/session-navigation/index.ts | 1 - .../src/renderer/features/workhub/index.ts | 7 +- .../src/renderer/features/workhub/testing.ts | 1 + .../session-history-navigation.stories.tsx | 2 +- 16 files changed, 1455 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/renderer/features/connection-settings/testing.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1c79a7b7e3..19f3b5415b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -324,6 +324,195 @@ "src/renderer/features/conversation/ui/composer-staging-context.ts", "src/renderer/features/conversation/ui/conversation-context.ts" ], + "rootSymbolUses": { + "src/renderer/features/app-update/index.ts": { + "appShell": [ + "AppUpdateProvider" + ], + "composition": [ + "AppUpdateServicesProvider" + ] + }, + "src/renderer/features/client-plugins/index.ts": { + "composition": [ + "ClientPluginRoot", + "ClientPluginServicesProvider" + ] + }, + "src/renderer/features/connection-settings/index.ts": { + "composition": [ + "ConnectionSettingsServicesProvider" + ] + }, + "src/renderer/features/conversation/index.ts": { + "appShell": [ + "ComposerStagingProvider", + "ConversationComposerRegion", + "ConversationLifecycle", + "ConversationMessageConsumer", + "ConversationProvider", + "ConversationTranscriptRegion", + "NEW_TASK_PENDING_KEY", + "PlanExecutionSurface", + "PlanProvider", + "SessionLocalMessages", + "activeHostTurn", + "canSubmitExecutor", + "chatTurnActivity", + "createComposerStagingCommands", + "createRevisionAwareOnSend", + "createStagedFollowUp", + "deriveTaskReadinessNotice", + "desktopSlashCommandPresentation", + "executorComposerProps", + "newTaskConfiguration", + "resolveTaskReadinessModelTarget", + "toSubmittedAttachments", + "useActiveExecutionBoundary", + "useAppShellSessionUiReads", + "useAppShellSessionUiState", + "useNewTaskChoice", + "useShellChatModel", + "useShellResume" + ], + "composition": [ + "ComposerStagingServicesProvider", + "ConversationServicesProvider", + "PlanServicesProvider" + ] + }, + "src/renderer/features/diagnostics/index.ts": { + "appShell": [ + "DiagnosticReportToastProvider", + "PreviousMainProcessInterruptionNotice" + ], + "composition": [ + "DiagnosticsServicesProvider" + ] + }, + "src/renderer/features/external-agent-settings/index.ts": { + "composition": [ + "ExternalAgentSettingsServicesProvider" + ] + }, + "src/renderer/features/goals/index.ts": { + "appShell": [ + "GoalHost", + "GoalProvider" + ], + "composition": [ + "GoalServicesProvider" + ] + }, + "src/renderer/features/module-hub/index.ts": { + "appShell": [ + "ModuleHubHost", + "ModuleHubProvider", + "ModuleHubScheduledTasksBoundary", + "ModuleHubSkillCatalogRevisionBoundary", + "createModuleHubCommandPort" + ], + "composition": [ + "ModuleHubServicesProvider" + ] + }, + "src/renderer/features/overlays/index.ts": { + "appShell": [ + "CommandPalette", + "KeyboardHelpModal", + "OverlaysConsumer", + "OverlaysRoot", + "SearchModalHost" + ], + "composition": [ + "OverlaysServicesProvider" + ] + }, + "src/renderer/features/runtime-host-management/index.ts": { + "appShell": [ + "RuntimeHostHandoffOverlay" + ], + "composition": [ + "RuntimeHostManagementServicesProvider" + ] + }, + "src/renderer/features/session-bundle/index.ts": { + "composition": [ + "SessionBundleServicesProvider" + ] + }, + "src/renderer/features/session-collaboration/index.ts": { + "appShell": [ + "GuestTurnRequests", + "SessionCollaborationDialogRoot", + "SessionCollaborationNavigation", + "SessionTurnRequestApprovalForSession", + "SessionTurnRequestBadge", + "SessionTurnRequestInboxProvider" + ], + "composition": [ + "SessionCollaborationServicesProvider" + ] + }, + "src/renderer/features/session-navigation/index.ts": { + "appShell": [ + "SessionNavigationProvider", + "createSessionOpenCommand", + "sessionRailLayoutStore", + "useSessionNavigationReads" + ], + "composition": [ + "SessionNavigationServicesProvider" + ] + }, + "src/renderer/features/session-settings/index.ts": { + "appShell": [ + "SessionSettingsProvider", + "useSessionSettingIntent" + ], + "composition": [ + "SessionSettingsServicesProvider" + ] + }, + "src/renderer/features/storage-usage/index.ts": { + "composition": [ + "StorageUsageServicesProvider" + ] + }, + "src/renderer/features/task-entry/index.ts": { + "appShell": [ + "TaskEntryHost", + "TaskEntryRoot", + "TaskEntryWorkspacePickerConsumer" + ], + "composition": [ + "TaskEntryServicesProvider" + ] + }, + "src/renderer/features/workbar/index.ts": { + "appShell": [ + "WorkbarHost", + "WorkbarProvider", + "WorkbarShellRoot", + "WorkbarTitlebarActions" + ], + "composition": [ + "WorkbarServicesProvider" + ] + }, + "src/renderer/features/workhub/index.ts": { + "appShell": [ + "WorkHubControlOverlay", + "WorkHubDock", + "WorkHubMainNavigation" + ], + "composition": [ + "WorkHubRoot", + "WorkHubServicesProvider", + "WorkHubSurfaceSwitch" + ] + } + }, "legacyAppShell": { "files": { "src/renderer/app-shell-chat-actions.ts": { diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 6a1745097d..6a460aff7d 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -108,6 +108,8 @@ const RENDERER_VITE_CONFIG = 'vite.config.ts'; const RENDERER_BUILD_SCRIPT = 'vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs'; const DESKTOP_SELF_PREFIX = '@maka/desktop/'; +const FEATURE_PUBLIC_ENTRY = /^src\/renderer\/features\/[^/]+\/index\.(?:(?:c|m)?(?:js|ts)x?)$/u; +const ROOT_SYMBOL_ZONES = ['appShell', 'bootstrap', 'composition']; const CAPABILITY_DEBT_METRICS = [ 'actionFactories', 'bridgePaths', @@ -216,6 +218,7 @@ function validateArchitectureConfig(config, label, violations) { )) { reject('featurePrivateModules must contain normalized feature source paths'); } + validateRootSymbolUsesShape(config.rootSymbolUses, reject); if ( !isRecord(config.legacyAppShell) || !isRecord(config.legacyAppShell.files) || @@ -329,6 +332,38 @@ function validateArchitectureConfig(config, label, violations) { return valid; } +function validateRootSymbolUsesShape(rootSymbolUses, reject) { + if (rootSymbolUses === undefined) return; + if (!isRecord(rootSymbolUses)) { + reject('rootSymbolUses must be an object'); + return; + } + const entries = Object.keys(rootSymbolUses); + if (!isSortedUniqueStrings(entries)) reject('rootSymbolUses entries must be sorted'); + for (const [entry, zones] of Object.entries(rootSymbolUses)) { + if (!FEATURE_PUBLIC_ENTRY.test(entry) || entry.includes('..')) { + reject(`${entry}: rootSymbolUses keys must be normalized feature public entry paths`); + } + if (!isRecord(zones) || Object.keys(zones).length === 0) { + reject(`${entry}: rootSymbolUses must map root zones to the symbols they use`); + continue; + } + const zoneNames = Object.keys(zones); + if (!isSortedUniqueStrings(zoneNames) || zoneNames.some((zone) => !ROOT_SYMBOL_ZONES.includes(zone))) { + reject(`${entry}: rootSymbolUses zones must be sorted and among ${ROOT_SYMBOL_ZONES.join(', ')}`); + } + for (const [zone, symbols] of Object.entries(zones)) { + if ( + !isSortedUniqueStrings(symbols) || + symbols.length === 0 || + symbols.some((symbol) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(symbol)) + ) { + reject(`${entry}: rootSymbolUses ${zone} must list sorted unique export names`); + } + } + } +} + // Several visitors walk the same AST, so enumerate each node's children once. const CHILD_NODES = Symbol('childNodes'); @@ -1179,6 +1214,22 @@ function enclosingFunctionName(node, parents) { return undefined; } +// `import x = NS.member` aliases a runtime value even though its reference is +// spelled as a TS qualified name. +function hasRuntimeImportEqualsAncestor(node, parents) { + for (let current = parents.get(node); current && current.type !== 'Program'; current = parents.get(current)) { + if (current.type === 'TSImportEqualsDeclaration') return !current.isTypeOnly; + } + return false; +} + +function isJsxElementNameReference(node, parent) { + return ( + ((parent?.type === 'JSXOpeningElement' || parent?.type === 'JSXClosingElement') && parent.name === node) || + (parent?.type === 'JSXMemberExpression' && parent.object === node) + ); +} + function analyzeModuleImportBindingUsages(program, bindings, parents) { const bindingByName = new Map( bindings.map((binding, index) => [binding.name, { binding, index }]), @@ -1186,12 +1237,32 @@ function analyzeModuleImportBindingUsages(program, bindings, parents) { const usages = bindings.map(() => ({ directCalls: 0, references: 0, + // Bare references that can carry the runtime binding somewhere else; + // erased type positions and object keys do not. + valueReferences: 0, directCallOwners: [], memberCalls: {}, memberReferences: {}, + jsxMembers: {}, + jsxReferences: 0, })); function visit(node) { + if (node.type === 'JSXIdentifier' && bindingByName.has(node.name)) { + const imported = bindingByName.get(node.name); + const parent = parents.get(node); + if ( + isJsxElementNameReference(node, parent) && + lexicalBindingIdentifier(node, imported.binding.name, parents) === imported.binding + ) { + const usage = usages[imported.index]; + if (parent.type === 'JSXMemberExpression') { + usage.jsxMembers[parent.property.name] = (usage.jsxMembers[parent.property.name] ?? 0) + 1; + } else { + usage.jsxReferences += 1; + } + } + } const imported = node.type === 'Identifier' ? bindingByName.get(node.name) : undefined; if ( @@ -1229,9 +1300,13 @@ function analyzeModuleImportBindingUsages(program, bindings, parents) { (usage.memberReferences[property] ?? 0) + 1; } else { usage.references += 1; + usage.valueReferences += 1; } } else { usage.references += 1; + if (isValueReferencePosition(node, parent, parents) || hasRuntimeImportEqualsAncestor(node, parents)) { + usage.valueReferences += 1; + } } } for (const child of childNodes(node)) visit(child); @@ -1240,6 +1315,7 @@ function analyzeModuleImportBindingUsages(program, bindings, parents) { visit(program); return usages.map((usage) => ({ ...usage, + jsxMembers: sortedObject(usage.jsxMembers), memberCalls: sortedObject(usage.memberCalls), memberReferences: sortedObject(usage.memberReferences), })); @@ -1280,6 +1356,7 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { const moduleDirectExportBindings = []; const moduleLocalExports = []; const moduleReexports = []; + let hasDefaultExport = false; let importDeclarations = 0; let importSpecifiers = 0; const importDeclarationsBySource = {}; @@ -1374,6 +1451,7 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { }); } } + if (node.type === 'ExportDefaultDeclaration') hasDefaultExport = true; if (node.type === 'TSImportEqualsDeclaration') { importDeclarations += 1; importSpecifiers += 1; @@ -1582,6 +1660,7 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { dependencies, dependencyPaths: sortedObject(dependencyPaths), environmentCapabilities: sortedObject(environmentCapabilities), + hasDefaultExport, hookCalls: sortedObject(hookCalls), importDeclarations, importDeclarationsBySource: sortedObject(importDeclarationsBySource), @@ -2090,6 +2169,544 @@ function validateControllerOwners({ } } +function legacyAppShellFiles(desktopRoot) { + const rendererRoot = resolve(desktopRoot, 'src/renderer'); + if (!existsSync(rendererRoot)) return []; + return readdirSync(rendererRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && LEGACY_APP_SHELL_FILE.test(entry.name)) + .map((entry) => `src/renderer/${entry.name}`) + .sort(); +} + +/** The callers whose feature public symbols `rootSymbolUses` records, by root zone. */ +function rootSymbolCallers(desktopRoot, config) { + const callers = legacyAppShellFiles(desktopRoot).map((path) => ({ path, zone: 'appShell' })); + for (const zone of ['bootstrap', 'composition']) { + for (const file of sourceFiles(resolve(desktopRoot, `src/renderer/${zone}`))) { + callers.push({ path: normalizePath(relative(desktopRoot, file)), zone }); + } + } + // The guarded renderer entries mount the application until bootstrap owns it. + for (const path of Object.keys(config.rootDebt ?? {}).sort()) { + if (existsSync(resolve(desktopRoot, path))) callers.push({ path, zone: 'bootstrap' }); + } + return callers.filter(({ path }) => !isTestConsumer(path)); +} + +/** + * Resolves what a module exports by following named re-exports, re-exported + * imports and `export *` through the Desktop source graph. Resolution stops at + * a feature public entry; deeper feature modules are already rejected for + * outside callers by the zone rules. + */ +function createModuleExportGraph(desktopRoot, knownAnalyses = new Map()) { + const index = sourceFileIndex(desktopRoot); + const analyses = new Map(knownAnalyses); + const surfaces = new Map(); + const reaches = new Map(); + const relativePath = (file) => normalizePath(relative(desktopRoot, file)); + const target = (importer, source) => resolveSourceFile(desktopRoot, importer, source, index); + const featureEntryOf = (file) => { + const path = relativePath(file); + return FEATURE_PUBLIC_ENTRY.test(path) ? path : undefined; + }; + const insideFeature = (file) => relativePath(file).startsWith('src/renderer/features/'); + + function analysisOf(file) { + const key = normalizePath(file); + if (!analyses.has(key)) { + let analysis; + try { + analysis = analyzeRendererSource(readFileSync(file, 'utf8'), relativePath(file)); + } catch { + // The full source scan reports parse failures. + } + analyses.set(key, analysis); + } + return analyses.get(key); + } + + /** Runtime export names, including the names an `export *` forwards. */ + function surfaceOf(file, active = new Set()) { + const key = normalizePath(file); + if (surfaces.has(key)) return surfaces.get(key); + if (active.has(key)) return new Set(); + active.add(key); + const names = new Set(); + const analysis = analysisOf(file); + if (analysis?.hasDefaultExport) names.add('default'); + for (const entry of analysis?.moduleLocalExports ?? []) names.add(entry.exported); + for (const entry of analysis?.moduleReexports ?? []) { + if (entry.kind !== 'all') { + names.add(entry.exported); + continue; + } + const next = target(file, entry.source); + for (const name of next ? surfaceOf(next, active) : []) { + if (name !== 'default') names.add(name); + } + } + active.delete(key); + surfaces.set(key, names); + return names; + } + + /** Whether importing `file` can hand out any feature runtime binding. */ + function reachesFeature(file, active = new Set()) { + if (insideFeature(file)) return true; + const key = normalizePath(file); + if (reaches.has(key)) return reaches.get(key); + if (active.has(key)) return false; + active.add(key); + const analysis = analysisOf(file); + const exposedSources = [ + ...(analysis?.moduleReexports ?? []).map((entry) => entry.source), + ...(analysis?.moduleLocalExports ?? []).flatMap((entry) => + analysis.moduleImports.filter((item) => item.local === entry.local).map((item) => item.source), + ), + ]; + const result = exposedSources.some((source) => { + const next = target(file, source); + return next ? reachesFeature(next, active) : false; + }); + active.delete(key); + reaches.set(key, result); + return result; + } + + /** + * What importing `name` from `file` yields: `{ entry, symbol }` for a feature + * public export, `{ namespace: true }` for a whole feature namespace object, + * `{ other: true }` for anything else, or undefined when `file` does not + * export `name`. + */ + function resolveImport(file, name, active = new Set()) { + const entry = featureEntryOf(file); + if (entry) return { entry, symbol: name }; + if (insideFeature(file)) return { other: true }; + const key = `${normalizePath(file)}#${name}`; + if (active.has(key)) return undefined; + active.add(key); + try { + return resolveModuleExport(file, name, active); + } finally { + active.delete(key); + } + } + + function resolveModuleExport(file, name, active) { + const analysis = analysisOf(file); + if (!analysis) return { other: true }; + const follow = (source, imported) => { + const next = target(file, source); + return (next && resolveImport(next, imported, active)) ?? { other: true }; + }; + const wholeNamespace = (source) => { + const next = target(file, source); + return next && reachesFeature(next) ? { namespace: true } : { other: true }; + }; + for (const entry of analysis.moduleReexports) { + if (entry.kind === 'all' || entry.exported !== name) continue; + return entry.kind === 'namespace' ? wholeNamespace(entry.source) : follow(entry.source, entry.imported); + } + for (const entry of analysis.moduleLocalExports) { + if (entry.exported !== name) continue; + const binding = analysis.moduleImports.find((item) => item.local === entry.local); + if (!binding) return { other: true }; + if (binding.kind === 'namespace') return wholeNamespace(binding.source); + return follow(binding.source, binding.kind === 'default' ? 'default' : binding.imported); + } + if (name === 'default') return analysis.hasDefaultExport ? { other: true } : undefined; + for (const entry of analysis.moduleReexports) { + if (entry.kind !== 'all') continue; + const next = target(file, entry.source); + if (!next) continue; + if (featureEntryOf(next)) { + if (surfaceOf(next).has(name)) return { entry: featureEntryOf(next), symbol: name }; + continue; + } + const resolved = resolveImport(next, name, active); + if (resolved) return resolved; + } + return undefined; + } + + return { analysisOf, featureEntryOf, reachesFeature, resolveImport, surfaceOf, target }; +} + +function rootSymbolKey(entry, zone, symbol) { + return `${entry}#${zone}#${symbol}`; +} + +function flattenRootSymbolUses(record) { + return Object.entries(record ?? {}).flatMap(([entry, zones]) => + Object.entries(zones).flatMap(([zone, symbols]) => + symbols.map((symbol) => ({ entry, zone, symbol, key: rootSymbolKey(entry, zone, symbol) })), + ), + ); +} + +/** + * Collects the feature public symbols each root zone takes, through named and + * default imports, static namespace members (JSX included), and re-exports in + * any intermediate module. A use that cannot be attributed to named symbols — + * a namespace value escaping, a wildcard or namespace re-export, or a runtime + * module load — is a violation instead of a record. + */ +function collectRootSymbolUses(desktopRoot, config, knownAnalyses) { + const graph = createModuleExportGraph(desktopRoot, knownAnalyses); + const uses = new Map(); + const violations = []; + for (const { path, zone } of rootSymbolCallers(desktopRoot, config)) { + const file = resolve(desktopRoot, path); + const analysis = graph.analysisOf(file); + if (!analysis) continue; + const recordUse = (resolved, via) => { + if (resolved?.namespace) { + violations.push(`${path}: ${zone} root receives a whole feature namespace object through ${via}; import the public symbols it needs by name`); + } else if (resolved?.entry) { + const key = rootSymbolKey(resolved.entry, zone, resolved.symbol); + if (!uses.has(key)) uses.set(key, { entry: resolved.entry, zone, symbol: resolved.symbol, callers: new Set() }); + uses.get(key).callers.add(path); + } + }; + for (const binding of analysis.moduleImports) { + const next = graph.target(file, binding.source); + if (!next) continue; + if (binding.kind !== 'namespace') { + recordUse(graph.resolveImport(next, binding.kind === 'default' ? 'default' : binding.imported), binding.source); + continue; + } + if (!graph.reachesFeature(next)) continue; + const members = new Set([ + ...Object.keys(binding.memberCalls), + ...Object.keys(binding.memberReferences), + ...Object.keys(binding.jsxMembers), + ]); + for (const member of members) recordUse(graph.resolveImport(next, member), `${binding.local}.${member}`); + if (binding.valueReferences + binding.jsxReferences > 0) { + violations.push(`${path}: ${zone} root lets namespace ${binding.local} from ${binding.source} escape; read feature public symbols as static members only`); + } + } + for (const entry of analysis.moduleReexports) { + const next = graph.target(file, entry.source); + if (!next) continue; + if (entry.kind === 'named') { + recordUse(graph.resolveImport(next, entry.imported), entry.source); + } else if (graph.reachesFeature(next)) { + violations.push(`${path}: ${zone} root re-exports ${entry.source} wholesale; re-export feature public symbols by name`); + } + } + for (const load of analysis.moduleLoads) { + if (load.kind === 'side-effect') continue; + const next = graph.target(file, load.source); + if (next && graph.reachesFeature(next)) { + violations.push(`${path}: ${zone} root loads ${load.source} through ${load.kind}; import feature public symbols statically by name`); + } + } + } + + const record = {}; + for (const use of uses.values()) ((record[use.entry] ??= {})[use.zone] ??= []).push(use.symbol); + const sortedRecord = Object.fromEntries( + Object.keys(record).sort().map((entry) => [ + entry, + Object.fromEntries(Object.keys(record[entry]).sort().map((zone) => [zone, record[entry][zone].sort()])), + ]), + ); + return { record: sortedRecord, uses, violations }; +} + +function validateRootSymbolUses({ desktopRoot, config, sourceAnalyses, violations }) { + const knownAnalyses = new Map( + [...sourceAnalyses.values()].map(({ analysis, file }) => [normalizePath(file), analysis]), + ); + const observed = collectRootSymbolUses(desktopRoot, config, knownAnalyses); + violations.push(...observed.violations); + const recorded = new Set(flattenRootSymbolUses(config.rootSymbolUses).map((use) => use.key)); + for (const [key, use] of observed.uses) { + if (recorded.has(key)) continue; + violations.push( + `${[...use.callers].sort().join(', ')}: ${use.zone} root uses ${use.symbol} from ${use.entry}, which rootSymbolUses does not record`, + ); + } + for (const use of flattenRootSymbolUses(config.rootSymbolUses)) { + if (!observed.uses.has(use.key)) { + violations.push(`${use.entry}: stale rootSymbolUses entry; ${use.zone} root no longer uses ${use.symbol}`); + } + } +} + +/** Each feature's public runtime export names, keyed by feature directory. */ +export function collectFeatureEntrySurfaces(desktopRoot) { + const graph = createModuleExportGraph(desktopRoot); + const surfaces = new Map(); + for (const file of sourceFiles(resolve(desktopRoot, 'src/renderer/features'))) { + const entry = graph.featureEntryOf(file); + if (entry) surfaces.set(dirname(entry), graph.surfaceOf(file)); + } + return surfaces; +} + +/** + * Root symbol uses may only shrink against the base, with two exceptions. A + * symbol the same change adds to the entry's public surface may be taken: + * that is an explicit public-contract change rather than a new grip on an + * existing capability. And a use may move one way, out of the legacy AppShell + * into composition or bootstrap, when AppShell gives it up in the same change. + */ +function compareRootSymbolUses(config, baseConfig, baseEntrySurfaces) { + const result = { admitted: [], violations: [] }; + if (!baseConfig || baseConfig.rootSymbolUses === undefined) return result; + if (config.rootSymbolUses === undefined) { + result.violations.push('rootSymbolUses: the root public symbol record cannot be removed'); + return result; + } + const base = new Set(flattenRootSymbolUses(baseConfig.rootSymbolUses).map((use) => use.key)); + const current = new Set(flattenRootSymbolUses(config.rootSymbolUses).map((use) => use.key)); + const movedOutOfAppShell = new Set(); + for (const use of flattenRootSymbolUses(config.rootSymbolUses)) { + if (base.has(use.key)) continue; + const appShellKey = rootSymbolKey(use.entry, 'appShell', use.symbol); + if ( + use.zone !== 'appShell' && + base.has(appShellKey) && + !current.has(appShellKey) && + !movedOutOfAppShell.has(appShellKey) + ) { + movedOutOfAppShell.add(appShellKey); + result.admitted.push(`${use.entry}: ${use.zone} ${use.symbol} (moved out of appShell)`); + continue; + } + if (!baseEntrySurfaces) { + result.violations.push( + `${use.entry}: ${use.zone} root newly uses ${use.symbol}, and without the base tree's public surface the use cannot be admitted`, + ); + } else if (baseEntrySurfaces.get(dirname(use.entry))?.has(use.symbol)) { + result.violations.push( + `${use.entry}: ${use.zone} root newly uses existing public export ${use.symbol}; only an export the same change adds may join rootSymbolUses`, + ); + } else { + result.admitted.push(`${use.entry}: ${use.zone} ${use.symbol} (new public export)`); + } + } + return result; +} + +const RETAINED_ROOT_DOC = 'src/renderer/README.md'; +const RETAINED_ROOT_TABLE_START = ''; +const RETAINED_ROOT_TABLE_END = ''; +const RETAINED_ROOT_COLUMNS = [ + 'Component', + 'Hook', + 'Call site', + 'Consumer', + 'Owner', + 'Allowed capability', + 'Root reason', + 'Removal', +]; +const RETAINED_ROOT_REASONS = ['application lifecycle', 'cross-region command', 'layout', 'locale', 'navigation']; +const CONVERSATION_README = 'src/renderer/features/conversation/README.md'; +const CONVERSATION_TRANSITIONAL_ANCHOR = 'Remaining transitional capabilities'; + +function defaultAppShellHookGatePath(desktopRoot) { + return resolve(desktopRoot, '../../scripts/check-app-shell-hooks.mjs'); +} + +/** + * Reads the AppShell hook gate's hand-maintained `ALLOWED` inventory as a + * static literal. The gate is not imported or run, and it stays the owner of + * its own counts. + */ +function readAppShellHookGate(path) { + const program = parse(readFileSync(path, 'utf8'), { sourceType: 'module' }).program; + const declarator = program.body + .filter((statement) => statement.type === 'ExportNamedDeclaration' && statement.declaration?.type === 'VariableDeclaration') + .flatMap((statement) => statement.declaration.declarations) + .find((item) => item.id?.type === 'Identifier' && item.id.name === 'ALLOWED'); + const inventory = unwrapExpression(declarator?.init); + if (inventory?.type !== 'ObjectExpression') throw new Error('no exported ALLOWED object literal'); + const entries = new Map(); + for (const componentProperty of inventory.properties) { + const component = componentProperty.type === 'ObjectProperty' && !componentProperty.computed + ? memberName(componentProperty.key) + : undefined; + const hooks = unwrapExpression(componentProperty.value); + if (!component || hooks?.type !== 'ObjectExpression') throw new Error('ALLOWED must map component names to object literals'); + for (const hookProperty of hooks.properties) { + const hook = hookProperty.type === 'ObjectProperty' && !hookProperty.computed ? memberName(hookProperty.key) : undefined; + const count = unwrapExpression(hookProperty.value); + if (!hook || count?.type !== 'NumericLiteral' || !Number.isInteger(count.value) || count.value < 1) { + throw new Error(`ALLOWED.${component} must map hook names to positive integer literals`); + } + entries.set(`${component}.${hook}`, { component, hook, count: count.value }); + } + } + return entries; +} + +function markdownTableCells(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|') || !trimmed.endsWith('|') || trimmed.length < 2) return undefined; + return trimmed.slice(1, -1).split('|').map((cell) => cell.trim()); +} + +/** The first Markdown table at or after `fromLine`, with 1-based line numbers. */ +function markdownTable(lines, fromLine = 0) { + let start = fromLine; + while (start < lines.length && !lines[start].trim().startsWith('|')) start += 1; + const rows = []; + for (let index = start; index < lines.length && lines[index].trim().startsWith('|'); index += 1) { + rows.push({ cells: markdownTableCells(lines[index]), line: index + 1 }); + } + if (rows.length < 2 || !rows[1].cells?.every((cell) => /^:?-{3,}:?$/u.test(cell))) return undefined; + return { header: rows[0].cells, rows: rows.slice(2) }; +} + +function readRetainedRootTable(desktopRoot) { + const path = resolve(desktopRoot, RETAINED_ROOT_DOC); + if (!existsSync(path)) return undefined; + const lines = readFileSync(path, 'utf8').split('\n'); + const start = lines.findIndex((line) => line.trim() === RETAINED_ROOT_TABLE_START); + const end = lines.findIndex((line, index) => index > start && line.trim() === RETAINED_ROOT_TABLE_END); + if (start < 0 || end < 0) return undefined; + return markdownTable(lines.slice(0, end), start + 1) ?? { header: undefined, rows: [] }; +} + +const plainCell = (cell) => cell.replace(/^`([^`]*)`$/u, '$1'); +const emptyCell = (cell) => cell === '' || cell === '—' || cell === '-'; + +/** + * Every hook the AppShell hook gate still allows needs one retained-root row + * per call site, saying why it stays at the root or which module removes it. + */ +function validateRetainedRootTable({ desktopRoot, hookGatePath, violations }) { + if (!existsSync(hookGatePath)) return undefined; + let gate; + try { + gate = readAppShellHookGate(hookGatePath); + } catch (error) { + violations.push(`AppShell hook gate inventory could not be read: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + const table = readRetainedRootTable(desktopRoot); + if (!table) { + violations.push(`${RETAINED_ROOT_DOC}: the retained-root hook table is missing; every AppShell hook gate entry needs a row`); + return { gate, rows: [] }; + } + if (JSON.stringify(table.header) !== JSON.stringify(RETAINED_ROOT_COLUMNS)) { + violations.push(`${RETAINED_ROOT_DOC}: the retained-root hook table must have the columns ${RETAINED_ROOT_COLUMNS.join(' | ')}`); + return { gate, rows: [] }; + } + const rows = []; + const callSites = new Map(); + for (const { cells, line } of table.rows) { + const at = `${RETAINED_ROOT_DOC}:${line}`; + if (cells?.length !== RETAINED_ROOT_COLUMNS.length) { + violations.push(`${at}: retained-root row must have ${RETAINED_ROOT_COLUMNS.length} cells`); + continue; + } + const [component, hook, callSite, consumer, owner, capability, reason, removal] = cells.map(plainCell); + const key = `${component}.${hook}`; + if (!gate.has(key)) { + violations.push(`${at}: retained-root row names ${key}, which the AppShell hook gate does not list`); + continue; + } + if ([callSite, consumer, owner, capability].some(emptyCell)) { + violations.push(`${at}: retained-root row for ${key} must name its call site, consumer, owner and allowed capability`); + } + const hasReason = !emptyCell(reason); + const hasRemoval = !emptyCell(removal); + if ( + hasReason === hasRemoval || + (hasReason && !RETAINED_ROOT_REASONS.includes(reason)) || + (hasRemoval && !/^M[0-5]$/u.test(removal)) + ) { + violations.push( + `${at}: retained-root row for ${key} needs exactly one root reason (${RETAINED_ROOT_REASONS.join(', ')}) or removal module (M0–M5)`, + ); + } + if (!callSites.has(key)) callSites.set(key, new Set()); + if (callSites.get(key).has(callSite)) violations.push(`${at}: duplicate retained-root call site ${callSite} for ${key}`); + callSites.get(key).add(callSite); + rows.push({ key, reason: hasReason ? reason : undefined, removal: hasRemoval ? removal : undefined }); + } + for (const [key, entry] of gate) { + const recorded = callSites.get(key)?.size ?? 0; + if (recorded === 0) { + violations.push(`${key}: AppShell hook gate entry has no retained-root row in ${RETAINED_ROOT_DOC}`); + } else if (recorded !== entry.count) { + violations.push(`${key}: the AppShell hook gate counts ${entry.count} call sites, the retained-root table has ${recorded} rows`); + } + } + return { gate, rows }; +} + +function countTableRowsAfter(path, anchor) { + if (!existsSync(path)) return undefined; + const lines = readFileSync(path, 'utf8').split('\n'); + const at = lines.findIndex((line) => line.includes(anchor)); + return at < 0 ? undefined : markdownTable(lines, at + 1)?.rows.length; +} + +/** + * The completion measures #4582 tracks for M3 and M5, read from the ledger and + * the documents the checker already validates. Reporting only: the falling + * numbers stay guarded by the existing no-growth ratchets, not new gates. + */ +export function rendererArchitectureReport({ + desktopRoot, + config, + appShellHookGatePath = defaultAppShellHookGatePath(desktopRoot), +}) { + const files = Object.entries(config.legacyAppShell?.files ?? {}); + const bridgeByFile = files + .map(([path, debt]) => [path, metricTotal(debt.bridgePaths ?? {})]) + .filter(([, count]) => count > 0) + .sort(([leftPath, left], [rightPath, right]) => right - left || leftPath.localeCompare(rightPath)); + const bridgeTotal = bridgeByFile.reduce((total, [, count]) => total + count, 0); + const appShellBridge = bridgeByFile.find(([path]) => /\/app-shell\.(?:(?:c|m)?(?:js|ts)x?)$/u.test(path))?.[1] ?? 0; + const factories = files.flatMap(([, debt]) => debt.actionFactories ?? []).sort(); + const transitional = countTableRowsAfter(resolve(desktopRoot, CONVERSATION_README), CONVERSATION_TRANSITIONAL_ANCHOR); + const lines = [ + `AppShell-family bridge references: ${bridgeTotal} (app-shell.tsx ${appShellBridge})`, + ...bridgeByFile.map(([path, count]) => ` ${path}: ${count}`), + `AppShell-family action factories: ${factories.length}${factories.length > 0 ? ` (${factories.join(', ')})` : ''}`, + `Transitional Conversation capabilities: ${transitional ?? 'table not found'}`, + ]; + const tableViolations = []; + const retained = validateRetainedRootTable({ desktopRoot, hookGatePath: appShellHookGatePath, violations: tableViolations }); + if (retained) { + const callSites = [...retained.gate.values()].reduce((total, entry) => total + entry.count, 0); + const covered = new Set(retained.rows.map((row) => row.key)); + const missing = [...retained.gate.keys()].filter((key) => !covered.has(key)).length; + const tally = (field) => + Object.entries( + retained.rows.reduce((counts, row) => { + if (row[field]) counts[row[field]] = (counts[row[field]] ?? 0) + 1; + return counts; + }, {}), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, count]) => `${name} ${count}`) + .join(', ') || 'none'; + lines.push( + `AppShell hook gate: ${retained.gate.size} entries / ${callSites} call sites; entries without a retained-root row: ${missing}`, + ` retained at the root: ${tally('reason')}`, + ` scheduled for removal: ${tally('removal')}`, + ); + } else { + lines.push('AppShell hook gate: not found'); + } + const zones = flattenRootSymbolUses(config.rootSymbolUses).reduce((counts, use) => { + counts[use.zone] = (counts[use.zone] ?? 0) + 1; + return counts; + }, Object.fromEntries(ROOT_SYMBOL_ZONES.map((zone) => [zone, 0]))); + lines.push(`Root symbol uses: ${ROOT_SYMBOL_ZONES.map((zone) => `${zone} ${zones[zone]}`).join(', ')}`); + return lines; +} + function isPublicFeaturePath(subpath) { return subpath === '' || subpath === 'index'; } @@ -2346,10 +2963,7 @@ function validateLegacyLedger(desktopRoot, config, violations) { ); } - const actualFiles = readdirSync(rendererRoot, { withFileTypes: true }) - .filter((entry) => entry.isFile() && LEGACY_APP_SHELL_FILE.test(entry.name)) - .map((entry) => `src/renderer/${entry.name}`) - .sort(); + const actualFiles = legacyAppShellFiles(desktopRoot); const expectedFiles = Object.keys(config.legacyAppShell.files).sort(); if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { violations.push(`legacy AppShell file set changed; expected ${JSON.stringify(expectedFiles)}, received ${JSON.stringify(actualFiles)}`); @@ -3201,10 +3815,7 @@ export function generateArchitectureConfig(desktopRoot, config) { .map((path) => normalizePath(relative(desktopRoot, path))) .filter((path) => zoneFor(path).kind === 'legacy') .sort(); - const appShellFiles = readdirSync(rendererRoot, { withFileTypes: true }) - .filter((entry) => entry.isFile() && LEGACY_APP_SHELL_FILE.test(entry.name)) - .map((entry) => `src/renderer/${entry.name}`) - .sort(); + const appShellFiles = legacyAppShellFiles(desktopRoot); const closureViolations = []; const closureFiles = collectRootDependencyClosure(desktopRoot, appShellFiles, closureViolations, 'AppShell'); if (closureViolations.length > 0) { @@ -3233,6 +3844,7 @@ export function generateArchitectureConfig(desktopRoot, config) { legacyPlatformImports: imports.platform, controllerOwners: controllerOwnersOf(config), featurePrivateModules: featurePrivateModulesOf(config), + rootSymbolUses: collectRootSymbolUses(desktopRoot, config).record, legacyAppShell: { files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path, 'legacyAppShell')])), closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])), @@ -3245,8 +3857,9 @@ export function generateArchitectureConfig(desktopRoot, config) { }; } -function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { +function validateMonotonicDebt(config, baseConfig, desktopRoot, violations, baseEntrySurfaces) { if (!baseConfig) return; + violations.push(...compareRootSymbolUses(config, baseConfig, baseEntrySurfaces).violations); const currentPrivateModules = new Set(featurePrivateModulesOf(config)); for (const path of featurePrivateModulesOf(baseConfig)) { if (!currentPrivateModules.has(path)) { @@ -3391,6 +4004,8 @@ export function checkRendererArchitecture({ desktopRoot, config, baseConfig, + baseEntrySurfaces, + appShellHookGatePath, enforceRendererEntryContract = true, } = {}) { const resolvedDesktopRoot = resolve(desktopRoot ?? fileURLToPath(new URL('..', import.meta.url))); @@ -3405,7 +4020,7 @@ export function checkRendererArchitecture({ } validateLegacyLedger(resolvedDesktopRoot, resolvedConfig, violations); validateCopyCatalogFiles(resolvedDesktopRoot, violations); - validateMonotonicDebt(resolvedConfig, baseConfig, resolvedDesktopRoot, violations); + validateMonotonicDebt(resolvedConfig, baseConfig, resolvedDesktopRoot, violations, baseEntrySurfaces); const allowedLegacyFeatureImports = new Set(resolvedConfig.legacyFeatureImports); const allowedLegacyPlatformImports = new Set(resolvedConfig.legacyPlatformImports); const observedLegacyFeatureImports = new Set(); @@ -3450,6 +4065,17 @@ export function checkRendererArchitecture({ sourceAnalyses, violations, }); + validateRootSymbolUses({ + desktopRoot: resolvedDesktopRoot, + config: resolvedConfig, + sourceAnalyses, + violations, + }); + validateRetainedRootTable({ + desktopRoot: resolvedDesktopRoot, + hookGatePath: appShellHookGatePath ?? defaultAppShellHookGatePath(resolvedDesktopRoot), + violations, + }); for (const edge of allowedLegacyFeatureImports) { if (!observedLegacyFeatureImports.has(edge)) violations.push(`${edge}: stale feature-to-legacy import budget`); @@ -3537,6 +4163,7 @@ async function crossCheckUnderBaseChecker({ base, baseCommittedConfig, baseDesktopRoot, + baseEntrySurfaces, desktopRoot, repoRoot, strictBase, @@ -3604,7 +4231,7 @@ async function crossCheckUnderBaseChecker({ return skip(`the checker at ${base} does not produce the current ledger shape (${shapeViolations.join('; ')})`); } const violations = []; - validateMonotonicDebt(currentUnderBaseRules, baseUnderBaseRules, desktopRoot, violations); + validateMonotonicDebt(currentUnderBaseRules, baseUnderBaseRules, desktopRoot, violations, baseEntrySurfaces); console.log( `Renderer architecture check: ${relativeScript} differs from ${base}; cross-checked debt under the base checker.`, ); @@ -3668,6 +4295,8 @@ async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false try { baseTree = materializeBaseTree(repoRoot, base); } catch (error) { + // Without a base tree there is no base public surface, so root symbol + // growth is never admitted on this fallback. return { baseConfig: baseTreeFallback({ base, baseCommittedConfig, error, strictBase }), crossCheckViolations: [], @@ -3682,24 +4311,27 @@ async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false } catch (error) { baseConfig = baseTreeFallback({ base, baseCommittedConfig, error, strictBase }); } + const baseEntrySurfaces = collectFeatureEntrySurfaces(baseDesktopRoot); const crossCheckViolations = await crossCheckUnderBaseChecker({ base, baseCommittedConfig, baseDesktopRoot, + baseEntrySurfaces, desktopRoot, repoRoot, strictBase, }); - return { baseConfig, crossCheckViolations, introducedLedger: false }; + return { baseConfig, baseEntrySurfaces, crossCheckViolations, introducedLedger: false }; } finally { baseTree.remove(); } } -const CLI_USAGE = 'usage: check-renderer-architecture.mjs [--write] [--base [--strict-base]]'; +const CLI_USAGE = 'usage: check-renderer-architecture.mjs [--write] [--report] [--base [--strict-base]]'; function parseCliArguments(args) { let base; + let report = false; let strictBase = false; let write = false; for (let index = 0; index < args.length; index += 1) { @@ -3708,6 +4340,10 @@ function parseCliArguments(args) { write = true; continue; } + if (argument === '--report' && !report) { + report = true; + continue; + } if (argument === '--strict-base' && !strictBase) { strictBase = true; continue; @@ -3722,7 +4358,7 @@ function parseCliArguments(args) { throw new Error(CLI_USAGE); } if (strictBase && base === undefined) throw new Error(`--strict-base requires --base \n${CLI_USAGE}`); - return { base, strictBase, write }; + return { base, report, strictBase, write }; } async function runCli() { @@ -3731,10 +4367,11 @@ async function runCli() { let base; let config; let loadedBase; + let report; let strictBase; let write; try { - ({ base, strictBase, write } = parseCliArguments(process.argv.slice(2))); + ({ base, report, strictBase, write } = parseCliArguments(process.argv.slice(2))); config = JSON.parse(readFileSync(join(desktopRoot, 'renderer-architecture.json'), 'utf8')); loadedBase = await loadBaseConfig(repoRoot, desktopRoot, base, { strictBase }); if (write) { @@ -3747,8 +4384,18 @@ async function runCli() { process.exitCode = 1; return; } - const { baseConfig, crossCheckViolations, introducedLedger } = loadedBase; - const violations = [...checkRendererArchitecture({ baseConfig, config, desktopRoot }), ...crossCheckViolations]; + const { baseConfig, baseEntrySurfaces, crossCheckViolations, introducedLedger } = loadedBase; + const violations = [ + ...checkRendererArchitecture({ baseConfig, baseEntrySurfaces, config, desktopRoot }), + ...crossCheckViolations, + ]; + for (const use of compareRootSymbolUses(config, baseConfig, baseEntrySurfaces).admitted) { + console.log(`Renderer architecture check: root symbol use admitted: ${use}`); + } + if (report) { + console.log('Renderer architecture report:'); + for (const line of rendererArchitectureReport({ desktopRoot, config })) console.log(` ${line}`); + } if (violations.length > 0) { console.error('Renderer architecture check failed:'); for (const violation of violations) console.error(`- ${violation}`); diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 043d0ba556..341bac5235 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -38,7 +38,9 @@ import { fileURLToPath } from 'node:url'; import { analyzeRendererSource, checkRendererArchitecture, + collectFeatureEntrySurfaces, generateArchitectureConfig, + rendererArchitectureReport, } from './check-renderer-architecture.mjs'; import { assertRendererEntryHtml, @@ -77,6 +79,7 @@ function architectureConfig({ rootDebtClosure = {}, legacyRendererFiles = Object.keys(rootDebt), ownership = [], + rootSymbolUses, } = {}) { return { version: 1, @@ -90,6 +93,7 @@ function architectureConfig({ ), ), featurePrivateModules: [...featurePrivateModules].sort(), + ...(rootSymbolUses === undefined ? {} : { rootSymbolUses }), legacyAppShell: { files: legacyFiles, closure: legacyAppShellClosureDebt ?? {}, @@ -393,7 +397,8 @@ describe('renderer architecture checker fixtures', () => { `, }, (desktopRoot) => { - assert.deepEqual(violationsFor(desktopRoot), []); + const rootSymbolUses = { 'src/renderer/features/alpha/index.ts': { composition: ['AlphaHost'] } }; + assert.deepEqual(violationsFor(desktopRoot, architectureConfig({ rootSymbolUses })), []); }, ); }); @@ -2308,15 +2313,18 @@ describe('renderer architecture checker fixtures', () => { legacyPaths: [appShellPath], }, ]; + const rootSymbolUses = { 'src/renderer/features/alpha/index.ts': { appShell: ['AlphaHost'] } }; const currentConfig = architectureConfig({ legacyFiles: { [appShellPath]: currentDebt }, legacyRendererFiles: [appShellPath], ownership, + rootSymbolUses, }); const baseConfig = architectureConfig({ legacyFiles: { [appShellPath]: baseDebt }, legacyRendererFiles: [appShellPath], ownership, + rootSymbolUses, }); await withDesktopFixture( @@ -3441,6 +3449,51 @@ describe('renderer architecture base-tree derivation (git fixtures)', () => { }); }); + it('admits root symbol growth only for an export the head change adds', async () => { + await withGitFixture(async (fixture) => { + const entry = 'src/renderer/features/alpha/index.ts'; + const composition = 'src/renderer/composition/desktop-application.tsx'; + await fixture.writeFiles({ + [entry]: "export { AlphaPanel, AlphaProvider } from './ui/alpha.js';\n", + 'src/renderer/features/alpha/ui/alpha.tsx': ` + export function AlphaProvider(_props: { readonly children?: unknown }) { return null; } + export function AlphaPanel() { return null; } + export function AlphaInspector() { return null; } + `, + [composition]: "import { AlphaProvider } from '../features/alpha';\nexport const DesktopApplication = () => ;\n", + }); + await fixture.writeLedger(); + const base = fixture.commit('base'); + + await fixture.writeFiles({ + [composition]: "import { AlphaPanel, AlphaProvider } from '../features/alpha';\nexport const DesktopApplication = () => ;\n", + }); + await fixture.writeLedger(); + fixture.commit('take an export the base entry already had'); + const existing = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(existing.status, 0); + assert.match( + existing.stderr, + /^- src\/renderer\/features\/alpha\/index\.ts: composition root newly uses existing public export AlphaPanel;/mu, + ); + + await fixture.writeFiles({ + [entry]: "export { AlphaInspector, AlphaPanel, AlphaProvider } from './ui/alpha.js';\n", + [composition]: "import { AlphaInspector, AlphaProvider } from '../features/alpha';\nexport const DesktopApplication = () => ;\n", + }); + const headLedger = await fixture.writeLedger(); + assert.deepEqual(headLedger.rootSymbolUses, { [entry]: { composition: ['AlphaInspector', 'AlphaProvider'] } }); + fixture.commit('take an export the same change adds'); + const added = fixture.runChecker(['--base', base, '--strict-base', '--report']); + assertPassed(added, base, 'new public export'); + assert.match(added.stdout, /Renderer architecture report:\n[^]*Root symbol uses: appShell 0, bootstrap 0, composition 2\n/u); + assert.match( + added.stdout, + /root symbol use admitted: src\/renderer\/features\/alpha\/index\.ts: composition AlphaInspector \(new public export\)/u, + ); + }); + }); + it('rejects a new unclassified legacy renderer file relative to the derived base tree', async () => { await withGitFixture(async (fixture) => { await fixture.writeLedger(); @@ -3737,3 +3790,405 @@ describe('private feature construction boundaries', () => { }); }); }); + +describe('root public symbol uses', () => { + const ENTRY = 'src/renderer/features/alpha/index.ts'; + const APP_SHELL = 'src/renderer/app-shell.tsx'; + const COMPOSITION = 'src/renderer/composition/desktop-application.tsx'; + const ROOT_SYMBOL_VIOLATION = /\b(?:appShell|bootstrap|composition) root\b|rootSymbolUses/u; + const alphaFeature = { + [ENTRY]: ` + export { AlphaPanel, AlphaProvider } from './ui/alpha-provider.js'; + export * from './controller/alpha-reads.js'; + export type { AlphaSnapshot } from './controller/alpha-reads.js'; + `, + 'src/renderer/features/alpha/ui/alpha-provider.tsx': ` + export function AlphaProvider(_props: { readonly children?: unknown }) { return null; } + export function AlphaPanel() { return null; } + `, + 'src/renderer/features/alpha/controller/alpha-reads.ts': ` + export interface AlphaSnapshot { readonly value: number } + export function useAlphaReads(): AlphaSnapshot { return { value: 0 }; } + export function useAlphaState() { return { value: 0, setValue(_value: number) {} }; } + `, + }; + + function generatedUses(desktopRoot, seed = architectureConfig()) { + return generateArchitectureConfig(desktopRoot, seed).rootSymbolUses; + } + + function rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces } = {}) { + return checkRendererArchitecture({ + baseConfig, + baseEntrySurfaces, + config, + desktopRoot, + enforceRendererEntryContract: false, + }).filter((violation) => ROOT_SYMBOL_VIOLATION.test(violation)); + } + + it('records named, namespace member and JSX member uses per root zone', async () => { + await withDesktopFixture({ + ...alphaFeature, + [APP_SHELL]: ` + import * as Alpha from './features/alpha'; + import type { AlphaSnapshot } from './features/alpha'; + type WholeAlpha = typeof Alpha; + export function AppShell(_whole?: WholeAlpha) { + const reads: AlphaSnapshot = Alpha.useAlphaReads(); + const typed: Alpha.AlphaSnapshot = reads; + return {typed.value}; + } + `, + [COMPOSITION]: ` + import { AlphaPanel } from '@maka/desktop/src/renderer/features/alpha'; + export function DesktopApplication() { return ; } + `, + 'src/renderer/composition/__tests__/desktop-application.test.tsx': ` + import { AlphaProvider } from '../../features/alpha/index.js'; + export const fixture = AlphaProvider; + `, + [RENDERER_ENTRY_PATH]: ` + import { useAlphaState } from './features/alpha/index.js'; + export const main = useAlphaState; + `, + }, (desktopRoot) => { + const seed = rendererEntrySeedConfig(); + const rootSymbolUses = { + [ENTRY]: { + appShell: ['AlphaProvider', 'useAlphaReads'], + bootstrap: ['useAlphaState'], + composition: ['AlphaPanel'], + }, + }; + assert.deepEqual(generatedUses(desktopRoot, seed), rootSymbolUses); + assert.deepEqual(rootViolations(desktopRoot, { ...seed, rootSymbolUses }), []); + + const unrecorded = rootViolations(desktopRoot, { ...seed, rootSymbolUses: {} }); + assertHasViolation(unrecorded, /app-shell\.tsx: appShell root uses useAlphaReads from src\/renderer\/features\/alpha\/index\.ts, which rootSymbolUses does not record/u); + assertHasViolation(unrecorded, /desktop-application\.tsx: composition root uses AlphaPanel from/u); + assertHasViolation(unrecorded, /main\.tsx: bootstrap root uses useAlphaState from/u); + assert.equal(unrecorded.length, 4); + + const stale = { [ENTRY]: { ...rootSymbolUses[ENTRY], appShell: ['AlphaProvider', 'useAlphaReads', 'useAlphaState'] } }; + assert.deepEqual(rootViolations(desktopRoot, { ...seed, rootSymbolUses: stale }), [ + `${ENTRY}: stale rootSymbolUses entry; appShell root no longer uses useAlphaState`, + ]); + }); + }); + + it('follows named, aliased and wildcard re-exports through intermediate modules', async () => { + await withDesktopFixture({ + ...alphaFeature, + 'src/renderer/alpha-shim.ts': "export { useAlphaState as useShimState } from './features/alpha/index.js';", + 'src/renderer/alpha-barrel.ts': "export * from './alpha-shim.js';\nexport const legacyOnly = 1;", + 'src/renderer/alpha-alias.ts': "import { AlphaProvider } from './features/alpha';\nexport { AlphaProvider as ShellProvider };", + [APP_SHELL]: ` + import { legacyOnly, useShimState } from './alpha-barrel'; + import * as Aliases from './alpha-alias'; + export function AppShell() { + return {useShimState().value + legacyOnly}; + } + `, + }, (desktopRoot) => { + assert.deepEqual(generatedUses(desktopRoot), { [ENTRY]: { appShell: ['AlphaProvider', 'useAlphaState'] } }); + assertHasViolation(rootViolations(desktopRoot, architectureConfig({ rootSymbolUses: {} })), /app-shell\.tsx: appShell root uses useAlphaState from/u); + }); + }); + + it('leaves deep feature imports to the zone rule instead of recording them', async () => { + await withDesktopFixture({ + ...alphaFeature, + [COMPOSITION]: ` + import { AlphaPanel } from '../features/alpha/ui/alpha-provider.js'; + export function DesktopApplication() { return ; } + `, + }, (desktopRoot) => { + assert.deepEqual(generatedUses(desktopRoot), {}); + const config = architectureConfig({ rootSymbolUses: {} }); + assertHasViolation(violationsFor(desktopRoot, config), /desktop-application\.tsx: feature imports must use index/u); + assert.deepEqual(rootViolations(desktopRoot, config), []); + }); + }); + + for (const [name, files, pattern] of [ + ['passes the namespace object on', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nfunction register(value: unknown) { return value; }\nexport const registered = register(Alpha);", + }, /composition root lets namespace Alpha from \.\.\/features\/alpha escape/u], + ['destructures the namespace', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nexport const { AlphaPanel } = Alpha;", + }, /lets namespace Alpha .* escape/u], + ['reads a computed namespace member', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nconst key = 'AlphaPanel';\nexport const panel = Alpha[key];", + }, /lets namespace Alpha .* escape/u], + ['spreads the namespace', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nexport const all = { ...Alpha };", + }, /lets namespace Alpha .* escape/u], + ['re-exports the namespace binding', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nexport { Alpha };", + }, /lets namespace Alpha .* escape/u], + ['renders the namespace object', { + [COMPOSITION]: "import * as Alpha from '../features/alpha';\nexport const view = ;", + }, /lets namespace Alpha .* escape/u], + ['aliases a member through import-equals', { + 'src/renderer/composition/desktop-application.ts': "import * as Alpha from '../features/alpha';\nimport panel = Alpha.AlphaPanel;\nexport { panel };", + }, /lets namespace Alpha .* escape/u], + ['wildcard re-exports the entry', { + [COMPOSITION]: "export * from '../features/alpha';", + }, /composition root re-exports \.\.\/features\/alpha wholesale/u], + ['namespace re-exports the entry', { + [COMPOSITION]: "export * as Alpha from '../features/alpha/index.js';", + }, /re-exports .* wholesale/u], + ['wildcard re-exports a legacy barrel over the entry', { + 'src/renderer/alpha-barrel.ts': "export * from './features/alpha';", + [COMPOSITION]: "export * from '../alpha-barrel';", + }, /re-exports \.\.\/alpha-barrel wholesale/u], + ['receives a namespace object from a legacy module', { + 'src/renderer/alpha-namespace.ts': "export * as Alpha from './features/alpha';", + [COMPOSITION]: "import { Alpha } from '../alpha-namespace';\nexport const panel = Alpha.AlphaPanel;", + }, /receives a whole feature namespace object through \.\.\/alpha-namespace/u], + ['loads the entry dynamically', { + [COMPOSITION]: "export const load = () => import('../features/alpha');", + }, /loads \.\.\/features\/alpha through dynamic-import/u], + ['requires the entry', { + [COMPOSITION]: "const alpha = require('../features/alpha');\nexport default alpha;", + }, /loads \.\.\/features\/alpha through require/u], + ]) { + it(`rejects a root that ${name}`, async () => { + await withDesktopFixture({ ...alphaFeature, ...files }, (desktopRoot) => { + assertHasViolation(rootViolations(desktopRoot, architectureConfig({ rootSymbolUses: generatedUses(desktopRoot) })), pattern); + }); + }); + } + + it('resolves each public entry surface through wildcard and aliased re-exports', async () => { + await withDesktopFixture(alphaFeature, (desktopRoot) => { + const surfaces = collectFeatureEntrySurfaces(desktopRoot); + assert.deepEqual([...surfaces.keys()], ['src/renderer/features/alpha']); + assert.deepEqual( + [...surfaces.get('src/renderer/features/alpha')].sort(), + ['AlphaPanel', 'AlphaProvider', 'useAlphaReads', 'useAlphaState'], + ); + }); + }); + + it('only admits root growth that takes an export the same change adds', async () => { + await withDesktopFixture({ + ...alphaFeature, + [COMPOSITION]: ` + import { AlphaPanel, AlphaProvider } from '../features/alpha'; + export function DesktopApplication() { return ; } + `, + }, (desktopRoot) => { + const config = architectureConfig({ rootSymbolUses: { [ENTRY]: { composition: ['AlphaPanel', 'AlphaProvider'] } } }); + const baseConfig = architectureConfig({ rootSymbolUses: { [ENTRY]: { composition: ['AlphaProvider'] } } }); + const surfacesWith = (...names) => new Map([['src/renderer/features/alpha', new Set(names)]]); + + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces: surfacesWith('AlphaProvider') }), []); + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces: new Map() }), []); + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces: surfacesWith('AlphaPanel', 'AlphaProvider') }), [ + `${ENTRY}: composition root newly uses existing public export AlphaPanel; only an export the same change adds may join rootSymbolUses`, + ]); + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig }), [ + `${ENTRY}: composition root newly uses AlphaPanel, and without the base tree's public surface the use cannot be admitted`, + ]); + // The base that introduces the record has nothing to ratchet against. + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig: architectureConfig() }), []); + // Dropping the record would disable the rule, so it is rejected outright. + assertHasViolation( + rootViolations(desktopRoot, architectureConfig(), { baseConfig }), + /^rootSymbolUses: the root public symbol record cannot be removed$/u, + ); + }); + }); + + it('admits a use moving out of appShell, but not a copy or the reverse move', async () => { + await withDesktopFixture({ + ...alphaFeature, + [COMPOSITION]: ` + import { AlphaPanel } from '../features/alpha'; + export function DesktopApplication() { return ; } + `, + }, (desktopRoot) => { + const baseEntrySurfaces = new Map([['src/renderer/features/alpha', new Set(['AlphaPanel', 'AlphaProvider'])]]); + const ratchet = (current, base) => + rootViolations(desktopRoot, architectureConfig({ rootSymbolUses: current }), { + baseConfig: architectureConfig({ rootSymbolUses: base }), + baseEntrySurfaces, + }).filter((violation) => /newly uses/u.test(violation)); + + assert.deepEqual(ratchet({ [ENTRY]: { composition: ['AlphaPanel'] } }, { [ENTRY]: { appShell: ['AlphaPanel'] } }), []); + assert.deepEqual( + ratchet({ [ENTRY]: { appShell: ['AlphaPanel'], composition: ['AlphaPanel'] } }, { [ENTRY]: { appShell: ['AlphaPanel'] } }), + [`${ENTRY}: composition root newly uses existing public export AlphaPanel; only an export the same change adds may join rootSymbolUses`], + ); + assert.deepEqual( + ratchet({ [ENTRY]: { appShell: ['AlphaPanel'] } }, { [ENTRY]: { composition: ['AlphaPanel'] } }), + [`${ENTRY}: appShell root newly uses existing public export AlphaPanel; only an export the same change adds may join rootSymbolUses`], + ); + }); + }); + + it('validates the root symbol record shape', async () => { + await withDesktopFixture({}, (desktopRoot) => { + for (const [rootSymbolUses, pattern] of [ + [{ 'src/renderer/features/alpha/ui/alpha-provider.tsx': { composition: ['AlphaPanel'] } }, /keys must be normalized feature public entry paths/u], + [{ [ENTRY]: { shell: ['AlphaPanel'] } }, /zones must be sorted and among appShell, bootstrap, composition/u], + [{ [ENTRY]: { composition: ['AlphaProvider', 'AlphaPanel'] } }, /composition must list sorted unique export names/u], + [{ [ENTRY]: {} }, /must map root zones to the symbols they use/u], + ]) { + assertHasViolation(violationsFor(desktopRoot, architectureConfig({ rootSymbolUses })), pattern); + } + }); + }); +}); + +describe('retained root hook table', () => { + const GATE = 'gate/check-app-shell-hooks.mjs'; + const README = 'src/renderer/README.md'; + const RETAINED_ROOT_VIOLATION = /retained-root|AppShell hook gate/u; + const HEADER = '| Component | Hook | Call site | Consumer | Owner | Allowed capability | Root reason | Removal |'; + const gateSource = (inventory = `{ + AppShell: { useState: 2 }, + AppShellContent: { + // The gate's own commentary sits between entries. + useToast: 1, + }, + }`) => `#!/usr/bin/env node\nexport const ALLOWED = ${inventory};\nif (process.argv[1] === undefined) main();\n`; + const row = (component, hook, callSite, reason, removal = '—') => + `| \`${component}\` | \`${hook}\` | \`${callSite}\` | consumer | owner | capability | ${reason} | ${removal} |`; + const readme = (rows, header = HEADER) => [ + '# Renderer', + '', + header, + '| --- | --- | --- | --- | --- | --- | --- | --- |', + ...rows, + '', + '', + ].join('\n'); + const complete = [ + row('AppShell', 'useState', 'uiLocalePreference', 'locale'), + row('AppShell', 'useState', 'uiLocaleOverride', 'locale'), + row('AppShellContent', 'useToast', 'toastApi', '—', 'M5'), + ]; + + async function withTable(files, run) { + await withDesktopFixture({ [GATE]: gateSource(), ...files }, (desktopRoot) => + run( + checkRendererArchitecture({ + appShellHookGatePath: join(desktopRoot, GATE), + config: architectureConfig(), + desktopRoot, + enforceRendererEntryContract: false, + }).filter((violation) => RETAINED_ROOT_VIOLATION.test(violation)), + desktopRoot, + ), + ); + } + + it('accepts one row per gate call site', async () => { + await withTable({ [README]: readme(complete) }, (violations) => assert.deepEqual(violations, [])); + }); + + it('stays inactive without a hook gate', async () => { + await withDesktopFixture({ [README]: '# Renderer\n' }, (desktopRoot) => { + const violations = checkRendererArchitecture({ + appShellHookGatePath: join(desktopRoot, GATE), + config: architectureConfig(), + desktopRoot, + enforceRendererEntryContract: false, + }); + assert.deepEqual(violations.filter((violation) => RETAINED_ROOT_VIOLATION.test(violation)), []); + }); + }); + + for (const [name, files, pattern] of [ + ['a missing table', { [README]: '# Renderer\n' }, /src\/renderer\/README\.md: the retained-root hook table is missing/u], + ['a gate entry without a row', { [README]: readme(complete.slice(0, 2)) }, /^AppShellContent\.useToast: AppShell hook gate entry has no retained-root row/u], + ['a row count below the gate count', { [README]: readme(complete.slice(1)) }, /^AppShell\.useState: the AppShell hook gate counts 2 call sites, the retained-root table has 1 rows$/u], + ['a row for a hook the gate no longer lists', { + [README]: readme([...complete, row('AppShellContent', 'useOnboardingSnapshot', 'onboarding', '—', 'M5')]), + }, /README\.md:\d+: retained-root row names AppShellContent\.useOnboardingSnapshot, which the AppShell hook gate does not list/u], + ['a row with both a reason and a removal module', { + [README]: readme([...complete.slice(0, 2), row('AppShellContent', 'useToast', 'toastApi', 'layout', 'M5')]), + }, /retained-root row for AppShellContent\.useToast needs exactly one root reason/u], + ['a row with neither a reason nor a removal module', { + [README]: readme([...complete.slice(0, 2), row('AppShellContent', 'useToast', 'toastApi', '—', '—')]), + }, /needs exactly one root reason/u], + ['an unknown root reason', { + [README]: readme([...complete.slice(0, 2), row('AppShellContent', 'useToast', 'toastApi', 'convenience')]), + }, /needs exactly one root reason/u], + ['an unknown removal module', { + [README]: readme([...complete.slice(0, 2), row('AppShellContent', 'useToast', 'toastApi', '—', 'M9')]), + }, /needs exactly one root reason/u], + ['a duplicate call site', { + [README]: readme([complete[0], complete[0], complete[2]]), + }, /duplicate retained-root call site uiLocalePreference for AppShell\.useState/u], + ['a row with an empty owner', { + [README]: readme([...complete.slice(0, 2), '| `AppShellContent` | `useToast` | `toastApi` | consumer | — | capability | — | M5 |']), + }, /must name its call site, consumer, owner and allowed capability/u], + ['a row with too few cells', { + [README]: readme([...complete, '| `AppShell` | `useState` | `extra` | consumer | owner | locale | — |']), + }, /README\.md:\d+: retained-root row must have 8 cells/u], + ['different columns', { + [README]: readme(complete, '| Component | Hook | Call site | Consumer | Owner | Capability | Root reason | Removal |'), + }, /the retained-root hook table must have the columns Component \| Hook/u], + ]) { + it(`rejects ${name}`, async () => { + await withTable(files, (violations) => assertHasViolation(violations, pattern)); + }); + } + + it('reads the gate inventory only as a static literal', async () => { + await withDesktopFixture({ [GATE]: gateSource('buildInventory()'), [README]: readme(complete) }, (desktopRoot) => { + assertHasViolation( + checkRendererArchitecture({ + appShellHookGatePath: join(desktopRoot, GATE), + config: architectureConfig(), + desktopRoot, + enforceRendererEntryContract: false, + }), + /^AppShell hook gate inventory could not be read: no exported ALLOWED object literal$/u, + ); + }); + }); + + it('reports the M3 and M5 completion measures', async () => { + await withDesktopFixture({ + [GATE]: gateSource(), + [README]: readme(complete), + 'src/renderer/features/conversation/README.md': [ + 'Remaining transitional capabilities have explicit consumers and removal work:', + '', + '| Capability | Current consumer | Removal module |', + '| --- | --- | --- |', + '| one | AppShell | M3 |', + '| two | AppShell | M3 |', + '', + ].join('\n'), + }, (desktopRoot) => { + const config = architectureConfig({ + legacyFiles: { + 'src/renderer/app-shell.tsx': emptyDebt({ + actionFactories: ['createAppShellChatActions'], + bridgePaths: { 'window.maka.attachments.readBytes': 2 }, + }), + 'src/renderer/app-shell-effects.ts': emptyDebt({ bridgePaths: { 'window.maka.app.info': 3 } }), + 'src/renderer/app-shell-e2e-fixture.ts': emptyDebt({ actionFactories: ['createAppShellE2eFixtureActions'] }), + }, + rootSymbolUses: { 'src/renderer/features/alpha/index.ts': { appShell: ['AlphaHost', 'useAlpha'], composition: ['AlphaServicesProvider'] } }, + }); + assert.deepEqual(rendererArchitectureReport({ appShellHookGatePath: join(desktopRoot, GATE), config, desktopRoot }), [ + 'AppShell-family bridge references: 5 (app-shell.tsx 2)', + ' src/renderer/app-shell-effects.ts: 3', + ' src/renderer/app-shell.tsx: 2', + 'AppShell-family action factories: 2 (createAppShellChatActions, createAppShellE2eFixtureActions)', + 'Transitional Conversation capabilities: 2', + 'AppShell hook gate: 2 entries / 3 call sites; entries without a retained-root row: 0', + ' retained at the root: locale 2', + ' scheduled for removal: M5 1', + 'Root symbol uses: appShell 2, bootstrap 0, composition 1', + ]); + }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts index 63a727e103..32dd7b8add 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-model.test.ts @@ -23,7 +23,7 @@ import { createAgentGraphPanelModel, reduceAgentGraphPanelModel, shouldShowAgentGraphPanel, -} from '../../renderer/features/overlays/index.js'; +} from '../../renderer/features/overlays/testing.js'; describe('AgentGraphPanelModel', () => { it('keeps presentation state separate from backend snapshots', () => { diff --git a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts index 2d6ddaeeb2..741c872a95 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -25,7 +25,7 @@ import { providerEndpointPresentation, } from '../../renderer/settings/provider-endpoint-presentation.js'; -import { providerRequestUrlPreview } from '../../renderer/features/connection-settings/index.js'; +import { providerRequestUrlPreview } from '../../renderer/features/connection-settings/testing.js'; // A 40-char hex-shaped run, built rather than written: long enough to trip // the display redactor's long-opaque-token rule wherever it is left alone. diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts index 13ec4ebcc0..fc925dbed3 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts @@ -23,7 +23,7 @@ import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { startWorkHubCoordinationLifecycle, type WorkHubCoordinationHostChange, -} from '../../renderer/features/workhub/index.js'; +} from '../../renderer/application/contracts/workhub-workspace/coordination-lifecycle.js'; const coordinationSessionId = (hostId: string) => desktopSessionKey({ hostId, diff --git a/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts b/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts index 1e14022adb..8f0d6d4b36 100644 --- a/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-linked-work.test.ts @@ -21,11 +21,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createElement } from "react"; import { parseHTML } from 'linkedom'; -import { - workHubLinkedWork, -} from "../../renderer/features/workhub/index.js"; import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; -import { WorkHubConversation } from '../../renderer/features/workhub/testing.js'; +import { WorkHubConversation, workHubLinkedWork } from '../../renderer/features/workhub/testing.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index c621b9df5e..37a1e3dac9 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -133,6 +133,31 @@ checks module edges, not the behavior of arbitrary wrappers; public capability shapes and ownership still require review. Conversation uses it to keep raw Session UI construction and whole-state inspection out of production consumers. +`rootSymbolUses` records, per feature public entry, the runtime symbols each +root zone takes from it: `appShell` (the AppShell family above), +`composition`, and `bootstrap` (`bootstrap/` plus the guarded `main.tsx` and +`app.tsx` entries). Being allowed to import an entry does not make every export +appropriate for the root. The checker attributes named and default imports, +static namespace members (including `` in JSX) and named re-exports, and +follows `export { x } from`, re-exported imports and `export *` through any +intermediate module until it reaches a feature public entry, so a legacy shim +cannot hide which symbol the root holds. A root namespace binding may only be +read as static members. Passing it on, destructuring or spreading it, computed +access, `import x = NS.y`, `export *` / `export * as` over an entry, and a +runtime `import()` or `require` of an entry are rejected because their symbols +cannot be attributed. Type-only imports and type positions are not recorded. +Deep feature imports stay with the zone rules. `--write` regenerates the record +and the tree must match it exactly. Against the base the record may only +shrink, with two exceptions the CLI lists as it admits them. A new root use +passes when the same change adds that export to the entry's public surface, +measured on the materialized base tree. A use may also move one way out of +`appShell` into `composition` or `bootstrap` when `appShell` gives it up in the +same change; a copy or the reverse move fails. Taking an export the base entry +already had fails, and so does removing the record. This is a module-graph rule: +it does not see a legacy helper that wraps a feature export and returns the +result, and it does not prove that an admitted export is narrow at runtime. Both +remain review concerns. + Dependency-path debt prices only regressive runtime edges. Type-only imports are erased at compile time and never count. Edges into a shell, feature public, or application public/contract boundary are the direction the migration wants, @@ -217,6 +242,94 @@ This initial guardrail is source-policy and migration metadata only. It does not change runtime behavior, provider order, IPC/storage contracts, bootstrap, Composer mount semantics, Session switching, or Workbar resource lifecycles. +`--report` prints the completion measures #4582 tracks for M3 and M5: the +AppShell-family bridge references and action factories from +`legacyAppShell.files`, the rows of the Conversation README's transitional +capability table, the hook-gate entries that lack a retained-root row, and the +root symbol uses per zone. It only reports. These numbers fall over several +PRs, and the existing no-growth ratchets already stop them rising. + +### Retained root hooks + +Every hook the AppShell hook gate (`scripts/check-app-shell-hooks.mjs`) still +allows has one row per call site below. A row either names why the call stays +at the root (locale, navigation, layout, a cross-region command, or an +application lifecycle) or the R2 module that removes it, never both. The +architecture checker reads the gate's `ALLOWED` literal without running or +editing it, and fails when a gate entry has no row, when its row count differs +from the gate's call-site count, or when a row names a hook the gate no longer +lists. A change that moves a hook out of AppShell therefore edits the gate and +deletes the matching rows together. The checker validates the table's shape, +not the accuracy of each consumer or owner, which stays with review. + + +| Component | Hook | Call site | Consumer | Owner | Allowed capability | Root reason | Removal | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `AppShell` | `useState` | `uiLocalePreference` | `LocaleProvider`; appearance settings | AppShell | the persisted locale preference and its setter | locale | — | +| `AppShell` | `useState` | `uiLocaleOverride` | `LocaleProvider`; E2E locale override | AppShell | a runtime locale override above every region | locale | — | +| `AppShell` | `useSystemUiLocale` | `systemUiLocale` | `resolveUiLocale` for `LocaleProvider` | AppShell | read the OS locale and its changes | locale | — | +| `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control; chat actions reload it | Conversation | read and reload the owner Session's execution boundary | — | M3 | +| `AppShellContent` | `useAppShellBootstrapSubscriptions` | Main change subscriptions | Session, connection, Host-profile and settings refreshers; app-window commands | legacy `app-shell-effects.ts` | subscribe to Main change events and dispatch them to region refreshers | — | M5 | +| `AppShellContent` | `useAppShellHostEffects` | platform tag and titlebar modal sync | ``; titlebar | legacy `app-shell-effects.ts` | read the platform once and observe top-layer modals; the `app.info` read moves behind an adapter in M5 | layout | — | +| `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results | AppShell | mirror the navigation selection into a ref | navigation | — | +| `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | +| `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name; project picker and commands | legacy `use-project-context.ts` | read the owner Session's project and run project commands | — | M5 | +| `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | stop, interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | +| `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller; its transient and interaction commands leave with M3 | navigation | — | +| `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | +| `AppShellContent` | `useEffect` | WorkHub enablement subscription | `workHubEnabled`, `workHubActive` | AppShell | read the client WorkHub setting and follow its changes | — | M5 | +| `AppShellContent` | `useEffect` | onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding snapshot | — | M5 | +| `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | +| `AppShellContent` | `useNewTaskChoice` | new-task permission choice | Composer permission control; chat actions | Conversation (transitional) | the per-draft permission choice | — | M3 | +| `AppShellContent` | `useOnboardingSnapshot` | onboarding snapshot | hero, connection seed, readiness, send outcomes | legacy `use-onboarding-snapshot.ts` | read onboarding state from Main | — | M5 | +| `AppShellContent` | `useSessionNavigationReads` | rail reads | command palette sessions, titlebar parent, `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout | navigation | — | +| `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model and mode controls | Session Settings | an equality-selected overlay read and setting commands | — | M3 | +| `AppShellContent` | `useShellAppearance` | appearance settings | theme, palette, user label, Workbar toggle position, locale update gate | legacy `use-shell-appearance.ts` | read and write client appearance settings | layout | — | +| `AppShellContent` | `useShellChatModel` | Composer model selection | model picker, health notice, new-chat model | Conversation (transitional) | derive model, thinking and executor selection | — | M3 | +| `AppShellContent` | `useShellConnections` | `newTaskConnections` | new-task model choices | legacy `use-shell-connections.ts` | the new-task target's connection snapshot and refresh | application lifecycle | — | +| `AppShellContent` | `useShellConnections` | `defaultHostConnections` | Settings, global commands, model setup | legacy `use-shell-connections.ts` | the default Host's connection snapshot and refresh | application lifecycle | — | +| `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices | legacy `use-shell-connections.ts` | the owner Session Host's connection snapshot and refresh | application lifecycle | — | +| `AppShellContent` | `useShellLiveTurn` | live-turn flags | mode-change gating, model switch, pet activity | Conversation reads | derive streaming and settled flags from the owner Session snapshot | — | M3 | +| `AppShellContent` | `useShellMemoryPill` | memory pill | titlebar memory pill | legacy `use-shell-memory-pill.ts` | read and refresh the owner Session's memory state | layout | — | +| `AppShellContent` | `useShellResume` | resume offer | Composer send slot | Conversation | per-Session resume availability and stop notes | — | M3 | +| `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | E2E fixture command | AppShell | apply test fixtures across navigation, rail, Workbar and appearance | cross-region command | — | +| `AppShellContent` | `useStableActions` | `createAppShellChatActions` | Composer send and interaction responses | legacy `app-shell-chat-actions.ts` | send, enqueue and interaction commands | — | M3 | +| `AppShellContent` | `useStableActions` | `createAppShellTurnActions` | turn footer | legacy `app-shell-turn-actions.ts` | turn footer commands | — | M3 | +| `AppShellContent` | `useStableActions` | `createAppShellRevisionActions` | edit and resend | legacy `app-shell-revision-actions.ts` | revision draft commands | — | M3 | +| `AppShellContent` | `useState` | `newTaskSendPending` | Composer send slot | AppShell | the pending flag of a new-task send | — | M3 | +| `AppShellContent` | `useState` | `newChatPlanModeActive` | Composer Plan toggle; mentions; chat actions | AppShell | the new chat's Plan choice | — | M3 | +| `AppShellContent` | `useState` | `newChatOrchestrationMode` | Composer orchestration control; chat actions | AppShell | the new chat's orchestration choice | — | M3 | +| `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter the transcript bumps when the active Turn completes | cross-region command | — | +| `AppShellContent` | `useState` | `navigationState` | navigation sections; stored navigation | AppShell | the selected section and each hub's module | navigation | — | +| `AppShellContent` | `useState` | `workHubEnabled` | WorkHub dock; Workbar input | AppShell | the client WorkHub setting | — | M5 | +| `AppShellContent` | `useState` | `workHubActive` | WorkHub or Session surface | AppShell | whether the WorkHub surface is shown | navigation | — | +| `AppShellContent` | `useState` | `revisionDraft` | edit and resend; Composer | AppShell | the open revision draft | — | M3 | +| `AppShellContent` | `useTaskSubmissionReadiness` | task readiness | Composer readiness notice | legacy `use-task-submission-readiness.ts` | read readiness for the Composer target | — | M3 | +| `AppShellContent` | `useToast` | `toastApi` | toasts of every legacy action | Astryx toast provider | show toasts | cross-region command | — | +| `AppShellContent` | `useTurnActionRegistry` | pending turn actions | turn footer disabled mask; bootstrap clears | legacy `use-turn-action-registry.ts` | pending action keys per Session | — | M3 | + + +### Transitional feature exports outside Conversation + +Conversation keeps its own table of transitional capabilities in its README. +Outside it, the public exports the root takes that are not plain assembly +components (providers, roots, hosts and overlays mounted through JSX) are the +following. Exports only tests or Storybook read live in each feature's +`testing.ts`, not its public entry. + +| Feature | Export | Root consumer | Kind | Stays because / Removal | +| --- | --- | --- | --- | --- | +| overlays | `OverlaysConsumer` | `app-shell-overlays.tsx` (Settings modal, palette command list) | render-prop projection of overlay state | M5, with the legacy command actions | +| task-entry | `TaskEntryWorkspacePickerConsumer` | Composer region in `app-shell.tsx` | render-prop workspace picker | M3 | +| session-collaboration | `GuestTurnRequests` | Composer region in `app-shell.tsx` | render-prop guest composer projection over the Composer ref | M3 | +| module-hub | `ModuleHubSkillCatalogRevisionBoundary` | Composer mentions provider | render-prop skill catalog revision | M3 | +| module-hub | `ModuleHubScheduledTasksBoundary` | Session rail (`SessionNavigationProvider`) | render-prop scheduled tasks | stays: cross-region projection into navigation | +| module-hub | `createModuleHubCommandPort` | command palette; project selection | command port | stays: cross-region command | +| session-navigation | `createSessionOpenCommand` | open-Session command | command factory | stays: cross-region command | +| session-navigation | `sessionRailLayoutStore` | rail collapse handle; E2E fixture | layout store | stays: layout | +| session-navigation | `useSessionNavigationReads` | see the retained-root table | read hook | stays: navigation | +| session-settings | `useSessionSettingIntent` | Composer model and mode controls | read hook and commands | M3 | + `settings/` holds the settings pages and the `SettingsModal` shell — one page per `SettingsSection` (defined in `@maka/core`); the models/providers page is `ProvidersPanel`. Plus the `provider-*` files and the shared `settings-rows` / `settings-skeleton` / `settings-surface` helpers. ## Styles & tokens diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index f9c6be3657..22b96f0c24 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -48,4 +48,4 @@ export { parseContextWindowInput } from './context-window-input.js'; export { CapabilityEditor } from './provider-capability-editor.js'; export { AddModelDialog, ModelParametersDialog } from './provider-add-model-dialog.js'; -export { ProviderEndpointField, providerRequestUrlPreview } from './provider-endpoint-field.js'; +export { ProviderEndpointField } from './provider-endpoint-field.js'; diff --git a/apps/desktop/src/renderer/features/connection-settings/testing.ts b/apps/desktop/src/renderer/features/connection-settings/testing.ts new file mode 100644 index 0000000000..444398e2da --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/testing.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { providerRequestUrlPreview } from './provider-endpoint-field.js'; diff --git a/apps/desktop/src/renderer/features/overlays/index.ts b/apps/desktop/src/renderer/features/overlays/index.ts index c95213cdc2..d0ca3716e7 100644 --- a/apps/desktop/src/renderer/features/overlays/index.ts +++ b/apps/desktop/src/renderer/features/overlays/index.ts @@ -26,8 +26,3 @@ export { SearchModalHost } from './ui/search-modal-host.js'; export type { OverlaysServices } from './ports.js'; export type { Command } from './model/command.js'; export type { OverlaysShellProjection } from './model/overlays-projection.js'; -export { - createAgentGraphPanelModel, - reduceAgentGraphPanelModel, - shouldShowAgentGraphPanel, -} from './model/agent-graph-panel-model.js'; diff --git a/apps/desktop/src/renderer/features/overlays/testing.ts b/apps/desktop/src/renderer/features/overlays/testing.ts index e3928abfcd..6a9e628210 100644 --- a/apps/desktop/src/renderer/features/overlays/testing.ts +++ b/apps/desktop/src/renderer/features/overlays/testing.ts @@ -32,6 +32,11 @@ export { } from './model/settings-modal-state.js'; export type { OverlaysShellProjection } from './model/overlays-projection.js'; export type { OverlaysServices } from './ports.js'; +export { + createAgentGraphPanelModel, + reduceAgentGraphPanelModel, + shouldShowAgentGraphPanel, +} from './model/agent-graph-panel-model.js'; export function createFakeOverlaysServices( overrides: Partial = {}, diff --git a/apps/desktop/src/renderer/features/session-navigation/index.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts index 830e105ef1..6638310326 100644 --- a/apps/desktop/src/renderer/features/session-navigation/index.ts +++ b/apps/desktop/src/renderer/features/session-navigation/index.ts @@ -19,7 +19,6 @@ export { SessionNavigationServicesProvider } from './services-context.js'; export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; -export { SessionHistoryNavigation } from './ui/session-history-navigation.js'; export { ArchivedTaskScope, type ArchivedTaskScopeView, diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index a0422e555d..dd21fd54c9 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -17,7 +17,6 @@ * under the License. */ -export { workHubLinkedWork } from './model/linked-work.js'; export type { WorkHubServices, WorkHubTranscriptSnapshot } from './ports.js'; export { WorkHubServicesProvider } from './services.js'; export { WorkHubRoot } from './ui/workhub-root.js'; @@ -25,8 +24,4 @@ export { WorkHubDock } from './ui/workhub-dock.js'; export { WorkHubSurfaceSwitch } from './ui/workhub-surface-switch.js'; export { WorkHubControlOverlay } from './ui/control-overlay.js'; export { WorkHubMainNavigation } from './ui/main-navigation.js'; -export { - startWorkHubCoordinationLifecycle, - WorkHubModelConfigurationRequiredError, - type WorkHubCoordinationHostChange, -} from '../../application/contracts/workhub-workspace/coordination-lifecycle.js'; +export { WorkHubModelConfigurationRequiredError } from '../../application/contracts/workhub-workspace/coordination-lifecycle.js'; diff --git a/apps/desktop/src/renderer/features/workhub/testing.ts b/apps/desktop/src/renderer/features/workhub/testing.ts index f9bcb6b5f8..4d01f6de8e 100644 --- a/apps/desktop/src/renderer/features/workhub/testing.ts +++ b/apps/desktop/src/renderer/features/workhub/testing.ts @@ -25,3 +25,4 @@ export { WorkHubConversation } from './ui/workhub-conversation.js'; export { WorkHubHighlightContext } from './ui/workhub-work-identity.js'; export { workspaceNameFromCwd } from './model/workspace-name.js'; export { allocateWorkHubHues } from './model/identity-colors.js'; +export { workHubLinkedWork } from './model/linked-work.js'; diff --git a/apps/desktop/stories/session-history-navigation.stories.tsx b/apps/desktop/stories/session-history-navigation.stories.tsx index 8caa6035a8..c4a0ac6040 100644 --- a/apps/desktop/stories/session-history-navigation.stories.tsx +++ b/apps/desktop/stories/session-history-navigation.stories.tsx @@ -22,7 +22,7 @@ import { expect, userEvent, waitFor, within } from 'storybook/test'; import { useState, useSyncExternalStore } from 'react'; import { ChatSurfaceLayout, MarkdownBody } from '@maka/ui'; import { createSessionCatalogController } from '../src/renderer/application/contracts/session-catalog/session-catalog-state.js'; -import { SessionHistoryNavigation, createSessionOpenCommand } from '../src/renderer/features/session-navigation/index.js'; +import { SessionHistoryNavigation, createSessionOpenCommand } from '../src/renderer/features/session-navigation/testing.js'; import type { DesktopSessionSummary } from '../src/shared/desktop-session-projection.js'; const meta = { title: 'Primitives/Session History Navigation' } satisfies Meta; From 4dbfc2c199ef0de45c50d864fe2218140a4951b7 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 22:13:07 +0800 Subject: [PATCH 2/2] chore(desktop): compare root symbol growth by binding and bind table rows to calls Review follow-ups on the R2 enforcement rules. Root symbol admission now compares bindings, not export names. Each public export resolves to its declaring module and local name, so re-exporting an existing binding under a new alias no longer counts as a new public export. The AppShell closure stays outside the root zones, but its feature-entry uses are now visible: --report lists them per file, and a --base run prints each one a change adds, reported and never ratcheted. Where a hook-gate entry has several call sites, each retained-root row must name an identifier of exactly one of those calls in app-shell.tsx, and no two rows may name the same call. The two useEffect rows now name setWorkHubEnabled and defaultHostConnections. Generated-by: Claude Opus 5.5 --- .../scripts/check-renderer-architecture.mjs | 239 +++++++++++++++++- .../check-renderer-architecture.test.mjs | 129 +++++++++- apps/desktop/src/renderer/README.md | 26 +- 3 files changed, 367 insertions(+), 27 deletions(-) diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 6a460aff7d..29252357c8 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2204,6 +2204,7 @@ function createModuleExportGraph(desktopRoot, knownAnalyses = new Map()) { const analyses = new Map(knownAnalyses); const surfaces = new Map(); const reaches = new Map(); + const bindings = new Map(); const relativePath = (file) => normalizePath(relative(desktopRoot, file)); const target = (importer, source) => resolveSourceFile(desktopRoot, importer, source, index); const featureEntryOf = (file) => { @@ -2331,7 +2332,55 @@ function createModuleExportGraph(desktopRoot, knownAnalyses = new Map()) { return undefined; } - return { analysisOf, featureEntryOf, reachesFeature, resolveImport, surfaceOf, target }; + /** + * Where the runtime binding exported as `name` is declared: a + * Desktop-relative `path#local`, or `specifier#name` for a package + * re-export. Every alias of one binding shares this key. + */ + function bindingOf(file, name, active = new Set()) { + const key = `${normalizePath(file)}#${name}`; + if (bindings.has(key)) return bindings.get(key); + if (active.has(key)) return undefined; + active.add(key); + const result = declarationOf(file, name, active); + active.delete(key); + bindings.set(key, result); + return result; + } + + function declarationOf(file, name, active) { + const analysis = analysisOf(file); + if (!analysis) return undefined; + const follow = (source, imported) => { + const next = target(file, source); + if (next) return bindingOf(next, imported, active); + return isBarePackageSpecifier(source) ? `${source}#${imported}` : undefined; + }; + const namespaceOf = (source) => { + const next = target(file, source); + return `${next ? relativePath(next) : source}#*`; + }; + for (const entry of analysis.moduleReexports) { + if (entry.kind === 'all' || entry.exported !== name) continue; + return entry.kind === 'namespace' ? namespaceOf(entry.source) : follow(entry.source, entry.imported); + } + for (const entry of analysis.moduleLocalExports) { + if (entry.exported !== name) continue; + const binding = analysis.moduleImports.find((item) => item.local === entry.local); + if (!binding) return `${relativePath(file)}#${entry.local}`; + if (binding.kind === 'namespace') return namespaceOf(binding.source); + return follow(binding.source, binding.kind === 'default' ? 'default' : binding.imported); + } + if (name === 'default') return analysis.hasDefaultExport ? `${relativePath(file)}#default` : undefined; + for (const entry of analysis.moduleReexports) { + if (entry.kind !== 'all') continue; + const next = target(file, entry.source); + if (next && surfaceOf(next).has(name)) return bindingOf(next, name, active); + } + return undefined; + } + + return { analysisOf, bindingOf, featureEntryOf, reachesFeature, resolveImport, surfaceOf, target }; } function rootSymbolKey(entry, zone, symbol) { @@ -2354,10 +2403,14 @@ function flattenRootSymbolUses(record) { * module load — is a violation instead of a record. */ function collectRootSymbolUses(desktopRoot, config, knownAnalyses) { + return collectFeatureEntryUses(desktopRoot, rootSymbolCallers(desktopRoot, config), knownAnalyses); +} + +function collectFeatureEntryUses(desktopRoot, callers, knownAnalyses) { const graph = createModuleExportGraph(desktopRoot, knownAnalyses); const uses = new Map(); const violations = []; - for (const { path, zone } of rootSymbolCallers(desktopRoot, config)) { + for (const { path, zone } of callers) { const file = resolve(desktopRoot, path); const analysis = graph.analysisOf(file); if (!analysis) continue; @@ -2417,6 +2470,26 @@ function collectRootSymbolUses(desktopRoot, config, knownAnalyses) { return { record: sortedRecord, uses, violations }; } +/** + * Feature public symbols the legacy files reachable from AppShell take. They + * are not a root zone: legacy-to-entry edges stay free, so these are reported, + * never ratcheted. + */ +function collectAppShellClosureUses(desktopRoot) { + const callers = collectRootDependencyClosure(desktopRoot, legacyAppShellFiles(desktopRoot), [], 'AppShell') + .filter((path) => path.startsWith('src/renderer/') && !isTestConsumer(path)) + .map((path) => ({ path, zone: 'appShellClosure' })); + const { uses } = collectFeatureEntryUses(desktopRoot, callers); + return [...uses.values()] + .flatMap((use) => [...use.callers].map((path) => ({ path, entry: use.entry, symbol: use.symbol }))) + .sort((left, right) => + left.path.localeCompare(right.path) || left.entry.localeCompare(right.entry) || left.symbol.localeCompare(right.symbol), + ); +} + +const closureUseKey = (use) => `${use.path}#${use.entry}#${use.symbol}`; +const featureNameOf = (entry) => entry.split('/')[3]; + function validateRootSymbolUses({ desktopRoot, config, sourceAnalyses, violations }) { const knownAnalyses = new Map( [...sourceAnalyses.values()].map(({ analysis, file }) => [normalizePath(file), analysis]), @@ -2437,26 +2510,38 @@ function validateRootSymbolUses({ desktopRoot, config, sourceAnalyses, violation } } -/** Each feature's public runtime export names, keyed by feature directory. */ +/** + * Each feature's public runtime exports, keyed by feature directory, with the + * binding each export name resolves to. + */ export function collectFeatureEntrySurfaces(desktopRoot) { const graph = createModuleExportGraph(desktopRoot); const surfaces = new Map(); for (const file of sourceFiles(resolve(desktopRoot, 'src/renderer/features'))) { const entry = graph.featureEntryOf(file); - if (entry) surfaces.set(dirname(entry), graph.surfaceOf(file)); + if (!entry) continue; + const names = [...graph.surfaceOf(file)].sort(); + surfaces.set(dirname(entry), new Map(names.map((name) => [name, graph.bindingOf(file, name)]))); } return surfaces; } /** * Root symbol uses may only shrink against the base, with two exceptions. A - * symbol the same change adds to the entry's public surface may be taken: + * binding the same change adds to the entry's public surface may be taken: * that is an explicit public-contract change rather than a new grip on an - * existing capability. And a use may move one way, out of the legacy AppShell - * into composition or bootstrap, when AppShell gives it up in the same change. + * existing capability. Bindings are compared, not names, so a new alias of an + * export the base entry already had is not new. And a use may move one way, + * out of the legacy AppShell into composition or bootstrap, when AppShell + * gives it up in the same change. */ -function compareRootSymbolUses(config, baseConfig, baseEntrySurfaces) { +function compareRootSymbolUses(config, baseConfig, baseEntrySurfaces, desktopRoot) { const result = { admitted: [], violations: [] }; + let currentSurfaces; + const currentBinding = (use) => { + currentSurfaces ??= desktopRoot ? collectFeatureEntrySurfaces(desktopRoot) : new Map(); + return currentSurfaces.get(dirname(use.entry))?.get(use.symbol); + }; if (!baseConfig || baseConfig.rootSymbolUses === undefined) return result; if (config.rootSymbolUses === undefined) { result.violations.push('rootSymbolUses: the root public symbol record cannot be removed'); @@ -2487,7 +2572,17 @@ function compareRootSymbolUses(config, baseConfig, baseEntrySurfaces) { `${use.entry}: ${use.zone} root newly uses existing public export ${use.symbol}; only an export the same change adds may join rootSymbolUses`, ); } else { - result.admitted.push(`${use.entry}: ${use.zone} ${use.symbol} (new public export)`); + const binding = currentBinding(use); + const baseName = binding === undefined + ? undefined + : [...(baseEntrySurfaces.get(dirname(use.entry)) ?? new Map())].find(([, baseBinding]) => baseBinding === binding)?.[0]; + if (baseName) { + result.violations.push( + `${use.entry}: ${use.zone} root newly uses ${use.symbol}, an alias of existing public export ${baseName}; only a binding the same change adds may join rootSymbolUses`, + ); + } else { + result.admitted.push(`${use.entry}: ${use.zone} ${use.symbol} (new public export)`); + } } } return result; @@ -2574,7 +2669,80 @@ function readRetainedRootTable(desktopRoot) { return markdownTable(lines.slice(0, end), start + 1) ?? { header: undefined, rows: [] }; } +const RETAINED_ROOT_SOURCE = 'src/renderer/app-shell.tsx'; +const FUNCTION_NODES = new Set([ + 'ArrowFunctionExpression', + 'ClassMethod', + 'ClassPrivateMethod', + 'FunctionDeclaration', + 'FunctionExpression', + 'ObjectMethod', +]); + +/** + * Each gate hook's calls in the render bodies of the AppShell components, + * with the identifiers that name a call: the bindings it declares and every + * identifier in its arguments. Undefined when the source cannot be read. + */ +function appShellHookCalls(desktopRoot, gate) { + const path = resolve(desktopRoot, RETAINED_ROOT_SOURCE); + if (!existsSync(path)) return undefined; + let program; + try { + program = parse(readFileSync(path, 'utf8'), { sourceType: 'module', plugins: ['jsx', 'typescript'] }).program; + } catch { + return undefined; + } + const parents = buildParentMap(program); + const calls = new Map(); + for (const statement of program.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type !== 'FunctionDeclaration' || !declaration.id) continue; + const component = declaration.id.name; + const visit = (node) => { + if (node !== declaration && FUNCTION_NODES.has(node.type)) return; + if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') { + const callee = unwrapExpression(node.callee); + const hook = callee?.type === 'Identifier' ? callee.name : memberPropertyName(callee); + const key = `${component}.${hook}`; + if (gate.has(key)) { + if (!calls.has(key)) calls.set(key, []); + calls.get(key).push(callSiteNames(node, parents)); + } + } + for (const child of childNodes(node)) visit(child); + }; + visit(declaration); + } + return calls; +} + +function callSiteNames(call, parents) { + const names = new Set(); + let owner = parents.get(call); + while (owner && ['ParenthesizedExpression', 'TSAsExpression', 'TSNonNullExpression', 'TSSatisfiesExpression'].includes(owner.type)) { + owner = parents.get(owner); + } + if (owner?.type === 'VariableDeclarator') { + for (const name of bindingNames(owner.id)) names.add(name); + for (const property of owner.id.type === 'ObjectPattern' ? owner.id.properties : []) { + const key = property.type === 'ObjectProperty' && !property.computed ? memberName(property.key) : undefined; + if (key) names.add(key); + } + } + const collect = (node) => { + if (node.type === 'Identifier') names.add(node.name); + for (const child of childNodes(node)) collect(child); + }; + for (const argument of call.arguments) collect(argument); + return names; +} + const plainCell = (cell) => cell.replace(/^`([^`]*)`$/u, '$1'); +const callSiteTokens = (cell) => { + const quoted = [...cell.matchAll(/`([A-Za-z_$][\w$]*)`/gu)].map((match) => match[1]); + return quoted.length > 0 ? quoted : [cell].filter((value) => /^[A-Za-z_$][\w$]*$/u.test(value)); +}; const emptyCell = (cell) => cell === '' || cell === '—' || cell === '-'; /** @@ -2601,6 +2769,7 @@ function validateRetainedRootTable({ desktopRoot, hookGatePath, violations }) { } const rows = []; const callSites = new Map(); + const labels = new Map(); for (const { cells, line } of table.rows) { const at = `${RETAINED_ROOT_DOC}:${line}`; if (cells?.length !== RETAINED_ROOT_COLUMNS.length) { @@ -2630,6 +2799,8 @@ function validateRetainedRootTable({ desktopRoot, hookGatePath, violations }) { if (!callSites.has(key)) callSites.set(key, new Set()); if (callSites.get(key).has(callSite)) violations.push(`${at}: duplicate retained-root call site ${callSite} for ${key}`); callSites.get(key).add(callSite); + if (!labels.has(key)) labels.set(key, []); + labels.get(key).push({ at, callSite, tokens: callSiteTokens(cells[2]) }); rows.push({ key, reason: hasReason ? reason : undefined, removal: hasRemoval ? removal : undefined }); } for (const [key, entry] of gate) { @@ -2640,6 +2811,29 @@ function validateRetainedRootTable({ desktopRoot, hookGatePath, violations }) { violations.push(`${key}: the AppShell hook gate counts ${entry.count} call sites, the retained-root table has ${recorded} rows`); } } + // Where a hook has several call sites, each row must name one of them, so a + // row cannot drift onto another call. The gate owns the counts; when the + // source and the gate disagree the gate already fails, so the entry is left + // to it here. + const hookCalls = appShellHookCalls(desktopRoot, gate); + for (const [key, entry] of hookCalls ? gate : []) { + const calls = hookCalls.get(key) ?? []; + if (entry.count < 2 || calls.length !== entry.count) continue; + const named = new Map(); + for (const label of labels.get(key) ?? []) { + const matches = calls.flatMap((names, index) => (label.tokens.some((token) => names.has(token)) ? [index] : [])); + if (matches.length !== 1) { + violations.push( + `${label.at}: retained-root call site ${label.callSite} must name an identifier of exactly one ${key} call in ${RETAINED_ROOT_SOURCE}; it matches ${matches.length}`, + ); + continue; + } + named.set(matches[0], [...(named.get(matches[0]) ?? []), label.at]); + } + for (const ats of named.values()) { + if (ats.length > 1) violations.push(`${key}: retained-root rows ${ats.join(', ')} name the same call site`); + } + } return { gate, rows }; } @@ -2699,6 +2893,15 @@ export function rendererArchitectureReport({ } else { lines.push('AppShell hook gate: not found'); } + const closureUses = collectAppShellClosureUses(desktopRoot); + const closureFiles = [...new Set(closureUses.map((use) => use.path))]; + lines.push( + `AppShell closure feature-entry uses (reported, not ratcheted): ${closureUses.length} in ${closureFiles.length} files`, + ...closureFiles.map((path) => ` ${path}: ${closureUses + .filter((use) => use.path === path) + .map((use) => `${featureNameOf(use.entry)}.${use.symbol}`) + .join(', ')}`), + ); const zones = flattenRootSymbolUses(config.rootSymbolUses).reduce((counts, use) => { counts[use.zone] = (counts[use.zone] ?? 0) + 1; return counts; @@ -3859,7 +4062,7 @@ export function generateArchitectureConfig(desktopRoot, config) { function validateMonotonicDebt(config, baseConfig, desktopRoot, violations, baseEntrySurfaces) { if (!baseConfig) return; - violations.push(...compareRootSymbolUses(config, baseConfig, baseEntrySurfaces).violations); + violations.push(...compareRootSymbolUses(config, baseConfig, baseEntrySurfaces, desktopRoot).violations); const currentPrivateModules = new Set(featurePrivateModulesOf(config)); for (const path of featurePrivateModulesOf(baseConfig)) { if (!currentPrivateModules.has(path)) { @@ -4312,6 +4515,7 @@ async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false baseConfig = baseTreeFallback({ base, baseCommittedConfig, error, strictBase }); } const baseEntrySurfaces = collectFeatureEntrySurfaces(baseDesktopRoot); + const baseClosureUses = collectAppShellClosureUses(baseDesktopRoot); const crossCheckViolations = await crossCheckUnderBaseChecker({ base, baseCommittedConfig, @@ -4321,7 +4525,7 @@ async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false repoRoot, strictBase, }); - return { baseConfig, baseEntrySurfaces, crossCheckViolations, introducedLedger: false }; + return { baseConfig, baseClosureUses, baseEntrySurfaces, crossCheckViolations, introducedLedger: false }; } finally { baseTree.remove(); } @@ -4384,14 +4588,23 @@ async function runCli() { process.exitCode = 1; return; } - const { baseConfig, baseEntrySurfaces, crossCheckViolations, introducedLedger } = loadedBase; + const { baseConfig, baseClosureUses, baseEntrySurfaces, crossCheckViolations, introducedLedger } = loadedBase; const violations = [ ...checkRendererArchitecture({ baseConfig, baseEntrySurfaces, config, desktopRoot }), ...crossCheckViolations, ]; - for (const use of compareRootSymbolUses(config, baseConfig, baseEntrySurfaces).admitted) { + for (const use of compareRootSymbolUses(config, baseConfig, baseEntrySurfaces, desktopRoot).admitted) { console.log(`Renderer architecture check: root symbol use admitted: ${use}`); } + if (baseClosureUses) { + const baseKeys = new Set(baseClosureUses.map(closureUseKey)); + for (const use of collectAppShellClosureUses(desktopRoot)) { + if (baseKeys.has(closureUseKey(use))) continue; + console.log( + `Renderer architecture check: AppShell closure newly uses ${use.symbol} from ${use.entry} in ${use.path} (reported, not ratcheted)`, + ); + } + } if (report) { console.log('Renderer architecture report:'); for (const line of rendererArchitectureReport({ desktopRoot, config })) console.log(` ${line}`); diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 341bac5235..ea05128a17 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -3477,6 +3477,19 @@ describe('renderer architecture base-tree derivation (git fixtures)', () => { /^- src\/renderer\/features\/alpha\/index\.ts: composition root newly uses existing public export AlphaPanel;/mu, ); + await fixture.writeFiles({ + [entry]: "export { AlphaPanel, AlphaProvider } from './ui/alpha.js';\nexport { AlphaPanel as AlphaPanelAlias } from './ui/alpha.js';\n", + [composition]: "import { AlphaPanelAlias, AlphaProvider } from '../features/alpha';\nexport const DesktopApplication = () => ;\n", + }); + await fixture.writeLedger(); + fixture.commit('take an existing export through a new alias'); + const aliased = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(aliased.status, 0); + assert.match( + aliased.stderr, + /^- src\/renderer\/features\/alpha\/index\.ts: composition root newly uses AlphaPanelAlias, an alias of existing public export AlphaPanel;/mu, + ); + await fixture.writeFiles({ [entry]: "export { AlphaInspector, AlphaPanel, AlphaProvider } from './ui/alpha.js';\n", [composition]: "import { AlphaInspector, AlphaProvider } from '../features/alpha';\nexport const DesktopApplication = () => ;\n", @@ -3494,6 +3507,39 @@ describe('renderer architecture base-tree derivation (git fixtures)', () => { }); }); + it('reports, without ratcheting, a feature symbol newly taken in the AppShell closure', async () => { + await withGitFixture(async (fixture) => { + const appShell = 'src/renderer/app-shell.ts'; + const helper = 'src/renderer/legacy-session-helper.ts'; + const seed = architectureConfig({ + rootDebt: { [RENDERER_ENTRY_PATH]: emptyDebt() }, + ownership: [ + { capability: 'fixture-root', targetZone: 'bootstrap', legacyPaths: [RENDERER_ENTRY_PATH] }, + { capability: 'fixture-app-shell', targetZone: 'shell', legacyPaths: [appShell] }, + ], + }); + await fixture.writeFiles({ + 'src/renderer/features/alpha/index.ts': "export function alphaLabel() { return 'alpha'; }\n", + [appShell]: "import { legacySessionHelper } from './legacy-session-helper.js';\nexport const AppShell = legacySessionHelper;\n", + [helper]: 'export const legacySessionHelper = 1;\n', + }); + await fixture.writeLedger(seed); + const base = fixture.commit('base'); + await fixture.writeFiles({ + [helper]: "import { alphaLabel } from './features/alpha/index.js';\nexport const legacySessionHelper = alphaLabel();\n", + }); + await fixture.writeLedger(seed); + fixture.commit('the closure takes a feature symbol'); + + const result = fixture.runChecker(['--base', base, '--strict-base']); + assertPassed(result, base, 'closure growth is only reported'); + assert.match( + result.stdout, + /AppShell closure newly uses alphaLabel from src\/renderer\/features\/alpha\/index\.ts in src\/renderer\/legacy-session-helper\.ts \(reported, not ratcheted\)/u, + ); + }); + }); + it('rejects a new unclassified legacy renderer file relative to the derived base tree', async () => { await withGitFixture(async (fixture) => { await fixture.writeLedger(); @@ -3965,10 +4011,12 @@ describe('root public symbol uses', () => { await withDesktopFixture(alphaFeature, (desktopRoot) => { const surfaces = collectFeatureEntrySurfaces(desktopRoot); assert.deepEqual([...surfaces.keys()], ['src/renderer/features/alpha']); - assert.deepEqual( - [...surfaces.get('src/renderer/features/alpha')].sort(), - ['AlphaPanel', 'AlphaProvider', 'useAlphaReads', 'useAlphaState'], - ); + assert.deepEqual(Object.fromEntries(surfaces.get('src/renderer/features/alpha')), { + AlphaPanel: 'src/renderer/features/alpha/ui/alpha-provider.tsx#AlphaPanel', + AlphaProvider: 'src/renderer/features/alpha/ui/alpha-provider.tsx#AlphaProvider', + useAlphaReads: 'src/renderer/features/alpha/controller/alpha-reads.ts#useAlphaReads', + useAlphaState: 'src/renderer/features/alpha/controller/alpha-reads.ts#useAlphaState', + }); }); }); @@ -3982,7 +4030,10 @@ describe('root public symbol uses', () => { }, (desktopRoot) => { const config = architectureConfig({ rootSymbolUses: { [ENTRY]: { composition: ['AlphaPanel', 'AlphaProvider'] } } }); const baseConfig = architectureConfig({ rootSymbolUses: { [ENTRY]: { composition: ['AlphaProvider'] } } }); - const surfacesWith = (...names) => new Map([['src/renderer/features/alpha', new Set(names)]]); + const surfacesWith = (...names) => new Map([[ + 'src/renderer/features/alpha', + new Map([...collectFeatureEntrySurfaces(desktopRoot).get('src/renderer/features/alpha')].filter(([name]) => names.includes(name))), + ]]); assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces: surfacesWith('AlphaProvider') }), []); assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig, baseEntrySurfaces: new Map() }), []); @@ -4002,6 +4053,34 @@ describe('root public symbol uses', () => { }); }); + for (const [name, aliasExport] of [ + ['a re-exported alias', "export { AlphaPanel as AlphaPanelAlias } from './ui/alpha-provider.js';"], + ['an imported and re-exported alias', "import { AlphaPanel as panel } from './ui/alpha-provider.js';\nexport { panel as AlphaPanelAlias };"], + ]) { + it(`does not admit ${name} of an existing export as a new export`, async () => { + await withDesktopFixture({ + ...alphaFeature, + [ENTRY]: `${alphaFeature[ENTRY]}\n${aliasExport}\nexport { AlphaInspector } from './ui/alpha-inspector.js';\n`, + 'src/renderer/features/alpha/ui/alpha-inspector.tsx': 'export function AlphaInspector() { return null; }\n', + [COMPOSITION]: ` + import { AlphaInspector, AlphaPanelAlias } from '../features/alpha'; + export function DesktopApplication() { return <>; } + `, + }, (desktopRoot) => { + const current = collectFeatureEntrySurfaces(desktopRoot).get('src/renderer/features/alpha'); + assert.equal(current.get('AlphaPanelAlias'), current.get('AlphaPanel')); + const baseEntrySurfaces = new Map([[ + 'src/renderer/features/alpha', + new Map([...current].filter(([exported]) => !['AlphaInspector', 'AlphaPanelAlias'].includes(exported))), + ]]); + const config = architectureConfig({ rootSymbolUses: { [ENTRY]: { composition: ['AlphaInspector', 'AlphaPanelAlias'] } } }); + assert.deepEqual(rootViolations(desktopRoot, config, { baseConfig: architectureConfig({ rootSymbolUses: {} }), baseEntrySurfaces }), [ + `${ENTRY}: composition root newly uses AlphaPanelAlias, an alias of existing public export AlphaPanel; only a binding the same change adds may join rootSymbolUses`, + ]); + }); + }); + } + it('admits a use moving out of appShell, but not a copy or the reverse move', async () => { await withDesktopFixture({ ...alphaFeature, @@ -4010,7 +4089,7 @@ describe('root public symbol uses', () => { export function DesktopApplication() { return ; } `, }, (desktopRoot) => { - const baseEntrySurfaces = new Map([['src/renderer/features/alpha', new Set(['AlphaPanel', 'AlphaProvider'])]]); + const baseEntrySurfaces = collectFeatureEntrySurfaces(desktopRoot); const ratchet = (current, base) => rootViolations(desktopRoot, architectureConfig({ rootSymbolUses: current }), { baseConfig: architectureConfig({ rootSymbolUses: base }), @@ -4139,6 +4218,39 @@ describe('retained root hook table', () => { }); } + describe('rows of a hook with several call sites', () => { + const appShell = { + 'src/renderer/app-shell.tsx': ` + import { useState } from 'react'; + import { useToast } from '@astryxdesign/core/Toast'; + export function AppShell() { + const [uiLocalePreference] = useState('auto'); + const [uiLocaleOverride] = useState(null); + return ; + } + function AppShellContent(_props: unknown) { + const toastApi = useToast(); + return toastApi ? null : null; + } + `, + }; + const stateRow = (callSite) => `| \`AppShell\` | \`useState\` | ${callSite} | consumer | owner | capability | locale | — |`; + + it('binds each row to one call in app-shell.tsx', async () => { + await withTable({ ...appShell, [README]: readme(complete) }, (violations) => assert.deepEqual(violations, [])); + }); + + for (const [name, rows, pattern] of [ + ['a call site that names no call', [stateRow('`uiLocalePreference`'), stateRow('`uiLocaleBogus`')], /retained-root call site uiLocaleBogus must name an identifier of exactly one AppShell\.useState call in src\/renderer\/app-shell\.tsx; it matches 0/u], + ['a call site that names two calls', [stateRow('`uiLocalePreference`'), stateRow('`uiLocalePreference` or `uiLocaleOverride`')], /must name an identifier of exactly one AppShell\.useState call .*; it matches 2/u], + ['two rows on one call', [stateRow('`uiLocalePreference`'), stateRow('`uiLocalePreference`, again')], /^AppShell\.useState: retained-root rows src\/renderer\/README\.md:\d+, src\/renderer\/README\.md:\d+ name the same call site$/u], + ]) { + it(`rejects ${name}`, async () => { + await withTable({ ...appShell, [README]: readme([...rows, complete[2]]) }, (violations) => assertHasViolation(violations, pattern)); + }); + } + }); + it('reads the gate inventory only as a static literal', async () => { await withDesktopFixture({ [GATE]: gateSource('buildInventory()'), [README]: readme(complete) }, (desktopRoot) => { assertHasViolation( @@ -4166,6 +4278,9 @@ describe('retained root hook table', () => { '| two | AppShell | M3 |', '', ].join('\n'), + 'src/renderer/app-shell.tsx': "import { LegacyPanel } from './legacy-panel';\nexport const AppShell = LegacyPanel;\n", + 'src/renderer/legacy-panel.tsx': "import { AlphaPanel } from './features/alpha';\nexport const LegacyPanel = AlphaPanel;\n", + 'src/renderer/features/alpha/index.ts': 'export function AlphaPanel() { return null; }\n', }, (desktopRoot) => { const config = architectureConfig({ legacyFiles: { @@ -4187,6 +4302,8 @@ describe('retained root hook table', () => { 'AppShell hook gate: 2 entries / 3 call sites; entries without a retained-root row: 0', ' retained at the root: locale 2', ' scheduled for removal: M5 1', + 'AppShell closure feature-entry uses (reported, not ratcheted): 1 in 1 files', + ' src/renderer/legacy-panel.tsx: alpha.AlphaPanel', 'Root symbol uses: appShell 2, bootstrap 0, composition 1', ]); }); diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 37a1e3dac9..c74c8d541c 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -149,8 +149,10 @@ cannot be attributed. Type-only imports and type positions are not recorded. Deep feature imports stay with the zone rules. `--write` regenerates the record and the tree must match it exactly. Against the base the record may only shrink, with two exceptions the CLI lists as it admits them. A new root use -passes when the same change adds that export to the entry's public surface, -measured on the materialized base tree. A use may also move one way out of +passes when the same change adds that binding to the entry's public surface, +measured on the materialized base tree. Bindings are compared by their +declaring module and local name, so a new alias of an export the base entry +already had is not new. A use may also move one way out of `appShell` into `composition` or `bootstrap` when `appShell` gives it up in the same change; a copy or the reverse move fails. Taking an export the base entry already had fails, and so does removing the record. This is a module-graph rule: @@ -247,7 +249,11 @@ AppShell-family bridge references and action factories from `legacyAppShell.files`, the rows of the Conversation README's transitional capability table, the hook-gate entries that lack a retained-root row, and the root symbol uses per zone. It only reports. These numbers fall over several -PRs, and the existing no-growth ratchets already stop them rising. +PRs, and the existing no-growth ratchets already stop them rising. It also +lists the feature public symbols that legacy files in the AppShell closure +take, and a `--base` run prints each one a change adds there. Those files are +not a root zone and their entry edges stay free, so this is reported, never +ratcheted. ### Retained root hooks @@ -258,9 +264,13 @@ application lifecycle) or the R2 module that removes it, never both. The architecture checker reads the gate's `ALLOWED` literal without running or editing it, and fails when a gate entry has no row, when its row count differs from the gate's call-site count, or when a row names a hook the gate no longer -lists. A change that moves a hook out of AppShell therefore edits the gate and -deletes the matching rows together. The checker validates the table's shape, -not the accuracy of each consumer or owner, which stays with review. +lists. Where an entry has several call sites, each row's call site must name, +in backticks, an identifier of exactly one of those calls in `app-shell.tsx` +(a binding it declares or an identifier in its arguments), and no two rows may +name the same call. A change that moves a hook out of AppShell therefore edits +the gate and deletes the matching rows together. The checker validates the +table's shape and call-site binding, not the accuracy of each consumer, owner +or reason, which stays with review. | Component | Hook | Call site | Consumer | Owner | Allowed capability | Root reason | Removal | @@ -277,8 +287,8 @@ not the accuracy of each consumer or owner, which stays with review. | `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | stop, interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller; its transient and interaction commands leave with M3 | navigation | — | | `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | -| `AppShellContent` | `useEffect` | WorkHub enablement subscription | `workHubEnabled`, `workHubActive` | AppShell | read the client WorkHub setting and follow its changes | — | M5 | -| `AppShellContent` | `useEffect` | onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding snapshot | — | M5 | +| `AppShellContent` | `useEffect` | `setWorkHubEnabled`: WorkHub enablement subscription | `workHubEnabled`, `workHubActive` | AppShell | read the client WorkHub setting and follow its changes | — | M5 | +| `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding snapshot | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | | `AppShellContent` | `useNewTaskChoice` | new-task permission choice | Composer permission control; chat actions | Conversation (transitional) | the per-draft permission choice | — | M3 | | `AppShellContent` | `useOnboardingSnapshot` | onboarding snapshot | hero, connection seed, readiness, send outcomes | legacy `use-onboarding-snapshot.ts` | read onboarding state from Main | — | M5 |