export type ContractSeverity = 'error' | 'warning' | 'info'; export type RegistryKind = 'machines' | 'dossiers' | 'blueprints' | 'animations'; export interface CoreMachineContractMinimums { factsPerMachine: number; relatedMachinesPerMachine: number; recommendedRelatedMachinesPerMachine: number; componentsPerMachine: number; describedComponentsPerMachine: number; cameraPresetsPerMachine: number; labelsPerMachine: number; tourStepsPerMachine: number; blueprintNodesPerMachine: number; descriptionCharacters: number; } export interface CatalogueContractIssue { severity: ContractSeverity; code: string; message: string; machineId?: string; path?: string; details?: Record; } export interface RegistryEntry { id: string; value: unknown; sourcePath: string; } export interface RegistrySelection { kind: RegistryKind; sourcePath: string; entries: RegistryEntry[]; confidence: number; } export interface RegistrySelectionSummary { kind: RegistryKind; sourcePath: string; count: number; confidence: number; } export interface RegistryCoverageReport { sourcePath?: string; count: number; missingIds: string[]; extraIds: string[]; } export interface MachineContractSummary { id: string; title: string; category?: string; difficulty?: string; factCount: number; relatedCount: number; componentCount: number; describedComponentCount: number; cameraPresetCount: number; validCameraPresetCount: number; labelCount: number; anchoredLabelCount: number; tourStepCount: number; blueprintNodeCount: number; hasThumbnailStrategy: boolean; hasExplodedMetadata: boolean; hasAssetReplacementPoint: boolean; hasAnimation: boolean; issueCount: number; } export interface CoreMachineCatalogueContractInput { machines: unknown; dossiers?: unknown; blueprints?: unknown; animations?: unknown; catalogueHelpers?: unknown; expectedMachineCount?: number; minimums?: Partial; } export interface CoreMachineCatalogueContractReport { ok: boolean; expectedMachineCount: number; machineCount: number; minimums: CoreMachineContractMinimums; registries: Partial>; coverage: { dossiers: RegistryCoverageReport; blueprints: RegistryCoverageReport; animations: RegistryCoverageReport; }; summaries: MachineContractSummary[]; issues: CatalogueContractIssue[]; errors: CatalogueContractIssue[]; warnings: CatalogueContractIssue[]; infos: CatalogueContractIssue[]; } export interface FormatCatalogueContractReportOptions { includeMachineSummaries?: boolean; maxIssues?: number; } export interface AssertCoreMachineCatalogueContractOptions { warningsAsErrors?: boolean; } const DEFAULT_EXPECTED_MACHINE_COUNT = 28; const DEFAULT_MINIMUMS: CoreMachineContractMinimums = { factsPerMachine: 3, relatedMachinesPerMachine: 1, recommendedRelatedMachinesPerMachine: 2, componentsPerMachine: 3, describedComponentsPerMachine: 3, cameraPresetsPerMachine: 3, labelsPerMachine: 3, tourStepsPerMachine: 3, blueprintNodesPerMachine: 4, descriptionCharacters: 60, }; const ID_KEYS = [ 'id', 'machineId', 'machine', 'slug', 'key', 'systemId', 'catalogueId', 'catalogId', ]; const TITLE_KEYS = [ 'title', 'name', 'displayName', 'displayTitle', 'label', 'machineTitle', ]; const DESCRIPTION_KEYS = [ 'description', 'summary', 'overview', 'intro', 'abstract', 'shortDescription', 'longDescription', ]; const CATEGORY_KEYS = [ 'category', 'group', 'family', 'systemCategory', 'type', ]; const DIFFICULTY_KEYS = [ 'difficulty', 'complexity', 'level', 'difficultyLevel', 'complexityLevel', ]; const FACT_KEYS = [ 'facts', 'engineeringFacts', 'keyFacts', 'technicalFacts', 'educationalFacts', 'learningFacts', 'principles', 'engineeringNotes', ]; const RELATED_FIELD_KEYS = [ 'related', 'relatedMachines', 'relatedMachineIds', 'relatedIds', 'seeAlso', 'similarMachines', 'dependencies', ]; const COMPONENT_FIELD_KEYS = [ 'components', 'componentHierarchy', 'parts', 'partList', 'componentList', 'subsystems', 'assemblies', 'nodes', ]; const COMPONENT_CHILD_FIELD_KEYS = [ 'children', 'components', 'parts', 'subcomponents', 'subassemblies', 'assemblies', 'nodes', 'items', ]; const COMPONENT_DESCRIPTION_KEYS = [ 'description', 'summary', 'function', 'role', 'purpose', 'explanation', 'engineeringNote', 'notes', 'educationalNote', ]; const NAME_KEYS = [ 'name', 'title', 'label', 'displayName', 'id', 'slug', ]; const CAMERA_FIELD_KEYS = [ 'cameraPresets', 'cameraViews', 'viewPresets', 'views', 'savedViews', 'cameraStates', 'presets', ]; const CAMERA_POSITION_KEYS = [ 'position', 'cameraPosition', 'eye', 'from', 'location', ]; const CAMERA_TARGET_KEYS = [ 'target', 'lookAt', 'focus', 'pivot', 'orbitTarget', 'center', 'centre', ]; const LABEL_FIELD_KEYS = [ 'labels', 'annotations', 'hotspots', 'callouts', 'pins', 'markers', ]; const LABEL_TEXT_KEYS = [ 'text', 'label', 'title', 'name', 'caption', 'description', ]; const LABEL_ANCHOR_KEYS = [ 'componentId', 'partId', 'targetId', 'nodeId', 'anchor', 'part', 'component', 'machinePartId', ]; const TOUR_FIELD_KEYS = [ 'guidedTour', 'guidedTourScript', 'tour', 'tourSteps', 'walkthrough', 'lessonSteps', 'learningPath', 'script', ]; const TOUR_CHILD_FIELD_KEYS = [ 'steps', 'items', 'chapters', 'segments', 'slides', 'script', ]; const TOUR_STEP_TEXT_KEYS = [ 'title', 'text', 'description', 'body', 'copy', 'narration', 'caption', 'instruction', ]; const THUMBNAIL_FIELD_KEYS = [ 'thumbnail', 'thumbnailStrategy', 'thumbnailUrl', 'thumbnailPath', 'catalogueThumbnail', 'catalogThumbnail', 'image', 'imageUrl', 'preview', 'previewImage', 'thumbnailRenderer', 'thumbnailSeed', ]; const EXPLODED_FIELD_KEYS = [ 'exploded', 'explodedView', 'explosion', 'explode', 'explodedOffset', 'explodeOffset', 'explodedPosition', 'explodeVector', 'separationAxis', 'separation', 'separationVector', ]; const ASSET_REPLACEMENT_FIELD_KEYS = [ 'assetReplacement', 'assetReplacementPoint', 'assetReplacementPoints', 'replacementAsset', 'replacementHint', 'glbReplacement', 'gltfReplacement', 'glbPath', 'gltfPath', 'modelPath', 'modelUrl', 'assetPath', 'sourceGlb', 'sourceGltf', 'professionalAsset', 'cadReplacement', ]; const BLUEPRINT_FIELD_KEYS = [ 'nodes', 'parts', 'components', 'assemblies', 'meshes', 'primitives', 'shapes', 'geometries', 'layers', 'groups', ]; const BLUEPRINT_CHILD_FIELD_KEYS = [ 'children', 'nodes', 'parts', 'components', 'assemblies', 'subassemblies', 'meshes', 'primitives', 'items', 'groups', ]; const RENDERABLE_NODE_KEYS = [ 'geometry', 'primitive', 'shape', 'mesh', 'type', 'kind', 'dimensions', 'size', 'radius', 'diameter', 'height', 'length', 'width', 'depth', 'position', 'rotation', 'scale', 'material', 'profile', 'path', ]; const ANIMATION_BEHAVIOR_KEYS = [ 'update', 'tick', 'timeline', 'timelines', 'tracks', 'keyframes', 'components', 'rotations', 'transforms', 'phase', 'phases', 'duration', 'speed', 'driver', 'kinematics', 'loop', 'cycles', 'create', 'factory', ]; const RELATED_ITEM_DATA_KEYS = [ ...ID_KEYS, 'title', 'name', 'description', 'summary', 'reason', 'relationship', ]; const RELATED_ID_LIKE_KEYS = [ ...ID_KEYS, 'target', 'targetId', 'href', 'machineSlug', ]; const IGNORED_TRAVERSAL_KEYS = new Set( [ 'description', 'summary', 'overview', 'facts', 'engineeringFacts', 'materials', 'material', 'metadata', 'cameraPresets', 'cameraViews', 'labels', 'annotations', 'thumbnail', 'thumbnailStrategy', 'animation', 'animations', 'tour', 'guidedTour', 'dimensions', 'position', 'rotation', 'scale', 'color', 'colour', 'opacity', ].map(normalisePropertyKey), ); interface ExtractedPart { key: string; id?: string; name?: string; description?: string; sourcePath: string; hasExplodedMetadata: boolean; } interface ExtractedBlueprintNode { key: string; sourcePath: string; isRenderable: boolean; hasAssetReplacementPoint: boolean; hasExplodedMetadata: boolean; } interface ExtractedCameraPreset { key: string; sourcePath: string; hasPosition: boolean; hasTarget: boolean; } interface ExtractedLabel { key: string; sourcePath: string; text?: string; hasAnchor: boolean; hasPosition: boolean; } interface ExtractedTourStep { key: string; sourcePath: string; text?: string; } interface MachineValidationContext { machineIds: Set; dossierMap: Map; blueprintMap: Map; animationMap: Map; issues: CatalogueContractIssue[]; minimums: CoreMachineContractMinimums; relatedIdsByMachine: Map; hasSharedThumbnailStrategy: boolean; } export function createCoreMachineCatalogueReport( input: CoreMachineCatalogueContractInput, ): CoreMachineCatalogueContractReport { const expectedMachineCount = input.expectedMachineCount ?? DEFAULT_EXPECTED_MACHINE_COUNT; const minimums = { ...DEFAULT_MINIMUMS, ...(input.minimums ?? {}), }; const issues: CatalogueContractIssue[] = []; const registries: Partial> = {}; const machineSelection = selectBestRegistry(input.machines, { kind: 'machines', expectedCount: expectedMachineCount, }); if (!machineSelection) { pushIssue( issues, 'error', 'MACHINE_REGISTRY_NOT_FOUND', 'Unable to locate a core machine registry in the supplied machine module.', ); return finaliseReport({ expectedMachineCount, minimums, registries, coverage: { dossiers: createCoverage([], undefined), blueprints: createCoverage([], undefined), animations: createCoverage([], undefined), }, summaries: [], issues, }); } registries.machines = summariseRegistrySelection(machineSelection); for (const duplicate of findDuplicateIds(machineSelection.entries)) { pushIssue( issues, 'error', 'DUPLICATE_MACHINE_ID', `Machine id "${duplicate.id}" appears ${duplicate.paths.length} times in the selected registry.`, duplicate.id, machineSelection.sourcePath, { paths: duplicate.paths }, ); } const machineEntries = dedupeRegistryEntries(machineSelection.entries) .sort((a, b) => a.id.localeCompare(b.id)); if (machineEntries.length !== expectedMachineCount) { pushIssue( issues, 'error', 'UNEXPECTED_MACHINE_COUNT', `Expected ${expectedMachineCount} unique Phase 1 machines but found ${machineEntries.length}.`, undefined, machineSelection.sourcePath, { expectedMachineCount, actualMachineCount: machineEntries.length, }, ); } const machineIds = machineEntries.map((entry) => entry.id); const machineIdSet = new Set(machineIds); const dossierSelection = input.dossiers == null ? undefined : selectBestRegistry(input.dossiers, { kind: 'dossiers', expectedCount: expectedMachineCount, }); const blueprintSelection = input.blueprints == null ? undefined : selectBestRegistry(input.blueprints, { kind: 'blueprints', expectedCount: expectedMachineCount, }); const animationSelection = input.animations == null ? undefined : selectBestRegistry(input.animations, { kind: 'animations', expectedCount: expectedMachineCount, }); if (dossierSelection) { registries.dossiers = summariseRegistrySelection(dossierSelection); } if (blueprintSelection) { registries.blueprints = summariseRegistrySelection(blueprintSelection); } if (animationSelection) { registries.animations = summariseRegistrySelection(animationSelection); } const coverage = { dossiers: createCoverage(machineIds, dossierSelection), blueprints: createCoverage(machineIds, blueprintSelection), animations: createCoverage(machineIds, animationSelection), }; appendCoverageIssues('dossiers', coverage.dossiers, issues); appendCoverageIssues('blueprints', coverage.blueprints, issues); appendCoverageIssues('animations', coverage.animations, issues); const context: MachineValidationContext = { machineIds: machineIdSet, dossierMap: registryMap(dossierSelection), blueprintMap: registryMap(blueprintSelection), animationMap: registryMap(animationSelection), issues, minimums, relatedIdsByMachine: new Map(), hasSharedThumbnailStrategy: hasNamedExportMatching(input.catalogueHelpers, [/thumbnail/i], 3) || hasNamedExportMatching(input.machines, [/thumbnail/i], 2), }; let summaries = machineEntries.map((entry) => validateMachine(entry, context)); appendReciprocalRelatedWarnings(context); const issueCountByMachine = countIssuesByMachine(issues); summaries = summaries.map((summary) => ({ ...summary, issueCount: issueCountByMachine.get(summary.id) ?? 0, })); return finaliseReport({ expectedMachineCount, minimums, registries, coverage, summaries, issues, }); } export function assertCoreMachineCatalogueContract( input: CoreMachineCatalogueContractInput, options: AssertCoreMachineCatalogueContractOptions = {}, ): CoreMachineCatalogueContractReport { const report = createCoreMachineCatalogueReport(input); const failingIssues = options.warningsAsErrors ? [...report.errors, ...report.warnings] : report.errors; if (failingIssues.length > 0) { throw new Error(formatCatalogueContractReport(report, { includeMachineSummaries: true, maxIssues: Math.max(50, failingIssues.length), })); } return report; } export function collectRegistryCandidates( source: unknown, options: { kind: RegistryKind; expectedCount?: number; maxDepth?: number; }, ): RegistrySelection[] { const maxDepth = options.maxDepth ?? 5; const candidates: RegistrySelection[] = []; const seen = new WeakSet(); const visit = (value: unknown, sourcePath: string, depth: number): void => { if (value == null || depth > maxDepth) { return; } if (!isObjectLike(value)) { return; } if (seen.has(value)) { return; } seen.add(value); if (Array.isArray(value) || value instanceof Map || isRecord(value)) { const entries = entriesFromCollection(value, sourcePath, options.kind); if (entries.length > 0) { candidates.push({ kind: options.kind, sourcePath, entries, confidence: calculateCandidateConfidence( entries, sourcePath, options.kind, options.expectedCount, ), }); } } if (Array.isArray(value)) { value.forEach((child, index) => { if (Array.isArray(child) || (isRecord(child) && !isLikelySingleEntry(child, options.kind))) { visit(child, `${sourcePath}[${index}]`, depth + 1); } }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { if (Array.isArray(child) || (isRecord(child) && !isLikelySingleEntry(child, options.kind))) { visit(child, `${sourcePath}.${String(key)}`, depth + 1); } } return; } if (isRecord(value) && !isLikelySingleEntry(value, options.kind)) { for (const [key, child] of Object.entries(value)) { if (child == null || typeof child === 'boolean') { continue; } if (Array.isArray(child) || child instanceof Map || isRecord(child)) { visit(child, `${sourcePath}.${key}`, depth + 1); } } } }; visit(source, '$', 0); return candidates.sort((a, b) => b.confidence - a.confidence); } export function selectBestRegistry( source: unknown, options: { kind: RegistryKind; expectedCount?: number; maxDepth?: number; }, ): RegistrySelection | undefined { return collectRegistryCandidates(source, options)[0]; } export function formatCatalogueContractReport( report: CoreMachineCatalogueContractReport, options: FormatCatalogueContractReportOptions = {}, ): string { const maxIssues = options.maxIssues ?? 200; const lines: string[] = []; lines.push(`Core machine catalogue contract: ${report.ok ? 'PASS' : 'FAIL'}`); lines.push(`Machines: ${report.machineCount}/${report.expectedMachineCount}`); lines.push(`Issues: ${report.errors.length} error(s), ${report.warnings.length} warning(s), ${report.infos.length} info`); lines.push(''); lines.push('Selected registries:'); for (const kind of ['machines', 'dossiers', 'blueprints', 'animations'] satisfies RegistryKind[]) { const selection = report.registries[kind]; lines.push( selection ? ` - ${kind}: ${selection.count} entries from ${selection.sourcePath} (confidence ${selection.confidence})` : ` - ${kind}: not found`, ); } lines.push(''); lines.push('Cross-registry coverage:'); lines.push(formatCoverageLine('dossiers', report.coverage.dossiers)); lines.push(formatCoverageLine('blueprints', report.coverage.blueprints)); lines.push(formatCoverageLine('animations', report.coverage.animations)); if (report.issues.length > 0) { lines.push(''); lines.push(`Issues${report.issues.length > maxIssues ? ` (first ${maxIssues} of ${report.issues.length})` : ''}:`); const orderedIssues = [ ...report.errors, ...report.warnings, ...report.infos, ]; for (const issue of orderedIssues.slice(0, maxIssues)) { lines.push(formatIssue(issue)); } if (report.issues.length > maxIssues) { lines.push(` ... ${report.issues.length - maxIssues} additional issue(s) omitted.`); } } if (options.includeMachineSummaries) { lines.push(''); lines.push('Machine depth summary:'); lines.push(' id facts rel comps desc cams labels tour nodes asset explode anim issues'); for (const summary of report.summaries) { lines.push(formatMachineSummary(summary)); } } return lines.join('\n'); } export function normaliseMachineId(raw: unknown): string { if (typeof raw !== 'string' && typeof raw !== 'number') { return ''; } const text = String(raw).trim(); if (!text) { return ''; } return text .replace(/([a-z\d])([A-Z])/g, '$1-$2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .replace(/&/g, ' and ') .replace(/['’]/g, '') .replace(/[^a-zA-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .toLowerCase(); } function validateMachine( entry: RegistryEntry, context: MachineValidationContext, ): MachineContractSummary { const id = entry.id; const dossier = context.dossierMap.get(id); const blueprint = context.blueprintMap.get(id); const animation = context.animationMap.get(id); const title = pickString([entry.value, dossier?.value], TITLE_KEYS) ?? humaniseId(id); const description = pickString([entry.value, dossier?.value], DESCRIPTION_KEYS); const category = pickString([entry.value, dossier?.value], CATEGORY_KEYS); const difficulty = pickString([entry.value, dossier?.value], DIFFICULTY_KEYS); const facts = uniqueMeaningfulStrings([ ...extractFactStrings(entry.value), ...extractFactStrings(dossier?.value), ], 8); const relatedIds = uniqueIds([ ...extractRelatedIds(entry.value), ...extractRelatedIds(dossier?.value), ]); const nonSelfRelatedIds = relatedIds.filter((relatedId) => relatedId !== id); context.relatedIdsByMachine.set(id, nonSelfRelatedIds); const describedParts = uniqueParts([ ...extractParts(entry.value, `machine:${id}`), ...extractParts(dossier?.value, `dossier:${id}`), ]); const blueprintParts = uniqueParts( blueprint ? extractParts(blueprint.value, `blueprint:${id}`) : [], ); const componentCount = Math.max(describedParts.length, blueprintParts.length); const describedComponentCount = describedParts.filter((part) => isUsefulDescription(part.description)).length; const cameraPresets = uniqueCameraPresets([ ...extractCameraPresets(entry.value, `machine:${id}`), ...extractCameraPresets(dossier?.value, `dossier:${id}`), ...extractCameraPresets(blueprint?.value, `blueprint:${id}`), ]); const validCameraPresetCount = cameraPresets.filter( (preset) => preset.hasPosition && preset.hasTarget, ).length; const labels = uniqueLabels([ ...extractLabels(entry.value, `machine:${id}`), ...extractLabels(dossier?.value, `dossier:${id}`), ...extractLabels(blueprint?.value, `blueprint:${id}`), ]); const anchoredLabelCount = labels.filter((label) => label.hasAnchor || label.hasPosition).length; const tourSteps = uniqueTourSteps([ ...extractTourSteps(dossier?.value, `dossier:${id}`), ...extractTourSteps(entry.value, `machine:${id}`), ]); const blueprintNodes = uniqueBlueprintNodes( blueprint ? extractBlueprintNodes(blueprint.value, `blueprint:${id}`) : [], ); const hasThumbnailStrategy = hasAnyKeyDeep(entry.value, THUMBNAIL_FIELD_KEYS, 4) || hasAnyKeyDeep(dossier?.value, THUMBNAIL_FIELD_KEYS, 4) || context.hasSharedThumbnailStrategy; const hasExplodedMetadata = hasAnyKeyDeep(entry.value, EXPLODED_FIELD_KEYS, 6) || hasAnyKeyDeep(blueprint?.value, EXPLODED_FIELD_KEYS, 6) || describedParts.some((part) => part.hasExplodedMetadata) || blueprintParts.some((part) => part.hasExplodedMetadata) || blueprintNodes.some((node) => node.hasExplodedMetadata); const hasAssetReplacementPoint = hasAnyKeyDeep(blueprint?.value, ASSET_REPLACEMENT_FIELD_KEYS, 7) || blueprintNodes.some((node) => node.hasAssetReplacementPoint); if (!normaliseMachineId(id)) { pushIssue( context.issues, 'error', 'INVALID_MACHINE_ID', `Machine id "${id}" is not URL-safe after normalisation.`, id, entry.sourcePath, ); } if (!pickString([entry.value, dossier?.value], TITLE_KEYS)) { pushIssue( context.issues, 'error', 'MISSING_TITLE', 'Machine is missing a title/name field.', id, entry.sourcePath, ); } if (!description) { pushIssue( context.issues, 'error', 'MISSING_DESCRIPTION', 'Machine is missing an educational description.', id, entry.sourcePath, ); } else if (description.length < context.minimums.descriptionCharacters) { pushIssue( context.issues, 'warning', 'SHORT_DESCRIPTION', `Machine description is ${description.length} characters; target at least ${context.minimums.descriptionCharacters}.`, id, entry.sourcePath, { actualCharacters: description.length }, ); } if (!category) { pushIssue( context.issues, 'error', 'MISSING_CATEGORY', 'Machine is missing a catalogue category.', id, entry.sourcePath, ); } if (!difficulty) { pushIssue( context.issues, 'error', 'MISSING_DIFFICULTY', 'Machine is missing a difficulty/complexity level.', id, entry.sourcePath, ); } if (facts.length < context.minimums.factsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_ENGINEERING_FACTS', `Machine has ${facts.length} engineering fact(s); expected at least ${context.minimums.factsPerMachine}.`, id, entry.sourcePath, { actualFacts: facts.length }, ); } if (relatedIds.includes(id)) { pushIssue( context.issues, 'error', 'SELF_RELATED_MACHINE', 'Machine lists itself as a related machine.', id, entry.sourcePath, ); } if (nonSelfRelatedIds.length < context.minimums.relatedMachinesPerMachine) { pushIssue( context.issues, 'error', 'MISSING_RELATED_MACHINES', `Machine has ${nonSelfRelatedIds.length} related machine(s); expected at least ${context.minimums.relatedMachinesPerMachine}.`, id, entry.sourcePath, { actualRelatedMachines: nonSelfRelatedIds.length }, ); } else if (nonSelfRelatedIds.length < context.minimums.recommendedRelatedMachinesPerMachine) { pushIssue( context.issues, 'warning', 'SPARSE_RELATED_MACHINES', `Machine has ${nonSelfRelatedIds.length} related machine(s); recommended ${context.minimums.recommendedRelatedMachinesPerMachine}.`, id, entry.sourcePath, { actualRelatedMachines: nonSelfRelatedIds.length }, ); } for (const relatedId of nonSelfRelatedIds) { if (!context.machineIds.has(relatedId)) { pushIssue( context.issues, 'warning', 'UNKNOWN_RELATED_MACHINE', `Related machine "${relatedId}" is not present in the Phase 1 registry.`, id, entry.sourcePath, { relatedId }, ); } } if (componentCount < context.minimums.componentsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_COMPONENTS', `Machine exposes ${componentCount} component(s); expected at least ${context.minimums.componentsPerMachine}.`, id, entry.sourcePath, { actualComponents: componentCount }, ); } if (describedComponentCount < context.minimums.describedComponentsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_COMPONENT_DESCRIPTIONS', `Machine has ${describedComponentCount} described component(s); expected at least ${context.minimums.describedComponentsPerMachine}.`, id, entry.sourcePath, { actualDescribedComponents: describedComponentCount }, ); } if (cameraPresets.length < context.minimums.cameraPresetsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_CAMERA_PRESETS', `Machine has ${cameraPresets.length} camera preset(s); expected at least ${context.minimums.cameraPresetsPerMachine}.`, id, entry.sourcePath, { actualCameraPresets: cameraPresets.length }, ); } else if (validCameraPresetCount < context.minimums.cameraPresetsPerMachine) { pushIssue( context.issues, 'warning', 'CAMERA_PRESETS_WITHOUT_VECTORS', `Machine has ${validCameraPresetCount} camera preset(s) with both position and target vectors; expected ${context.minimums.cameraPresetsPerMachine}.`, id, entry.sourcePath, { validCameraPresets: validCameraPresetCount }, ); } if (labels.length < context.minimums.labelsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_LABELS', `Machine has ${labels.length} label(s); expected at least ${context.minimums.labelsPerMachine}.`, id, entry.sourcePath, { actualLabels: labels.length }, ); } else if (anchoredLabelCount < context.minimums.labelsPerMachine) { pushIssue( context.issues, 'warning', 'LABELS_WITHOUT_ANCHORS', `Machine has ${anchoredLabelCount} label(s) with component anchors or positions; expected ${context.minimums.labelsPerMachine}.`, id, entry.sourcePath, { anchoredLabels: anchoredLabelCount }, ); } if (!hasThumbnailStrategy) { pushIssue( context.issues, 'error', 'MISSING_THUMBNAIL_STRATEGY', 'Machine does not expose a thumbnail strategy and no shared thumbnail helper was detected.', id, entry.sourcePath, ); } if (!hasExplodedMetadata) { pushIssue( context.issues, 'error', 'MISSING_EXPLODED_VIEW_METADATA', 'Machine/blueprint does not expose exploded-view separation metadata.', id, entry.sourcePath, ); } if (dossier && tourSteps.length < context.minimums.tourStepsPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_GUIDED_TOUR', `Dossier has ${tourSteps.length} guided tour step(s); expected at least ${context.minimums.tourStepsPerMachine}.`, id, dossier.sourcePath, { actualTourSteps: tourSteps.length }, ); } if (blueprint) { if (blueprintNodes.length < context.minimums.blueprintNodesPerMachine) { pushIssue( context.issues, 'error', 'INSUFFICIENT_BLUEPRINT_NODES', `Procedural blueprint has ${blueprintNodes.length} renderable node(s); expected at least ${context.minimums.blueprintNodesPerMachine}.`, id, blueprint.sourcePath, { actualBlueprintNodes: blueprintNodes.length }, ); } if (!hasAssetReplacementPoint) { pushIssue( context.issues, 'error', 'MISSING_ASSET_REPLACEMENT_POINT', 'Procedural blueprint does not document a GLB/GLTF replacement point.', id, blueprint.sourcePath, ); } } if (animation && !hasAnimationBehavior(animation.value)) { pushIssue( context.issues, 'warning', 'ANIMATION_WITHOUT_DISCOVERABLE_BEHAVIOR', 'Animation entry exists but no update/timeline/track/kinematics behavior was detected.', id, animation.sourcePath, ); } return { id, title, category, difficulty, factCount: facts.length, relatedCount: nonSelfRelatedIds.length, componentCount, describedComponentCount, cameraPresetCount: cameraPresets.length, validCameraPresetCount, labelCount: labels.length, anchoredLabelCount, tourStepCount: tourSteps.length, blueprintNodeCount: blueprintNodes.length, hasThumbnailStrategy, hasExplodedMetadata, hasAssetReplacementPoint, hasAnimation: Boolean(animation), issueCount: 0, }; } function finaliseReport(input: { expectedMachineCount: number; minimums: CoreMachineContractMinimums; registries: Partial>; coverage: CoreMachineCatalogueContractReport['coverage']; summaries: MachineContractSummary[]; issues: CatalogueContractIssue[]; }): CoreMachineCatalogueContractReport { const errors = input.issues.filter((issue) => issue.severity === 'error'); const warnings = input.issues.filter((issue) => issue.severity === 'warning'); const infos = input.issues.filter((issue) => issue.severity === 'info'); return { ok: errors.length === 0, expectedMachineCount: input.expectedMachineCount, machineCount: input.summaries.length, minimums: input.minimums, registries: input.registries, coverage: input.coverage, summaries: input.summaries, issues: input.issues, errors, warnings, infos, }; } function appendCoverageIssues( kind: Exclude, coverage: RegistryCoverageReport, issues: CatalogueContractIssue[], ): void { const singular = singularRegistryKind(kind); if (!coverage.sourcePath) { pushIssue( issues, 'error', `${singular.toUpperCase()}_REGISTRY_NOT_FOUND`, `Unable to locate a ${singular} registry.`, ); } for (const missingId of coverage.missingIds) { pushIssue( issues, 'error', `MISSING_${singular.toUpperCase()}`, `Machine "${missingId}" is missing a ${singular} entry.`, missingId, coverage.sourcePath, ); } for (const extraId of coverage.extraIds) { pushIssue( issues, 'warning', `EXTRA_${singular.toUpperCase()}`, `${singular} registry contains "${extraId}", which is not present in the selected machine registry.`, extraId, coverage.sourcePath, ); } } function appendReciprocalRelatedWarnings(context: MachineValidationContext): void { for (const [id, relatedIds] of context.relatedIdsByMachine.entries()) { for (const relatedId of relatedIds) { if (!context.machineIds.has(relatedId)) { continue; } const inverseRelations = context.relatedIdsByMachine.get(relatedId) ?? []; if (!inverseRelations.includes(id)) { pushIssue( context.issues, 'warning', 'RELATED_MACHINE_NOT_RECIPROCAL', `Machine "${id}" links to "${relatedId}", but the reverse relationship is not present.`, id, undefined, { relatedId }, ); } } } } function singularRegistryKind(kind: Exclude): string { if (kind === 'dossiers') { return 'dossier'; } if (kind === 'blueprints') { return 'blueprint'; } return 'animation'; } function entriesFromCollection( value: unknown, sourcePath: string, kind: RegistryKind, ): RegistryEntry[] { if (Array.isArray(value)) { return value .map((item, index) => createRegistryEntry(item, String(index), `${sourcePath}[${index}]`)) .filter(isDefined); } if (value instanceof Map) { return [...value.entries()] .map(([key, item]) => createRegistryEntry(item, String(key), `${sourcePath}.${String(key)}`)) .filter(isDefined); } if (!isRecord(value)) { return []; } if (isLikelySingleEntry(value, kind)) { const rawId = extractRawId(value) ?? lastPathSegment(sourcePath); const id = normaliseMachineId(rawId); return id ? [{ id, value, sourcePath }] : []; } return Object.entries(value) .filter(([, child]) => isRegistryChildValue(child, kind)) .map(([key, child]) => createRegistryEntry(child, key, `${sourcePath}.${key}`)) .filter(isDefined); } function createRegistryEntry( value: unknown, fallbackKey: string, sourcePath: string, ): RegistryEntry | undefined { const rawId = extractRawId(value) ?? (typeof value === 'string' || typeof value === 'number' ? value : fallbackKey); const id = normaliseMachineId(rawId); if (!id) { return undefined; } return { id, value, sourcePath, }; } function isRegistryChildValue(value: unknown, kind: RegistryKind): boolean { if (value == null || typeof value === 'boolean') { return false; } if (typeof value === 'function') { return kind === 'animations'; } return ( typeof value === 'string' || typeof value === 'number' || Array.isArray(value) || value instanceof Map || isRecord(value) ); } function isLikelySingleEntry(value: unknown, kind: RegistryKind): boolean { if (!isRecord(value) || !extractRawId(value)) { return false; } return scoreEntryValue(value, kind) >= 4; } function calculateCandidateConfidence( entries: RegistryEntry[], sourcePath: string, kind: RegistryKind, expectedCount?: number, ): number { const entryScores = entries.map((entry) => scoreEntryValue(entry.value, kind)); const averageEntryScore = entryScores.length === 0 ? 0 : entryScores.reduce((sum, score) => sum + score, 0) / entryScores.length; const objectishCount = entries.filter( (entry) => isRecord(entry.value) || typeof entry.value === 'function', ).length; const objectishRatio = entries.length === 0 ? 0 : objectishCount / entries.length; let score = averageEntryScore * 12 + entries.length * 2; if (expectedCount != null) { const distance = Math.abs(entries.length - expectedCount); score += entries.length === expectedCount ? 420 : Math.max(0, 180 - distance * 8); if (entries.length > expectedCount * 1.5 || entries.length < Math.max(1, expectedCount * 0.25)) { score -= 60; } } if (kind !== 'animations' && objectishRatio < 0.75) { score -= 150; } if (kind === 'animations' && objectishRatio < 0.5) { score -= 80; } score += pathBias(sourcePath, kind); return Math.round(score); } function pathBias(sourcePath: string, kind: RegistryKind): number { const lowerPath = sourcePath.toLowerCase(); let bias = 0; if (lowerPath.includes('registry')) { bias += 45; } if (lowerPath.includes('coremachine') || lowerPath.includes('core-machine')) { bias += 40; } if (kind === 'machines' && lowerPath.includes('machine')) { bias += 25; } if (kind === 'dossiers' && lowerPath.includes('dossier')) { bias += 35; } if (kind === 'blueprints' && lowerPath.includes('blueprint')) { bias += 35; } if (kind === 'animations' && lowerPath.includes('animation')) { bias += 35; } if (lowerPath.includes('ids') || lowerPath.includes('materials') || lowerPath.includes('types')) { bias -= 90; } if (lowerPath.includes('factory') || lowerPath.includes('helper')) { bias -= 45; } return bias; } function scoreEntryValue(value: unknown, kind: RegistryKind): number { if (typeof value === 'function') { return kind === 'animations' ? 6 : 0; } if (!isRecord(value)) { return typeof value === 'string' || typeof value === 'number' ? 1 : 0; } let score = 0; if (extractRawId(value)) { score += 1; } if (pickString([value], TITLE_KEYS)) { score += kind === 'machines' ? 3 : 1; } if (pickString([value], DESCRIPTION_KEYS)) { score += kind === 'machines' || kind === 'dossiers' ? 3 : 1; } if (pickString([value], CATEGORY_KEYS)) { score += kind === 'machines' ? 2 : 1; } if (pickString([value], DIFFICULTY_KEYS)) { score += kind === 'machines' ? 2 : 1; } if (extractFactStrings(value).length > 0) { score += kind === 'machines' || kind === 'dossiers' ? 2 : 1; } if (extractRelatedIds(value).length > 0) { score += 1; } if (getFieldsByNames(value, COMPONENT_FIELD_KEYS).length > 0) { score += kind === 'machines' ? 3 : 1; } if (getFieldsByNames(value, CAMERA_FIELD_KEYS).length > 0) { score += kind === 'machines' ? 2 : 1; } if (getFieldsByNames(value, LABEL_FIELD_KEYS).length > 0) { score += kind === 'machines' ? 2 : 1; } if (getFieldsByNames(value, TOUR_FIELD_KEYS).length > 0) { score += kind === 'dossiers' ? 4 : 1; } if (getFieldsByNames(value, BLUEPRINT_FIELD_KEYS).length > 0 || hasAnyDirectKey(value, RENDERABLE_NODE_KEYS)) { score += kind === 'blueprints' ? 5 : 1; } if (hasAnyKeyDeep(value, ASSET_REPLACEMENT_FIELD_KEYS, 3) || hasAnyKeyDeep(value, EXPLODED_FIELD_KEYS, 3)) { score += kind === 'blueprints' ? 3 : 1; } if (hasAnimationBehavior(value)) { score += kind === 'animations' ? 5 : 1; } return score; } function createCoverage( expectedIds: string[], selection: RegistrySelection | undefined, ): RegistryCoverageReport { const expectedSet = new Set(expectedIds); const actualIds = selection ? uniqueIds(selection.entries.map((entry) => entry.id)).sort((a, b) => a.localeCompare(b)) : []; return { sourcePath: selection?.sourcePath, count: actualIds.length, missingIds: expectedIds.filter((id) => !actualIds.includes(id)), extraIds: actualIds.filter((id) => !expectedSet.has(id)), }; } function registryMap(selection: RegistrySelection | undefined): Map { const map = new Map(); for (const entry of selection?.entries ?? []) { if (!map.has(entry.id)) { map.set(entry.id, entry); } } return map; } function summariseRegistrySelection(selection: RegistrySelection): RegistrySelectionSummary { return { kind: selection.kind, sourcePath: selection.sourcePath, count: dedupeRegistryEntries(selection.entries).length, confidence: selection.confidence, }; } function dedupeRegistryEntries(entries: RegistryEntry[]): RegistryEntry[] { const map = new Map(); for (const entry of entries) { if (!map.has(entry.id)) { map.set(entry.id, entry); } } return [...map.values()]; } function findDuplicateIds(entries: RegistryEntry[]): Array<{ id: string; paths: string[] }> { const pathsById = new Map(); for (const entry of entries) { pathsById.set(entry.id, [...(pathsById.get(entry.id) ?? []), entry.sourcePath]); } return [...pathsById.entries()] .filter(([, paths]) => paths.length > 1) .map(([id, paths]) => ({ id, paths })); } function countIssuesByMachine(issues: CatalogueContractIssue[]): Map { const counts = new Map(); for (const issue of issues) { if (!issue.machineId) { continue; } counts.set(issue.machineId, (counts.get(issue.machineId) ?? 0) + 1); } return counts; } function extractFactStrings(source: unknown): string[] { const facts: string[] = []; for (const field of getFieldsByNames(source, FACT_KEYS)) { facts.push(...normaliseStringList(field.value)); } return uniqueMeaningfulStrings(facts, 8); } function extractRelatedIds(source: unknown): string[] { const ids: string[] = []; for (const field of getFieldsByNames(source, RELATED_FIELD_KEYS)) { flattenRelatedIds(field.value, `${field.key}`, ids); } return uniqueIds(ids); } function flattenRelatedIds( value: unknown, path: string, result: string[], fallbackKey?: string, depth = 0, seen = new WeakSet(), ): void { if (value == null || depth > 6) { return; } if (typeof value === 'string' || typeof value === 'number') { const id = normaliseMachineId(value); if (id) { result.push(id); } return; } if (Array.isArray(value)) { value.forEach((item, index) => { flattenRelatedIds(item, `${path}[${index}]`, result, String(index), depth + 1, seen); }); return; } if (value instanceof Map) { for (const [key, item] of value.entries()) { flattenRelatedIds(item, `${path}.${String(key)}`, result, String(key), depth + 1, seen); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); const rawId = extractRawId(value) ?? pickString([value], RELATED_ID_LIKE_KEYS); if (rawId) { const id = normaliseMachineId(rawId); if (id) { result.push(id); } return; } const entries = Object.entries(value).filter(([, child]) => !isEmptyValue(child)); const looksLikeIdMap = entries.length > 0 && entries.every(([key]) => !RELATED_ITEM_DATA_KEYS.map(normalisePropertyKey).includes(normalisePropertyKey(key))); if (looksLikeIdMap) { for (const [key] of entries) { const id = normaliseMachineId(key); if (id) { result.push(id); } } return; } for (const [key, child] of entries) { if (RELATED_ID_LIKE_KEYS.map(normalisePropertyKey).includes(normalisePropertyKey(key))) { flattenRelatedIds(child, `${path}.${key}`, result, key, depth + 1, seen); } } if (fallbackKey && !/^\d+$/.test(fallbackKey)) { const id = normaliseMachineId(fallbackKey); if (id) { result.push(id); } } } function extractParts(source: unknown, sourceLabel: string): ExtractedPart[] { const parts: ExtractedPart[] = []; const roots = getFieldsByNames(source, COMPONENT_FIELD_KEYS); for (const root of roots) { flattenPartTree(root.value, `${sourceLabel}.${root.key}`, 0, new WeakSet(), undefined, parts); } if (roots.length === 0 && hasAnyDirectKey(source, COMPONENT_CHILD_FIELD_KEYS)) { flattenPartTree(source, sourceLabel, 0, new WeakSet(), undefined, parts); } return uniqueParts(parts); } function flattenPartTree( value: unknown, sourcePath: string, depth: number, seen: WeakSet, fallbackKey: string | undefined, result: ExtractedPart[], ): void { if (value == null || depth > 8) { return; } if (Array.isArray(value)) { value.forEach((child, index) => { flattenPartTree(child, `${sourcePath}[${index}]`, depth + 1, seen, String(index), result); }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { flattenPartTree(child, `${sourcePath}.${String(key)}`, depth + 1, seen, String(key), result); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); const partLike = isPartLike(value); if (partLike) { result.push(partFromRecord(value, sourcePath, fallbackKey)); } const childFields = getFieldsByNames(value, COMPONENT_CHILD_FIELD_KEYS); const traversedKeys = new Set(childFields.map((field) => field.key)); for (const childField of childFields) { flattenPartTree( childField.value, `${sourcePath}.${childField.key}`, depth + 1, seen, childField.key, result, ); } if (childFields.length === 0 || !partLike) { for (const [key, child] of Object.entries(value)) { if (traversedKeys.has(key) || !shouldTraverseMapEntry(key, child)) { continue; } flattenPartTree(child, `${sourcePath}.${key}`, depth + 1, seen, key, result); } } } function isPartLike(value: Record): boolean { const hasNameOrId = Boolean(extractRawId(value) || pickString([value], NAME_KEYS)); const hasPartSemantics = hasAnyDirectKey(value, [ ...COMPONENT_DESCRIPTION_KEYS, 'material', 'geometry', 'type', 'mesh', 'position', 'dimensions', ...EXPLODED_FIELD_KEYS, ]) || getFieldsByNames(value, COMPONENT_CHILD_FIELD_KEYS).length > 0; return hasNameOrId && hasPartSemantics; } function partFromRecord( value: Record, sourcePath: string, fallbackKey?: string, ): ExtractedPart { const rawId = extractRawId(value) ?? fallbackKey; const name = pickString([value], NAME_KEYS) ?? (rawId ? humaniseId(String(rawId)) : undefined); const key = normaliseMachineId(rawId ?? name ?? sourcePath) || sourcePath; return { key, id: rawId ? normaliseMachineId(rawId) : undefined, name, description: pickString([value], COMPONENT_DESCRIPTION_KEYS), sourcePath, hasExplodedMetadata: hasAnyKeyDeep(value, EXPLODED_FIELD_KEYS, 3), }; } function uniqueParts(parts: ExtractedPart[]): ExtractedPart[] { const map = new Map(); for (const part of parts) { const key = part.id || normaliseMachineId(part.name) || part.key || part.sourcePath; const existing = map.get(key); if (!existing) { map.set(key, part); continue; } map.set(key, { ...existing, description: existing.description || part.description, hasExplodedMetadata: existing.hasExplodedMetadata || part.hasExplodedMetadata, }); } return [...map.values()]; } function extractBlueprintNodes(source: unknown, sourceLabel: string): ExtractedBlueprintNode[] { const nodes: ExtractedBlueprintNode[] = []; const roots = getFieldsByNames(source, BLUEPRINT_FIELD_KEYS); if (roots.length > 0) { for (const root of roots) { flattenBlueprintNodeTree(root.value, `${sourceLabel}.${root.key}`, 0, new WeakSet(), root.key, nodes); } } else { flattenBlueprintNodeTree(source, sourceLabel, 0, new WeakSet(), undefined, nodes); } return uniqueBlueprintNodes(nodes); } function flattenBlueprintNodeTree( value: unknown, sourcePath: string, depth: number, seen: WeakSet, fallbackKey: string | undefined, result: ExtractedBlueprintNode[], ): void { if (value == null || depth > 8) { return; } if (Array.isArray(value)) { value.forEach((child, index) => { flattenBlueprintNodeTree(child, `${sourcePath}[${index}]`, depth + 1, seen, String(index), result); }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { flattenBlueprintNodeTree(child, `${sourcePath}.${String(key)}`, depth + 1, seen, String(key), result); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); const nodeLike = isRenderableNodeLike(value); if (nodeLike) { result.push(blueprintNodeFromRecord(value, sourcePath, fallbackKey)); } const childFields = getFieldsByNames(value, BLUEPRINT_CHILD_FIELD_KEYS); const traversedKeys = new Set(childFields.map((field) => field.key)); for (const childField of childFields) { flattenBlueprintNodeTree( childField.value, `${sourcePath}.${childField.key}`, depth + 1, seen, childField.key, result, ); } if (childFields.length === 0 || !nodeLike) { for (const [key, child] of Object.entries(value)) { if (traversedKeys.has(key) || !shouldTraverseMapEntry(key, child)) { continue; } flattenBlueprintNodeTree(child, `${sourcePath}.${key}`, depth + 1, seen, key, result); } } } function isRenderableNodeLike(value: Record): boolean { return hasAnyDirectKey(value, RENDERABLE_NODE_KEYS); } function blueprintNodeFromRecord( value: Record, sourcePath: string, fallbackKey?: string, ): ExtractedBlueprintNode { const rawId = extractRawId(value) ?? fallbackKey; const key = normaliseMachineId(rawId ?? sourcePath) || sourcePath; return { key, sourcePath, isRenderable: true, hasAssetReplacementPoint: hasAnyKeyDeep(value, ASSET_REPLACEMENT_FIELD_KEYS, 3), hasExplodedMetadata: hasAnyKeyDeep(value, EXPLODED_FIELD_KEYS, 3), }; } function uniqueBlueprintNodes(nodes: ExtractedBlueprintNode[]): ExtractedBlueprintNode[] { const map = new Map(); for (const node of nodes.filter((candidate) => candidate.isRenderable)) { const existing = map.get(node.key); if (!existing) { map.set(node.key, node); continue; } map.set(node.key, { ...existing, hasAssetReplacementPoint: existing.hasAssetReplacementPoint || node.hasAssetReplacementPoint, hasExplodedMetadata: existing.hasExplodedMetadata || node.hasExplodedMetadata, }); } return [...map.values()]; } function extractCameraPresets(source: unknown, sourceLabel: string): ExtractedCameraPreset[] { const presets: ExtractedCameraPreset[] = []; for (const field of getFieldsByNames(source, CAMERA_FIELD_KEYS)) { flattenCameraPresetCollection( field.value, `${sourceLabel}.${field.key}`, 0, new WeakSet(), field.key, presets, ); } return uniqueCameraPresets(presets); } function flattenCameraPresetCollection( value: unknown, sourcePath: string, depth: number, seen: WeakSet, fallbackKey: string | undefined, result: ExtractedCameraPreset[], ): void { if (value == null || depth > 6) { return; } if (Array.isArray(value)) { value.forEach((child, index) => { flattenCameraPresetCollection(child, `${sourcePath}[${index}]`, depth + 1, seen, String(index), result); }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { flattenCameraPresetCollection(child, `${sourcePath}.${String(key)}`, depth + 1, seen, String(key), result); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); if (isCameraPresetLike(value)) { result.push(cameraPresetFromRecord(value, sourcePath, fallbackKey)); return; } for (const [key, child] of Object.entries(value)) { if (shouldTraverseMapEntry(key, child)) { flattenCameraPresetCollection(child, `${sourcePath}.${key}`, depth + 1, seen, key, result); } } } function isCameraPresetLike(value: Record): boolean { return Boolean( readVectorByNames(value, CAMERA_POSITION_KEYS) || readVectorByNames(value, CAMERA_TARGET_KEYS) || hasAnyDirectKey(value, ['fov', 'zoom', 'distance', 'azimuth', 'polar', 'name', 'title']), ); } function cameraPresetFromRecord( value: Record, sourcePath: string, fallbackKey?: string, ): ExtractedCameraPreset { const rawId = extractRawId(value) ?? pickString([value], NAME_KEYS) ?? fallbackKey ?? sourcePath; const key = normaliseMachineId(rawId) || sourcePath; return { key, sourcePath, hasPosition: Boolean(readVectorByNames(value, CAMERA_POSITION_KEYS)), hasTarget: Boolean(readVectorByNames(value, CAMERA_TARGET_KEYS)), }; } function uniqueCameraPresets(presets: ExtractedCameraPreset[]): ExtractedCameraPreset[] { const map = new Map(); for (const preset of presets) { const existing = map.get(preset.key); if (!existing) { map.set(preset.key, preset); continue; } map.set(preset.key, { ...existing, hasPosition: existing.hasPosition || preset.hasPosition, hasTarget: existing.hasTarget || preset.hasTarget, }); } return [...map.values()]; } function extractLabels(source: unknown, sourceLabel: string): ExtractedLabel[] { const labels: ExtractedLabel[] = []; for (const field of getFieldsByNames(source, LABEL_FIELD_KEYS)) { flattenLabelCollection( field.value, `${sourceLabel}.${field.key}`, 0, new WeakSet(), field.key, labels, ); } return uniqueLabels(labels); } function flattenLabelCollection( value: unknown, sourcePath: string, depth: number, seen: WeakSet, fallbackKey: string | undefined, result: ExtractedLabel[], ): void { if (value == null || depth > 6) { return; } if (typeof value === 'string') { result.push({ key: normaliseMachineId(fallbackKey ?? value) || sourcePath, sourcePath, text: value, hasAnchor: Boolean(fallbackKey && !/^\d+$/.test(fallbackKey)), hasPosition: false, }); return; } if (Array.isArray(value)) { value.forEach((child, index) => { flattenLabelCollection(child, `${sourcePath}[${index}]`, depth + 1, seen, String(index), result); }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { flattenLabelCollection(child, `${sourcePath}.${String(key)}`, depth + 1, seen, String(key), result); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); if (isLabelLike(value)) { result.push(labelFromRecord(value, sourcePath, fallbackKey)); return; } for (const [key, child] of Object.entries(value)) { if (shouldTraverseMapEntry(key, child) || typeof child === 'string') { flattenLabelCollection(child, `${sourcePath}.${key}`, depth + 1, seen, key, result); } } } function isLabelLike(value: Record): boolean { return Boolean( pickString([value], LABEL_TEXT_KEYS) || pickString([value], LABEL_ANCHOR_KEYS) || readVectorByNames(value, CAMERA_POSITION_KEYS), ); } function labelFromRecord( value: Record, sourcePath: string, fallbackKey?: string, ): ExtractedLabel { const text = pickString([value], LABEL_TEXT_KEYS); const anchor = pickString([value], LABEL_ANCHOR_KEYS) ?? (fallbackKey && !/^\d+$/.test(fallbackKey) ? fallbackKey : undefined); const rawId = extractRawId(value) ?? anchor ?? text ?? sourcePath; const key = normaliseMachineId(rawId) || sourcePath; return { key, sourcePath, text, hasAnchor: Boolean(anchor), hasPosition: Boolean(readVectorByNames(value, CAMERA_POSITION_KEYS)), }; } function uniqueLabels(labels: ExtractedLabel[]): ExtractedLabel[] { const map = new Map(); for (const label of labels) { const key = label.key || normaliseMachineId(label.text) || label.sourcePath; const existing = map.get(key); if (!existing) { map.set(key, label); continue; } map.set(key, { ...existing, text: existing.text || label.text, hasAnchor: existing.hasAnchor || label.hasAnchor, hasPosition: existing.hasPosition || label.hasPosition, }); } return [...map.values()]; } function extractTourSteps(source: unknown, sourceLabel: string): ExtractedTourStep[] { const steps: ExtractedTourStep[] = []; for (const field of getFieldsByNames(source, TOUR_FIELD_KEYS)) { flattenTourCollection( field.value, `${sourceLabel}.${field.key}`, 0, new WeakSet(), field.key, steps, ); } return uniqueTourSteps(steps); } function flattenTourCollection( value: unknown, sourcePath: string, depth: number, seen: WeakSet, fallbackKey: string | undefined, result: ExtractedTourStep[], ): void { if (value == null || depth > 7) { return; } if (typeof value === 'string') { result.push({ key: normaliseMachineId(fallbackKey ?? value) || sourcePath, sourcePath, text: value, }); return; } if (Array.isArray(value)) { value.forEach((child, index) => { flattenTourCollection(child, `${sourcePath}[${index}]`, depth + 1, seen, String(index), result); }); return; } if (value instanceof Map) { for (const [key, child] of value.entries()) { flattenTourCollection(child, `${sourcePath}.${String(key)}`, depth + 1, seen, String(key), result); } return; } if (!isRecord(value)) { return; } if (seen.has(value)) { return; } seen.add(value); const childFields = getFieldsByNames(value, TOUR_CHILD_FIELD_KEYS); if (childFields.length > 0) { for (const childField of childFields) { flattenTourCollection( childField.value, `${sourcePath}.${childField.key}`, depth + 1, seen, childField.key, result, ); } return; } if (isTourStepLike(value)) { result.push(tourStepFromRecord(value, sourcePath, fallbackKey)); return; } for (const [key, child] of Object.entries(value)) { if (shouldTraverseMapEntry(key, child) || typeof child === 'string') { flattenTourCollection(child, `${sourcePath}.${key}`, depth + 1, seen, key, result); } } } function isTourStepLike(value: Record): boolean { return Boolean(pickString([value], TOUR_STEP_TEXT_KEYS)); } function tourStepFromRecord( value: Record, sourcePath: string, fallbackKey?: string, ): ExtractedTourStep { const text = pickString([value], TOUR_STEP_TEXT_KEYS); const rawId = extractRawId(value) ?? pickString([value], NAME_KEYS) ?? fallbackKey ?? text ?? sourcePath; return { key: normaliseMachineId(rawId) || sourcePath, sourcePath, text, }; } function uniqueTourSteps(steps: ExtractedTourStep[]): ExtractedTourStep[] { const map = new Map(); for (const step of steps) { const key = step.key || normaliseMachineId(step.text) || step.sourcePath; if (!map.has(key)) { map.set(key, step); } } return [...map.values()]; } function hasAnimationBehavior(value: unknown): boolean { if (typeof value === 'function') { return true; } if (!isRecord(value)) { return false; } if (hasAnyKeyDeep(value, ANIMATION_BEHAVIOR_KEYS, 5)) { return true; } return Object.values(value).some((child) => typeof child === 'function'); } function readVectorByNames( value: Record, keys: readonly string[], ): [number, number, number] | undefined { for (const field of getFieldsByNames(value, keys)) { const vector = normaliseVector(field.value); if (vector) { return vector; } } for (const nestedField of getFieldsByNames(value, ['camera', 'cameraState', 'viewState', 'state'])) { if (!isRecord(nestedField.value)) { continue; } for (const field of getFieldsByNames(nestedField.value, keys)) { const vector = normaliseVector(field.value); if (vector) { return vector; } } } return undefined; } function normaliseVector(value: unknown): [number, number, number] | undefined { if (Array.isArray(value) && value.length >= 3) { const numbers = value.slice(0, 3).map(toFiniteNumber); if (numbers.every((number) => number != null)) { return numbers as [number, number, number]; } } if (isRecord(value)) { const x = toFiniteNumber(value.x); const y = toFiniteNumber(value.y); const z = toFiniteNumber(value.z); if (x != null && y != null && z != null) { return [x, y, z]; } const zero = toFiniteNumber(value[0]); const one = toFiniteNumber(value[1]); const two = toFiniteNumber(value[2]); if (zero != null && one != null && two != null) { return [zero, one, two]; } } return undefined; } function toFiniteNumber(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value.trim()) { const number = Number(value); return Number.isFinite(number) ? number : undefined; } return undefined; } function getFieldsByNames( value: unknown, keys: readonly string[], ): Array<{ key: string; value: unknown }> { if (!isRecord(value)) { return []; } const wanted = new Set(keys.map(normalisePropertyKey)); return Object.entries(value) .filter(([key]) => wanted.has(normalisePropertyKey(key))) .map(([key, fieldValue]) => ({ key, value: fieldValue })); } function hasAnyDirectKey(value: unknown, keys: readonly string[]): boolean { return getFieldsByNames(value, keys).some((field) => !isEmptyValue(field.value)); } function hasAnyKeyDeep( value: unknown, keys: readonly string[], maxDepth: number, depth = 0, seen = new WeakSet(), ): boolean { if (value == null || depth > maxDepth) { return false; } if (Array.isArray(value)) { return value.some((child) => hasAnyKeyDeep(child, keys, maxDepth, depth + 1, seen)); } if (value instanceof Map) { return [...value.values()].some((child) => hasAnyKeyDeep(child, keys, maxDepth, depth + 1, seen)); } if (!isRecord(value)) { return false; } if (seen.has(value)) { return false; } seen.add(value); const wanted = new Set(keys.map(normalisePropertyKey)); for (const [key, child] of Object.entries(value)) { if (wanted.has(normalisePropertyKey(key)) && !isEmptyValue(child)) { return true; } } return Object.values(value).some((child) => ( isObjectLike(child) && hasAnyKeyDeep(child, keys, maxDepth, depth + 1, seen) )); } function hasNamedExportMatching( value: unknown, patterns: RegExp[], maxDepth: number, depth = 0, seen = new WeakSet(), ): boolean { if (value == null || depth > maxDepth) { return false; } if (Array.isArray(value)) { return value.some((child) => hasNamedExportMatching(child, patterns, maxDepth, depth + 1, seen)); } if (value instanceof Map) { return [...value.entries()].some(([key, child]) => ( patterns.some((pattern) => pattern.test(String(key))) || hasNamedExportMatching(child, patterns, maxDepth, depth + 1, seen) )); } if (!isRecord(value)) { return false; } if (seen.has(value)) { return false; } seen.add(value); for (const [key, child] of Object.entries(value)) { if (patterns.some((pattern) => pattern.test(key)) && !isEmptyValue(child)) { return true; } } return Object.values(value).some((child) => ( isObjectLike(child) && hasNamedExportMatching(child, patterns, maxDepth, depth + 1, seen) )); } function pickString( sources: unknown[], keys: readonly string[], ): string | undefined { for (const source of sources) { for (const field of getFieldsByNames(source, keys)) { const value = valueToString(field.value); if (value) { return value; } } } return undefined; } function valueToString( value: unknown, depth = 0, seen = new WeakSet(), ): string | undefined { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed || undefined; } if (typeof value === 'number' && Number.isFinite(value)) { return String(value); } if (!isRecord(value) || depth > 2) { return undefined; } if (seen.has(value)) { return undefined; } seen.add(value); for (const field of getFieldsByNames(value, ['text', 'label', 'title', 'name', 'value', 'description', 'summary'])) { const stringValue = valueToString(field.value, depth + 1, seen); if (stringValue) { return stringValue; } } return undefined; } function normaliseStringList( value: unknown, seen = new WeakSet(), depth = 0, ): string[] { if (value == null || depth > 5) { return []; } if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? [trimmed] : []; } if (typeof value === 'number' && Number.isFinite(value)) { return [String(value)]; } if (Array.isArray(value)) { return value.flatMap((item) => normaliseStringList(item, seen, depth + 1)); } if (value instanceof Map) { return [...value.values()].flatMap((item) => normaliseStringList(item, seen, depth + 1)); } if (!isRecord(value)) { return []; } if (seen.has(value)) { return []; } seen.add(value); const directFields = getFieldsByNames(value, [ 'text', 'fact', 'label', 'title', 'description', 'body', 'copy', 'value', 'caption', ]); if (directFields.length > 0) { return uniqueMeaningfulStrings( directFields.flatMap((field) => normaliseStringList(field.value, seen, depth + 1)), 1, ); } return Object.values(value).flatMap((item) => normaliseStringList(item, seen, depth + 1)); } function extractRawId(value: unknown): string | undefined { if (!isRecord(value)) { return undefined; } return pickString([value], ID_KEYS); } function uniqueMeaningfulStrings(values: string[], minLength: number): string[] { const seen = new Set(); const result: string[] = []; for (const value of values) { const trimmed = value.trim().replace(/\s+/g, ' '); const key = trimmed.toLowerCase(); if (trimmed.length < minLength || seen.has(key)) { continue; } seen.add(key); result.push(trimmed); } return result; } function uniqueIds(values: string[]): string[] { const seen = new Set(); const result: string[] = []; for (const value of values) { const id = normaliseMachineId(value); if (!id || seen.has(id)) { continue; } seen.add(id); result.push(id); } return result; } function isUsefulDescription(description: string | undefined): boolean { return Boolean(description && description.trim().length >= 18); } function shouldTraverseMapEntry(key: string, value: unknown): boolean { if (!isObjectLike(value) && typeof value !== 'string') { return false; } return !IGNORED_TRAVERSAL_KEYS.has(normalisePropertyKey(key)); } function normalisePropertyKey(key: string): string { return key.toLowerCase().replace(/[-_\s]/g, ''); } function humaniseId(id: string): string { return id .split(/[-_]/g) .filter(Boolean) .map((part) => part ? part[0].toUpperCase() + part.slice(1) : part) .join(' '); } function lastPathSegment(path: string): string { return path .split(/[.[\]]/g) .map((part) => part.replace(/^['"]|['"]$/g, '')) .filter(Boolean) .pop() ?? ''; } function isRecord(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Map) && !(value instanceof Set) ); } function isObjectLike(value: unknown): value is object { return (typeof value === 'object' && value !== null) || typeof value === 'function'; } function isEmptyValue(value: unknown): boolean { if (value == null) { return true; } if (typeof value === 'string') { return value.trim().length === 0; } if (Array.isArray(value)) { return value.length === 0; } if (value instanceof Map || value instanceof Set) { return value.size === 0; } if (isRecord(value)) { return Object.keys(value).length === 0; } return false; } function isDefined(value: T | undefined): value is T { return value !== undefined; } function pushIssue( issues: CatalogueContractIssue[], severity: ContractSeverity, code: string, message: string, machineId?: string, path?: string, details?: Record, ): void { const issue: CatalogueContractIssue = { severity, code, message, }; if (machineId) { issue.machineId = machineId; } if (path) { issue.path = path; } if (details) { issue.details = details; } issues.push(issue); } function formatCoverageLine(label: string, coverage: RegistryCoverageReport): string { const missing = coverage.missingIds.length === 0 ? 'none' : coverage.missingIds.join(', '); const extra = coverage.extraIds.length === 0 ? 'none' : coverage.extraIds.join(', '); return ` - ${label}: ${coverage.count} entries; missing: ${missing}; extra: ${extra}`; } function formatIssue(issue: CatalogueContractIssue): string { const machine = issue.machineId ? ` ${issue.machineId}` : ''; const path = issue.path ? ` (${issue.path})` : ''; return ` [${issue.severity.toUpperCase()}] ${issue.code}${machine}: ${issue.message}${path}`; } function formatMachineSummary(summary: MachineContractSummary): string { return [ ' ', pad(summary.id, 28), pad(String(summary.factCount), 5), pad(String(summary.relatedCount), 4), pad(String(summary.componentCount), 6), pad(String(summary.describedComponentCount), 5), pad(`${summary.validCameraPresetCount}/${summary.cameraPresetCount}`, 5), pad(`${summary.anchoredLabelCount}/${summary.labelCount}`, 7), pad(String(summary.tourStepCount), 5), pad(String(summary.blueprintNodeCount), 6), pad(yesNo(summary.hasAssetReplacementPoint), 6), pad(yesNo(summary.hasExplodedMetadata), 8), pad(yesNo(summary.hasAnimation), 5), String(summary.issueCount), ].join(' '); } function pad(value: string, width: number): string { if (value.length > width) { return `${value.slice(0, Math.max(0, width - 1))}…`; } return value.padEnd(width, ' '); } function yesNo(value: boolean): string { return value ? 'yes' : 'no'; }