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..29252357c8 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,747 @@ 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 bindings = 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; + } + + /** + * 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) { + 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) { + 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 callers) { + 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 }; +} + +/** + * 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]), + ); + 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 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) 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 + * 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. 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, 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'); + 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 { + 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; +} + +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 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 === '-'; + +/** + * 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(); + const labels = 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); + 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) { + 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`); + } + } + // 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 }; +} + +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 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; + }, 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 +3166,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 +4018,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 +4047,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 +4060,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, desktopRoot).violations); const currentPrivateModules = new Set(featurePrivateModulesOf(config)); for (const path of featurePrivateModulesOf(baseConfig)) { if (!currentPrivateModules.has(path)) { @@ -3391,6 +4207,8 @@ export function checkRendererArchitecture({ desktopRoot, config, baseConfig, + baseEntrySurfaces, + appShellHookGatePath, enforceRendererEntryContract = true, } = {}) { const resolvedDesktopRoot = resolve(desktopRoot ?? fileURLToPath(new URL('..', import.meta.url))); @@ -3405,7 +4223,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 +4268,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 +4366,7 @@ async function crossCheckUnderBaseChecker({ base, baseCommittedConfig, baseDesktopRoot, + baseEntrySurfaces, desktopRoot, repoRoot, strictBase, @@ -3604,7 +4434,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 +4498,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 +4514,28 @@ async function loadBaseConfig(repoRoot, desktopRoot, base, { strictBase = false } catch (error) { baseConfig = baseTreeFallback({ base, baseCommittedConfig, error, strictBase }); } + const baseEntrySurfaces = collectFeatureEntrySurfaces(baseDesktopRoot); + const baseClosureUses = collectAppShellClosureUses(baseDesktopRoot); const crossCheckViolations = await crossCheckUnderBaseChecker({ base, baseCommittedConfig, baseDesktopRoot, + baseEntrySurfaces, desktopRoot, repoRoot, strictBase, }); - return { baseConfig, crossCheckViolations, introducedLedger: false }; + return { baseConfig, baseClosureUses, 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 +4544,10 @@ function parseCliArguments(args) { write = true; continue; } + if (argument === '--report' && !report) { + report = true; + continue; + } if (argument === '--strict-base' && !strictBase) { strictBase = true; continue; @@ -3722,7 +4562,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 +4571,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 +4588,27 @@ async function runCli() { process.exitCode = 1; return; } - const { baseConfig, crossCheckViolations, introducedLedger } = loadedBase; - const violations = [...checkRendererArchitecture({ baseConfig, config, desktopRoot }), ...crossCheckViolations]; + const { baseConfig, baseClosureUses, baseEntrySurfaces, crossCheckViolations, introducedLedger } = loadedBase; + const violations = [ + ...checkRendererArchitecture({ baseConfig, baseEntrySurfaces, config, desktopRoot }), + ...crossCheckViolations, + ]; + 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}`); + } 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..ea05128a17 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,97 @@ 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 { 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", + }); + 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('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(); @@ -3737,3 +3836,476 @@ 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(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', + }); + }); + }); + + 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 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() }), []); + 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, + ); + }); + }); + + 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, + [COMPOSITION]: ` + import { AlphaPanel } from '../features/alpha'; + export function DesktopApplication() { return ; } + `, + }, (desktopRoot) => { + const baseEntrySurfaces = collectFeatureEntrySurfaces(desktopRoot); + 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)); + }); + } + + 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( + 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'), + '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: { + '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', + '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/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..c74c8d541c 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -133,6 +133,33 @@ 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 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: +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 +244,102 @@ 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. 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 + +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. 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 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `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` | `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 | +| `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;