Files
git.stella-ops.org/docs/modules/ui/component-preservation-map/_tools/generate-map.cjs

1039 lines
36 KiB
JavaScript

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();