#!/usr/bin/env node /** * Deep catalogue-quality audit for the Phase 1 core machine set. * * This script intentionally uses the TypeScript compiler API instead of importing * application modules. That keeps the check useful in CI even when React/Three * runtime dependencies, browser APIs, or Vite-specific transforms are not loaded. */ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.resolve(__dirname, '..'); const DEFAULT_EXPECTED_COUNT = 28; const MACHINE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const PATHS = { catalogueDir: path.join(ROOT_DIR, 'src/modules/machines/catalogue'), coreRegistry: path.join(ROOT_DIR, 'src/modules/machines/coreMachines.ts'), blueprint: path.join(ROOT_DIR, 'src/modules/machines/procedural/coreMachineBlueprints.ts'), dossierDir: path.join(ROOT_DIR, 'src/modules/machines/dossiers'), animation: path.join(ROOT_DIR, 'src/animations/coreMachineAnimations.ts'), replacementDocs: [ path.join(ROOT_DIR, 'docs/procedural-blueprints-and-asset-replacements.md'), path.join(ROOT_DIR, 'docs/core-machine-catalogue-expansion.md'), path.join(ROOT_DIR, 'docs/core-machine-integration-audit.md'), ], }; const ALIASES = { id: ['id', 'machineId', 'slug'], title: ['title', 'name', 'displayName'], description: ['description', 'summary', 'overview', 'shortDescription'], category: ['category', 'family', 'group'], difficulty: ['difficulty', 'level', 'complexity'], facts: ['engineeringFacts', 'facts', 'technicalFacts', 'learningFacts', 'keyFacts'], related: ['relatedMachines', 'relatedMachineIds', 'related', 'seeAlso', 'connections'], components: ['components', 'componentHierarchy', 'parts', 'partList', 'assemblies', 'nodes'], childComponents: ['children', 'subcomponents', 'components', 'parts', 'assemblies', 'nodes', 'items'], cameras: ['cameraPresets', 'viewPresets', 'cameraViews', 'savedViews', 'views'], labels: ['labels', 'hotspots', 'annotations', 'callouts', 'labelAnchors'], thumbnail: ['thumbnail', 'thumbnailStrategy', 'catalogueThumbnail', 'preview', 'previewStrategy'], exploded: ['explodedView', 'exploded', 'explodedViewMetadata', 'explosion', 'explode'], animation: ['animation', 'animationBehavior', 'animationConfig', 'motion', 'simulation'], componentDescription: ['description', 'summary', 'function', 'purpose', 'engineeringNote', 'notes'], componentName: ['name', 'title', 'label', 'displayName'], componentExplodeVector: [ 'explodedOffset', 'explodeOffset', 'explosionOffset', 'explodeVector', 'separationVector', 'explodedPosition', 'explodedViewOffset', 'targetPosition', ], geometrySignals: ['geometry', 'shape', 'mesh', 'primitive', 'model', 'dimensions', 'position'], assetReplacement: [ 'asset', 'assetStrategy', 'assetReplacement', 'glbReplacement', 'glbReplacementPoint', 'model', 'modelUrl', 'gltf', 'glb', ], }; let ts; async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } ts = await loadTypeScript(); const audit = runAudit(options); const rendered = renderAudit(audit, options); if (options.output) { const outputPath = path.resolve(ROOT_DIR, options.output); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${rendered}\n`, 'utf8'); if (!options.quiet) { console.log(`Wrote catalogue audit report to ${toRelative(outputPath)}`); } } else if (!options.quiet) { console.log(rendered); } const shouldFail = audit.errors.length > 0 || (options.strictWarnings && audit.warnings.length > 0); if (shouldFail) { process.exitCode = 1; } } function parseArgs(argv) { const options = { expectedCount: DEFAULT_EXPECTED_COUNT, format: 'text', output: '', quiet: false, strictWarnings: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-h') { options.help = true; } else if (arg === '--ci') { options.format = 'text'; } else if (arg === '--quiet') { options.quiet = true; } else if (arg === '--strict-warnings') { options.strictWarnings = true; } else if (arg === '--format') { options.format = argv[index + 1] ?? options.format; index += 1; } else if (arg.startsWith('--format=')) { options.format = arg.slice('--format='.length); } else if (arg === '--output') { options.output = argv[index + 1] ?? ''; index += 1; } else if (arg.startsWith('--output=')) { options.output = arg.slice('--output='.length); } else if (arg === '--expected-count') { options.expectedCount = Number(argv[index + 1] ?? DEFAULT_EXPECTED_COUNT); index += 1; } else if (arg.startsWith('--expected-count=')) { options.expectedCount = Number(arg.slice('--expected-count='.length)); } else { throw new Error(`Unknown argument: ${arg}`); } } if (!['text', 'markdown', 'json'].includes(options.format)) { throw new Error(`Unsupported format "${options.format}". Use text, markdown, or json.`); } if (!Number.isInteger(options.expectedCount) || options.expectedCount <= 0) { throw new Error(`Expected count must be a positive integer. Received ${options.expectedCount}.`); } return options; } function printHelp() { console.log(`Core Machine Catalogue Depth Audit Usage: node scripts/audit-core-machine-catalogue-depth.mjs [options] Options: --expected-count Expected number of Phase 1 catalogue machines. Default: 28 --format Report format: text, markdown, or json. Default: text --output Write the report to a file instead of stdout --strict-warnings Exit non-zero for warnings as well as errors --quiet Suppress stdout except thrown errors --ci CI-friendly alias; keeps text output and fails on errors --help Show this help text Examples: node scripts/audit-core-machine-catalogue-depth.mjs node scripts/audit-core-machine-catalogue-depth.mjs --format=markdown --output=docs/generated/core-machine-audit.md node scripts/audit-core-machine-catalogue-depth.mjs --strict-warnings `); } async function loadTypeScript() { try { const typeScriptModule = await import('typescript'); return typeScriptModule.default ?? typeScriptModule; } catch (error) { console.error( [ 'Unable to load the "typescript" package required for the catalogue audit.', 'Run npm install first, then retry:', ' node scripts/audit-core-machine-catalogue-depth.mjs', ].join('\n'), ); throw error; } } function runAudit(options) { const issues = []; const catalogueFiles = walkTsFiles(PATHS.catalogueDir).filter( (filePath) => !filePath.endsWith('/catalogueHelpers.ts') && !filePath.endsWith('\\catalogueHelpers.ts'), ); if (catalogueFiles.length === 0) { issues.push(createIssue('error', 'global', 'No catalogue TypeScript files were found.', PATHS.catalogueDir)); } const catalogueContexts = catalogueFiles.map(parseTypeScriptFile); const discoveredMachines = catalogueContexts.flatMap(findMachineCandidates); const duplicateIds = findDuplicateIds(discoveredMachines); const machinesById = new Map(); for (const machine of discoveredMachines) { if (!machinesById.has(machine.id)) { machinesById.set(machine.id, machine); } } const machines = [...machinesById.values()].sort(compareMachineRecords); const machineIds = new Set(machines.map((machine) => machine.id)); if (machines.length !== options.expectedCount) { issues.push( createIssue( 'error', 'global', `Expected ${options.expectedCount} Phase 1 machines but found ${machines.length}.`, PATHS.catalogueDir, ), ); } for (const duplicate of duplicateIds) { issues.push( createIssue( 'error', duplicate.id, `Duplicate machine id "${duplicate.id}" appears ${duplicate.count} times.`, duplicate.locations.join(', '), ), ); } const coreRegistryText = readText(PATHS.coreRegistry); const blueprintText = readText(PATHS.blueprint); const animationText = readText(PATHS.animation); const dossierText = walkTsFiles(PATHS.dossierDir).map(readText).join('\n'); const replacementDocsText = PATHS.replacementDocs.map(readText).join('\n'); if (!coreRegistryText) { issues.push(createIssue('error', 'global', 'Missing core machine registry file.', PATHS.coreRegistry)); } if (!blueprintText) { issues.push(createIssue('error', 'global', 'Missing procedural blueprint registry file.', PATHS.blueprint)); } if (!animationText) { issues.push(createIssue('error', 'global', 'Missing core machine animation registry file.', PATHS.animation)); } if (!dossierText) { issues.push(createIssue('error', 'global', 'Missing machine dossier registry files.', PATHS.dossierDir)); } for (const machine of machines) { machine.coverage = { registry: hasRegistryCoverage(coreRegistryText, machine), blueprint: mentionsLiteralId(blueprintText, machine.id), dossier: mentionsLiteralId(dossierText, machine.id), animation: mentionsLiteralId(animationText, machine.id), assetReplacement: machine.hasAssetReplacement || hasNearbyReplacementLanguage(blueprintText, machine.id) || hasNearbyReplacementLanguage(replacementDocsText, machine.id), }; machine.score = calculateMachineScore(machine); addMachineIssues(machine, machineIds, issues); } const categoryCounts = countBy(machines, (machine) => normalizeValue(machine.category, 'uncategorized')); const categories = Object.entries(categoryCounts).sort(([left], [right]) => left.localeCompare(right)); if (categories.length < 5 && machines.length > 0) { issues.push( createIssue( 'warning', 'global', `Only ${categories.length} catalogue categories were detected. Phase 1 normally spans engines, transmissions/drives, pumps/fluid systems, mechanisms, and structural/other systems.`, PATHS.catalogueDir, ), ); } const errors = issues.filter((issue) => issue.severity === 'error'); const warnings = issues.filter((issue) => issue.severity === 'warning'); return { generatedAt: new Date().toISOString(), expectedCount: options.expectedCount, machineCount: machines.length, categories, machines, issues, errors, warnings, sourceFiles: catalogueFiles.map(toRelative), }; } function addMachineIssues(machine, machineIds, issues) { if (!MACHINE_ID_PATTERN.test(machine.id)) { issues.push( createIssue( 'error', machine.id, `Machine id should be kebab-case and URL-safe. Received "${machine.id}".`, machine.location, ), ); } if (!machine.title) { issues.push(createIssue('error', machine.id, 'Missing machine title.', machine.location)); } if (!machine.category) { issues.push(createIssue('error', machine.id, 'Missing machine category.', machine.location)); } if (!machine.difficulty) { issues.push(createIssue('error', machine.id, 'Missing machine difficulty.', machine.location)); } if (machine.descriptionLength < 60) { issues.push( createIssue( 'error', machine.id, `Description is too thin (${machine.descriptionLength} chars). Add a useful learner-facing overview.`, machine.location, ), ); } if (machine.factsCount < 2) { issues.push( createIssue( 'error', machine.id, `Expected at least 2 engineering facts; found ${machine.factsCount}.`, machine.location, ), ); } else if (machine.factsCount < 3) { issues.push( createIssue( 'warning', machine.id, `Only ${machine.factsCount} engineering facts detected; 3+ is the catalogue target.`, machine.location, ), ); } if (machine.relatedIds.length === 0) { issues.push( createIssue( 'warning', machine.id, 'No related machines detected. Add navigable connections for discovery.', machine.location, ), ); } else if (machine.relatedIds.length < 2) { issues.push( createIssue( 'warning', machine.id, `Only ${machine.relatedIds.length} related machine detected; 2+ is preferred.`, machine.location, ), ); } for (const relatedId of machine.relatedIds) { if (relatedId === machine.id) { issues.push(createIssue('warning', machine.id, 'Machine relates to itself.', machine.location)); } else if (MACHINE_ID_PATTERN.test(relatedId) && !machineIds.has(relatedId)) { issues.push( createIssue( 'error', machine.id, `Related machine "${relatedId}" does not exist in the Phase 1 catalogue.`, machine.location, ), ); } else if (!MACHINE_ID_PATTERN.test(relatedId)) { issues.push( createIssue( 'warning', machine.id, `Related entry "${relatedId}" is not a canonical machine id.`, machine.location, ), ); } } if (machine.componentsCount < 3) { issues.push( createIssue( 'error', machine.id, `Expected a real component hierarchy with at least 3 components; found ${machine.componentsCount}.`, machine.location, ), ); } else if (machine.componentsCount < 5) { issues.push( createIssue( 'warning', machine.id, `Component hierarchy has ${machine.componentsCount} components. Consider adding secondary parts and housings where useful.`, machine.location, ), ); } if (machine.describedComponentsCount < Math.min(3, machine.componentsCount)) { issues.push( createIssue( 'error', machine.id, `Only ${machine.describedComponentsCount}/${machine.componentsCount} components have substantial descriptions.`, machine.location, ), ); } else if (machine.describedComponentsCount < machine.componentsCount) { issues.push( createIssue( 'warning', machine.id, `${machine.componentsCount - machine.describedComponentsCount} component(s) lack substantial descriptions.`, machine.location, ), ); } if (machine.duplicateComponentIds.length > 0) { issues.push( createIssue( 'warning', machine.id, `Duplicate component ids detected: ${machine.duplicateComponentIds.join(', ')}.`, machine.location, ), ); } if (machine.cameraPresetCount < 2) { issues.push( createIssue( 'error', machine.id, `Expected saved camera presets; found ${machine.cameraPresetCount}.`, machine.location, ), ); } else if (machine.cameraPresetCount < 4) { issues.push( createIssue( 'warning', machine.id, `Only ${machine.cameraPresetCount} camera presets detected; front/side/isometric/detail coverage is preferred.`, machine.location, ), ); } if (machine.labelCount === 0) { issues.push(createIssue('error', machine.id, 'No labels, hotspots, or label-ready component names detected.', machine.location)); } else if (machine.labelCount < 3) { issues.push( createIssue( 'warning', machine.id, `Only ${machine.labelCount} label anchors/component labels detected.`, machine.location, ), ); } if (!machine.hasExplodedMetadata) { issues.push( createIssue( 'error', machine.id, 'Missing exploded-view metadata or per-component explosion vectors.', machine.location, ), ); } if (!machine.hasThumbnailStrategy) { issues.push( createIssue( 'warning', machine.id, 'Missing catalogue thumbnail strategy metadata.', machine.location, ), ); } if (!machine.coverage.registry) { issues.push( createIssue( 'error', machine.id, 'Machine is not visibly covered by src/modules/machines/coreMachines.ts.', machine.location, ), ); } if (!machine.coverage.blueprint) { issues.push( createIssue( 'error', machine.id, 'Machine id is missing from procedural core machine blueprints.', machine.location, ), ); } if (!machine.coverage.dossier) { issues.push( createIssue( 'error', machine.id, 'Machine id is missing from the educational dossier layer.', machine.location, ), ); } if (!machine.coverage.animation && !machine.hasAnimationMetadata) { issues.push( createIssue( 'error', machine.id, 'Machine id is missing from animation metadata/registry coverage.', machine.location, ), ); } if (!machine.coverage.assetReplacement) { issues.push( createIssue( 'warning', machine.id, 'No GLB/GLTF asset replacement language detected near this machine id.', machine.location, ), ); } } function findMachineCandidates(context) { const machines = []; visit(context.sourceFile); return machines; function visit(node) { if (ts.isObjectLiteralExpression(node)) { const map = propertyMap(node, context); const id = expressionToString(getAliasExpression(map, ALIASES.id), context); if (id && looksLikeMachineDefinition(map)) { machines.push(extractMachineRecord(node, map, context, id)); } } ts.forEachChild(node, visit); } } function looksLikeMachineDefinition(map) { const signals = [ hasAnyAlias(map, ALIASES.title), hasAnyAlias(map, ALIASES.description), hasAnyAlias(map, ALIASES.category) || hasAnyAlias(map, ALIASES.difficulty), hasAnyAlias(map, ALIASES.components), ]; return signals.every(Boolean); } function extractMachineRecord(node, map, context, id) { const title = expressionToString(getAliasExpression(map, ALIASES.title), context) ?? ''; const description = expressionToString(getAliasExpression(map, ALIASES.description), context) ?? ''; const category = expressionToString(getAliasExpression(map, ALIASES.category), context) ?? ''; const difficulty = expressionToString(getAliasExpression(map, ALIASES.difficulty), context) ?? ''; const factsExpression = getAliasExpression(map, ALIASES.facts); const relatedExpression = getAliasExpression(map, ALIASES.related); const componentsExpression = getAliasExpression(map, ALIASES.components); const cameraExpression = getAliasExpression(map, ALIASES.cameras); const labelExpression = getAliasExpression(map, ALIASES.labels); const explodedExpression = getAliasExpression(map, ALIASES.exploded); const animationExpression = getAliasExpression(map, ALIASES.animation); const thumbnailExpression = getAliasExpression(map, ALIASES.thumbnail); const assetExpression = getAliasExpression(map, ALIASES.assetReplacement); const components = collectComponents(componentsExpression, context); const componentIdCounts = countBy(components, (component) => component.id); const duplicateComponentIds = Object.entries(componentIdCounts) .filter(([, count]) => count > 1) .map(([componentId]) => componentId); const factsCount = Math.max( countEntries(factsExpression, context), collectStringLiterals(factsExpression, context).filter((fact) => fact.length >= 12).length, ); const explicitLabelCount = countEntries(labelExpression, context); const componentLabelCount = components.filter((component) => Boolean(component.name)).length; return { id, title, descriptionLength: description.length, category, difficulty, factsCount, relatedIds: collectRelatedMachineIds(relatedExpression, context), componentsCount: components.length, describedComponentsCount: components.filter((component) => component.hasDescription).length, duplicateComponentIds, cameraPresetCount: countEntries(cameraExpression, context), explicitLabelCount, componentLabelCount, labelCount: Math.max(explicitLabelCount, componentLabelCount), hasExplodedMetadata: Boolean(explodedExpression && countEntries(explodedExpression, context) > 0) || components.some((component) => component.hasExplodedVector), hasAnimationMetadata: Boolean(animationExpression), hasThumbnailStrategy: Boolean(thumbnailExpression), hasAssetReplacement: Boolean(assetExpression), sourceFile: toRelative(context.filePath), sourceStem: path.basename(context.filePath, path.extname(context.filePath)), location: `${toRelative(context.filePath)}:${lineAndColumn(context.sourceFile, node)}`, coverage: { registry: false, blueprint: false, dossier: false, animation: false, assetReplacement: false, }, score: 0, }; } function collectComponents(expression, context) { if (!expression) { return []; } const components = []; const seenNodes = new Set(); collect(expression, ''); return components; function collect(candidate, inheritedId) { const node = unwrapExpression(resolveExpression(candidate, context)); if (!node || seenNodes.has(node)) { return; } seenNodes.add(node); if (ts.isArrayLiteralExpression(node)) { for (const element of node.elements) { if (ts.isSpreadElement(element)) { collect(element.expression, ''); } else { collect(element, ''); } } return; } if (ts.isCallExpression(node)) { for (const arg of node.arguments) { const resolvedArg = unwrapExpression(resolveExpression(arg, context)); if ( resolvedArg && (ts.isArrayLiteralExpression(resolvedArg) || ts.isObjectLiteralExpression(resolvedArg)) ) { collect(resolvedArg, inheritedId); } } return; } if (!ts.isObjectLiteralExpression(node)) { return; } const map = propertyMap(node, context); const id = expressionToString(getAliasExpression(map, ['id', 'componentId', 'partId', 'key']), context) ?? inheritedId; const name = expressionToString(getAliasExpression(map, ALIASES.componentName), context) ?? ''; const description = expressionToString(getAliasExpression(map, ALIASES.componentDescription), context) ?? ''; const childExpressions = getAliasExpressions(map, ALIASES.childComponents); const hasGeometrySignal = hasAnyAlias(map, ALIASES.geometrySignals); const looksLikeComponent = Boolean( id && (name || description || childExpressions.length > 0 || hasGeometrySignal), ); if (looksLikeComponent) { components.push({ id, name, hasDescription: description.trim().length >= 20, hasExplodedVector: hasAnyAlias(map, ALIASES.componentExplodeVector), }); for (const childExpression of childExpressions) { collect(childExpression, ''); } return; } for (const property of node.properties) { if (!ts.isPropertyAssignment(property)) { continue; } const propertyName = getPropertyName(property.name, context); const initializer = unwrapExpression(resolveExpression(property.initializer, context)); if ( initializer && (ts.isObjectLiteralExpression(initializer) || ts.isArrayLiteralExpression(initializer)) ) { collect(initializer, isUsableMapKey(propertyName) ? propertyName : ''); } } } } function collectRelatedMachineIds(expression, context) { if (!expression) { return []; } const ids = []; const seenNodes = new Set(); collect(expression); return unique(ids.map((id) => id.trim()).filter(Boolean)); function collect(candidate) { const node = unwrapExpression(resolveExpression(candidate, context)); if (!node || seenNodes.has(node)) { return; } seenNodes.add(node); if (isStringLiteral(node)) { ids.push(node.text); return; } if (ts.isArrayLiteralExpression(node)) { for (const element of node.elements) { if (ts.isSpreadElement(element)) { collect(element.expression); } else { collect(element); } } return; } if (ts.isObjectLiteralExpression(node)) { const map = propertyMap(node, context); const directId = expressionToString( getAliasExpression(map, ['id', 'machineId', 'relatedMachineId', 'slug', 'ref']), context, ); if (directId) { ids.push(directId); return; } for (const property of node.properties) { if (ts.isPropertyAssignment(property)) { const propertyName = getPropertyName(property.name, context); if (isUsableMapKey(propertyName) && MACHINE_ID_PATTERN.test(propertyName)) { ids.push(propertyName); } else { collect(property.initializer); } } } return; } if (ts.isCallExpression(node)) { for (const arg of node.arguments) { collect(arg); } } } } function countEntries(expression, context, seenNodes = new Set()) { if (!expression) { return 0; } const node = unwrapExpression(resolveExpression(expression, context)); if (!node || seenNodes.has(node)) { return 0; } seenNodes.add(node); if (ts.isArrayLiteralExpression(node)) { return node.elements.reduce((total, element) => { if (ts.isSpreadElement(element)) { return total + countEntries(element.expression, context, seenNodes); } return total + 1; }, 0); } if (ts.isObjectLiteralExpression(node)) { return node.properties.reduce((total, property) => { if (ts.isSpreadAssignment(property)) { return total + countEntries(property.expression, context, seenNodes); } return total + 1; }, 0); } if (ts.isCallExpression(node)) { const collectionArg = node.arguments.find((arg) => { const resolvedArg = unwrapExpression(resolveExpression(arg, context)); return ( resolvedArg && (ts.isArrayLiteralExpression(resolvedArg) || ts.isObjectLiteralExpression(resolvedArg)) ); }); return collectionArg ? countEntries(collectionArg, context, seenNodes) : 0; } return 0; } function collectStringLiterals(expression, context, seenNodes = new Set()) { if (!expression) { return []; } const strings = []; visit(expression); return strings; function visit(candidate) { const node = unwrapExpression(resolveExpression(candidate, context)); if (!node || seenNodes.has(node)) { return; } seenNodes.add(node); if (isStringLiteral(node)) { strings.push(node.text); return; } if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) { strings.push(node.getText(context.sourceFile).replace(/^`|`$/g, '')); return; } if (ts.isArrayLiteralExpression(node)) { for (const element of node.elements) { if (ts.isSpreadElement(element)) { visit(element.expression); } else { visit(element); } } return; } if (ts.isObjectLiteralExpression(node)) { for (const property of node.properties) { if (ts.isPropertyAssignment(property)) { visit(property.initializer); } else if (ts.isSpreadAssignment(property)) { visit(property.expression); } } return; } if (ts.isCallExpression(node)) { for (const arg of node.arguments) { visit(arg); } } } } function parseTypeScriptFile(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); return { filePath, text, sourceFile, bindings: collectTopLevelBindings(sourceFile), }; } function collectTopLevelBindings(sourceFile) { const bindings = new Map(); for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) { continue; } for (const declaration of statement.declarationList.declarations) { if (ts.isIdentifier(declaration.name) && declaration.initializer) { bindings.set(declaration.name.text, declaration.initializer); } } } return bindings; } function propertyMap(objectLiteral, context, seenNodes = new Set()) { const map = new Map(); if (seenNodes.has(objectLiteral)) { return map; } seenNodes.add(objectLiteral); for (const property of objectLiteral.properties) { if (ts.isSpreadAssignment(property)) { const spreadExpression = unwrapExpression(resolveExpression(property.expression, context)); if (spreadExpression && ts.isObjectLiteralExpression(spreadExpression)) { for (const [key, value] of propertyMap(spreadExpression, context, seenNodes)) { map.set(key, value); } } continue; } if (ts.isPropertyAssignment(property)) { map.set(getPropertyName(property.name, context), property.initializer); continue; } if (ts.isShorthandPropertyAssignment(property)) { map.set(property.name.text, property.name); continue; } if (ts.isMethodDeclaration(property)) { map.set(getPropertyName(property.name, context), property); } } return map; } function getPropertyName(name, context) { if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { return name.text; } if (ts.isComputedPropertyName(name)) { const expression = unwrapExpression(name.expression); if (expression && isStringLiteral(expression)) { return expression.text; } } return name.getText(context.sourceFile); } function getAliasExpression(map, aliases) { for (const alias of aliases) { if (map.has(alias)) { return map.get(alias); } } return undefined; } function getAliasExpressions(map, aliases) { return aliases.filter((alias) => map.has(alias)).map((alias) => map.get(alias)); } function hasAnyAlias(map, aliases) { return aliases.some((alias) => map.has(alias)); } function expressionToString(expression, context) { if (!expression) { return undefined; } const node = unwrapExpression(resolveExpression(expression, context)); if (!node) { return undefined; } if (isStringLiteral(node)) { return node.text.trim(); } if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) { return node.getText(context.sourceFile).replace(/^`|`$/g, '').trim(); } if ( ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isNumericLiteral(node) ) { return compactText(node.getText(context.sourceFile)); } if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { return node.getText(context.sourceFile); } return undefined; } function resolveExpression(expression, context, seenIdentifiers = new Set()) { let node = unwrapExpression(expression); if (node && ts.isIdentifier(node) && context.bindings.has(node.text)) { if (seenIdentifiers.has(node.text)) { return node; } seenIdentifiers.add(node.text); return resolveExpression(context.bindings.get(node.text), context, seenIdentifiers); } return node; } function unwrapExpression(expression) { let node = expression; let changed = true; while (node && changed) { changed = false; if (ts.isParenthesizedExpression(node)) { node = node.expression; changed = true; } else if (ts.isAsExpression(node)) { node = node.expression; changed = true; } else if (typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(node)) { node = node.expression; changed = true; } else if (typeof ts.isTypeAssertionExpression === 'function' && ts.isTypeAssertionExpression(node)) { node = node.expression; changed = true; } else if (typeof ts.isNonNullExpression === 'function' && ts.isNonNullExpression(node)) { node = node.expression; changed = true; } } return node; } function isStringLiteral(node) { return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node); } function hasRegistryCoverage(coreRegistryText, machine) { if (!coreRegistryText) { return false; } if (mentionsLiteralId(coreRegistryText, machine.id)) { return true; } return ( coreRegistryText.includes(`catalogue/${machine.sourceStem}`) || coreRegistryText.includes(`catalogue\\${machine.sourceStem}`) || coreRegistryText.includes(`./catalogue/${machine.sourceStem}`) || coreRegistryText.includes(`./catalogue\\${machine.sourceStem}`) ); } function mentionsLiteralId(text, id) { if (!text) { return false; } const escapedId = escapeRegExp(id); return new RegExp(`(['"\`])${escapedId}\\1`).test(text); } function hasNearbyReplacementLanguage(text, id) { if (!text || !id) { return false; } const index = text.indexOf(id); if (index === -1) { return false; } const nearbyText = text .slice(Math.max(0, index - 2500), Math.min(text.length, index + 6000)) .toLowerCase(); return /\b(glb|gltf|asset|replacement|cad|modelurl|model url|mesh asset|professional asset)\b/.test( nearbyText, ); } function calculateMachineScore(machine) { const checks = [ Boolean(machine.title), machine.descriptionLength >= 60, Boolean(machine.category), Boolean(machine.difficulty), machine.factsCount >= 3, machine.relatedIds.length >= 2, machine.componentsCount >= 5, machine.describedComponentsCount >= machine.componentsCount && machine.componentsCount > 0, machine.cameraPresetCount >= 4, machine.labelCount >= 3, machine.hasExplodedMetadata, machine.hasThumbnailStrategy, machine.coverage.registry, machine.coverage.blueprint, machine.coverage.dossier, machine.coverage.animation || machine.hasAnimationMetadata, machine.coverage.assetReplacement, ]; const passed = checks.filter(Boolean).length; return Math.round((passed / checks.length) * 100); } function renderAudit(audit, options) { if (options.format === 'json') { return JSON.stringify( { generatedAt: audit.generatedAt, expectedCount: audit.expectedCount, machineCount: audit.machineCount, categories: audit.categories, errors: audit.errors, warnings: audit.warnings, machines: audit.machines.map(toSerializableMachine), }, null, 2, ); } if (options.format === 'markdown') { return renderMarkdown(audit); } return renderText(audit); } function renderText(audit) { const lines = []; lines.push('Core Machine Catalogue Depth Audit'); lines.push('=================================='); lines.push(`Generated: ${audit.generatedAt}`); lines.push(`Machines: ${audit.machineCount}/${audit.expectedCount}`); lines.push(`Categories:${audit.categories.length ? ` ${audit.categories.map(([category, count]) => `${category} (${count})`).join(', ')}` : ' none'}`); lines.push(`Errors: ${audit.errors.length}`); lines.push(`Warnings: ${audit.warnings.length}`); lines.push(''); if (audit.machines.length > 0) { lines.push('Machine matrix'); lines.push('--------------'); for (const machine of audit.machines) { lines.push( [ `${machine.id.padEnd(30)}`, `score ${String(machine.score).padStart(3)}%`, `parts ${String(machine.describedComponentsCount).padStart(2)}/${String(machine.componentsCount).padEnd(2)}`, `facts ${String(machine.factsCount).padStart(2)}`, `related ${String(machine.relatedIds.length).padStart(2)}`, `cams ${String(machine.cameraPresetCount).padStart(2)}`, `labels ${String(machine.labelCount).padStart(2)}`, `B:${yesNo(machine.coverage.blueprint)}`, `D:${yesNo(machine.coverage.dossier)}`, `A:${yesNo(machine.coverage.animation || machine.hasAnimationMetadata)}`, `R:${yesNo(machine.coverage.registry)}`, ].join(' '), ); } lines.push(''); } if (audit.errors.length > 0) { lines.push('Errors'); lines.push('------'); for (const issue of audit.errors) { lines.push(`- [${issue.machineId}] ${issue.message}${issue.location ? ` (${issue.location})` : ''}`); } lines.push(''); } if (audit.warnings.length > 0) { lines.push('Warnings'); lines.push('--------'); for (const issue of audit.warnings) { lines.push(`- [${issue.machineId}] ${issue.message}${issue.location ? ` (${issue.location})` : ''}`); } lines.push(''); } if (audit.errors.length === 0) { lines.push('All blocking catalogue-depth gates passed.'); } return lines.join('\n'); } function renderMarkdown(audit) { const lines = []; lines.push('# Core Machine Catalogue Depth Audit'); lines.push(''); lines.push(`Generated: \`${audit.generatedAt}\``); lines.push(''); lines.push('| Metric | Value |'); lines.push('| --- | ---: |'); lines.push(`| Machines | ${audit.machineCount}/${audit.expectedCount} |`); lines.push(`| Categories | ${audit.categories.length} |`); lines.push(`| Errors | ${audit.errors.length} |`); lines.push(`| Warnings | ${audit.warnings.length} |`); lines.push(''); if (audit.categories.length > 0) { lines.push('## Category Coverage'); lines.push(''); lines.push('| Category | Machines |'); lines.push('| --- | ---: |'); for (const [category, count] of audit.categories) { lines.push(`| ${escapeMarkdown(category)} | ${count} |`); } lines.push(''); } lines.push('## Machine Matrix'); lines.push(''); lines.push( '| Machine | Category | Score | Components | Facts | Related | Cameras | Labels | Blueprint | Dossier | Animation | Registry | Asset swap |', ); lines.push('| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | :---: | :---: | :---: | :---: | :---: |'); for (const machine of audit.machines) { lines.push( [ escapeMarkdown(machine.id), escapeMarkdown(normalizeValue(machine.category, '—')), `${machine.score}%`, `${machine.describedComponentsCount}/${machine.componentsCount}`, String(machine.factsCount), String(machine.relatedIds.length), String(machine.cameraPresetCount), String(machine.labelCount), mark(machine.coverage.blueprint), mark(machine.coverage.dossier), mark(machine.coverage.animation || machine.hasAnimationMetadata), mark(machine.coverage.registry), mark(machine.coverage.assetReplacement), ] .map((cell) => ` ${cell} `) .join('|') .replace(/^/, '|') .replace(/$/, '|'), ); } lines.push(''); if (audit.errors.length > 0) { lines.push('## Errors'); lines.push(''); for (const issue of audit.errors) { lines.push(`- **${escapeMarkdown(issue.machineId)}**: ${escapeMarkdown(issue.message)}${issue.location ? ` _(${escapeMarkdown(issue.location)})_` : ''}`); } lines.push(''); } if (audit.warnings.length > 0) { lines.push('## Warnings'); lines.push(''); for (const issue of audit.warnings) { lines.push(`- **${escapeMarkdown(issue.machineId)}**: ${escapeMarkdown(issue.message)}${issue.location ? ` _(${escapeMarkdown(issue.location)})_` : ''}`); } lines.push(''); } if (audit.errors.length === 0) { lines.push('✅ All blocking catalogue-depth gates passed.'); } return lines.join('\n'); } function toSerializableMachine(machine) { return { id: machine.id, title: machine.title, category: machine.category, difficulty: machine.difficulty, score: machine.score, descriptionLength: machine.descriptionLength, factsCount: machine.factsCount, relatedIds: machine.relatedIds, componentsCount: machine.componentsCount, describedComponentsCount: machine.describedComponentsCount, duplicateComponentIds: machine.duplicateComponentIds, cameraPresetCount: machine.cameraPresetCount, labelCount: machine.labelCount, hasExplodedMetadata: machine.hasExplodedMetadata, hasThumbnailStrategy: machine.hasThumbnailStrategy, coverage: machine.coverage, sourceFile: machine.sourceFile, location: machine.location, }; } function walkTsFiles(dirPath) { if (!fs.existsSync(dirPath)) { return []; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const files = []; for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { files.push(...walkTsFiles(entryPath)); } else if ( entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) && !entry.name.endsWith('.d.ts') ) { files.push(entryPath); } } return files.sort((left, right) => left.localeCompare(right)); } function readText(filePath) { if (!fs.existsSync(filePath)) { return ''; } return fs.readFileSync(filePath, 'utf8'); } function findDuplicateIds(machines) { const byId = new Map(); for (const machine of machines) { const entry = byId.get(machine.id) ?? { id: machine.id, count: 0, locations: [] }; entry.count += 1; entry.locations.push(machine.location); byId.set(machine.id, entry); } return [...byId.values()].filter((entry) => entry.count > 1); } function countBy(items, getKey) { return items.reduce((counts, item) => { const key = getKey(item); counts[key] = (counts[key] ?? 0) + 1; return counts; }, {}); } function unique(items) { return [...new Set(items)]; } function createIssue(severity, machineId, message, location = '') { return { severity, machineId, message, location: location ? toRelative(location) : '', }; } function compareMachineRecords(left, right) { return ( normalizeValue(left.category, '').localeCompare(normalizeValue(right.category, '')) || left.id.localeCompare(right.id) ); } function normalizeValue(value, fallback) { const normalized = String(value ?? '').trim(); return normalized || fallback; } function isUsableMapKey(value) { return Boolean(value && /^[A-Za-z0-9_-]+$/.test(value)); } function lineAndColumn(sourceFile, node) { const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); return `${position.line + 1}:${position.character + 1}`; } function compactText(value) { return String(value).replace(/\s+/g, ' ').trim(); } function toRelative(filePath) { if (!filePath) { return ''; } return path.relative(ROOT_DIR, path.resolve(filePath)).replaceAll(path.sep, '/'); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function escapeMarkdown(value) { return String(value ?? '').replace(/\|/g, '\\|'); } function yesNo(value) { return value ? 'Y' : 'N'; } function mark(value) { return value ? '✅' : '❌'; } main().catch((error) => { console.error(error); process.exitCode = 1; });