diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 669e5507ae..454afb483f 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -290,6 +290,7 @@ "legacyPlatformImports": [ "src/renderer/platform/desktop/create-workbar-services.ts -> src/renderer/session-message-settlement" ], + "controllerOwners": [], "legacyAppShell": { "files": { "src/renderer/app-shell-app-update.ts": { @@ -847,11 +848,10 @@ "useEffect": 14, "useKeyboardHelp": 1, "useLayoutEffect": 2, - "useModuleHubController": 1, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 25, + "useRef": 24, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -979,8 +979,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "importSpecifiers": 183, + "nonTriviaTokens": 15674 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index b59400f62c..461fc233de 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -169,6 +169,14 @@ function validateCountMap(value) { ); } +function controllerOwnersOf(config) { + return config.controllerOwners ?? []; +} + +function controllerOwnerKey(owner) { + return `${owner.implementation}#${owner.symbol}`; +} + function validateArchitectureConfig(config, label, violations) { if (!isRecord(config)) { violations.push(`${label} architecture ledger must be an object`); @@ -195,6 +203,7 @@ function validateArchitectureConfig(config, label, violations) { for (const field of ['legacyFeatureImports', 'legacyPlatformImports']) { if (!isSortedUniqueStrings(config[field])) reject(`${field} must be sorted unique strings`); } + if (!Array.isArray(controllerOwnersOf(config))) reject('controllerOwners must be an array'); if ( !isRecord(config.legacyAppShell) || !isRecord(config.legacyAppShell.files) || @@ -255,6 +264,56 @@ function validateArchitectureConfig(config, label, violations) { reject(`${owner.capability}: legacyPaths must be sorted unique strings`); } } + const controllerOwnerKeys = new Set(); + let previousControllerOwnerKey = ''; + for (const owner of controllerOwnersOf(config)) { + if (!isRecord(owner)) { + reject('controllerOwners entries must be objects'); + continue; + } + const key = controllerOwnerKey(owner); + if (controllerOwnerKeys.has(key)) reject(`duplicate controller owner ${key}`); + if (key.localeCompare(previousControllerOwnerKey) < 0) { + reject('controllerOwners must be sorted by implementation and symbol'); + } + controllerOwnerKeys.add(key); + previousControllerOwnerKey = key; + for (const field of ['symbol', 'ownerSymbol']) { + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(owner[field] ?? '')) { + reject(`${key}: ${field} must be a JavaScript identifier`); + } + } + for (const field of ['implementation', 'owner']) { + const path = owner[field]; + if ( + typeof path !== 'string' || + !path.startsWith('src/renderer/features/') || + path.includes('..') || + path.includes('\\') || + !SOURCE_FILE.test(path) + ) { + reject(`${key}: ${field} must be a normalized feature source path`); + } + } + const implementationFeature = owner.implementation?.match( + /^src\/renderer\/features\/([^/]+)\//u, + )?.[1]; + const ownerFeature = owner.owner?.match( + /^src\/renderer\/features\/([^/]+)\//u, + )?.[1]; + if (!implementationFeature || implementationFeature !== ownerFeature) { + reject(`${key}: implementation and owner must belong to the same feature`); + } + if ( + isTestConsumer(owner.owner ?? '') || + /\/(?:index|testing)\.(?:(?:c|m)?(?:js|ts)x?)$/u.test(owner.owner ?? '') + ) { + reject(`${key}: owner must be a production feature implementation file`); + } + if (![0, 1].includes(owner.count)) { + reject(`${key}: count must be 0 or 1`); + } + } return valid; } @@ -1041,6 +1100,98 @@ function enclosingClass(node, parents) { return undefined; } +function enclosingFunctionName(node, parents) { + let current = parents.get(node); + while (current) { + if (current.type === 'FunctionDeclaration') return current.id?.name; + if ( + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' + ) { + const owner = parents.get(current); + if (owner?.type === 'VariableDeclarator' && owner.id?.type === 'Identifier') { + return owner.id.name; + } + if (owner?.type === 'ObjectProperty') return memberName(owner.key); + } + if ( + current.type === 'ObjectMethod' || + current.type === 'ClassMethod' || + current.type === 'ClassPrivateMethod' + ) { + return memberName(current.key); + } + current = parents.get(current); + } + return undefined; +} + +function analyzeModuleImportBindingUsages(program, bindings, parents) { + const bindingByName = new Map( + bindings.map((binding, index) => [binding.name, { binding, index }]), + ); + const usages = bindings.map(() => ({ + directCalls: 0, + references: 0, + directCallOwners: [], + memberCalls: {}, + memberReferences: {}, + })); + + function visit(node) { + const imported = + node.type === 'Identifier' ? bindingByName.get(node.name) : undefined; + if ( + imported && + node !== imported.binding && + parents.get(node)?.type !== 'ImportSpecifier' && + lexicalBindingIdentifier(node, imported.binding.name, parents) === + imported.binding + ) { + const usage = usages[imported.index]; + const parent = parents.get(node); + if ( + (parent?.type === 'CallExpression' || + parent?.type === 'OptionalCallExpression') && + unwrapExpression(parent.callee) === node + ) { + usage.directCalls += 1; + usage.directCallOwners.push(enclosingFunctionName(parent, parents)); + } else if ( + isMemberExpression(parent) && + unwrapExpression(parent.object) === node + ) { + const property = memberPropertyName(parent); + const owner = parents.get(parent); + if ( + property && + (owner?.type === 'CallExpression' || + owner?.type === 'OptionalCallExpression') && + unwrapExpression(owner.callee) === parent + ) { + usage.memberCalls[property] = + (usage.memberCalls[property] ?? 0) + 1; + } else if (property) { + usage.memberReferences[property] = + (usage.memberReferences[property] ?? 0) + 1; + } else { + usage.references += 1; + } + } else { + usage.references += 1; + } + } + for (const child of childNodes(node)) visit(child); + } + + visit(program); + return usages.map((usage) => ({ + ...usage, + memberCalls: sortedObject(usage.memberCalls), + memberReferences: sortedObject(usage.memberReferences), + })); +} + export function analyzeRendererSource(source, file = 'fixture.ts') { const typedSource = /\.(?:(?:c|m)?ts|tsx)$/u.test(file); const jsxSource = /\.(?:jsx|tsx)$/u.test(file); @@ -1070,6 +1221,12 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { const actionFactories = []; const dependencies = []; const dependencyPaths = {}; + const moduleImports = []; + const moduleImportBindings = []; + const moduleLoads = []; + const moduleDirectExportBindings = []; + const moduleLocalExports = []; + const moduleReexports = []; let importDeclarations = 0; let importSpecifiers = 0; let unresolvedDependencies = 0; @@ -1086,15 +1243,129 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { if (node.type === 'ImportDeclaration') { importDeclarations += 1; importSpecifiers += node.specifiers.length; + const source = staticString(node.source); + if (source !== undefined && node.importKind !== 'type') { + for (const specifier of node.specifiers) { + if (specifier.importKind === 'type') continue; + if (specifier.type === 'ImportSpecifier') { + const entry = { + source, + kind: 'named', + imported: memberName(specifier.imported), + local: specifier.local.name, + }; + moduleImports.push(entry); + moduleImportBindings.push(specifier.local); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + const entry = { + source, + kind: 'namespace', + local: specifier.local.name, + }; + moduleImports.push(entry); + moduleImportBindings.push(specifier.local); + } else if (specifier.type === 'ImportDefaultSpecifier') { + const entry = { + source, + kind: 'default', + local: specifier.local.name, + }; + moduleImports.push(entry); + moduleImportBindings.push(specifier.local); + } + } + if (node.specifiers.length === 0) { + moduleLoads.push({ source, kind: 'side-effect' }); + } + } + } + if (node.type === 'ExportNamedDeclaration' && node.source == null) { + const declaration = node.declaration; + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name + ) { + const entry = { + local: declaration.id.name, + exported: declaration.id.name, + }; + moduleLocalExports.push(entry); + moduleDirectExportBindings.push({ ...entry, binding: declaration.id }); + } else if (declaration?.type === 'VariableDeclaration') { + for (const item of declaration.declarations) { + for (const name of bindingNames(item.id)) { + const entry = { local: name, exported: name }; + moduleLocalExports.push(entry); + moduleDirectExportBindings.push({ + ...entry, + binding: bindingIdentifier(item.id, name), + }); + } + } + } + for (const specifier of node.specifiers) { + if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') { + continue; + } + moduleLocalExports.push({ + local: memberName(specifier.local), + exported: memberName(specifier.exported), + }); + } } if (node.type === 'TSImportEqualsDeclaration') { importDeclarations += 1; importSpecifiers += 1; } + if ( + (node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration') && + node.exportKind !== 'type' + ) { + const source = staticString(node.source); + if (source !== undefined) { + if (node.type === 'ExportAllDeclaration') { + moduleReexports.push({ source, kind: 'all' }); + } else { + for (const specifier of node.specifiers) { + if (specifier.exportKind === 'type') continue; + if (specifier.type === 'ExportSpecifier') { + moduleReexports.push({ + source, + kind: 'named', + imported: memberName(specifier.local), + exported: memberName(specifier.exported), + }); + } else if (specifier.type === 'ExportNamespaceSpecifier') { + moduleReexports.push({ + source, + kind: 'namespace', + exported: memberName(specifier.exported), + }); + } + } + } + } + } const dependency = sourceDependency(node); if (dependency !== undefined) { dependencies.push(dependency); dependencyPaths[dependency] = (dependencyPaths[dependency] ?? 0) + 1; + if (node.type === 'ImportExpression') { + moduleLoads.push({ source: dependency, kind: 'dynamic-import' }); + } else if (node.type === 'TSImportEqualsDeclaration' && !node.isTypeOnly) { + moduleLoads.push({ source: dependency, kind: 'import-equals' }); + } else if ( + (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && + (node.callee?.type === 'Import' || + (node.callee?.type === 'Identifier' && node.callee.name === 'require')) + ) { + moduleLoads.push({ + source: dependency, + kind: node.callee.type === 'Import' ? 'dynamic-import' : 'require', + }); + } } if ( (node.type === 'ImportExpression' && staticString(node.source) === undefined) || @@ -1231,6 +1502,16 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { } visit(ast.program, undefined); + const moduleImportUsages = analyzeModuleImportBindingUsages( + ast.program, + moduleImportBindings, + parents, + ); + const moduleDirectExportUsages = analyzeModuleImportBindingUsages( + ast.program, + moduleDirectExportBindings.map((entry) => entry.binding), + parents, + ); return { actionFactories: actionFactories.sort(), bridgePaths: sortedObject(bridgePaths), @@ -1241,6 +1522,18 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { importDeclarations, importSpecifiers, lifecycleMethods: sortedObject(lifecycleMethods), + moduleImports: moduleImports.map((entry, index) => ({ + ...entry, + ...moduleImportUsages[index], + })), + moduleDirectExports: moduleDirectExportBindings.map((entry, index) => ({ + local: entry.local, + exported: entry.exported, + ...moduleDirectExportUsages[index], + })), + moduleLoads, + moduleLocalExports, + moduleReexports, nonTriviaTokens: ast.tokens.length, unresolvedDependencies, }; @@ -1275,9 +1568,16 @@ function isRootClosureDebtSource(path) { } function resolveDependency(desktopRoot, importer, dependency) { + const normalizedDependency = dependency.split(/[?#]/u, 1)[0]; let target; - if (dependency.startsWith('.')) target = resolve(dirname(importer), dependency); - else if (dependency.startsWith(DESKTOP_SELF_PREFIX)) target = resolve(desktopRoot, dependency.slice(DESKTOP_SELF_PREFIX.length)); + if (normalizedDependency.startsWith('.')) { + target = resolve(dirname(importer), normalizedDependency); + } else if (normalizedDependency.startsWith(DESKTOP_SELF_PREFIX)) { + target = resolve( + desktopRoot, + normalizedDependency.slice(DESKTOP_SELF_PREFIX.length), + ); + } else return undefined; const normalized = normalizePath(target); return normalized.replace(/\.(?:c|m)?(?:js|ts)x?$/u, ''); @@ -1400,6 +1700,298 @@ function isTestConsumer(path) { ); } +function validateControllerOwners({ + desktopRoot, + config, + sourceAnalyses, + violations, +}) { + for (const contract of controllerOwnersOf(config)) { + const key = controllerOwnerKey(contract); + const featureName = contract.implementation.match( + /^src\/renderer\/features\/([^/]+)\//u, + )?.[1]; + const featureRoot = `src/renderer/features/${featureName}`; + const publicEntry = `${featureRoot}/index.ts`; + const testingEntry = `${featureRoot}/testing.ts`; + const implementationTarget = normalizePath( + resolve(desktopRoot, contract.implementation), + ).replace(SOURCE_EXTENSION, ''); + const ownerTarget = normalizePath(resolve(desktopRoot, contract.owner)).replace( + SOURCE_EXTENSION, + '', + ); + const publicEntryTargets = new Set( + [featureRoot, publicEntry].map((path) => + normalizePath(resolve(desktopRoot, path)).replace(SOURCE_EXTENSION, ''), + ), + ); + + if (contract.count === 1) { + for (const path of [ + contract.implementation, + contract.owner, + publicEntry, + ]) { + if (!existsSync(resolve(desktopRoot, path))) { + violations.push(`${key}: controller owner source is missing: ${path}`); + } + } + } + + let ownerImports = 0; + let ownerCalls = 0; + let ownerSymbolExported = false; + for (const [fileRelative, { analysis, file }] of sourceAnalyses) { + const targetsImplementation = (source) => + resolveDependency(desktopRoot, file, source) === implementationTarget; + const targetsOwner = (source) => + resolveDependency(desktopRoot, file, source) === ownerTarget; + const targetsPublicEntry = (source) => + publicEntryTargets.has(resolveDependency(desktopRoot, file, source)); + const implementationImports = analysis.moduleImports.filter((entry) => + targetsImplementation(entry.source), + ); + const controllerImports = implementationImports.filter( + (entry) => + entry.kind === 'named' && entry.imported === contract.symbol, + ); + const implementationReexports = analysis.moduleReexports.filter( + (entry) => targetsImplementation(entry.source), + ); + const controllerLoads = analysis.moduleLoads.filter((entry) => + targetsImplementation(entry.source), + ); + const ownerProviderImports = analysis.moduleImports.filter( + (entry) => + targetsOwner(entry.source) && + (entry.kind !== 'named' || entry.imported === contract.ownerSymbol), + ); + const ownerProviderReexports = analysis.moduleReexports.filter( + (entry) => + targetsOwner(entry.source) && + (entry.kind !== 'named' || entry.imported === contract.ownerSymbol), + ); + const ownerLoads = analysis.moduleLoads.filter((entry) => + targetsOwner(entry.source), + ); + + if (fileRelative === contract.owner) { + ownerImports += controllerImports.length; + ownerCalls += controllerImports.reduce( + (total, entry) => total + entry.directCalls, + 0, + ); + const directOwnerExports = analysis.moduleDirectExports.filter( + (entry) => + entry.local === contract.ownerSymbol && + entry.exported === contract.ownerSymbol, + ); + ownerSymbolExported = directOwnerExports.length === 1; + for (const entry of directOwnerExports) { + const valueUsages = + entry.directCalls + + entry.references + + Object.values(entry.memberCalls).reduce( + (total, count) => total + count, + 0, + ) + + Object.values(entry.memberReferences).reduce( + (total, count) => total + count, + 0, + ); + if (valueUsages > 0) { + violations.push( + `${key}: owner must expose ${contract.ownerSymbol} only as a JSX component`, + ); + } + } + if (implementationImports.length !== controllerImports.length) { + violations.push( + `${key}: owner may only import the registered controller symbol`, + ); + } + if (controllerLoads.length > 0) { + violations.push( + `${key}: owner must consume the controller through one direct import`, + ); + } + for (const entry of controllerImports) { + const indirectReferences = + entry.references + + Object.values(entry.memberCalls).reduce( + (total, count) => total + count, + 0, + ) + + Object.values(entry.memberReferences).reduce( + (total, count) => total + count, + 0, + ); + if (indirectReferences > 0) { + violations.push( + `${key}: owner must not alias or expose the controller binding`, + ); + } + if ( + entry.directCallOwners.some( + (ownerSymbol) => ownerSymbol !== contract.ownerSymbol, + ) + ) { + violations.push( + `${key}: controller must be called inside ${contract.ownerSymbol}`, + ); + } + } + } else if (implementationImports.length > 0) { + violations.push( + `${key}: controller implementation is imported by non-owner ${fileRelative}`, + ); + } + + const testingOnlyNamedExport = + fileRelative === testingEntry && + implementationReexports.length > 0 && + implementationReexports.every( + (entry) => + entry.kind === 'named' && entry.imported === contract.symbol, + ); + if (controllerLoads.length > 0 && fileRelative !== contract.owner) { + violations.push( + `${key}: controller implementation is referenced by non-owner ${fileRelative}`, + ); + } + + if (implementationReexports.length > 0) { + if (!testingOnlyNamedExport) { + violations.push( + `${key}: controller implementation is re-exported by ${fileRelative}`, + ); + } + } + + if ( + fileRelative === publicEntry && + (implementationImports.length > 0 || + implementationReexports.length > 0) + ) { + violations.push( + `${key}: public feature entry must not expose the controller`, + ); + } + + if ( + fileRelative !== publicEntry && + fileRelative !== testingEntry && + !isTestConsumer(file) && + (ownerProviderImports.length > 0 || + ownerProviderReexports.length > 0 || + ownerLoads.length > 0) + ) { + violations.push( + `${key}: ${fileRelative} must consume ${contract.ownerSymbol} through the public feature entry`, + ); + } + + if (fileRelative === publicEntry) { + const ownerImportsFromImplementation = analysis.moduleImports.filter( + (entry) => + targetsOwner(entry.source) && + (entry.kind === 'namespace' || + (entry.kind === 'named' && + entry.imported === contract.ownerSymbol)), + ); + if (ownerImportsFromImplementation.length > 0) { + violations.push( + `${key}: public feature entry must re-export the owner directly`, + ); + } + const ownerReexports = analysis.moduleReexports.filter( + (entry) => targetsOwner(entry.source), + ); + const registeredOwnerReexports = ownerReexports.filter( + (entry) => + entry.kind === 'named' && entry.imported === contract.ownerSymbol, + ); + if ( + contract.count === 1 && + !registeredOwnerReexports.some( + (entry) => entry.exported === contract.ownerSymbol, + ) + ) { + violations.push( + `${key}: public feature entry must export ${contract.ownerSymbol} without renaming it`, + ); + } + if ( + ownerReexports.some( + (entry) => + entry.kind !== 'named' || + (entry.imported === contract.ownerSymbol && + entry.exported !== contract.ownerSymbol), + ) + ) { + violations.push( + `${key}: public feature entry must not alias or wildcard-export the owner`, + ); + } + } + + if (fileRelative !== publicEntry && !isTestConsumer(file)) { + const publicImports = analysis.moduleImports.filter( + (entry) => targetsPublicEntry(entry.source), + ); + const publicOwnerReexports = analysis.moduleReexports.filter( + (entry) => + targetsPublicEntry(entry.source) && + (entry.kind !== 'named' || entry.imported === contract.ownerSymbol), + ); + for (const entry of publicImports) { + const directOwnerCall = + entry.kind === 'named' && + entry.imported === contract.ownerSymbol && + (entry.directCalls > 0 || entry.references > 0); + const namespaceOwnerCall = + entry.kind === 'namespace' && + ((entry.memberCalls[contract.ownerSymbol] ?? 0) > 0 || + (entry.memberReferences[contract.ownerSymbol] ?? 0) > 0 || + entry.references > 0); + if (directOwnerCall || namespaceOwnerCall) { + violations.push( + `${key}: ${fileRelative} must mount ${contract.ownerSymbol} through JSX`, + ); + } + } + if (publicOwnerReexports.length > 0) { + violations.push( + `${key}: ${fileRelative} must not re-export ${contract.ownerSymbol} from the public feature entry`, + ); + } + if (analysis.moduleLoads.some((entry) => targetsPublicEntry(entry.source))) { + violations.push( + `${key}: ${fileRelative} must import ${contract.ownerSymbol} statically and mount it through JSX`, + ); + } + } + } + + if (ownerImports !== contract.count) { + violations.push( + `${key}: owner must directly import the controller ${contract.count} time(s), received ${ownerImports}`, + ); + } + if (ownerCalls !== contract.count) { + violations.push( + `${key}: owner ${contract.owner} must call the controller ${contract.count} time(s), received ${ownerCalls}`, + ); + } + if (contract.count === 1 && !ownerSymbolExported) { + violations.push( + `${key}: owner must export ${contract.ownerSymbol}`, + ); + } + } +} + function isPublicFeaturePath(subpath) { return subpath === '' || subpath === 'index'; } @@ -2482,6 +3074,7 @@ export function generateArchitectureConfig(desktopRoot, config) { legacyGrowthDirectories: config.legacyGrowthDirectories ?? DEFAULT_LEGACY_GROWTH_DIRECTORIES, legacyFeatureImports: imports.feature, legacyPlatformImports: imports.platform, + controllerOwners: controllerOwnersOf(config), legacyAppShell: { files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path)])), closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])), @@ -2496,6 +3089,28 @@ export function generateArchitectureConfig(desktopRoot, config) { function validateMonotonicDebt(config, baseConfig, desktopRoot, violations) { if (!baseConfig) return; + const currentControllerOwners = new Map( + controllerOwnersOf(config).map((owner) => [controllerOwnerKey(owner), owner]), + ); + for (const baseOwner of controllerOwnersOf(baseConfig)) { + const key = controllerOwnerKey(baseOwner); + const currentOwner = currentControllerOwners.get(key); + if (!currentOwner) { + violations.push(`${key}: historical controller owner entries cannot be removed`); + continue; + } + if ( + currentOwner.owner !== baseOwner.owner || + currentOwner.ownerSymbol !== baseOwner.ownerSymbol + ) { + violations.push(`${key}: historical controller owner cannot change`); + } + if (currentOwner.count > baseOwner.count) { + violations.push( + `${key}: controller call count cannot increase from ${baseOwner.count} to ${currentOwner.count}`, + ); + } + } for (const section of ['legacyAppShell', 'legacyAppShellClosure', 'rootDebt', 'rootDebtClosure']) { const currentFiles = section === 'legacyAppShell' @@ -2631,6 +3246,7 @@ export function checkRendererArchitecture({ const allowedLegacyPlatformImports = new Set(resolvedConfig.legacyPlatformImports); const observedLegacyFeatureImports = new Set(); const observedLegacyPlatformImports = new Set(); + const sourceAnalyses = new Map(); for (const scanRoot of ['src', 'stories', 'e2e']) { for (const file of sourceFiles(resolve(resolvedDesktopRoot, scanRoot))) { @@ -2642,6 +3258,7 @@ export function checkRendererArchitecture({ violations.push(`${fileRelative}: could not parse source: ${error instanceof Error ? error.message : String(error)}`); continue; } + sourceAnalyses.set(fileRelative, { analysis, file }); validateStrictZone({ fileRelative, analysis, violations }); validateDependencies({ allowedLegacyFeatureImports, @@ -2657,6 +3274,13 @@ export function checkRendererArchitecture({ } } + validateControllerOwners({ + desktopRoot: resolvedDesktopRoot, + config: resolvedConfig, + sourceAnalyses, + violations, + }); + for (const edge of allowedLegacyFeatureImports) { if (!observedLegacyFeatureImports.has(edge)) violations.push(`${edge}: stale feature-to-legacy import budget`); } diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index e6043da2b0..9a491dfa10 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -55,6 +55,7 @@ function emptyDebt(overrides = {}) { } function architectureConfig({ + controllerOwners = [], legacyAppShellClosureDebt, legacyFeatureImports = [], legacyFiles = {}, @@ -71,6 +72,11 @@ function architectureConfig({ legacyGrowthDirectories: [...legacyGrowthDirectories].sort(), legacyFeatureImports: [...legacyFeatureImports].sort(), legacyPlatformImports: [...legacyPlatformImports].sort(), + controllerOwners: [...controllerOwners].sort((left, right) => + `${left.implementation}#${left.symbol}`.localeCompare( + `${right.implementation}#${right.symbol}`, + ), + ), legacyAppShell: { files: legacyFiles, closure: legacyAppShellClosureDebt ?? {}, @@ -2308,6 +2314,447 @@ describe('renderer architecture checker fixtures', () => { ); }); + it('enforces a unique feature owner for registered controllers', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export interface AlphaController { readonly ready: boolean } + export function useAlphaController(): AlphaController { + return { ready: true }; + } + `, + [controllerOwner.owner]: ` + import { useAlphaController as useController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { + const controller = useController(); + return controller.ready ? null : null; + } + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaProvider } from './ui/alpha-provider.js'; + `, + 'src/renderer/features/alpha/testing.ts': ` + import type { AlphaController } from './controller/use-alpha-controller.js'; + export { + useAlphaController, + type AlphaController, + } from './controller/use-alpha-controller.js'; + `, + }, + (desktopRoot) => { + const config = architectureConfig({ controllerOwners: [controllerOwner] }); + assert.deepEqual( + violationsFor(desktopRoot, config, architectureConfig()), + [], + ); + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ + controllerOwners: [ + { + ...controllerOwner, + owner: + 'src/renderer/features/alpha/ui/alpha-provider.test.tsx', + }, + ], + }), + ), + /owner must be a production feature implementation file/u, + ); + }, + ); + }); + + it('keeps the registered owner as a JSX-only component inside its own file', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { useAlphaController(); return null; } + export const controllerFactory = (props) => AlphaProvider(props); + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaProvider, controllerFactory } from './ui/alpha-provider.js'; + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ), + /owner must expose AlphaProvider only as a JSX component/u, + ); + }, + ); + }); + + it('binds controller calls to the registered import instead of a same-name hook', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return 'registered'; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + import { useAlphaController as useOtherController } from '../other-controller.js'; + export function AlphaProvider() { return useOtherController(); } + `, + 'src/renderer/features/alpha/other-controller.ts': ` + export function useAlphaController() { return 'other'; } + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaProvider } from './ui/alpha-provider.js'; + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ), + /must call the controller 1 time\(s\), received 0/u, + ); + }, + ); + }); + + it('requires consumers to mount the registered owner through JSX', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { useAlphaController(); return null; } + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaProvider } from './ui/alpha-provider.js'; + `, + 'src/renderer/composition/direct-provider.ts': ` + import * as Alpha from '../features/alpha'; + export const direct = Alpha.AlphaProvider({}); + `, + 'src/renderer/composition/aliased-provider.ts': ` + import * as Alpha from '../features/alpha/index.js'; + const Provider = Alpha.AlphaProvider; + export const direct = Provider({}); + `, + 'src/renderer/composition/runtime-provider.ts': ` + const Alpha = require('../features/alpha'); + export const direct = Alpha.AlphaProvider({}); + `, + 'src/renderer/composition/provider-barrel.ts': ` + export { AlphaProvider as controllerFactory } from '../features/alpha/index.js'; + `, + 'src/renderer/features/alpha/ui/provider-factory.ts': ` + import { AlphaProvider } from './alpha-provider.js'; + export const controllerFactory = AlphaProvider; + `, + }, + (desktopRoot) => { + const violations = violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ); + assertHasViolation( + violations, + /direct-provider\.ts must mount AlphaProvider through JSX/u, + ); + assertHasViolation( + violations, + /aliased-provider\.ts must mount AlphaProvider through JSX/u, + ); + assertHasViolation( + violations, + /runtime-provider\.ts must import AlphaProvider statically and mount it through JSX/u, + ); + assertHasViolation( + violations, + /provider-barrel\.ts must not re-export AlphaProvider from the public feature entry/u, + ); + assertHasViolation( + violations, + /provider-factory\.ts must consume AlphaProvider through the public feature entry/u, + ); + }, + ); + }); + + it('rejects controller imports, runtime loading, and public re-exports outside the owner', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { useAlphaController(); return null; } + `, + 'src/renderer/features/alpha/secondary-owner.ts': ` + import { useAlphaController as useSecondary } from './controller/use-alpha-controller.js'; + export const secondary = () => useSecondary(); + `, + 'src/renderer/features/alpha/default-owner.ts': ` + import controller from './controller/use-alpha-controller.js'; + export const defaultOwner = controller; + `, + 'src/renderer/features/alpha/other-value-owner.ts': ` + import { helper } from './controller/use-alpha-controller.js'; + export const otherOwner = helper; + `, + 'src/renderer/features/alpha/query-owner.ts': ` + import { useAlphaController } from './controller/use-alpha-controller.js?raw'; + export const queryOwner = () => useAlphaController(); + `, + 'src/renderer/features/alpha/dynamic-owner.ts': ` + export const loadController = () => import('./controller/use-alpha-controller.js'); + `, + 'src/renderer/features/alpha/private-controller.ts': ` + export { useAlphaController } from './controller/use-alpha-controller.js'; + `, + 'src/renderer/features/alpha/index.ts': ` + export { default as useAlphaController } from './controller/use-alpha-controller.js'; + `, + }, + (desktopRoot) => { + const violations = violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ); + assertHasViolation( + violations, + /controller implementation is imported by non-owner .*secondary-owner\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is imported by non-owner .*default-owner\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is imported by non-owner .*other-value-owner\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is imported by non-owner .*query-owner\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is referenced by non-owner .*dynamic-owner\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is re-exported by .*private-controller\.ts/u, + ); + assertHasViolation( + violations, + /controller implementation is re-exported by .*index\.ts/u, + ); + assertHasViolation( + violations, + /public feature entry must not expose the controller/u, + ); + }, + ); + }); + + it('requires the registered owner to import and call its controller exactly once', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { + useAlphaController(); + useAlphaController(); + return null; + } + `, + }, + (desktopRoot) => { + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ), + /must call the controller 1 time\(s\), received 2/u, + ); + }, + ); + }); + + it('ratchets controller owner contracts against their base configuration', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + const alternateOwner = { + ...controllerOwner, + owner: 'src/renderer/features/alpha/ui/alternate-provider.tsx', + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [alternateOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlternateProvider() { useAlphaController(); return null; } + `, + }, + (desktopRoot) => { + const base = architectureConfig({ controllerOwners: [controllerOwner] }); + assertHasViolation( + violationsFor(desktopRoot, architectureConfig(), base), + /historical controller owner entries cannot be removed/u, + ); + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [alternateOwner] }), + base, + ), + /historical controller owner cannot change/u, + ); + + const retiredBase = architectureConfig({ + controllerOwners: [{ ...controllerOwner, count: 0 }], + }); + assertHasViolation( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + retiredBase, + ), + /controller call count cannot increase from 0 to 1/u, + ); + }, + ); + }); + + it('allows a registered controller to retire without allowing it to return', async () => { + const activeOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + const retiredOwner = { ...activeOwner, count: 0 }; + await withDesktopFixture( + { + [activeOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [activeOwner.owner]: ` + export function AlphaProvider() { return null; } + `, + 'src/renderer/features/alpha/index.ts': ` + export { AlphaProvider } from './ui/alpha-provider.js'; + `, + }, + (desktopRoot) => { + assert.deepEqual( + violationsFor( + desktopRoot, + architectureConfig({ controllerOwners: [retiredOwner] }), + architectureConfig({ controllerOwners: [activeOwner] }), + ), + [], + ); + }, + ); + }); + + it('preserves hand-authored controller owner policy when regenerating debt', async () => { + const controllerOwner = { + implementation: + 'src/renderer/features/alpha/controller/use-alpha-controller.ts', + symbol: 'useAlphaController', + owner: 'src/renderer/features/alpha/ui/alpha-provider.tsx', + ownerSymbol: 'AlphaProvider', + count: 1, + }; + await withDesktopFixture( + { + [controllerOwner.implementation]: ` + export function useAlphaController() { return true; } + `, + [controllerOwner.owner]: ` + import { useAlphaController } from '../controller/use-alpha-controller.js'; + export function AlphaProvider() { useAlphaController(); return null; } + `, + }, + (desktopRoot) => { + const generated = generateArchitectureConfig( + desktopRoot, + architectureConfig({ controllerOwners: [controllerOwner] }), + ); + assert.deepEqual(generated.controllerOwners, [controllerOwner]); + }, + ); + }); + it('fails closed when the CLI base argument is missing or invalid', () => { const checker = fileURLToPath(new URL('./check-renderer-architecture.mjs', import.meta.url)); const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); diff --git a/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts index 60b6b65d30..8be4091b99 100644 --- a/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-boundary.test.ts @@ -116,8 +116,17 @@ describe('Module Hub feature boundary', () => { ]) { assert.equal(appShell.includes(forbidden), false, forbidden); } - assert.equal(appShell.includes('useModuleHubController({'), true); - assert.equal(appShell.includes(''), true); + for (const forbidden of [ + 'useModuleHubController(', + 'moduleHub.commands', + 'moduleHub.selectors', + 'moduleHub.host', + ''), true); const effects = readFileSync( join(desktopRoot, 'src', 'renderer', 'app-shell-effects.ts'), diff --git a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts new file mode 100644 index 0000000000..a743e86c9f --- /dev/null +++ b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts @@ -0,0 +1,297 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { + createModuleHubCommandPort, + ModuleHubProvider, + ModuleHubScheduledTasksBoundary, + ModuleHubSkillCatalogRevisionBoundary, + ModuleHubServicesProvider, + createFakeModuleHubServices, + type ModuleHubCommands, + type ModuleHubServices, + useModuleHubController, +} from '../../renderer/features/module-hub/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +function task(id: string): ScheduledTask { + return { + id, + title: id, + intent: { kind: 'text', body: 'run' }, + schedule: { kind: 'once', runAt: 1 }, + effect: { kind: 'notify', channel: 'local' }, + status: 'active', + nextFireAt: 1, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: 1, + updatedAt: 1, + runs: [], + lastError: null, + }; +} + +afterEach(() => cleanupFakeDom()); + +function scheduledTasksHarness(): { + services: ModuleHubServices; + emit(tasks: ScheduledTask[]): void; +} { + const defaults = createFakeModuleHubServices(); + let handler: (() => void) | undefined; + let scheduledTasks: ScheduledTask[] = []; + return { + services: createFakeModuleHubServices({ + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => scheduledTasks, + subscribeChanges(next) { + handler = () => + next({ + type: 'scheduled_tasks_changed', + reason: 'test', + ts: 1, + }); + return () => { + handler = undefined; + }; + }, + }, + }), + emit(tasks) { + scheduledTasks = tasks; + assert.ok(handler); + handler(); + }, + }; +} + +test('controller scoping removes shell-wide work from Module Hub updates', async () => { + const { root } = installReactRenderer(); + const legacy = scheduledTasksHarness(); + const scoped = scheduledTasksHarness(); + const commandPort = createModuleHubCommandPort(); + const renders = { + legacyShell: 0, + legacyUnrelated: 0, + legacyReader: 0, + scopedShell: 0, + scopedUnrelated: 0, + scopedReader: 0, + scopedSkillReader: 0, + }; + let legacyObservedTasks: readonly ScheduledTask[] = []; + let scopedObservedTasks: readonly ScheduledTask[] = []; + let scopedObservedSkillRevision = -1; + const controllerInput = { + selection: { section: 'sessions' } as const, + selectModule: () => undefined, + useSkillInChat: () => undefined, + openSession: () => undefined, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + }; + + function LegacyUnrelatedProbe() { + renders.legacyUnrelated += 1; + return null; + } + + function ScopedUnrelatedProbe() { + renders.scopedUnrelated += 1; + return null; + } + + function LegacyScheduledTasksProbe(props: { + scheduledTasks: readonly ScheduledTask[]; + }) { + renders.legacyReader += 1; + legacyObservedTasks = props.scheduledTasks; + return null; + } + + function ScopedScheduledTasksProbe(props: { + scheduledTasks: readonly ScheduledTask[]; + }) { + renders.scopedReader += 1; + scopedObservedTasks = props.scheduledTasks; + return null; + } + + function ScopedSkillCatalogProbe(props: { skillCatalogRevision: number }) { + renders.scopedSkillReader += 1; + scopedObservedSkillRevision = props.skillCatalogRevision; + return null; + } + + function LegacyShellReplica() { + renders.legacyShell += 1; + const controller = useModuleHubController(controllerInput); + return createElement( + Fragment, + null, + createElement(LegacyUnrelatedProbe), + createElement(LegacyScheduledTasksProbe, { + scheduledTasks: controller.selectors.scheduledTasks, + }), + ); + } + + function ScopedShellReplica() { + renders.scopedShell += 1; + return createElement( + ModuleHubProvider, + { + ...controllerInput, + commandPort, + }, + createElement( + Fragment, + null, + createElement(ScopedUnrelatedProbe), + createElement(ModuleHubScheduledTasksBoundary, { + render: (scheduledTasks) => + createElement(ScopedScheduledTasksProbe, { scheduledTasks }), + }), + createElement(ModuleHubSkillCatalogRevisionBoundary, { + render: (skillCatalogRevision) => + createElement(ScopedSkillCatalogProbe, { skillCatalogRevision }), + }), + ), + ); + } + + await act(async () => { + const app = createElement( + Fragment, + null, + createElement( + ModuleHubServicesProvider, + { services: legacy.services }, + createElement(LegacyShellReplica), + ), + createElement( + ModuleHubServicesProvider, + { services: scoped.services }, + createElement(ScopedShellReplica), + ), + ); + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { children: app }), + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + }); + const baseline = { ...renders }; + + await act(async () => { + legacy.emit([task('legacy-task')]); + await new Promise((resolve) => setImmediate(resolve)); + }); + assert.deepEqual(renders, { + legacyShell: baseline.legacyShell + 1, + legacyUnrelated: baseline.legacyUnrelated + 1, + legacyReader: baseline.legacyReader + 1, + scopedShell: baseline.scopedShell, + scopedUnrelated: baseline.scopedUnrelated, + scopedReader: baseline.scopedReader, + scopedSkillReader: baseline.scopedSkillReader, + }); + assert.deepEqual( + legacyObservedTasks.map(({ id }) => id), + ['legacy-task'], + ); + + const afterLegacy = { ...renders }; + await act(async () => { + scoped.emit([task('scoped-task')]); + await new Promise((resolve) => setImmediate(resolve)); + }); + assert.deepEqual(renders, { + legacyShell: afterLegacy.legacyShell, + legacyUnrelated: afterLegacy.legacyUnrelated, + legacyReader: afterLegacy.legacyReader, + scopedShell: afterLegacy.scopedShell, + scopedUnrelated: afterLegacy.scopedUnrelated, + scopedReader: afterLegacy.scopedReader + 1, + scopedSkillReader: afterLegacy.scopedSkillReader, + }); + assert.deepEqual( + scopedObservedTasks.map(({ id }) => id), + ['scoped-task'], + ); + + const afterScheduledTasks = { ...renders }; + const previousSkillRevision = scopedObservedSkillRevision; + await act(async () => { + await commandPort.refreshProjectSkills(); + }); + assert.deepEqual(renders, { + legacyShell: afterScheduledTasks.legacyShell, + legacyUnrelated: afterScheduledTasks.legacyUnrelated, + legacyReader: afterScheduledTasks.legacyReader, + scopedShell: afterScheduledTasks.scopedShell, + scopedUnrelated: afterScheduledTasks.scopedUnrelated, + scopedReader: afterScheduledTasks.scopedReader, + scopedSkillReader: afterScheduledTasks.scopedSkillReader + 1, + }); + assert.equal(scopedObservedSkillRevision, previousSkillRevision + 1); +}); + +test('command port keeps the newest controller through stale cleanup', async () => { + const calls: string[] = []; + const commands = (name: string): ModuleHubCommands => ({ + refreshProjectSkills: async () => { + calls.push(`${name}:refresh`); + }, + openScheduledTaskCreate: () => calls.push(`${name}:create`), + copyTodayDailyReview: async () => { + calls.push(`${name}:copy`); + }, + pasteTodayDailyReview: async () => { + calls.push(`${name}:paste`); + }, + saveTodayDailyReview: async () => { + calls.push(`${name}:save`); + }, + }); + const port = createModuleHubCommandPort(); + const disconnectFirst = port.connect(commands('first')); + const disconnectSecond = port.connect(commands('second')); + + disconnectFirst(); + await port.refreshProjectSkills(); + port.openScheduledTaskCreate(); + assert.deepEqual(calls, ['second:refresh', 'second:create']); + + disconnectSecond(); + await port.copyTodayDailyReview(); + assert.deepEqual(calls, ['second:refresh', 'second:create']); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0a685bbab4..fe4f2231b0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -92,7 +92,7 @@ import { useWorkbarController, } from './features/workbar'; import * as Goals from './features/goals'; -import { ModuleHubHost, useModuleHubController } from './features/module-hub'; +import * as ModuleHub from './features/module-hub'; import { SessionNavigationProvider, createSessionOpenCommand, @@ -211,7 +211,10 @@ import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults' import { useTurnActionRegistry } from './use-turn-action-registry'; import { useComposerAttachments } from './use-composer-attachments'; import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; -import { ComposerMentionsProvider, type ComposerMentionsSurface } from './composer-mentions'; +import { + type ComposerMentionsSurfaceInput, + renderComposerMentionsProvider, +} from './composer-mentions'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellMemoryPill } from './use-shell-memory-pill'; import { useShellConnections } from './use-shell-connections'; @@ -1402,7 +1405,7 @@ function AppShellContent({ }, [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], ); - const refreshProjectSkillsRef = useRef<() => Promise>(async () => {}); + const moduleHubCommands = useMemo(ModuleHub.createModuleHubCommandPort, []); const { projectInfo, projects, @@ -1429,7 +1432,7 @@ function AppShellContent({ sessionProjectId: sharedSessionActive ? undefined : activeSession?.projectId, sessionProfileKind: sharedSessionActive ? undefined : activeDesktopSession?.profileKind, onProjectSelected: (ownerSessionId) => { - void refreshProjectSkillsRef.current(); + void moduleHubCommands.refreshProjectSkills(); if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); }, toastApi, @@ -1452,16 +1455,6 @@ function AppShellContent({ append: (text: string) => composer.appendText(text), }; }, []); - const moduleHub = useModuleHubController({ - selection: navSelection, - selectModule: setNavSelection, - ...(projectCapabilities.viewClientPath ? { openSkillsFolder } : {}), - useSkillInChat, - openSession: (sessionId) => openSessionInChatRef.current(sessionId), - appendComposerText: (text) => composerRef.current?.appendText(text), - captureActiveComposerClaim, - }); - refreshProjectSkillsRef.current = moduleHub.commands.refreshProjectSkills; const workHubProjectsRef = useRef(projects); workHubProjectsRef.current = projects; const workHubCoordinationGeneration = workHubCoordinationGenerationRef.current; @@ -1604,8 +1597,7 @@ function AppShellContent({ // resolved project path as a refresh key for new-chat project changes. Only // the SURFACE is named here — the projection itself is owned by // `ComposerMentionsProvider` below, so its reloads do not re-render the shell. - const composerMentionsSurface: ComposerMentionsSurface = { - skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, + const composerMentionsSurface: ComposerMentionsSurfaceInput = { sessionId: ownerActiveId, projectPath: activeId ? ownerActiveId @@ -2641,7 +2633,7 @@ function AppShellContent({ openHelp, openScheduledTaskCreate: () => { closePalette(); - moduleHub.commands.openScheduledTaskCreate(); + moduleHubCommands.openScheduledTaskCreate(); }, openProjectFolder, openSessionInChat, @@ -2651,9 +2643,9 @@ function AppShellContent({ openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, - copyTodayDailyReview: moduleHub.commands.copyTodayDailyReview, - pasteTodayDailyReview: moduleHub.commands.pasteTodayDailyReview, - saveTodayDailyReview: moduleHub.commands.saveTodayDailyReview, + copyTodayDailyReview: moduleHubCommands.copyTodayDailyReview, + pasteTodayDailyReview: moduleHubCommands.pasteTodayDailyReview, + saveTodayDailyReview: moduleHubCommands.saveTodayDailyReview, setNavSelection, setPermissionMode, setThemePref, @@ -2670,14 +2662,27 @@ function AppShellContent({ : 'im_hub'; return ( - // Goal state lives below the shell and wakes only its three readers. Composer - // mentions still wrap the frame so one projection serves every composer. + // Goal state and Module Hub ownership both live below the shell. Composer + // mentions still wrap the frame so one projection serves every composer, + // including side-chat panels, without rebuilding the frame on catalog moves. - + composerRef.current?.appendText(text)} + captureActiveComposerClaim={captureActiveComposerClaim} + commandPort={moduleHubCommands} + > + void createSession()} - workHubEntry={workHubEnabled ? { - active: workHubActive, - label: 'WorkHub', - onSelect: openWorkHub, - } : undefined} - projectActions={projectRowActions} - > - {SESSION_RAIL} - + ( + void createSession()} + workHubEntry={workHubEnabled ? { + active: workHubActive, + label: 'WorkHub', + onSelect: openWorkHub, + } : undefined} + projectActions={projectRowActions} + > + {SESSION_RAIL} + + )} + /> } > @@ -2848,7 +2857,7 @@ function AppShellContent({
- + {workHubEnabled && workHubActive && navSelection.section === 'sessions' ? ( workHubCoordinationSessionId ? (
- + + ); } diff --git a/apps/desktop/src/renderer/composer-mentions.tsx b/apps/desktop/src/renderer/composer-mentions.tsx index 4a4eeb62fc..4b0b331b36 100644 --- a/apps/desktop/src/renderer/composer-mentions.tsx +++ b/apps/desktop/src/renderer/composer-mentions.tsx @@ -56,7 +56,7 @@ export interface ComposerMentions { /** Which backend surface the popups should describe. */ export interface ComposerMentionsSurface { - /** Invalidates Runtime's invocable projection after installed Skills settle. */ + /** Handed over by the Module Hub boundary to invalidate Runtime's projection. */ skillCatalogRevision: number; sessionId?: string; projectPath?: string; @@ -66,6 +66,12 @@ export interface ComposerMentionsSurface { newTaskTarget?: DesktopNewTaskTarget; } +/** The surface AppShell assembles; the Module Hub boundary supplies the revision. */ +export type ComposerMentionsSurfaceInput = Omit< + ComposerMentionsSurface, + 'skillCatalogRevision' +>; + /** * Owns the composer mention popup wiring so app-shell.tsx keeps no inline * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives @@ -296,6 +302,22 @@ export function ComposerMentionsProvider({ return {children}; } +/** + * How the provider mounts under the Skill catalog revision the Module Hub + * boundary hands over: the shell passes the surface it assembled, the boundary + * supplies the revision and the frame it already built, and the provider's + * `skillCatalogRevision` stays a required, compiler-checked prop. + */ +export function renderComposerMentionsProvider( + surface: ComposerMentionsSurfaceInput, +): (skillCatalogRevision: number, children: ReactNode) => ReactNode { + return (skillCatalogRevision, children) => ( + + {children} + + ); +} + export function useComposerMentionsContext(): ComposerMentions | undefined { return useContext(ComposerMentionsContext); } diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index f93eb29dfc..98e5e15896 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -31,12 +31,15 @@ Automations: - selection and header composition for Skills, MCP, Scheduled Tasks, and Daily Review. -`AppShell` still owns top-level `NavSelection`, module-memory persistence, -Session/Project navigation, and the Composer. Those capabilities cross the -boundary only as intents. The feature exposes a read-only Scheduled Tasks -projection to the Session rail and a revision number that invalidates the -Composer's separate Runtime-owned invocable-Skills projection; neither makes -the Shell an owner of Module Hub data. +`ModuleHubProvider` owns the controller below `AppShell`. `AppShell` still owns +top-level `NavSelection`, module-memory persistence, Session/Project +navigation, and the Composer. Those capabilities cross the boundary only as +intents. Commands return through a stable imperative port because the Shell +calls them only from event handlers. Two typed boundaries hand the read-only +Scheduled Tasks projection to the Session rail and the Skill catalog revision +to Composer mentions through a `render` prop at their actual readers, so the +readers' props stay required and the compiler checks the wiring; Module Hub +updates therefore do not subscribe or re-render `AppShell`. ## Dependency direction @@ -54,6 +57,12 @@ MCP is the explicit exception to I/O ownership in this slice. `McpPage` keeps its existing page-owned controller and direct Desktop bridge. `ModuleHubHost` only selects and mounts that leaf; moving MCP internals is a separate change. +The production entry deliberately does not export `useModuleHubController`. +The renderer architecture policy records its implementation and +`ModuleHubProvider` as the unique production owner. Moving, duplicating, or +re-exporting that controller fails the central architecture check; tests may +reach it only through `testing.ts`. + ## Lifecycle invariants - The three Skills projections and Scheduled Tasks each have independent @@ -77,6 +86,11 @@ only selects and mounts that leaf; moving MCP internals is a separate change. feedback. - Opening Scheduled Task creation selects the page and increments the request nonce; the page acknowledgement resets it to zero. +- The Provider remains mounted across all routes. Navigation may hide every + Module Hub page, but it must not tear down default-Host refresh, Scheduled + Task subscriptions, Daily Review command state, or the create nonce. +- The command port connects and disconnects in a layout effect. A stale + Provider cleanup cannot detach a newer controller target. There is intentionally no feature-level reducer or store: these projections and commands have real lifecycle ownership, while navigation persistence remains a diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts index 8ebfce96b5..209c325382 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts @@ -51,15 +51,17 @@ export interface ModuleHubHostModel { readonly openSession: (sessionId: string) => void; } +export interface ModuleHubCommands { + refreshProjectSkills(): Promise; + openScheduledTaskCreate(): void; + copyTodayDailyReview(): Promise; + pasteTodayDailyReview(): Promise; + saveTodayDailyReview(): Promise; +} + export interface ModuleHubController { readonly host: ModuleHubHostModel; - readonly commands: { - refreshProjectSkills(): Promise; - openScheduledTaskCreate(): void; - copyTodayDailyReview(): Promise; - pasteTodayDailyReview(): Promise; - saveTodayDailyReview(): Promise; - }; + readonly commands: ModuleHubCommands; readonly selectors: { readonly scheduledTasks: readonly ScheduledTask[]; /** Invalidates the composer's Runtime-owned invocable Skills projection. */ diff --git a/apps/desktop/src/renderer/features/module-hub/index.ts b/apps/desktop/src/renderer/features/module-hub/index.ts index a99b752aff..f9d5c9f2cb 100644 --- a/apps/desktop/src/renderer/features/module-hub/index.ts +++ b/apps/desktop/src/renderer/features/module-hub/index.ts @@ -17,10 +17,15 @@ * under the License. */ -export { useModuleHubController } from './controller/use-module-hub-controller.js'; export { ModuleHubServicesProvider } from './services-context.js'; export type { ModuleHubClipboardService, ModuleHubServices, } from './ports.js'; -export { ModuleHubHost } from './ui/module-hub-host.js'; +export { ModuleHubHost, ModuleHubHostView } from './ui/module-hub-host.js'; +export { + createModuleHubCommandPort, + ModuleHubProvider, + ModuleHubScheduledTasksBoundary, + ModuleHubSkillCatalogRevisionBoundary, +} from './ui/module-hub-provider.js'; diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index ca504b3ce7..8dbc0a4223 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -23,9 +23,19 @@ import type { ModuleHubHostModel } from "./controller/use-module-hub-controller. export { ModuleHubServicesProvider } from "./services-context.js"; export type { ModuleHubServices } from "./ports.js"; +export { + createModuleHubCommandPort, + ModuleHubProvider, + ModuleHubScheduledTasksBoundary, + ModuleHubSkillCatalogRevisionBoundary, + type ModuleHubCommands, +} from "./ui/module-hub-provider.js"; export { startModuleHubLifecycle } from "./controller/module-hub-lifecycle.js"; export { resolveModuleHubHostRoute } from "./controller/module-hub-route.js"; -export type { ModuleHubHostModel } from "./controller/use-module-hub-controller.js"; +export { + useModuleHubController, + type ModuleHubHostModel, +} from "./controller/use-module-hub-controller.js"; export { createDailyReviewBridge, useDailyReviewController, diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx index 3b437d3a4f..7792351cd1 100644 --- a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx @@ -29,10 +29,15 @@ import { import { McpPage } from '../../../mcp-page.js'; import type { ModuleHubHostModel } from '../controller/use-module-hub-controller.js'; import { resolveModuleHubHostRoute } from '../controller/module-hub-route.js'; +import { useModuleHubHostModel } from './module-hub-provider.js'; /** Selects and mounts exactly one Module Hub leaf for the Shell selection. */ -export function ModuleHubHost(props: { model: ModuleHubHostModel }) { - const { model } = props; +export function ModuleHubHost() { + return ; +} + +/** Environment-free view seam for focused tests and Storybook. */ +export function ModuleHubHostView({ model }: { model: ModuleHubHostModel }) { const copy = getSharedUiCopy(useUiLocale()).moduleHubs; const selection = model.selection; const route = resolveModuleHubHostRoute(selection); diff --git a/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx new file mode 100644 index 0000000000..3abc28c3fb --- /dev/null +++ b/apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx @@ -0,0 +1,159 @@ +/* + * 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. + */ + +import { + createContext, + useContext, + useLayoutEffect, + type ReactNode, +} from 'react'; +import type { ScheduledTask } from '@maka/core/scheduled-task'; +import { + useModuleHubController, + type ModuleHubCommands, + type ModuleHubHostModel, + type UseModuleHubControllerInput, +} from '../controller/use-module-hub-controller.js'; + +const ModuleHubHostContext = createContext(null); +const ModuleHubScheduledTasksContext = createContext< + readonly ScheduledTask[] | null +>(null); +const ModuleHubSkillCatalogRevisionContext = createContext(null); + +export interface ModuleHubCommandPort extends ModuleHubCommands { + connect(target: ModuleHubCommands): () => void; +} + +export interface ModuleHubProviderProps extends UseModuleHubControllerInput { + readonly commandPort: ModuleHubCommandPort; + readonly children?: ReactNode; +} + +/** + * The stable command surface AppShell holds while the controller below it is + * replaced. + * + * `connect` runs from a layout effect keyed on `controller.commands`, and that + * object is rebuilt whenever the controller's `useMemo` input changes — which + * includes `openSession`, a fresh arrow on every AppShell render. So the effect + * re-runs on every shell render: cleanup, then connect. The identity check in + * the cleanup is what keeps that churn harmless — a stale cleanup must never + * detach a newer target that connected after it. + */ +export function createModuleHubCommandPort(): ModuleHubCommandPort { + let target: ModuleHubCommands | null = null; + return { + connect(next) { + target = next; + return () => { + if (target === next) target = null; + }; + }, + refreshProjectSkills: () => + target?.refreshProjectSkills() ?? Promise.resolve(), + openScheduledTaskCreate: () => target?.openScheduledTaskCreate(), + copyTodayDailyReview: () => + target?.copyTodayDailyReview() ?? Promise.resolve(), + pasteTodayDailyReview: () => + target?.pasteTodayDailyReview() ?? Promise.resolve(), + saveTodayDailyReview: () => + target?.saveTodayDailyReview() ?? Promise.resolve(), + }; +} + +/** + * Owns the Module Hub controller below AppShell. + * + * Module Hub updates re-render this provider and the three narrow readers + * below it. `children` is the element AppShell already built, so React can + * bail out of the rest of the frame instead of widening feature state back to + * the shell root. + */ +export function ModuleHubProvider({ + commandPort, + children, + ...input +}: ModuleHubProviderProps) { + const controller = useModuleHubController(input); + + useLayoutEffect( + () => commandPort.connect(controller.commands), + [commandPort, controller.commands], + ); + + return ( + + + + {children} + + + + ); +} + +export function useModuleHubHostModel(): ModuleHubHostModel { + const model = useContext(ModuleHubHostContext); + if (!model) throw new Error('ModuleHubProvider is missing'); + return model; +} + +/** + * Hands the rail's read-only Scheduled Tasks projection to its reader. + * + * `render` receives the projection together with the element AppShell already + * built, so the reader's prop stays required and typed at the call site, and a + * Scheduled Tasks change re-renders only this boundary and the element + * `render` returns — the children it forwards keep their identity. + */ +export function ModuleHubScheduledTasksBoundary(props: { + readonly render: ( + scheduledTasks: readonly ScheduledTask[], + children: ReactNode, + ) => ReactNode; + readonly children?: ReactNode; +}): ReactNode { + const scheduledTasks = useContext(ModuleHubScheduledTasksContext); + if (!scheduledTasks) throw new Error('ModuleHubProvider is missing'); + return props.render(scheduledTasks, props.children); +} + +/** Hands the Skill catalog revision to Composer mentions without waking AppShell. */ +export function ModuleHubSkillCatalogRevisionBoundary(props: { + readonly render: ( + skillCatalogRevision: number, + children: ReactNode, + ) => ReactNode; + readonly children?: ReactNode; +}): ReactNode { + const skillCatalogRevision = useContext( + ModuleHubSkillCatalogRevisionContext, + ); + if (skillCatalogRevision === null) { + throw new Error('ModuleHubProvider is missing'); + } + return props.render(skillCatalogRevision, props.children); +} + +export type { ModuleHubCommands } from '../controller/use-module-hub-controller.js'; diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 482dae04ce..762c0bef7d 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -35,8 +35,17 @@ import { } from '@maka/ui'; import { type ComponentProps, type ReactNode, useState } from 'react'; import { WorkbarTitlebarActions } from '../src/renderer/features/workbar'; -import { ModuleHubHost } from '../src/renderer/features/module-hub/index'; -import { createFakeModuleHubHostModel } from '../src/renderer/features/module-hub/testing'; +import { + createModuleHubCommandPort, + ModuleHubHost, + ModuleHubHostView, + ModuleHubProvider, + ModuleHubServicesProvider, +} from '../src/renderer/features/module-hub'; +import { + createFakeModuleHubHostModel, + createFakeModuleHubServices, +} from '../src/renderer/features/module-hub/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { McpPage } from '../src/renderer/mcp-page'; import { withScopedMakaBridge } from './maka-bridge'; @@ -812,7 +821,38 @@ function ModuleHubHostSurface(props: { : 'cron'; return ( - + + + ); +} + +function ProductionModuleHubHostSurface() { + const [commandPort] = useState(createModuleHubCommandPort); + const [services] = useState(() => { + const defaults = createFakeModuleHubServices(); + return createFakeModuleHubServices({ + skills: { + ...defaults.skills, + list: async () => INSTALLED_SKILLS, + listBundledCatalog: async () => BUNDLED_SKILLS, + }, + }); + }); + return ( + + + undefined} + commandPort={commandPort} + > + + + ); } @@ -854,15 +894,12 @@ export const ExtensionsSkillsEmpty: Story = { render: () => , }; -// Feature-slice composition coverage: the production Host, not a direct leaf. +// Full production composition: public Provider → Context → public Host. export const HostExtensionsSkills: Story = { - render: () => ( - - ), + render: () => , }; +// Focused view seams keep the other route variants deterministic. export const HostExtensionsMcp: Story = { decorators: [withEmptyMcpBridge], render: () => ( diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 14992542f9..24bec0574e 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 244 files — blocker 0, reimplementation 0, polish 1, aligned 243. +**Totals:** 245 files — blocker 0, reimplementation 0, polish 1, aligned 244. ## Exclusions (explicit) @@ -49,6 +49,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/goals/ui/goal-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx` | other | Button | aligned — uses Astryx (Button) | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx` | other | DropdownMenu, DropdownMenuItem | aligned — uses Astryx (DropdownMenu, DropdownMenuItem) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index e256ca9acb..b067e57bd8 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -20,6 +20,7 @@ apps/desktop/src/renderer/features/goals/ui/goal-host.tsx apps/desktop/src/renderer/features/goals/ui/goal-provider.tsx apps/desktop/src/renderer/features/module-hub/services-context.tsx apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx +apps/desktop/src/renderer/features/module-hub/ui/module-hub-provider.tsx apps/desktop/src/renderer/features/runtime-host-management/services-context.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/peer-mesh-peer-id-button.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index a7d81339ce..bc56032921 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -121,7 +121,6 @@ export const ALLOWED = { useEffect: 14, useKeyboardHelp: 1, useLayoutEffect: 2, - useModuleHubController: 1, useNewTaskChoice: 1, useOnboardingSnapshot: 1, usePlanModeState: 1,