1039 lines
36 KiB
JavaScript
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();
|