#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const CORE_MACHINES_FILE = 'src/modules/machines/coreMachines.ts'; const ROOT_MACHINE_INDEX_FILE = 'src/modules/machines/index.ts'; const ANIMATION_FILE = 'src/animations/coreMachineAnimations.ts'; const PROCEDURAL_BLUEPRINT_FILE = 'src/modules/machines/procedural/coreMachineBlueprints.ts'; const DOSSIER_REGISTRY_FILE = 'src/modules/machines/dossiers/registry.ts'; const DOSSIER_BARREL_FILE = 'src/modules/machines/dossiers/index.ts'; const EXPECTED_CATALOGUE_COUNTS = new Map([ ['src/modules/machines/catalogue/engines.ts', 8], ['src/modules/machines/catalogue/gearboxesAndDrives.ts', 6], ['src/modules/machines/catalogue/pumpsAndFluid.ts', 4], ['src/modules/machines/catalogue/mechanisms.ts', 6], ['src/modules/machines/catalogue/structuralOther.ts', 4], ]); const DOSSIER_DATA_FILES = [ 'src/modules/machines/dossiers/engines.ts', 'src/modules/machines/dossiers/gearboxesAndDrives.ts', 'src/modules/machines/dossiers/pumpsAndFluid.ts', 'src/modules/machines/dossiers/mechanisms.ts', 'src/modules/machines/dossiers/structuralOther.ts', ]; const REQUIRED_DOSSIER_REGISTRY_IMPORTS = [ './engines', './gearboxesAndDrives', './pumpsAndFluid', './mechanisms', './structuralOther', ]; const REQUIRED_DOSSIER_BARREL_EXPORT_TOKENS = [ 'coreMachineDossiers', 'coreMachineDossierLookup', 'getCoreMachineDossier', 'requireCoreMachineDossier', 'mechanismDossierExports', 'structuralOtherDossierExports', ]; const PART_CONTAINER_KEYS = [ 'parts', 'componentHierarchy', 'components', 'componentTree', 'componentGroups', ]; const PART_REFERENCE_SCALAR_KEYS = [ 'partId', 'focusPartId', 'targetPartId', 'selectedPartId', 'highlightPartId', 'labelPartId', 'componentId', 'focusComponentId', 'targetComponentId', ]; const PART_REFERENCE_ARRAY_KEYS = [ 'partIds', 'focusPartIds', 'targetPartIds', 'selectedPartIds', 'highlightPartIds', 'highlightedPartIds', 'visiblePartIds', 'hiddenPartIds', 'componentIds', 'labelPartIds', ]; const MACHINE_FEATURE_SIGNALS = [ { label: 'engineering facts', pattern: /\b(?:engineeringFacts|facts)\s*:/, }, { label: 'guided tour', pattern: /\b(?:guidedTour|guidedTourSteps|tour)\s*:/, }, { label: 'camera presets', pattern: /\bcameraPresets\s*:/, }, { label: 'related machines', pattern: /\brelatedMachines\s*:/, }, { label: 'exploded-view metadata', pattern: /\b(?:exploded|explode)\w*\s*:/i, }, { label: 'annotation labels', pattern: /\b(?:labels|annotationLabels)\s*:/, }, { label: 'catalogue thumbnail strategy', pattern: /\b(?:thumbnail|thumbnailStrategy|thumbnailKind)\s*:/, }, ]; const RECORD_FIELD_NAME_BLOCKLIST = new Set([ 'animation', 'annotations', 'assetReplacementPoints', 'cameraPresets', 'category', 'children', 'components', 'description', 'difficulty', 'engineeringFacts', 'exploded', 'facts', 'guidedTour', 'id', 'labels', 'materials', 'metadata', 'name', 'parts', 'relatedMachines', 'specs', 'thumbnail', 'title', 'tour', ]); const NON_PART_REFERENCE_VALUES = new Set([ 'all', 'none', 'root', 'scene', 'machine', 'system', 'body', 'x', 'y', 'z', 'front', 'back', 'left', 'right', 'top', 'bottom', 'isometric', ]); const errors = []; const warnings = []; function absolutePath(relativePath) { return path.join(repoRoot, relativePath); } function readRequired(relativePath) { const filename = absolutePath(relativePath); if (!fs.existsSync(filename)) { errors.push(`Missing required file: ${relativePath}`); return ''; } return fs.readFileSync(filename, 'utf8'); } function readOptional(relativePath) { const filename = absolutePath(relativePath); if (!fs.existsSync(filename)) { return ''; } return fs.readFileSync(filename, 'utf8'); } function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function stripComments(source) { let output = ''; let state = 'normal'; let escaped = false; for (let index = 0; index < source.length; index += 1) { const character = source[index]; const nextCharacter = source[index + 1]; if (state === 'lineComment') { if (character === '\n') { state = 'normal'; output += '\n'; } else { output += ' '; } continue; } if (state === 'blockComment') { if (character === '*' && nextCharacter === '/') { output += ' '; state = 'normal'; index += 1; } else { output += character === '\n' ? '\n' : ' '; } continue; } if (state === 'singleQuote' || state === 'doubleQuote' || state === 'template') { output += character; if (escaped) { escaped = false; continue; } if (character === '\\') { escaped = true; continue; } if ( (state === 'singleQuote' && character === "'") || (state === 'doubleQuote' && character === '"') || (state === 'template' && character === '`') ) { state = 'normal'; } continue; } if (character === '/' && nextCharacter === '/') { output += ' '; state = 'lineComment'; index += 1; continue; } if (character === '/' && nextCharacter === '*') { output += ' '; state = 'blockComment'; index += 1; continue; } if (character === "'") { state = 'singleQuote'; output += character; continue; } if (character === '"') { state = 'doubleQuote'; output += character; continue; } if (character === '`') { state = 'template'; output += character; continue; } output += character; } return output; } function findMatchingDelimiter(source, openIndex, openCharacter, closeCharacter) { if (source[openIndex] !== openCharacter) { return -1; } let depth = 0; let quote = ''; let escaped = false; for (let index = openIndex; index < source.length; index += 1) { const character = source[index]; if (quote) { if (escaped) { escaped = false; continue; } if (character === '\\') { escaped = true; continue; } if (character === quote) { quote = ''; } continue; } if (character === "'" || character === '"' || character === '`') { quote = character; continue; } if (character === openCharacter) { depth += 1; continue; } if (character === closeCharacter) { depth -= 1; if (depth === 0) { return index; } } } return -1; } function findAllObjectBlocks(source) { const blocks = []; for (let index = 0; index < source.length; index += 1) { if (source[index] !== '{') { continue; } const endIndex = findMatchingDelimiter(source, index, '{', '}'); if (endIndex !== -1) { blocks.push({ start: index, end: endIndex, text: source.slice(index, endIndex + 1), }); } } return blocks; } function findDelimitedBlocksForKey(source, key, openCharacter, closeCharacter) { const blocks = []; const keyPattern = new RegExp(`\\b${escapeRegex(key)}\\s*:`, 'g'); let match; while ((match = keyPattern.exec(source)) !== null) { const delimiterIndex = source.indexOf(openCharacter, keyPattern.lastIndex); if (delimiterIndex === -1) { continue; } const betweenKeyAndDelimiter = source.slice(keyPattern.lastIndex, delimiterIndex); if (!/^\s*$/.test(betweenKeyAndDelimiter)) { continue; } const endIndex = findMatchingDelimiter( source, delimiterIndex, openCharacter, closeCharacter, ); if (endIndex !== -1) { blocks.push(source.slice(delimiterIndex, endIndex + 1)); keyPattern.lastIndex = delimiterIndex + 1; } } return blocks; } function readQuotedStringAt(source, quoteIndex) { const quote = source[quoteIndex]; if (quote !== "'" && quote !== '"' && quote !== '`') { return undefined; } let value = ''; let escaped = false; for (let index = quoteIndex + 1; index < source.length; index += 1) { const character = source[index]; if (escaped) { value += character; escaped = false; continue; } if (character === '\\') { escaped = true; continue; } if (character === quote) { return { value, endIndex: index, }; } value += character; } return undefined; } function extractQuotedStrings(source) { const values = []; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (character !== "'" && character !== '"' && character !== '`') { continue; } const quotedString = readQuotedStringAt(source, index); if (!quotedString) { continue; } values.push(quotedString.value); index = quotedString.endIndex; } return values; } function extractKeyedStringEntries(source, keys) { if (keys.length === 0) { return []; } const keyPattern = keys.map(escapeRegex).join('|'); const regex = new RegExp(`\\b(${keyPattern})\\s*:\\s*(['"\`])`, 'g'); const entries = []; let match; while ((match = regex.exec(source)) !== null) { const quotedString = readQuotedStringAt(source, regex.lastIndex - 1); if (!quotedString) { continue; } entries.push({ key: match[1], value: quotedString.value, }); regex.lastIndex = quotedString.endIndex + 1; } return entries; } function extractKeyedStringValues(source, keys) { return extractKeyedStringEntries(source, keys).map((entry) => entry.value); } function extractQuotedObjectKeys(source) { const keys = []; const regex = /(['"`])([^'"`\\]*(?:\\.[^'"`\\]*)*)\1\s*:/g; let match; while ((match = regex.exec(source)) !== null) { const followingSource = source.slice(regex.lastIndex); if (/^\s*[{[]/.test(followingSource)) { keys.push(match[2].replace(/\\(['"`\\])/g, '$1')); } } return keys; } function hasAnyKey(source, keys) { return keys.some((key) => new RegExp(`\\b${escapeRegex(key)}\\s*:`).test(source)); } function isCandidatePartId(value, machineId) { if (!value || value === machineId) { return false; } if (RECORD_FIELD_NAME_BLOCKLIST.has(value)) { return false; } if (value.length > 96 || /\s/.test(value)) { return false; } if (/^(?:https?:|data:|#)/.test(value)) { return false; } return true; } function extractPartIdsFromMachineBlock(machineBlock, machineId) { const ids = new Set(); for (const containerKey of PART_CONTAINER_KEYS) { const arrayBlocks = findDelimitedBlocksForKey(machineBlock, containerKey, '[', ']'); const objectBlocks = findDelimitedBlocksForKey(machineBlock, containerKey, '{', '}'); for (const block of [...arrayBlocks, ...objectBlocks]) { for (const entry of extractKeyedStringEntries(block, ['id', 'partId', 'componentId'])) { if (isCandidatePartId(entry.value, machineId)) { ids.add(entry.value); } } for (const key of extractQuotedObjectKeys(block)) { if (isCandidatePartId(key, machineId)) { ids.add(key); } } } } return [...ids]; } function extractPartReferenceEntries(source) { const entries = [ ...extractKeyedStringEntries(source, PART_REFERENCE_SCALAR_KEYS), ]; for (const key of PART_REFERENCE_ARRAY_KEYS) { for (const arrayBlock of findDelimitedBlocksForKey(source, key, '[', ']')) { for (const value of extractQuotedStrings(arrayBlock)) { entries.push({ key, value }); } } } const seen = new Set(); return entries.filter((entry) => { const dedupeKey = `${entry.key}:${entry.value}`; if (seen.has(dedupeKey)) { return false; } seen.add(dedupeKey); return true; }); } function hasMachineDefinitionShape(block) { return ( hasAnyKey(block, ['id', 'machineId']) && hasAnyKey(block, ['title', 'name']) && hasAnyKey(block, ['category']) && hasAnyKey(block, ['difficulty']) && hasAnyKey(block, PART_CONTAINER_KEYS) && hasAnyKey(block, ['description', 'summary', 'engineeringFacts', 'facts', 'guidedTour']) ); } function extractMachineEntriesFromCatalogue(relativePath) { const source = stripComments(readRequired(relativePath)); const candidates = findAllObjectBlocks(source) .map((block) => block.text) .filter(hasMachineDefinitionShape) .map((block) => { const id = extractKeyedStringValues(block, ['id', 'machineId'])[0]; const title = extractKeyedStringValues(block, ['title', 'name'])[0] ?? id; return { id, title, sourceFile: relativePath, block, partIds: id ? extractPartIdsFromMachineBlock(block, id) : [], }; }) .filter((entry) => Boolean(entry.id)); const byId = new Map(); for (const candidate of candidates) { const existing = byId.get(candidate.id); if (!existing || candidate.block.length < existing.block.length) { byId.set(candidate.id, candidate); } } return [...byId.values()]; } function containsQuotedString(source, value) { const quotedStringPattern = new RegExp(`(['"\`])${escapeRegex(value)}\\1`); return quotedStringPattern.test(source); } function containsMachineDeclaration(source, machineId) { const quotedMachineId = `(['"\`])${escapeRegex(machineId)}\\1`; return ( new RegExp(`\\b(?:machineId|id)\\s*:\\s*${quotedMachineId}`).test(source) || new RegExp(`${quotedMachineId}\\s*:`).test(source) || new RegExp( `\\b(?:create|define|make|build)[A-Za-z]*(?:Dossier|Blueprint|Machine|Animation|Definition)[A-Za-z]*\\s*\\(\\s*${quotedMachineId}`, ).test(source) ); } function countKnownMachineIds(source, machineIds) { return machineIds.reduce( (count, machineId) => count + (containsQuotedString(source, machineId) ? 1 : 0), 0, ); } function findScopedDeclarationBlock(source, machineId, machineIds, signalPattern) { const cleanSource = stripComments(source); const candidateBlocks = findAllObjectBlocks(cleanSource) .map((block) => block.text) .filter( (block) => signalPattern.test(block) && (containsMachineDeclaration(block, machineId) || containsQuotedString(block, machineId)), ) .sort((left, right) => left.length - right.length); return ( candidateBlocks.find((block) => countKnownMachineIds(block, machineIds) <= 1) ?? candidateBlocks[0] ?? '' ); } function shouldIgnorePartReference(value, machineIdSet) { if (!value) { return true; } if (NON_PART_REFERENCE_VALUES.has(value)) { return true; } if (machineIdSet.has(value)) { return true; } if (value.startsWith('camera-') || value.startsWith('preset-')) { return true; } if (/^(?:https?:|data:|#)/.test(value)) { return true; } return false; } function validatePartReferences(context, references, validPartIds, machineIdSet, targetErrors) { for (const reference of references) { if (shouldIgnorePartReference(reference.value, machineIdSet)) { continue; } if (!validPartIds.has(reference.value)) { targetErrors.push( `${context}: ${reference.key} references unknown part "${reference.value}".`, ); } } } function validateGlobalPartReferences(context, references, globalPartIds, machineIdSet, targetErrors) { for (const reference of references) { if (shouldIgnorePartReference(reference.value, machineIdSet)) { continue; } if (!globalPartIds.has(reference.value)) { targetErrors.push( `${context}: ${reference.key} references "${reference.value}", which is not declared by any catalogue part list.`, ); } } } const catalogueEntries = []; const catalogueCounts = new Map(); const machineById = new Map(); for (const catalogueFile of EXPECTED_CATALOGUE_COUNTS.keys()) { const entries = extractMachineEntriesFromCatalogue(catalogueFile); catalogueCounts.set(catalogueFile, entries.length); for (const entry of entries) { const existing = machineById.get(entry.id); if (existing && existing.sourceFile !== entry.sourceFile) { errors.push( `Duplicate machine id "${entry.id}" in ${existing.sourceFile} and ${entry.sourceFile}.`, ); } machineById.set(entry.id, entry); catalogueEntries.push(entry); } } const machines = [...machineById.values()].sort((left, right) => { const leftFileIndex = [...EXPECTED_CATALOGUE_COUNTS.keys()].indexOf(left.sourceFile); const rightFileIndex = [...EXPECTED_CATALOGUE_COUNTS.keys()].indexOf(right.sourceFile); if (leftFileIndex !== rightFileIndex) { return leftFileIndex - rightFileIndex; } return left.title.localeCompare(right.title); }); const machineIds = machines.map((machine) => machine.id); const machineIdSet = new Set(machineIds); const globalPartIds = new Set(machines.flatMap((machine) => machine.partIds)); for (const [catalogueFile, expectedCount] of EXPECTED_CATALOGUE_COUNTS) { const actualCount = catalogueCounts.get(catalogueFile) ?? 0; if (actualCount !== expectedCount) { errors.push( `${catalogueFile}: expected ${expectedCount} Phase 1 machines, found ${actualCount}.`, ); } } if (machineIds.length !== 28) { errors.push(`Expected 28 unique Phase 1 machine ids, found ${machineIds.length}.`); } const coreMachinesSource = readRequired(CORE_MACHINES_FILE); if (!/\bcoreMachines\b/.test(coreMachinesSource)) { errors.push(`${CORE_MACHINES_FILE}: expected exported coreMachines aggregate.`); } for (const catalogueFile of EXPECTED_CATALOGUE_COUNTS.keys()) { const moduleSpecifier = `./catalogue/${path.basename(catalogueFile, '.ts')}`; if (!coreMachinesSource.includes(moduleSpecifier)) { warnings.push( `${CORE_MACHINES_FILE}: did not find direct reference to ${moduleSpecifier}; verify the aggregate still includes this catalogue module.`, ); } } const rootIndexSource = readOptional(ROOT_MACHINE_INDEX_FILE); if (rootIndexSource && !rootIndexSource.includes('./coreMachines')) { warnings.push( `${ROOT_MACHINE_INDEX_FILE}: did not find a coreMachines export; public imports may need a direct module path.`, ); } const animationSource = readRequired(ANIMATION_FILE); const proceduralBlueprintSource = readRequired(PROCEDURAL_BLUEPRINT_FILE); const dossierRegistrySource = readRequired(DOSSIER_REGISTRY_FILE); const dossierBarrelSource = readRequired(DOSSIER_BARREL_FILE); const dossierCombinedSource = DOSSIER_DATA_FILES.map(readRequired).join('\n\n'); for (const importSpecifier of REQUIRED_DOSSIER_REGISTRY_IMPORTS) { if (!dossierRegistrySource.includes(importSpecifier)) { errors.push( `${DOSSIER_REGISTRY_FILE}: missing shared dossier registry import for "${importSpecifier}".`, ); } } for (const exportToken of REQUIRED_DOSSIER_BARREL_EXPORT_TOKENS) { if (!dossierBarrelSource.includes(exportToken)) { errors.push(`${DOSSIER_BARREL_FILE}: missing public dossier export token "${exportToken}".`); } } for (const machine of machines) { const validPartIds = new Set(machine.partIds); if (validPartIds.size === 0) { errors.push(`${machine.id}: catalogue entry has no extracted part ids.`); } else if (validPartIds.size < 3) { warnings.push( `${machine.id}: only ${validPartIds.size} part ids were extracted; verify the component hierarchy is intentionally compact.`, ); } validatePartReferences( `${machine.id} catalogue metadata`, extractPartReferenceEntries(machine.block), validPartIds, machineIdSet, errors, ); const animationHasMachine = containsMachineDeclaration(animationSource, machine.id) || containsQuotedString(animationSource, machine.id); if (!animationHasMachine) { errors.push(`${ANIMATION_FILE}: missing animation mapping for "${machine.id}".`); } const blueprintHasMachine = containsMachineDeclaration(proceduralBlueprintSource, machine.id) || containsQuotedString(proceduralBlueprintSource, machine.id); if (!blueprintHasMachine) { errors.push(`${PROCEDURAL_BLUEPRINT_FILE}: missing procedural blueprint for "${machine.id}".`); } const blueprintBlock = findScopedDeclarationBlock( proceduralBlueprintSource, machine.id, machineIds, /(?:blueprint|replacement|assembly|assemblies|primitive|mesh|label|explode|exploded)/i, ); if (blueprintHasMachine && !blueprintBlock) { warnings.push( `${PROCEDURAL_BLUEPRINT_FILE}: could not isolate a scoped blueprint block for "${machine.id}" to validate replacement-point part references.`, ); } if (blueprintBlock) { if (!/(?:assetReplacementPoints|replacementPoints|replacement)/i.test(blueprintBlock)) { errors.push( `${PROCEDURAL_BLUEPRINT_FILE}: blueprint for "${machine.id}" does not expose replacement-point metadata.`, ); } if (countKnownMachineIds(blueprintBlock, machineIds) <= 1) { validatePartReferences( `${machine.id} procedural blueprint`, extractPartReferenceEntries(blueprintBlock), validPartIds, machineIdSet, errors, ); } else { warnings.push( `${PROCEDURAL_BLUEPRINT_FILE}: scoped block for "${machine.id}" contains multiple machine ids; skipped machine-local part-reference validation for that block.`, ); } } const dossierHasMachine = containsMachineDeclaration(dossierCombinedSource, machine.id); if (!dossierHasMachine) { errors.push(`Dossier data files: missing dossier declaration for "${machine.id}".`); } const dossierBlock = findScopedDeclarationBlock( dossierCombinedSource, machine.id, machineIds, /(?:dossier|sections|componentHierarchy|engineeringFacts|facts|guidedTour|parts|partDetails)/i, ); if (dossierHasMachine && !dossierBlock) { warnings.push( `Dossier data files: could not isolate a scoped dossier block for "${machine.id}" to validate part references.`, ); } if (dossierBlock && countKnownMachineIds(dossierBlock, machineIds) <= 1) { validatePartReferences( `${machine.id} dossier`, extractPartReferenceEntries(dossierBlock), validPartIds, machineIdSet, errors, ); } const combinedMachineMetadata = `${machine.block}\n${blueprintBlock}\n${dossierBlock}`; for (const featureSignal of MACHINE_FEATURE_SIGNALS) { if (!featureSignal.pattern.test(combinedMachineMetadata)) { warnings.push( `${machine.id}: did not find ${featureSignal.label} in catalogue/blueprint/dossier metadata.`, ); } } } validateGlobalPartReferences( 'animation module metadata', extractPartReferenceEntries(animationSource), globalPartIds, machineIdSet, errors, ); validateGlobalPartReferences( 'procedural blueprint metadata', extractPartReferenceEntries(proceduralBlueprintSource), globalPartIds, machineIdSet, errors, ); console.log('Mechanical Systems Explorer core catalogue validation'); console.log('-----------------------------------------------------'); console.log(`Repository root: ${repoRoot}`); console.log(`Machines discovered: ${machineIds.length}`); console.log(`Part ids discovered: ${globalPartIds.size}`); console.log(''); for (const [catalogueFile, expectedCount] of EXPECTED_CATALOGUE_COUNTS) { const actualCount = catalogueCounts.get(catalogueFile) ?? 0; console.log(`${catalogueFile}: ${actualCount}/${expectedCount}`); } if (warnings.length > 0) { console.log(''); console.log(`Warnings (${warnings.length}):`); for (const warning of warnings) { console.log(` - ${warning}`); } } if (errors.length > 0) { console.log(''); console.error(`Errors (${errors.length}):`); for (const error of errors) { console.error(` - ${error}`); } process.exitCode = 1; } else { console.log(''); console.log( 'Validation passed: catalogue ids, dossier coverage, animation mappings, procedural blueprint coverage, and known part references are internally consistent.', ); }