export type CatalogueIntegritySeverity = 'error' | 'warning'; export type CatalogueIntegrityIssueCode = | 'CATALOGUE_NOT_ARRAY' | 'CATALOGUE_EMPTY' | 'MACHINE_NOT_OBJECT' | 'MISSING_MACHINE_ID' | 'DUPLICATE_MACHINE_ID' | 'NON_URL_SAFE_MACHINE_ID' | 'MISSING_MACHINE_TITLE' | 'MISSING_MACHINE_CATEGORY' | 'MISSING_MACHINE_DESCRIPTION' | 'MISSING_MACHINE_TAGS' | 'MISSING_MODEL_ASSET' | 'PLACEHOLDER_MODEL_ASSET' | 'MISSING_COMPONENTS' | 'COMPONENT_NOT_OBJECT' | 'MISSING_COMPONENT_ID' | 'DUPLICATE_COMPONENT_ID' | 'MISSING_COMPONENT_NAME' | 'MISSING_COMPONENT_DESCRIPTION' | 'INVALID_EXPLODED_VECTOR' | 'MISSING_EXPLODED_VECTOR' | 'INVALID_COMPONENT_REFERENCE' | 'MISSING_CAMERA_PRESETS' | 'MISSING_ANIMATION_METADATA' | 'MISSING_REQUIRED_CATEGORY'; export interface CatalogueIntegrityIssue { severity: CatalogueIntegritySeverity; code: CatalogueIntegrityIssueCode; path: string; message: string; machineId?: string; componentId?: string; recommendation?: string; } export interface CatalogueIntegrityOptions { /** * Empty machine lists are almost always accidental in the production registry, * but fixtures may opt out. */ requireAtLeastOneMachine?: boolean; /** * During foundation work, placeholder GLB paths are expected. Setting this to * false turns missing/placeholder model assets into hard errors for later * asset-integration milestones. */ allowPlaceholderAssets?: boolean; warnOnMissingComponents?: boolean; warnOnMissingCameraPresets?: boolean; warnOnMissingAnimationMetadata?: boolean; warnOnMissingExplodedMetadata?: boolean; requiredCategories?: readonly string[]; } export interface CatalogueIntegritySummary { passed: boolean; machineCount: number; componentCount: number; categoryCount: number; tagCount: number; categories: string[]; tags: string[]; errors: number; warnings: number; issues: CatalogueIntegrityIssue[]; } export interface FormatCatalogueIntegrityReportOptions { includeWarnings?: boolean; maxIssues?: number; } export interface CatalogueDiscoveryOptions { maxDepth?: number; minimumConfidence?: number; minimumMachineCount?: number; /** * Useful for blueprint modules that export one array per category at the * module root. Disabled by default to avoid accidentally merging unrelated * exports from general-purpose modules. */ aggregateRootSiblings?: boolean; } export interface CatalogueCandidate { name: string; machines: unknown[]; confidence: number; machineLikeCount: number; totalCount: number; aggregate: boolean; } type RecordLike = Record; interface ResolvedCatalogueIntegrityOptions { requireAtLeastOneMachine: boolean; allowPlaceholderAssets: boolean; warnOnMissingComponents: boolean; warnOnMissingCameraPresets: boolean; warnOnMissingAnimationMetadata: boolean; warnOnMissingExplodedMetadata: boolean; requiredCategories: readonly string[]; } const MACHINE_ID_KEYS = ['id', 'slug', 'machineId', 'key'] as const; const MACHINE_TITLE_KEYS = ['title', 'name', 'label'] as const; const MACHINE_CATEGORY_KEYS = ['category', 'type', 'family'] as const; const MACHINE_DESCRIPTION_KEYS = [ 'summary', 'description', 'shortDescription', 'overview', ] as const; const MACHINE_TAG_KEYS = ['tags', 'keywords'] as const; const MODEL_PATH_KEYS = [ 'modelPath', 'assetPath', 'glbPath', 'gltfPath', 'url', 'href', 'src', ] as const; const MODEL_OBJECT_KEYS = ['asset', 'model', 'gltf', 'glb'] as const; const COMPONENT_ARRAY_KEYS = ['components', 'parts', 'subassemblies'] as const; const COMPONENT_ID_KEYS = ['id', 'slug', 'componentId', 'partId', 'key'] as const; const COMPONENT_NAME_KEYS = ['name', 'title', 'label'] as const; const COMPONENT_DESCRIPTION_KEYS = ['description', 'summary', 'purpose', 'function'] as const; const EXPLODED_VECTOR_KEYS = [ 'explodedOffset', 'explodedPosition', 'explodedDirection', 'explodeOffset', 'explodeVector', ] as const; const CAMERA_PRESET_KEYS = ['cameraPresets', 'viewPresets', 'savedViews', 'views'] as const; const ANIMATION_KEYS = ['animation', 'animations', 'kinematics', 'simulation'] as const; const COMPONENT_REFERENCE_KEYS = [ 'parentComponentId', 'parentId', 'connectedComponentIds', 'dependsOnComponentIds', 'drivenByComponentId', 'constrainsComponentIds', ] as const; const URL_SAFE_ID_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/; const PLACEHOLDER_ASSET_PATTERN = /(placeholder|sample|mock|todo|temporary|temp)\.(glb|gltf)$|placeholder|todo/i; const DEFAULT_INTEGRITY_OPTIONS: ResolvedCatalogueIntegrityOptions = { requireAtLeastOneMachine: true, allowPlaceholderAssets: true, warnOnMissingComponents: true, warnOnMissingCameraPresets: true, warnOnMissingAnimationMetadata: true, warnOnMissingExplodedMetadata: false, requiredCategories: [], }; const DEFAULT_DISCOVERY_OPTIONS: Required = { maxDepth: 4, minimumConfidence: 0.5, minimumMachineCount: 1, aggregateRootSiblings: false, }; export function validateCatalogueIntegrity( catalogue: unknown, options: CatalogueIntegrityOptions = {}, ): CatalogueIntegritySummary { const resolvedOptions = resolveIntegrityOptions(options); const issues: CatalogueIntegrityIssue[] = []; const categories = new Set(); const tags = new Set(); const machineIds = new Map(); let componentCount = 0; if (!Array.isArray(catalogue)) { issues.push({ severity: 'error', code: 'CATALOGUE_NOT_ARRAY', path: 'machines', message: 'Machine catalogue must be exported as an array of machine records.', recommendation: 'Export a data array from the registry or use a selector that returns all machine definitions.', }); return buildSummary(0, 0, categories, tags, issues); } if (resolvedOptions.requireAtLeastOneMachine && catalogue.length === 0) { issues.push({ severity: 'error', code: 'CATALOGUE_EMPTY', path: 'machines', message: 'Machine catalogue is empty.', recommendation: 'Add at least one machine definition or disable this check for isolated fixtures.', }); } catalogue.forEach((machine, machineIndex) => { const machinePath = `machines[${machineIndex}]`; if (!isRecord(machine)) { issues.push({ severity: 'error', code: 'MACHINE_NOT_OBJECT', path: machinePath, message: 'Machine entry must be an object.', }); return; } const machineId = pickString(machine, MACHINE_ID_KEYS); if (!machineId) { issues.push({ severity: 'error', code: 'MISSING_MACHINE_ID', path: machinePath, message: 'Machine entry is missing a stable id.', recommendation: 'Provide a kebab-case id; it is used for routing, sharing, and state persistence.', }); } else { const normalizedMachineId = machineId.toLowerCase(); const existingIndex = machineIds.get(normalizedMachineId); if (existingIndex !== undefined) { issues.push({ severity: 'error', code: 'DUPLICATE_MACHINE_ID', path: `${machinePath}.id`, machineId, message: `Machine id "${machineId}" duplicates machines[${existingIndex}].`, recommendation: 'Machine ids must be globally unique because they are URL route identifiers.', }); } else { machineIds.set(normalizedMachineId, machineIndex); } if (!URL_SAFE_ID_PATTERN.test(machineId)) { issues.push({ severity: 'warning', code: 'NON_URL_SAFE_MACHINE_ID', path: `${machinePath}.id`, machineId, message: `Machine id "${machineId}" is not URL-safe kebab/snake case.`, recommendation: 'Use lowercase letters, numbers, hyphens, or underscores only.', }); } } const title = pickString(machine, MACHINE_TITLE_KEYS); const category = pickString(machine, MACHINE_CATEGORY_KEYS); const description = pickString(machine, MACHINE_DESCRIPTION_KEYS); const machineTags = pickStringArray(machine, MACHINE_TAG_KEYS); const modelPath = pickModelPath(machine); if (!title) { issues.push({ severity: 'warning', code: 'MISSING_MACHINE_TITLE', path: machinePath, machineId, message: 'Machine entry is missing a human-readable title/name.', }); } if (!category) { issues.push({ severity: 'warning', code: 'MISSING_MACHINE_CATEGORY', path: machinePath, machineId, message: 'Machine entry is missing a category.', recommendation: 'Categories drive catalogue filters and thumbnail grouping.', }); } else { categories.add(category); } if (!description) { issues.push({ severity: 'warning', code: 'MISSING_MACHINE_DESCRIPTION', path: machinePath, machineId, message: 'Machine entry is missing summary/description content.', }); } if (machineTags.length === 0) { issues.push({ severity: 'warning', code: 'MISSING_MACHINE_TAGS', path: machinePath, machineId, message: 'Machine entry has no tags/keywords.', recommendation: 'Tags improve search, related-machine suggestions, and future analytics.', }); } else { machineTags.forEach((tag) => tags.add(tag)); } if (!modelPath) { issues.push({ severity: resolvedOptions.allowPlaceholderAssets ? 'warning' : 'error', code: 'MISSING_MODEL_ASSET', path: machinePath, machineId, message: 'Machine entry does not declare a GLB/GLTF model asset path.', recommendation: 'Add modelPath/assetPath now, even if it points to a milestone placeholder asset.', }); } else if (PLACEHOLDER_ASSET_PATTERN.test(modelPath)) { issues.push({ severity: resolvedOptions.allowPlaceholderAssets ? 'warning' : 'error', code: 'PLACEHOLDER_MODEL_ASSET', path: machinePath, machineId, message: `Machine model asset "${modelPath}" appears to be a placeholder.`, recommendation: 'Replace placeholder model references before production catalogue release.', }); } componentCount += validateMachineComponents( machine, machinePath, machineId, issues, resolvedOptions, ); if ( resolvedOptions.warnOnMissingCameraPresets && !hasAnyArray(machine, CAMERA_PRESET_KEYS) && !isRecord(machine.viewerDefaults) ) { issues.push({ severity: 'warning', code: 'MISSING_CAMERA_PRESETS', path: machinePath, machineId, message: 'Machine entry has no camera/view preset metadata.', recommendation: 'Add saved camera presets for front, isometric, section, and detail views.', }); } if (resolvedOptions.warnOnMissingAnimationMetadata && !hasAnyValue(machine, ANIMATION_KEYS)) { issues.push({ severity: 'warning', code: 'MISSING_ANIMATION_METADATA', path: machinePath, machineId, message: 'Machine entry has no animation/kinematics metadata.', recommendation: 'Declare animation channels through the animation interface contract.', }); } }); resolvedOptions.requiredCategories.forEach((requiredCategory) => { if (!categories.has(requiredCategory)) { issues.push({ severity: 'error', code: 'MISSING_REQUIRED_CATEGORY', path: 'machines', message: `Required category "${requiredCategory}" is not represented in the catalogue.`, }); } }); return buildSummary(catalogue.length, componentCount, categories, tags, issues); } export function formatCatalogueIntegrityReport( summary: CatalogueIntegritySummary, options: FormatCatalogueIntegrityReportOptions = {}, ): string { const includeWarnings = options.includeWarnings ?? true; const maxIssues = options.maxIssues ?? 50; const visibleIssues = summary.issues .filter((issue) => includeWarnings || issue.severity === 'error') .slice(0, maxIssues); const lines = [ `Catalogue integrity: ${summary.passed ? 'passed' : 'failed'}`, `Machines: ${summary.machineCount}; components: ${summary.componentCount}; categories: ${summary.categoryCount}; tags: ${summary.tagCount}`, `Errors: ${summary.errors}; warnings: ${summary.warnings}`, ]; visibleIssues.forEach((issue) => { const context = [ issue.path, issue.machineId ? `machine=${issue.machineId}` : undefined, issue.componentId ? `component=${issue.componentId}` : undefined, ] .filter(Boolean) .join(' '); lines.push(`- [${issue.severity.toUpperCase()}] ${issue.code} ${context}: ${issue.message}`); if (issue.recommendation) { lines.push(` Recommendation: ${issue.recommendation}`); } }); const hiddenIssueCount = summary.issues.filter((issue) => includeWarnings || issue.severity === 'error').length - visibleIssues.length; if (hiddenIssueCount > 0) { lines.push(`- … ${hiddenIssueCount} additional issue(s) omitted.`); } return lines.join('\n'); } export function discoverCatalogueCandidates( moduleExports: Record, moduleName = 'module', options: CatalogueDiscoveryOptions = {}, ): CatalogueCandidate[] { const resolvedOptions = { ...DEFAULT_DISCOVERY_OPTIONS, ...options }; const seenObjects = new Set(); const candidates = visitForCatalogueCandidates( moduleExports, moduleName, 0, resolvedOptions, seenObjects, ); const bestCandidateForArray = new Map(); candidates.forEach((candidate) => { const previous = bestCandidateForArray.get(candidate.machines); if ( !previous || candidate.confidence > previous.confidence || (candidate.confidence === previous.confidence && candidate.name.length < previous.name.length) ) { bestCandidateForArray.set(candidate.machines, candidate); } }); return Array.from(bestCandidateForArray.values()).sort((a, b) => { if (b.machines.length !== a.machines.length) { return b.machines.length - a.machines.length; } if (b.confidence !== a.confidence) { return b.confidence - a.confidence; } return a.name.localeCompare(b.name); }); } function validateMachineComponents( machine: RecordLike, machinePath: string, machineId: string | undefined, issues: CatalogueIntegrityIssue[], options: ResolvedCatalogueIntegrityOptions, ): number { const components = pickArray(machine, COMPONENT_ARRAY_KEYS); if (!components) { if (options.warnOnMissingComponents) { issues.push({ severity: 'warning', code: 'MISSING_COMPONENTS', path: machinePath, machineId, message: 'Machine entry does not declare component metadata.', recommendation: 'Component definitions power the sidebar, opacity controls, exploded views, and facts.', }); } return 0; } const componentIds = new Set(); const componentIdLocations = new Map(); const componentReferences: Array<{ path: string; sourceComponentId?: string; targetComponentId: string; }> = []; components.forEach((component, componentIndex) => { const componentPath = `${machinePath}.components[${componentIndex}]`; if (!isRecord(component)) { issues.push({ severity: 'error', code: 'COMPONENT_NOT_OBJECT', path: componentPath, machineId, message: 'Component entry must be an object.', }); return; } const componentId = pickString(component, COMPONENT_ID_KEYS); if (!componentId) { issues.push({ severity: 'error', code: 'MISSING_COMPONENT_ID', path: componentPath, machineId, message: 'Component entry is missing a stable id.', recommendation: 'Component ids must be stable because URL state can reference hidden/isolated components.', }); } else { const normalizedComponentId = componentId.toLowerCase(); const previousIndex = componentIdLocations.get(normalizedComponentId); if (previousIndex !== undefined) { issues.push({ severity: 'error', code: 'DUPLICATE_COMPONENT_ID', path: `${componentPath}.id`, machineId, componentId, message: `Component id "${componentId}" duplicates components[${previousIndex}] in this machine.`, }); } else { componentIdLocations.set(normalizedComponentId, componentIndex); componentIds.add(componentId); } } if (!pickString(component, COMPONENT_NAME_KEYS)) { issues.push({ severity: 'warning', code: 'MISSING_COMPONENT_NAME', path: componentPath, machineId, componentId, message: 'Component entry is missing a human-readable name/title.', }); } if (!pickString(component, COMPONENT_DESCRIPTION_KEYS)) { issues.push({ severity: 'warning', code: 'MISSING_COMPONENT_DESCRIPTION', path: componentPath, machineId, componentId, message: 'Component entry is missing descriptive engineering copy.', }); } const explodedVector = pickFirstValue(component, EXPLODED_VECTOR_KEYS); if (explodedVector === undefined) { if (options.warnOnMissingExplodedMetadata) { issues.push({ severity: 'warning', code: 'MISSING_EXPLODED_VECTOR', path: componentPath, machineId, componentId, message: 'Component does not declare exploded-view offset/direction metadata.', }); } } else if (!isVector3Like(explodedVector)) { issues.push({ severity: 'error', code: 'INVALID_EXPLODED_VECTOR', path: componentPath, machineId, componentId, message: 'Exploded-view metadata must be a finite [x, y, z] tuple or { x, y, z } object.', }); } collectComponentReferenceIds(component).forEach((targetComponentId) => { componentReferences.push({ path: componentPath, sourceComponentId: componentId, targetComponentId, }); }); }); componentReferences.forEach((reference) => { if (!componentIds.has(reference.targetComponentId)) { issues.push({ severity: 'warning', code: 'INVALID_COMPONENT_REFERENCE', path: reference.path, machineId, componentId: reference.sourceComponentId, message: `Component references "${reference.targetComponentId}", which is not declared in this machine's component list.`, recommendation: 'Use component ids for all intra-machine references so isolation and dependency highlighting remain reliable.', }); } }); return components.length; } function visitForCatalogueCandidates( value: unknown, label: string, depth: number, options: Required, seenObjects: Set, ): CatalogueCandidate[] { if (Array.isArray(value)) { const candidate = createArrayCandidate(value, label, options); return candidate ? [candidate] : []; } if (!isRecord(value) || depth > options.maxDepth) { return []; } if (seenObjects.has(value)) { return []; } seenObjects.add(value); const childEntries = Object.entries(value); const candidates: CatalogueCandidate[] = []; const directArrayCandidates = childEntries .filter(([, child]) => Array.isArray(child)) .map(([key, child]) => createArrayCandidate(child, `${label}.${key}`, options)) .filter((candidate): candidate is CatalogueCandidate => Boolean(candidate)); if ( directArrayCandidates.length > 1 && (depth > 0 || options.aggregateRootSiblings) && directArrayCandidates.reduce((total, candidate) => total + candidate.machines.length, 0) >= options.minimumMachineCount ) { const mergedMachines = directArrayCandidates.flatMap((candidate) => candidate.machines); const machineLikeCount = directArrayCandidates.reduce( (total, candidate) => total + candidate.machineLikeCount, 0, ); const confidence = directArrayCandidates.reduce((total, candidate) => total + candidate.confidence, 0) / directArrayCandidates.length; candidates.push({ name: `${label}.{${directArrayCandidates .map((candidate) => candidate.name.split('.').at(-1)) .join(',')}}`, machines: mergedMachines, confidence, machineLikeCount, totalCount: mergedMachines.length, aggregate: true, }); } childEntries.forEach(([key, child]) => { candidates.push( ...visitForCatalogueCandidates(child, `${label}.${key}`, depth + 1, options, seenObjects), ); }); return candidates; } function createArrayCandidate( value: unknown[], name: string, options: Required, ): CatalogueCandidate | undefined { if (value.length < options.minimumMachineCount) { return undefined; } const machineLikeCount = value.filter(isMachineLikeRecord).length; const objectCount = value.filter(isRecord).length; const confidence = objectCount === 0 ? 0 : machineLikeCount / objectCount; if (machineLikeCount === 0 || confidence < options.minimumConfidence) { return undefined; } return { name, machines: value, confidence, machineLikeCount, totalCount: value.length, aggregate: false, }; } function isMachineLikeRecord(value: unknown): value is RecordLike { if (!isRecord(value)) { return false; } const hasIdentity = Boolean(pickString(value, MACHINE_ID_KEYS)); const hasExplicitMachineShape = Boolean(pickString(value, MACHINE_CATEGORY_KEYS)) || Boolean(pickString(value, ['title', 'summary', 'shortDescription', 'overview'])) || Boolean(pickModelPath(value)) || Boolean(pickArray(value, COMPONENT_ARRAY_KEYS)) || Boolean(pickArray(value, CAMERA_PRESET_KEYS)) || Boolean(pickStringArray(value, MACHINE_TAG_KEYS).length); const hasNameWithMachineContext = Boolean(pickString(value, ['name', 'label'])) && (Boolean(pickString(value, MACHINE_CATEGORY_KEYS)) || Boolean(pickStringArray(value, MACHINE_TAG_KEYS).length) || Boolean(pickFirstValue(value, ANIMATION_KEYS))); return hasIdentity && (hasExplicitMachineShape || hasNameWithMachineContext); } function resolveIntegrityOptions( options: CatalogueIntegrityOptions, ): ResolvedCatalogueIntegrityOptions { return { ...DEFAULT_INTEGRITY_OPTIONS, ...options, requiredCategories: options.requiredCategories ?? DEFAULT_INTEGRITY_OPTIONS.requiredCategories, }; } function buildSummary( machineCount: number, componentCount: number, categories: Set, tags: Set, issues: CatalogueIntegrityIssue[], ): CatalogueIntegritySummary { const errors = issues.filter((issue) => issue.severity === 'error').length; const warnings = issues.filter((issue) => issue.severity === 'warning').length; const sortedCategories = Array.from(categories).sort((a, b) => a.localeCompare(b)); const sortedTags = Array.from(tags).sort((a, b) => a.localeCompare(b)); return { passed: errors === 0, machineCount, componentCount, categoryCount: sortedCategories.length, tagCount: sortedTags.length, categories: sortedCategories, tags: sortedTags, errors, warnings, issues, }; } function pickString(record: RecordLike, keys: readonly string[]): string | undefined { for (const key of keys) { const value = record[key]; if (typeof value === 'string' && value.trim().length > 0) { return value.trim(); } } return undefined; } function pickStringArray(record: RecordLike, keys: readonly string[]): string[] { for (const key of keys) { const value = record[key]; if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean); } if (typeof value === 'string' && value.trim().length > 0) { return value .split(',') .map((item) => item.trim()) .filter(Boolean); } } return []; } function pickArray(record: RecordLike, keys: readonly string[]): unknown[] | undefined { for (const key of keys) { const value = record[key]; if (Array.isArray(value)) { return value; } } return undefined; } function pickFirstValue(record: RecordLike, keys: readonly string[]): unknown { for (const key of keys) { if (key in record) { return record[key]; } } return undefined; } function hasAnyValue(record: RecordLike, keys: readonly string[]): boolean { return keys.some((key) => record[key] !== undefined && record[key] !== null); } function hasAnyArray(record: RecordLike, keys: readonly string[]): boolean { return keys.some((key) => Array.isArray(record[key])); } function pickModelPath(machine: RecordLike): string | undefined { const directPath = pickString(machine, MODEL_PATH_KEYS); if (directPath) { return directPath; } for (const key of MODEL_OBJECT_KEYS) { const value = machine[key]; if (typeof value === 'string' && value.trim().length > 0) { return value.trim(); } if (isRecord(value)) { const nestedPath = pickString(value, MODEL_PATH_KEYS); if (nestedPath) { return nestedPath; } } } return undefined; } function collectComponentReferenceIds(component: RecordLike): string[] { const references: string[] = []; COMPONENT_REFERENCE_KEYS.forEach((key) => { const value = component[key]; if (typeof value === 'string' && value.trim().length > 0) { references.push(value.trim()); return; } if (Array.isArray(value)) { value.forEach((item) => { if (typeof item === 'string' && item.trim().length > 0) { references.push(item.trim()); } }); } }); return references; } function isVector3Like(value: unknown): boolean { if (Array.isArray(value)) { return value.length === 3 && value.every((item) => typeof item === 'number' && Number.isFinite(item)); } if (!isRecord(value)) { return false; } return ['x', 'y', 'z'].every((axis) => { const axisValue = value[axis]; return typeof axisValue === 'number' && Number.isFinite(axisValue); }); } function isRecord(value: unknown): value is RecordLike { return typeof value === 'object' && value !== null && !Array.isArray(value); }