214 lines
12 KiB
JavaScript
214 lines
12 KiB
JavaScript
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 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)
|
|
else addRecord(familyId, 'invalid', { id: fixture.id, typeId: fixture.typeId }, source)
|
|
}
|
|
}
|
|
await addOracleCases('config/freecad-part-builders-oracle.json', 'successCases', 'failureCases')
|
|
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)
|
|
}
|
|
}
|
|
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')
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|