const fs = require('fs'); const path = require('path'); const { createRequire } = require('module'); const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..'); const webRoot = path.join(repoRoot, 'src', 'Web', 'StellaOps.Web'); const appRoot = path.join(webRoot, 'src', 'app'); const outRoot = path.join(repoRoot, 'docs', 'modules', 'ui', 'component-preservation-map'); const requireFromWeb = createRequire(path.join(webRoot, 'package.json')); const ts = requireFromWeb('typescript'); const MENU_FILES = [ path.join(appRoot, 'layout', 'app-sidebar', 'app-sidebar.component.ts'), path.join(appRoot, 'core', 'navigation', 'navigation.config.ts'), path.join(appRoot, 'layout', 'app-topbar', 'app-topbar.component.ts'), path.join(appRoot, 'shared', 'components', 'user-menu', 'user-menu.component.ts'), ].map(path.normalize); const GENERATION_DATE = new Date().toISOString().slice(0, 10); function walk(dir) { const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.angular') { continue; } const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...walk(fullPath)); continue; } results.push(fullPath); } return results; } function read(filePath) { return fs.readFileSync(filePath, 'utf8'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function write(filePath, content) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content.replace(/\n/g, '\r\n')); } function repoRel(filePath) { return path.relative(repoRoot, filePath).replace(/\\/g, '/'); } function appRel(filePath) { return path.relative(appRoot, filePath).replace(/\\/g, '/'); } function normalizeRoute(routePath) { if (!routePath || routePath === '/') { return '/'; } let value = routePath.replace(/\\/g, '/').trim(); if (!value.startsWith('/')) { value = `/${value}`; } value = value.replace(/\/+/g, '/'); if (value.length > 1) { value = value.replace(/\/$/, ''); } return value; } function joinRoute(prefix, segment) { const cleanPrefix = normalizeRoute(prefix || '/'); if (!segment || segment === '') { return cleanPrefix; } if (segment.startsWith('/')) { return normalizeRoute(segment); } if (cleanPrefix === '/') { return normalizeRoute(segment); } return normalizeRoute(`${cleanPrefix}/${segment}`); } function sortUnique(values) { return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); } function titleCase(value) { return value .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[-_]/g, ' ') .replace(/\s+/g, ' ') .trim() .replace(/\b\w/g, (match) => match.toUpperCase()); } function humanizeComponentName(className) { return titleCase(className.replace(/Component$/, '')); } function slugify(value) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function canHaveDecorators(node) { return typeof ts.canHaveDecorators === 'function' && ts.canHaveDecorators(node); } function getDecorators(node) { if (!canHaveDecorators(node) || typeof ts.getDecorators !== 'function') { return []; } return ts.getDecorators(node) ?? []; } function stringLiteralValue(node) { if (!node) { return null; } if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { return node.text; } return null; } function propertyNameText(nameNode) { if (!nameNode) { return null; } if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNoSubstitutionTemplateLiteral(nameNode)) { return nameNode.text; } return null; } function getObjectProperty(objectLiteral, name) { for (const property of objectLiteral.properties) { if (!ts.isPropertyAssignment(property)) { continue; } if (propertyNameText(property.name) === name) { return property.initializer; } } return null; } function getStringProperty(objectLiteral, name) { return stringLiteralValue(getObjectProperty(objectLiteral, name)); } function getIdentifierProperty(objectLiteral, name) { const initializer = getObjectProperty(objectLiteral, name); return initializer && ts.isIdentifier(initializer) ? initializer.text : null; } function getArrayProperty(objectLiteral, name) { const initializer = getObjectProperty(objectLiteral, name); return initializer && ts.isArrayLiteralExpression(initializer) ? initializer : null; } function isRouteFile(filePath) { const normalized = filePath.replace(/\\/g, '/'); return normalized.endsWith('.routes.ts') || normalized.endsWith('/app.routes.ts'); } function isSpecOrStory(filePath) { const normalized = filePath.replace(/\\/g, '/'); return normalized.includes('.spec.') || normalized.includes('.stories.'); } function extractAbsoluteRoutes(text) { const routes = []; const pattern = /['"`](\/[A-Za-z0-9_:@?&=./-]*)['"`]/g; let match = pattern.exec(text); while (match) { routes.push(normalizeRoute(match[1])); match = pattern.exec(text); } return sortUnique(routes); } function deriveFamily(relativePath) { const parts = relativePath.split('/'); if (parts[0] === 'features') { return parts[1] ?? 'features'; } if (parts[0] === 'shared') { return parts[1] ?? 'shared'; } if (parts[0] === 'layout') { return parts[1] ?? 'layout'; } if (parts[0] === 'routes') { return parts[1] ?? 'routes'; } return parts[0]; } function componentRoleHint(component) { const name = component.className.toLowerCase(); const folders = component.relativePath.toLowerCase(); if (name.includes('dashboard') || name.includes('overview') || name.includes('home')) { return 'landing or overview surface'; } if (name.includes('editor') || name.includes('builder') || name.includes('composer')) { return 'authoring or editing surface'; } if (name.includes('simulate') || name.includes('simulation') || name.includes('shadow')) { return 'simulation and what-if analysis surface'; } if (name.includes('approval') || name.includes('review')) { return 'approval and review surface'; } if (name.includes('wizard') || name.includes('setup')) { return 'setup or guided workflow surface'; } if (name.includes('audit') || folders.includes('/audit')) { return 'audit and evidence surface'; } if (name.includes('drawer') || name.includes('panel') || name.includes('detail')) { return 'detail panel or supporting drill-down surface'; } if (name.includes('watch') || name.includes('alert') || name.includes('notify')) { return 'monitoring and alerting surface'; } if (name.includes('visual') || name.includes('graph') || name.includes('timeline')) { return 'visualization or exploration surface'; } return 'dedicated feature surface'; } function describeApparentPurpose(component) { const pathBits = component.relativePath .replace(/^features\//, '') .replace(/^shared\//, '') .replace(/^layout\//, '') .replace(/\/[^/]+\.component\.ts$/, '') .split('/') .filter(Boolean) .map(titleCase); const area = pathBits.length > 0 ? pathBits.join(' / ') : titleCase(component.family); return `${humanizeComponentName(component.className)} appears to be a ${componentRoleHint(component)} in the ${area} area.`; } function pathToDocLink(filePath) { return repoRel(filePath); } function defaultBranchTitle(family) { return titleCase(family); } const BRANCH_RULES = [ { key: 'policy-studio', test: (component) => component.relativePath.startsWith('features/policy-studio/'), title: 'Policy Studio Legacy', branchWhat: 'Legacy all-in-one policy authoring, approvals, dashboard, and simulation surfaces from the older IA.', recommendation: 'merge', preservationValue: 'high', whyDropped: 'The current route tree moved policy work into newer governance and simulation surfaces, leaving the old `/policy-studio` family unmounted.', preserve: 'Keep the authoring concepts, simulation affordances, approvals flow, and any editor UX that is stronger than the current split surfaces.', likelyDestination: '/admin/policy/governance and /admin/policy/simulation', relatedDocs: [ 'docs/contracts/policy-studio.md', 'docs/modules/ui/v2-rewire/source-of-truth.md', ], }, { key: 'release-control', test: (component) => component.relativePath.startsWith('features/release-control/'), title: 'Release Control Legacy', branchWhat: 'Pre-rewire release-control setup, governance, and landing pages retained after the route migration.', recommendation: 'archive', preservationValue: 'low', whyDropped: 'The canonical IA now redirects old `release-control/*` concepts into `/releases`, `/ops`, and `/setup`, so the original surfaces no longer anchor the product.', preserve: 'Preserve only reusable copy, sequencing, or setup checklists that still improve the newer release and topology flows.', likelyDestination: '/releases, /ops/platform-setup, and /setup/topology', relatedDocs: [ 'docs/modules/ui/v2-rewire/S00_route_deprecation_map.md', 'docs/modules/ui/v2-rewire/source-of-truth.md', ], }, { key: 'platform-ops', test: (component) => component.relativePath.startsWith('features/platform-ops/'), title: 'Platform Ops Legacy', branchWhat: 'Older operations console pages from before the current `/ops` shell consolidation.', recommendation: 'merge', preservationValue: 'medium', whyDropped: 'Ops views were consolidated under the newer platform and operations route families, leaving the older platform-ops pages behind.', preserve: 'Keep distinctive observability widgets, platform state views, and telemetry interactions that are still missing from the current ops pages.', likelyDestination: '/ops and /ops/platform-setup', relatedDocs: [ 'docs/modules/platform/architecture-overview.md', 'docs/modules/ui/v2-rewire/source-of-truth.md', ], }, { key: 'workflow-visualization', test: (component) => component.relativePath.startsWith('features/workflow-visualization/'), title: 'Workflow Visualization Prototype', branchWhat: 'A prototype workflow explorer with time-travel and step drill-down concepts.', recommendation: 'investigate', preservationValue: 'medium', whyDropped: 'The route file exists, but nothing mounts the workflow visualization branch in the active shell.', preserve: 'Keep the timeline, step-diff, and replay ideas if they can strengthen evidence or release run introspection.', likelyDestination: '/evidence or /releases/runs', relatedDocs: [ 'docs/modules/ui/architecture.md', 'docs/modules/platform/architecture-overview.md', ], }, { key: 'watchlist', test: (component) => component.relativePath.startsWith('features/watchlist/'), title: 'Watchlist', branchWhat: 'Operator watchlist and monitoring surfaces backed by a real client/provider but not wired into navigation.', recommendation: 'wire-in', preservationValue: 'high', whyDropped: 'The feature appears partially implemented but never surfaced in the current route tree or menu.', preserve: 'Keep the operational watchlist concept, status views, and monitoring workflows; this looks closer to productizable than many other dead branches.', likelyDestination: '/mission-control or /ops', relatedDocs: [ 'docs/operations/watchlist-monitoring-runbook.md', 'docs/modules/platform/architecture-overview.md', ], }, { key: 'reachability', test: (component) => component.relativePath.startsWith('features/reachability/'), title: 'Reachability Witnessing', branchWhat: 'Reachability proof, witness, and proof-of-exploit supporting surfaces.', recommendation: 'merge', preservationValue: 'high', whyDropped: 'The supporting pages and drawers are not mounted even though reachability remains a core Stella Ops differentiator.', preserve: 'Keep witness capture, proof overlays, and explainability flows that can reinforce policy and evidence decisions.', likelyDestination: '/security/reachability or /evidence', relatedDocs: [ 'docs/modules/platform/architecture-overview.md', 'docs/modules/ui/architecture.md', ], }, { key: 'vex-studio', test: (component) => component.relativePath.startsWith('features/vex-studio/'), title: 'VEX Studio', branchWhat: 'Conflict-resolution and authoring surfaces around VEX decisions.', recommendation: 'merge', preservationValue: 'high', whyDropped: 'The standalone studio is not mounted, but related VEX concepts and data structures still appear to matter elsewhere.', preserve: 'Keep conflict resolution workflows, rationale capture, and consensus tooling that could strengthen VEX Hub.', likelyDestination: '/admin/vex-hub', relatedDocs: [ 'docs/modules/platform/architecture-overview.md', 'docs/modules/ui/architecture.md', ], }, { key: 'triage', test: (component) => component.relativePath.startsWith('features/triage/'), title: 'Triage Workbench', branchWhat: 'Triage experiments, workbenches, and audit-bundle side surfaces around artifact analysis.', recommendation: 'merge', preservationValue: 'medium', whyDropped: 'Several triage surfaces read as design experiments that were never folded cleanly into the main artifact workspace.', preserve: 'Keep any explainability, quiet-lane, or audit-bundle interactions that meaningfully shorten operator triage loops.', likelyDestination: '/triage/artifacts or /evidence', relatedDocs: [ 'docs/modules/platform/architecture-overview.md', 'docs/modules/ui/architecture.md', ], }, { key: 'policy-governance', test: (component) => component.relativePath.startsWith('features/policy-governance/'), title: 'Policy Governance', branchWhat: 'Current policy governance admin subtree that looks real but still needs surface verification.', recommendation: 'preserve', preservationValue: 'high', whyDropped: 'These pages are routed but some leaves are weakly surfaced in static analysis because their navigation appears to rely on local tabs or relative routing.', preserve: 'Preserve the full governance branch; the main question is wiring quality, not product value.', likelyDestination: '/admin/policy/governance', relatedDocs: [ 'docs/modules/ui/v2-rewire/source-of-truth.md', 'docs/contracts/policy-studio.md', ], }, { key: 'policy-simulation', test: (component) => component.relativePath.startsWith('features/policy-simulation/'), title: 'Policy Simulation', branchWhat: 'Current policy simulation admin subtree that likely remains product-relevant.', recommendation: 'preserve', preservationValue: 'high', whyDropped: 'Static scans can miss child-tab flows, so weak surfacing here does not imply abandonment.', preserve: 'Preserve shadow mode, scenario comparison, and simulation drill-down surfaces; focus on navigation evidence before cutting anything.', likelyDestination: '/admin/policy/simulation', relatedDocs: [ 'docs/modules/ui/v2-rewire/source-of-truth.md', 'docs/contracts/policy-studio.md', ], }, ]; function resolveBranchRule(component) { return BRANCH_RULES.find((rule) => rule.test(component)) ?? { key: component.family, title: defaultBranchTitle(component.family), branchWhat: `${defaultBranchTitle(component.family)} components grouped from the static unused-component scan.`, recommendation: component.classification === 'dead' ? 'investigate' : 'preserve', preservationValue: component.classification === 'dead' ? 'medium' : 'high', whyDropped: component.classification === 'dead' ? 'No route or runtime references remain in the current Angular shell, which suggests the surface was dropped or replaced.' : 'The component is still routed, but the current scan did not find an obvious menu or absolute page-action path to it.', preserve: 'Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts.', likelyDestination: 'Needs branch-level review against current IA', relatedDocs: [ 'docs/modules/ui/README.md', 'docs/modules/ui/architecture.md', ], }; } function scanComponents() { const componentFiles = walk(appRoot).filter((filePath) => filePath.endsWith('.component.ts')); const components = []; for (const filePath of componentFiles) { const sourceText = read(filePath); const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); for (const statement of sourceFile.statements) { if (!ts.isClassDeclaration(statement) || !statement.name) { continue; } const decorators = getDecorators(statement); const componentDecorator = decorators.find((decorator) => { const expression = decorator.expression; return ts.isCallExpression(expression) && expression.expression.getText(sourceFile) === 'Component'; }); if (!componentDecorator) { continue; } const expression = componentDecorator.expression; const config = ts.isCallExpression(expression) ? expression.arguments[0] : null; const selector = config && ts.isObjectLiteralExpression(config) ? getStringProperty(config, 'selector') : null; const relativePath = appRel(filePath); components.push({ className: statement.name.text, selector, filePath, relativePath, family: deriveFamily(relativePath), }); } } return components.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } function isRouteArray(node) { if (!ts.isArrayLiteralExpression(node)) { return false; } return node.elements.some((element) => { return ts.isObjectLiteralExpression(element) && getObjectProperty(element, 'path'); }); } function getLoadTarget(initializer, sourceFile) { if (!initializer) { return null; } const text = initializer.getText(sourceFile); const importMatch = text.match(/import\(['"`](.+?)['"`]\)/); const exportMatch = text.match(/=>\s*[A-Za-z0-9_]+\.(\w+)/); if (!importMatch || !exportMatch) { return null; } return { importPath: importMatch[1], exportName: exportMatch[1], }; } function resolveTsModule(fromFile, importPath) { const absoluteBase = path.resolve(path.dirname(fromFile), importPath); const candidates = [ `${absoluteBase}.ts`, path.join(absoluteBase, 'index.ts'), ]; return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; } function loadRouteFile(filePath, cache) { if (cache.has(filePath)) { return cache.get(filePath); } const sourceText = read(filePath); const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); const arrays = new Map(); function visit(node) { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isRouteArray(node.initializer)) { arrays.set(node.name.text, node.initializer); } ts.forEachChild(node, visit); } visit(sourceFile); const parsed = { sourceFile, arrays }; cache.set(filePath, parsed); return parsed; } function collectRoutesFromArray(filePath, exportName, prefix, cache, visited, routeIndex) { const visitKey = `${filePath}::${exportName}::${prefix}`; if (visited.has(visitKey)) { return; } visited.add(visitKey); const parsed = loadRouteFile(filePath, cache); const arrayNode = parsed.arrays.get(exportName); if (!arrayNode) { return; } collectRouteArray(filePath, parsed.sourceFile, arrayNode, prefix, cache, visited, routeIndex); } function collectRouteArray(filePath, sourceFile, arrayNode, prefix, cache, visited, routeIndex) { for (const element of arrayNode.elements) { if (!ts.isObjectLiteralExpression(element)) { continue; } collectRouteObject(filePath, sourceFile, element, prefix, cache, visited, routeIndex); } } function collectRouteObject(filePath, sourceFile, objectLiteral, prefix, cache, visited, routeIndex) { const routePath = getStringProperty(objectLiteral, 'path') ?? ''; const fullPath = joinRoute(prefix, routePath); const componentName = getIdentifierProperty(objectLiteral, 'component') || (function () { const loadComponent = getObjectProperty(objectLiteral, 'loadComponent'); if (!loadComponent) { return null; } const text = loadComponent.getText(sourceFile); const match = text.match(/=>\s*[A-Za-z0-9_]+\.(\w+)/); return match ? match[1] : null; })(); if (componentName) { const current = routeIndex.get(componentName) ?? []; current.push({ path: fullPath, routeFile: repoRel(filePath), }); routeIndex.set(componentName, current); } const children = getArrayProperty(objectLiteral, 'children'); if (children) { collectRouteArray(filePath, sourceFile, children, fullPath, cache, visited, routeIndex); } const loadChildren = getLoadTarget(getObjectProperty(objectLiteral, 'loadChildren'), sourceFile); if (!loadChildren) { return; } const targetFile = resolveTsModule(filePath, loadChildren.importPath); if (!targetFile) { return; } collectRoutesFromArray(targetFile, loadChildren.exportName, fullPath, cache, visited, routeIndex); } function buildRouteIndex() { const routeIndex = new Map(); const cache = new Map(); const visited = new Set(); const rootRoutesFile = path.join(appRoot, 'app.routes.ts'); collectRoutesFromArray(rootRoutesFile, 'routes', '/', cache, visited, routeIndex); for (const [componentName, entries] of routeIndex.entries()) { const deduped = sortUnique(entries.map((entry) => `${entry.path}@@${entry.routeFile}`)).map((value) => { const [routePath, routeFile] = value.split('@@'); return { path: routePath, routeFile }; }); routeIndex.set(componentName, deduped); } return routeIndex; } function buildTextIndex() { const files = walk(appRoot).filter((filePath) => /\.(ts|html|scss)$/.test(filePath)); return files.map((filePath) => ({ filePath, relativePath: appRel(filePath), text: read(filePath), })); } function findRuntimeRefs(component, textIndex) { const refs = []; for (const file of textIndex) { if (file.filePath === component.filePath) { continue; } if (isSpecOrStory(file.filePath) || isRouteFile(file.filePath)) { continue; } if (file.text.includes(component.className) || (component.selector && file.text.includes(component.selector))) { refs.push(file.relativePath); } } return sortUnique(refs); } function buildRouteSurfaceIndex(textIndex) { const menuRoutes = new Map(); const actionRoutes = new Map(); for (const file of textIndex) { if (isRouteFile(file.filePath) || isSpecOrStory(file.filePath)) { continue; } const target = MENU_FILES.includes(path.normalize(file.filePath)) ? menuRoutes : actionRoutes; for (const routePath of extractAbsoluteRoutes(file.text)) { const files = target.get(routePath) ?? []; files.push(file.relativePath); target.set(routePath, sortUnique(files)); } } return { menuRoutes, actionRoutes }; } function classifyComponents(components, routeIndex, textIndex, routeSurfaceIndex) { const candidates = []; for (const component of components) { const routeEntries = routeIndex.get(component.className) ?? []; const routePaths = sortUnique(routeEntries.map((entry) => entry.path)); const routeFiles = sortUnique(routeEntries.map((entry) => entry.routeFile)); const runtimeRefs = findRuntimeRefs(component, textIndex); const menuSurfaceFiles = sortUnique( routePaths.flatMap((routePath) => routeSurfaceIndex.menuRoutes.get(routePath) ?? []) ); const actionSurfaceFiles = sortUnique( routePaths.flatMap((routePath) => routeSurfaceIndex.actionRoutes.get(routePath) ?? []) ); let classification = null; let confidence = null; if (routePaths.length === 0 && runtimeRefs.length === 0) { classification = 'dead'; confidence = 'high'; } else if (routePaths.length > 0 && runtimeRefs.length === 0 && menuSurfaceFiles.length === 0 && actionSurfaceFiles.length === 0) { classification = 'weak-route'; confidence = 'medium'; } if (!classification) { continue; } const enriched = { ...component, routePaths, routeFiles, runtimeRefs, menuSurfaceFiles, actionSurfaceFiles, classification, confidence, }; const branch = resolveBranchRule(enriched); candidates.push({ ...enriched, branchKey: branch.key, branchTitle: branch.title, branchWhat: branch.branchWhat, recommendation: branch.recommendation, preservationValue: branch.preservationValue, whyDropped: branch.whyDropped, preserve: branch.preserve, likelyDestination: branch.likelyDestination, relatedDocs: branch.relatedDocs, }); } return candidates.sort((left, right) => { if (left.classification !== right.classification) { return left.classification.localeCompare(right.classification); } if (left.branchKey !== right.branchKey) { return left.branchKey.localeCompare(right.branchKey); } return left.relativePath.localeCompare(right.relativePath); }); } function toComponentDocPath(component) { const parts = component.relativePath.split('/'); const fileName = `${component.className}.md`; const sourceDirParts = parts.slice(0, -1).map(slugify).filter(Boolean); return path.join(outRoot, 'components', component.classification, ...sourceDirParts, fileName); } function toBranchIndexPath(classification, family) { return path.join(outRoot, 'components', classification, slugify(family), 'README.md'); } function relativeLink(fromFile, toFile) { return path.relative(path.dirname(fromFile), toFile).replace(/\\/g, '/'); } function markdownList(values) { if (!values || values.length === 0) { return '- none'; } return values.map((value) => `- \`${value}\``).join('\n'); } function nextPassQuestions(component) { if (component.classification === 'weak-route') { return [ 'Confirm whether the route is reachable only through relative child-tab navigation.', 'Check the corresponding product/docs promise before treating the page as dropped.', 'Verify whether the route should be linked from the current shell or intentionally remain deep-linked only.', ]; } return [ 'Check whether newer routed pages already absorbed this workflow under a different name.', 'Review component templates and services for reusable UX or domain language worth salvaging.', 'Validate the preservation call against current Stella Ops product docs before archival.', ]; } function renderComponentDoc(component, docPath) { const routeBlock = component.routePaths.length > 0 ? markdownList(component.routePaths) : '- none'; const routeFileBlock = component.routeFiles.length > 0 ? markdownList(component.routeFiles) : '- none'; const menuBlock = component.menuSurfaceFiles.length > 0 ? markdownList(component.menuSurfaceFiles) : '- none'; const actionBlock = component.actionSurfaceFiles.length > 0 ? markdownList(component.actionSurfaceFiles) : '- none'; const runtimeBlock = component.runtimeRefs.length > 0 ? markdownList(component.runtimeRefs) : '- none'; const questions = nextPassQuestions(component).map((item) => `- ${item}`).join('\n'); const docsBlock = component.relatedDocs.length > 0 ? component.relatedDocs.map((doc) => `- [${doc}](${relativeLink(docPath, path.join(repoRoot, doc))})`).join('\n') : '- none'; return `# ${component.className} ## Status Snapshot - Classification: \`${component.classification}\` - Confidence: \`${component.confidence}\` - Recommendation: \`${component.recommendation}\` - Preservation value: \`${component.preservationValue}\` - Feature branch: \`${component.branchTitle}\` - Source: \`${component.relativePath}\` - Selector: \`${component.selector ?? 'n/a'}\` ## What Is It? ${describeApparentPurpose(component)} ## Why It Likely Fell Out Of The Product ${component.whyDropped} ## What Is Worth Preserving ${component.preserve} ## Likely Successor Or Merge Target ${component.likelyDestination} ## Static Evidence ### Routed paths ${routeBlock} ### Route files ${routeFileBlock} ### Menu surfaces ${menuBlock} ### Absolute page-action surfaces ${actionBlock} ### Runtime references outside routes/tests ${runtimeBlock} ## Related Docs ${docsBlock} ## Next-Pass Questions ${questions} `; } function renderBranchIndex(classification, family, items) { const branch = items[0]; const grouped = items .slice() .sort((left, right) => left.relativePath.localeCompare(right.relativePath)) .map((item) => { const docPath = toComponentDocPath(item); return `- [${item.className}](${relativeLink(toBranchIndexPath(classification, family), docPath)}) - \`${item.recommendation}\`, ${item.relativePath}`; }) .join('\n'); return `# ${branch.branchTitle} ## Branch Summary - Classification bucket: \`${classification}\` - Components in branch: ${items.length} - Default recommendation: \`${branch.recommendation}\` - Preservation value: \`${branch.preservationValue}\` ${branch.branchWhat} ## Why This Branch Matters ${branch.preserve} ## Likely Destination ${branch.likelyDestination} ## Components ${grouped} `; } function renderSummaryTree(branches) { const lines = [ '# Summary Tree', '', `Generated on ${GENERATION_DATE}. This is the branch-level view of candidate unused or weakly surfaced UI families.`, '', ]; for (const [classification, branchMap] of branches.entries()) { lines.push(`## ${titleCase(classification)}`); lines.push(''); for (const [, items] of branchMap.entries()) { const branch = items[0]; lines.push(`- ${branch.branchTitle}`); lines.push(` what: ${branch.branchWhat}`); lines.push(` keep: ${branch.preserve}`); lines.push(` action: \`${branch.recommendation}\``); lines.push(` why: ${branch.whyDropped}`); lines.push(` count: ${items.length} components`); } lines.push(''); } return `${lines.join('\n')}\n`; } function renderRootReadme(candidates, branches) { const totals = new Map(); for (const candidate of candidates) { totals.set(candidate.classification, (totals.get(candidate.classification) ?? 0) + 1); } const lines = [ '# UI Component Preservation Map', '', `Generated on ${GENERATION_DATE}. This map captures Angular components that currently look either high-confidence dead or routed-but-weakly-surfaced in the Stella Ops web console.`, '', '## How To Read This Map', '- `dead`: no active route target and no runtime references outside tests/stories.', '- `weak-route`: still routed, but the current static scan found no menu entry and no obvious absolute page-action path to the route.', '- Recommendation labels are intentionally constrained: `archive`, `merge`, `preserve`, `wire-in`, `investigate`.', '', '## Counts', `- Dead components: ${totals.get('dead') ?? 0}`, `- Weak-route components: ${totals.get('weak-route') ?? 0}`, `- Total candidates: ${candidates.length}`, '', '## Main Artifacts', '- [Summary Tree](./SUMMARY_TREE.md)', '- [Inventory JSON](./inventory.json)', '', '## Branch Index', ]; for (const [classification, branchMap] of branches.entries()) { lines.push(`### ${titleCase(classification)}`); for (const [family, items] of branchMap.entries()) { const branchFile = toBranchIndexPath(classification, family); const branch = items[0]; lines.push(`- [${branch.branchTitle}](./${repoRel(branchFile).replace(/^docs\/modules\/ui\/component-preservation-map\//, '')}) - ${items.length} components, default \`${branch.recommendation}\``); } lines.push(''); } lines.push('## Notes'); lines.push('- This is a first-pass map. The weak-route bucket especially needs follow-up review against relative tab navigation and Stella Ops product docs.'); lines.push('- Per-component dossiers are intentionally stable so later iterations can deepen the judgment rather than recreate the inventory.'); return `${lines.join('\n')}\n`; } function groupBranches(candidates) { const branches = new Map(); for (const candidate of candidates) { const classificationMap = branches.get(candidate.classification) ?? new Map(); const branchItems = classificationMap.get(candidate.branchKey) ?? []; branchItems.push(candidate); classificationMap.set(candidate.branchKey, branchItems); branches.set(candidate.classification, classificationMap); } for (const [, branchMap] of branches.entries()) { for (const [, items] of branchMap.entries()) { items.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } } return branches; } function cleanOutput() { if (!fs.existsSync(outRoot)) { return; } for (const entry of fs.readdirSync(outRoot, { withFileTypes: true })) { if (entry.name === '_tools') { continue; } const target = path.join(outRoot, entry.name); fs.rmSync(target, { recursive: true, force: true }); } } function main() { cleanOutput(); const components = scanComponents(); const routeIndex = buildRouteIndex(); const textIndex = buildTextIndex(); const routeSurfaceIndex = buildRouteSurfaceIndex(textIndex); const candidates = classifyComponents(components, routeIndex, textIndex, routeSurfaceIndex); const branches = groupBranches(candidates); for (const candidate of candidates) { const docPath = toComponentDocPath(candidate); write(docPath, renderComponentDoc(candidate, docPath)); } for (const [classification, branchMap] of branches.entries()) { for (const [family, items] of branchMap.entries()) { write(toBranchIndexPath(classification, family), renderBranchIndex(classification, family, items)); } } write(path.join(outRoot, 'README.md'), renderRootReadme(candidates, branches)); write(path.join(outRoot, 'SUMMARY_TREE.md'), renderSummaryTree(branches)); write( path.join(outRoot, 'inventory.json'), `${JSON.stringify({ generatedOn: GENERATION_DATE, counts: { dead: candidates.filter((item) => item.classification === 'dead').length, weakRoute: candidates.filter((item) => item.classification === 'weak-route').length, total: candidates.length, }, candidates: candidates.map((candidate) => ({ className: candidate.className, selector: candidate.selector, classification: candidate.classification, confidence: candidate.confidence, recommendation: candidate.recommendation, preservationValue: candidate.preservationValue, family: candidate.family, branchKey: candidate.branchKey, branchTitle: candidate.branchTitle, source: candidate.relativePath, routePaths: candidate.routePaths, routeFiles: candidate.routeFiles, menuSurfaceFiles: candidate.menuSurfaceFiles, actionSurfaceFiles: candidate.actionSurfaceFiles, runtimeRefs: candidate.runtimeRefs, relatedDocs: candidate.relatedDocs, likelyDestination: candidate.likelyDestination, })), }, null, 2)}\n` ); const counts = candidates.reduce((accumulator, candidate) => { accumulator[candidate.classification] = (accumulator[candidate.classification] ?? 0) + 1; return accumulator; }, {}); console.log(JSON.stringify({ generatedOn: GENERATION_DATE, counts, totalCandidates: candidates.length, outputRoot: repoRel(outRoot), }, null, 2)); } main();