import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' const root = resolve(new URL('..', import.meta.url).pathname) const load = async (file) => JSON.parse(await readFile(resolve(root, file), 'utf8')) const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, canonical(child)])) : value const stable = (value) => JSON.stringify(canonical(value)) const plan = await load('config/freecad-parameter-mutation-plan.json') const coverage = await load(plan.coveragePlan) const fail = (message) => { throw new Error(`FreeCAD parameter mutation report: ${message}`) } if (plan.schemaVersion !== 1 || coverage.schemaVersion !== 1 || coverage.baselineId !== plan.baselineId || coverage.sourceCommit !== plan.sourceCommit) fail('baseline is not locked to FreeCAD 1.1.1.') const familyByType = new Map() for (const family of coverage.families) { familyByType.set(family.id, family.id) for (const type of family.operationTypes) familyByType.set(type, family.id) } const typeAliases = new Map([ ['PartDesign::Revolution', 'partdesign-revolution'], ['PartDesign::AdditiveLoft', 'additive-loft'], ['PartDesign::SubtractiveLoft', 'subtractive-loft'], ['PartDesign::AdditivePipe', 'additive-pipe'], ['PartDesign::SubtractivePipe', 'subtractive-pipe'], ['PartDesign::Fillet', 'partdesign-fillet'], ['PartDesign::Chamfer', 'partdesign-chamfer'], ]) for (const [typeId, alias] of typeAliases) familyByType.set(alias, familyByType.get(typeId) || typeId) const records = new Map(coverage.families.map((family) => [family.id, { nominal: [], invalid: [], parameterVariants: new Map(), editRecovery: [], roundtrip: { required: family.roundtripIds.length, passed: 0 }, sources: new Set(), }])) const addRecord = (familyId, dimension, record, source) => { const bucket = records.get(familyId) if (!bucket) return bucket[dimension].push(record) bucket.sources.add(source) } const addVariant = (familyId, snapshot, source) => { const bucket = records.get(familyId) if (!bucket || snapshot === undefined) return bucket.parameterVariants.set(stable(snapshot), { snapshot, source }) bucket.sources.add(source) } const addMutation = (mutation, source) => { if (!mutation || typeof mutation !== 'object') return const familyId = familyByType.get(mutation.familyId) if (!familyId || mutation.status !== 'pass' || mutation.parameterChanged !== true || mutation.shapeChanged !== true || mutation.restoredExactly !== true) return addRecord(familyId, 'editRecovery', { transaction: `native-parameter:${mutation.featureName}:${mutation.propertyPath}`, ...mutation }, source) addVariant(familyId, mutation.before.parameterSnapshot, `${source}#before`) addVariant(familyId, mutation.edited.parameterSnapshot, `${source}#edited`) } const manifestCases = async (manifestPath, entryKey, directory) => { const manifest = await load(manifestPath) const base = resolve(root, directory) return Promise.all((manifest[entryKey] || []).map(async (entry) => ({ ...await load(resolve(base, entry.file)), source: `${manifestPath}/${entry.id}`, }))) } const [successFixtures, failureFixtures, supplementalSuccessFixtures, supplementalFailureFixtures] = await Promise.all([ manifestCases('fixtures/freecad-golden/manifest.json', 'scenarios', 'fixtures/freecad-golden'), manifestCases('fixtures/freecad-golden/failures/manifest.json', 'failures', 'fixtures/freecad-golden/failures'), manifestCases('fixtures/freecad-golden/feature-families/manifest.json', 'scenarios', 'fixtures/freecad-golden/feature-families'), manifestCases('fixtures/freecad-golden/feature-families/failures/manifest.json', 'failures', 'fixtures/freecad-golden/feature-families/failures'), ]) for (const fixture of [...successFixtures, ...supplementalSuccessFixtures]) { const familyId = familyByType.get(fixture.operation?.type) if (!familyId) continue addRecord(familyId, 'nominal', { id: fixture.id, parameterSnapshot: fixture.operation }, fixture.source) addVariant(familyId, fixture.operation, fixture.source) } for (const fixture of [...failureFixtures, ...supplementalFailureFixtures]) { const familyId = familyByType.get(fixture.operation?.type) if (!familyId) continue addRecord(familyId, 'invalid', { id: fixture.id, parameterSnapshot: fixture.operation }, fixture.source) } const addOracleCases = async (file, successKey, failureKey) => { const report = await load(file) const source = file for (const key of [successKey, failureKey]) for (const fixture of report[key] || []) { const familyId = familyByType.get(fixture.typeId) if (!familyId) continue if (key === successKey) { addRecord(familyId, 'nominal', { id: fixture.id, typeId: fixture.typeId }, source) addMutation(fixture.mutation, source) } else addRecord(familyId, 'invalid', { id: fixture.id, typeId: fixture.typeId }, source) } } await addOracleCases('config/freecad-part-builders-oracle.json', 'successCases', 'failureCases') const coreMutationOracle = await load('config/freecad-core-parameter-mutation-oracle.json') for (const mutation of coreMutationOracle.cases || []) addMutation(mutation, 'config/freecad-core-parameter-mutation-oracle.json') for (const file of ['config/freecad-partdesign-failure-oracle.json', 'config/freecad-partdesign-revolution-groove-oracle.json', 'config/freecad-partdesign-transform-oracle.json']) { const report = await load(file) for (const fixture of report.cases || []) { const familyId = familyByType.get(fixture.typeId) if (!familyId) continue if (fixture.passed === true && fixture.status === 'Valid' && fixture.shapeValid !== false && fixture.solids > 0) addRecord(familyId, 'nominal', { id: fixture.id, typeId: fixture.typeId }, file) if (fixture.passed === true && (fixture.observed === 'rejected' || fixture.state?.includes('Invalid'))) addRecord(familyId, 'invalid', { id: fixture.id, typeId: fixture.typeId }, file) addMutation(fixture.mutation, file) } } const baseOracle = await load('config/freecad-partdesign-base-oracle.json') for (const fixture of baseOracle.cases || []) { const familyId = fixture.id.startsWith('pad-') ? 'PartDesign::Pad' : fixture.id.startsWith('pocket-') ? 'PartDesign::Pocket' : undefined if (familyId && fixture.passed === true && fixture.status === 'Valid' && fixture.solids > 0) addRecord(familyId, 'nominal', { id: fixture.id, typeId: familyId, outcome: { volume: fixture.volume, faces: fixture.faces } }, 'config/freecad-partdesign-base-oracle.json') if (familyId) addMutation(fixture.mutation, 'config/freecad-partdesign-base-oracle.json') } for (const file of ['config/freecad-partdesign-loft-oracle.json', 'config/freecad-partdesign-dressup-oracle.json']) { const report = await load(file) for (const fixture of report.cases || []) { const familyId = familyByType.get(fixture.typeId) if (familyId && fixture.passed === true && fixture.status === 'Valid' && fixture.solids > 0) addRecord(familyId, 'nominal', { id: fixture.id, typeId: fixture.typeId, outcome: { volume: fixture.volume, faces: fixture.faces } }, file) addMutation(fixture.mutation, file) } } const lifecycle = await load('config/chrome-partdesign-lifecycle-verification.json') if (lifecycle.status === 'pass' && lifecycle.initial && lifecycle.edited && lifecycle.undone && lifecycle.reopened) { for (const familyId of ['PartDesign::Pad', 'PartDesign::Pocket']) addRecord(familyId, 'editRecovery', { transaction: 'pad-length-edit', before: lifecycle.initial, edited: lifecycle.edited, restored: lifecycle.undone, reopened: lifecycle.reopened }, 'config/chrome-partdesign-lifecycle-verification.json') } const loft = await load('config/chrome-partdesign-loft-verification.json') if (loft.status === 'pass') for (const [operation, recovery] of Object.entries(loft.failureRecovery || {})) { for (const familyId of operation === 'loft' ? ['PartDesign::AdditiveLoft', 'PartDesign::SubtractiveLoft'] : ['PartDesign::AdditivePipe', 'PartDesign::SubtractivePipe']) addRecord(familyId, 'editRecovery', { transaction: `${operation}-failure-recovery`, recovery }, 'config/chrome-partdesign-loft-verification.json') } const omitRuntimeFields = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return value const ignored = new Set(['objectId', 'tip', 'shapeId', 'volume', 'area', 'shapeCount', 'kernelReferenceCount', 'recompute', 'status', 'solids', 'faces', 'edges', 'vertices', 'structuralValid', 'base']) return Object.fromEntries(Object.entries(value).filter(([key]) => !ignored.has(key))) } const visitBrowserEvidence = (value, source) => { if (!value || typeof value !== 'object') return if (typeof value.command === 'string') { const familyId = familyByType.get(value.command) if (familyId) addVariant(familyId, omitRuntimeFields(value), source) } for (const child of Object.values(value)) visitBrowserEvidence(child, source) } for (const file of ['config/chrome-part-primitives-verification.json', 'config/chrome-partdesign-transform-verification.json', 'config/chrome-partdesign-loft-verification.json', 'config/chrome-partdesign-lifecycle-verification.json']) { visitBrowserEvidence(await load(file), file) } if (lifecycle.initial?.length !== undefined && lifecycle.edited?.length !== undefined && lifecycle.undone?.length !== undefined) { addVariant('PartDesign::Pad', { length: lifecycle.initial.length }, 'config/chrome-partdesign-lifecycle-verification.json#initial') addVariant('PartDesign::Pad', { length: lifecycle.edited.length }, 'config/chrome-partdesign-lifecycle-verification.json#edited') addVariant('PartDesign::Pad', { length: lifecycle.undone.length }, 'config/chrome-partdesign-lifecycle-verification.json#restored') } for (const family of coverage.families) { const bucket = records.get(family.id) const roundtrip = await load(coverage.fcstdRoundtrip) const requiredIds = new Set(family.roundtripIds) bucket.roundtrip.passed = roundtrip.scenarios.filter((scenario) => requiredIds.has(scenario.id) && scenario.status === 'pass' && (scenario.differences || []).length === 0).length } const families = coverage.families.map((family) => { const bucket = records.get(family.id) const evidence = { 'nominal-parameter-set': bucket.nominal.length > 0, 'invalid-boundary': bucket.invalid.length > 0, 'parameter-variant': bucket.parameterVariants.size >= 2, 'edit-recompute-recovery': bucket.editRecovery.length > 0, 'fcstd-roundtrip': bucket.roundtrip.required === 0 || bucket.roundtrip.passed === bucket.roundtrip.required, } const missing = Object.entries(evidence).filter(([, present]) => !present).map(([dimension]) => dimension) return { id: family.id, operationTypes: family.operationTypes, evidence, counts: { nominal: bucket.nominal.length, invalid: bucket.invalid.length, parameterVariants: bucket.parameterVariants.size, editRecovery: bucket.editRecovery.length, roundtripRequired: bucket.roundtrip.required, roundtripPassed: bucket.roundtrip.passed, }, parameterVariantSources: [...bucket.parameterVariants.values()].slice(0, 12), editRecoverySources: bucket.editRecovery.map((entry) => entry.transaction), sources: [...bucket.sources].sort(), complete: missing.length === 0, missing, } }) const complete = families.filter((family) => family.complete).length const missingDimensions = Object.fromEntries([...new Set(families.flatMap((family) => family.missing))].sort().map((dimension) => [dimension, families.filter((family) => family.missing.includes(dimension)).length])) const report = { schemaVersion: 1, baselineId: plan.baselineId, sourceCommit: plan.sourceCommit, status: complete === families.length ? 'pass' : 'fail-closed', exactPromotionReady: false, dimensions: plan.dimensions, summary: { families: families.length, completeFamilies: complete, incompleteFamilies: families.length - complete, nominalFamilies: families.filter((family) => family.evidence['nominal-parameter-set']).length, invalidBoundaryFamilies: families.filter((family) => family.evidence['invalid-boundary']).length, parameterVariantFamilies: families.filter((family) => family.evidence['parameter-variant']).length, editRecoveryFamilies: families.filter((family) => family.evidence['edit-recompute-recovery']).length, roundtripFamilies: families.filter((family) => family.evidence['fcstd-roundtrip']).length, }, blockers: [ `${missingDimensions['parameter-variant'] || 0} families lack two distinct parameter snapshots.`, `${missingDimensions['edit-recompute-recovery'] || 0} families lack a real edited/recomputed/restored transaction.`, ].filter((message) => !message.startsWith('0 ')), missingDimensions, families, } const outputPath = resolve(root, 'config/freecad-parameter-mutation-report.json') const serialized = `${JSON.stringify(report, null, 2)}\n` if (process.argv.includes('--check')) { const existing = await readFile(outputPath, 'utf8') if (existing !== serialized) fail('config/freecad-parameter-mutation-report.json is stale; run npm run generate:freecad-parameter-mutations.') console.log(JSON.stringify({ status: report.status === 'pass' ? 'freecad-parameter-mutation-pass' : 'freecad-parameter-mutation-fail-closed', ...report.summary, missingDimensions: report.missingDimensions, blockers: report.blockers, exactPromotionReady: false }, null, 2)) } else { await writeFile(outputPath, serialized) console.log(JSON.stringify({ status: 'freecad-parameter-mutation-report-generated', ...report.summary, blockers: report.blockers }, null, 2)) }