feat: advance FreeCAD exact parity evidence
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-14 22:39:16 -04:00
parent e3373c9d6c
commit 5bbd7b9d4f
64 changed files with 113069 additions and 21074 deletions

View File

@@ -1,11 +1,33 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { promisify } from 'node:util'
const root = resolve(new URL('..', import.meta.url).pathname)
const report = JSON.parse(await readFile(resolve(root, 'config/chrome-cam-linuxcnc-machine-verification.json'), 'utf8'))
const manifest = JSON.parse(await readFile(resolve(root, 'config/linuxcnc-wasm-machine-artifact.json'), 'utf8'))
const offlineResources = JSON.parse(await readFile(resolve(root, 'config/offline-resources.json'), 'utf8'))
const lockedWorktree = offlineResources.archives.find((entry) => entry.id === 'linuxcnc-worktree')
const artifactRoles = (manifest.artifacts || []).map((artifact) => artifact.role)
if (manifest.schemaVersion !== 1 || manifest.worktreeRevision !== lockedWorktree?.revision || manifest.chromeVersion !== offlineResources.hostTools?.chrome || JSON.stringify(artifactRoles) !== JSON.stringify(['entry-html', 'case-registry', 'machine-config', 'application', 'controller-worker', 'wasm-loader', 'wasm-core'])) throw new Error('LinuxCNC WASM runtime manifest does not match the offline resource locks.')
if (manifest.artifacts.some(({ runtimeLoaded }) => typeof runtimeLoaded !== 'boolean') || manifest.artifacts.find(({ role }) => role === 'wasm-loader')?.runtimeLoaded !== false || manifest.artifacts.filter(({ role }) => role !== 'wasm-loader').some(({ runtimeLoaded }) => !runtimeLoaded)) throw new Error('LinuxCNC runtime-loaded artifact boundary is invalid.')
if (manifest.machineConfigPath !== manifest.artifacts.find(({ role }) => role === 'machine-config')?.path) throw new Error('LinuxCNC machine configuration is not pinned as a runtime artifact.')
const actualRevision = (await promisify(execFile)('git', ['-C', resolve(root, 'cnc_wams_gpt6/linuxcnc-master'), 'rev-parse', 'HEAD'])).stdout.trim()
if (actualRevision !== manifest.worktreeRevision) throw new Error('LinuxCNC worktree does not match the pinned revision.')
for (const artifact of manifest.artifacts || []) {
const bytes = await readFile(resolve(root, manifest.distRoot, artifact.path))
const sha256 = createHash('sha256').update(bytes).digest('hex')
if (bytes.byteLength !== artifact.bytes || sha256 !== artifact.sha256) throw new Error(`LinuxCNC WASM artifact does not match its pinned identity: ${artifact.path}.`)
}
if (report.schemaVersion !== 1 || report.status !== 'pass' || report.browserId !== 'chrome') throw new Error('Chrome CAM LinuxCNC machine verification is not passing.')
if (JSON.stringify(report.stages) !== JSON.stringify(['CAD', 'OCL', 'CAMotics', 'GCODE', 'LinuxCNC WASM'])) throw new Error('CAM machine stage order is invalid.')
if (report.openCamLib?.backend !== 'upstream-opencamlib-wasm' || report.openCamLib.sourceRevision?.length !== 40 || report.openCamLib.artifactSha256?.length !== 64 || report.openCamLib.triangleCount < 2 || report.openCamLib.inputPoints < 2 || report.openCamLib.outputPoints < report.openCamLib.inputPoints || !(report.openCamLib.sampling > 0)) throw new Error('OpenCAMLib WASM stage evidence is incomplete.')
if (!report.gcode?.hasM428 || !report.gcode.hasM429 || !report.gcode.hasG93 || !report.gcode.hasG94 || !report.gcode.hasB || !report.gcode.hasC359 || !report.gcode.hasC361) throw new Error('LinuxCNC XYZBC RTCP G-code evidence is incomplete.')
if (report.machine?.backend !== 'linuxcnc-wasm' || report.machine.status !== 'accepted' || report.machine.dryRunStatus !== 'dry-run' || report.machine.parserAuthority !== 'linuxcnc-wasm' || report.machine.lines < 10 || report.machine.lines >= 1000 || report.machine.blocks < 2 || report.machine.blocks >= 1000 || report.machine.trajectorySegments < 2 || report.machine.trajectorySegments >= 1000) throw new Error('LinuxCNC WASM machine acceptance evidence is incomplete or belongs to a different program.')
if (report.machine?.backend !== 'linuxcnc-wasm' || report.machine.status !== 'accepted' || report.machine.dryRunStatus !== 'dry-run' || report.machine.parserAuthority !== 'linuxcnc-wasm' || report.machine.machineCase !== manifest.machineCase || report.machine.dryRunMachineCase !== manifest.machineCase || report.machine.lines < 10 || report.machine.lines >= 1000 || report.machine.blocks < 2 || report.machine.blocks >= 1000 || report.machine.trajectorySegments < 2 || report.machine.trajectorySegments >= 1000) throw new Error('LinuxCNC WASM machine acceptance evidence is incomplete or belongs to a different program or machine case.')
if (report.runtime?.server !== 'managed-vite-preview' || report.runtime.worktreeRevision !== manifest.worktreeRevision || report.runtime.machineCase !== report.machine.machineCase) throw new Error('LinuxCNC WASM managed runtime identity is incomplete.')
const chromeMajor = manifest.chromeVersion.split('.')[0]
if (report.browser?.product !== manifest.chromeVersion || !String(report.browser?.userAgent || '').includes(`Chrome/${chromeMajor}.`)) throw new Error('Chrome runtime is not the pinned version.')
if (JSON.stringify(report.runtime.artifacts) !== JSON.stringify(manifest.artifacts)) throw new Error('LinuxCNC WASM artifact hashes do not match the pinned manifest.')
if (JSON.stringify(report.runtime.loadedArtifacts) !== JSON.stringify(manifest.artifacts.filter(({ runtimeLoaded }) => runtimeLoaded).map(({ role, path }) => ({ role, path, status: 200 })))) throw new Error('Chrome did not load every pinned LinuxCNC runtime artifact.')
console.log(JSON.stringify({ status: 'chrome-cam-linuxcnc-machine-pass', stages: report.stages, openCamLib: report.openCamLib, gcode: report.gcode, machine: report.machine, browser: report.browser }, null, 2))

View File

@@ -1,16 +1,25 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { elementMap2SemanticDigest, parseElementMap2, validateElementMap2, writeElementMap2 } from '../src/facade/elementMap2.ts'
import { validateNativeNamingEvidence } from '../src/facade/nativeNamingEvidence.ts'
import { migrateStringHasherSchema, parseStringHasherTable, validateStringHasherTable, writeStringHasherTable } from '../src/facade/stringHasher.ts'
import { validateCompositeMutationEvidence } from './freecad-composite-mutation-evidence.mjs'
const root = resolve(new URL('..', import.meta.url).pathname)
const report = JSON.parse(await readFile(resolve(root, 'config/freecad-composite-history-elementmap-oracle.json'), 'utf8'))
const resaveReport = JSON.parse(await readFile(resolve(root, 'config/freecad-composite-history-resave-verification.json'), 'utf8'))
if (report.schemaVersion !== 1 || report.baselineId !== 'freecad-1.1.1-composite-history-elementmap2' || report.freecadVersion !== '1.1.1' || report.status !== 'pass' || report.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') throw new Error('FreeCAD composite history ElementMap2 oracle baseline is invalid.')
if (report.summary?.cases !== 30 || report.summary?.passed !== 30 || report.summary?.failed !== 0 || !Array.isArray(report.cases) || report.cases.length !== 30) throw new Error('FreeCAD composite oracle must contain exactly 30 passing cases.')
if (resaveReport.schemaVersion !== 1 || resaveReport.baselineId !== report.baselineId || resaveReport.freecadVersion !== report.freecadVersion || resaveReport.gitCommit !== report.gitCommit || resaveReport.status !== 'pass' || resaveReport.summary?.cases !== 30 || resaveReport.summary?.passed !== 30 || resaveReport.summary?.failed !== 0 || resaveReport.summary?.roundtripNameDrift !== 0 || resaveReport.summary?.resaveNameDrift !== 0 || resaveReport.summary?.nativeDesktopResaveCases !== 30 || !Array.isArray(resaveReport.cases) || resaveReport.cases.length !== 30) throw new Error('FreeCAD composite resave oracle must contain exactly 30 passing save/reopen/resave cases.')
if (report.summary?.cases !== 30 || report.summary?.passed !== 30 || report.summary?.failed !== 0 || report.summary?.stageCorrelations !== 219 || !(report.summary?.relationRecords > 0) || !Array.isArray(report.cases) || report.cases.length !== 30) throw new Error('FreeCAD composite oracle must contain exactly 30 passing cases and 219 correlated stages.')
if (resaveReport.schemaVersion !== 1 || resaveReport.baselineId !== report.baselineId || resaveReport.freecadVersion !== report.freecadVersion || resaveReport.gitCommit !== report.gitCommit || resaveReport.status !== 'pass' || resaveReport.summary?.cases !== 30 || resaveReport.summary?.passed !== 30 || resaveReport.summary?.failed !== 0 || resaveReport.summary?.roundtripNameDrift !== 0 || resaveReport.summary?.resaveNameDrift !== 0 || resaveReport.summary?.nativeDesktopResaveCases !== 30 || resaveReport.summary?.stageCorrelations !== 219 || resaveReport.summary?.relationRecords !== report.summary.relationRecords || !Array.isArray(resaveReport.cases) || resaveReport.cases.length !== 30) throw new Error('FreeCAD composite resave oracle must contain exactly 30 passing save/reopen/resave cases and 219 correlated stages.')
const reportIds = report.cases.map(({ id }) => id)
const resaveIds = resaveReport.cases.map(({ id }) => id)
if (new Set(reportIds).size !== 30 || JSON.stringify(resaveIds) !== JSON.stringify(reportIds)) throw new Error('FreeCAD composite and resave case identities are not joined one-to-one.')
const resaveById = new Map(resaveReport.cases.map((fixture) => [fixture.id, fixture]))
for (const fixture of resaveReport.cases) if (fixture.roundtripNameDrift !== 0 || fixture.resaveNameDrift !== 0 || fixture.nativeDesktopResaveCovered !== true) throw new Error(`FreeCAD composite fixture ${fixture.id} changed mapped names after native FreeCAD resave.`)
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, entry]) => [key, canonical(entry)])) : value
const digest = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
const mutationEvidence = validateCompositeMutationEvidence(report)
let parsedResources = 0
let parsedStringHasherResources = 0
let namingEvidenceStages = 0
@@ -22,12 +31,27 @@ let nativeIndexedNameStages = 0
let privateTokenEvidenceCompleteStages = 0
let internalBuilderEvidenceStages = 0
let internalBuilderEvidenceMissingStages = 0
let stageCorrelations = 0
let relationRecords = 0
let wrongBindings = 0
let unexplainedRelations = 0
for (const fixture of report.cases) {
if (fixture.status !== 'pass' || !fixture.finalObject || !fixture.stages?.length) throw new Error(`FreeCAD composite fixture ${fixture.id} has no valid feature history stages.`)
if (fixture.roundtripNameDrift !== 0) throw new Error(`FreeCAD composite fixture ${fixture.id} changed mapped names after FreeCAD reopen.`)
const names = new Set()
const stageNames = new Set(fixture.stages.map(({ name }) => name))
const stageByName = new Map(fixture.stages.map((stage) => [stage.name, stage]))
const dependencyClosure = (stageName, seen = new Set()) => {
if (seen.has(stageName)) return seen
seen.add(stageName)
for (const linked of stageByName.get(stageName)?.linkedObjects ?? []) if (stageNames.has(linked)) dependencyClosure(linked, seen)
return seen
}
const resaveFixture = resaveById.get(fixture.id)
const correlations = resaveFixture?.stageCorrelations
if (!correlations || !['initial', 'reopened', 'resaved'].every((phase) => Array.isArray(correlations[phase]) && correlations[phase].length === fixture.stages.length)) throw new Error(`FreeCAD composite fixture ${fixture.id} lacks complete initial/reopen/resave stage correlations.`)
let historyStages = 0
for (const stage of fixture.stages) {
for (const [ordinal, stage] of fixture.stages.entries()) {
if (!stage.shape?.valid || !stage.names?.length) throw new Error(`FreeCAD composite fixture ${fixture.id}/${stage.name} has invalid topology.`)
namingEvidenceStages += 1
if (!['final-shape-only', 'opaque-preserved', 'native-evidence', 'ambiguous', 'missing'].includes(stage.namingEvidenceStatus)) namingEvidenceMissing += 1
@@ -49,6 +73,25 @@ for (const fixture of report.cases) {
else if (stage.nativeEvidence.status === 'native-evidence' && stage.nativeEvidence.internalBuilderEvidence !== true && /^(Part::(Fuse|Cut|Common|Extrusion|Revolution|Loft|Sweep|Fillet|Chamfer)|PartDesign::(Pad|Pocket|Revolution|Groove|AdditiveLoft|SubtractiveLoft|AdditivePipe|SubtractivePipe|Fillet|Chamfer|Draft|Thickness|Mirrored|MultiTransform|LinearPattern|PolarPattern|Hole))$/.test(stage.typeId || '')) internalBuilderEvidenceMissingStages += 1
const stageHasHistory = stage.names.some((entry) => Array.isArray(entry.history) && entry.history.length > 0)
if (stageHasHistory) historyStages += 1
if (!Array.isArray(stage.linkedObjects) || !Array.isArray(stage.relations) || stage.relations.length !== stage.names.length || stage.relationDigest !== digest(stage.relations) || !/^[0-9a-f]{64}$/.test(stage.semanticNameDigest || '')) throw new Error(`FreeCAD composite fixture ${fixture.id}/${stage.name} lacks relation-level naming evidence.`)
const expectedKey = `${fixture.id}:${String(ordinal).padStart(3, '0')}:${stage.name}`
const phaseRecords = ['initial', 'reopened', 'resaved'].map((phase) => correlations[phase][ordinal])
for (const record of phaseRecords) if (record.key !== expectedKey || record.ordinal !== ordinal || record.name !== stage.name || record.typeId !== stage.typeId || record.relationCount !== stage.relations.length) throw new Error(`FreeCAD composite fixture ${expectedKey} changed stage identity across native resave.`)
if (phaseRecords.some((record) => record.relationDigest !== stage.relationDigest || record.semanticNameDigest !== stage.semanticNameDigest)) throw new Error(`FreeCAD composite fixture ${expectedKey} changed relation or semantic-name evidence across native resave.`)
stageCorrelations += 1
relationRecords += stage.relations.length
const allowedSources = dependencyClosure(stage.name)
allowedSources.delete(stage.name)
let stageSourceRelations = 0
for (const [relationIndex, relation] of stage.relations.entries()) {
if (relation.resultName !== stage.names[relationIndex]?.name || !['generated', 'modified', 'preserved'].includes(relation.relation) || !Array.isArray(relation.sources)) unexplainedRelations += 1
for (const source of relation.sources) {
stageSourceRelations += 1
if (!stageNames.has(source.sourceObject) || !/^(?:(?:Face|Edge|Vertex)\d+|#[a-zA-Z0-9]+:[0-9a-fA-F]+|#[0-9a-fA-F]+|g\d+(?:v\d+)?;SKT)$/.test(source.sourceElement || '')) unexplainedRelations += 1
else if (!allowedSources.has(source.sourceObject)) wrongBindings += 1
}
}
if (stage.nativeEvidence.internalBuilderEvidence === true && stageSourceRelations === 0) unexplainedRelations += 1
for (const entry of stage.names) {
if (names.has(`${stage.name}:${entry.name}`)) throw new Error(`Duplicate ElementMap name ${fixture.id}/${stage.name}/${entry.name}.`)
names.add(`${stage.name}:${entry.name}`)
@@ -74,7 +117,9 @@ for (const fixture of report.cases) {
parsedResources += 1
}
}
if (stageCorrelations !== 219 || stageCorrelations !== report.summary.stageCorrelations || relationRecords !== report.summary.relationRecords) throw new Error(`FreeCAD stage-correlation totals drifted: stages=${stageCorrelations}, relations=${relationRecords}.`)
if (wrongBindings !== 0 || unexplainedRelations !== 0) throw new Error(`FreeCAD relation-level topology correlation failed: wrongBindings=${wrongBindings}, unexplainedRelations=${unexplainedRelations}.`)
if (parsedResources === 0) throw new Error('FreeCAD composite oracle did not capture any ElementMap2 resources.')
if (nativeIndexedNameStages !== namingEvidenceStages) throw new Error(`FreeCAD composite oracle must retain direct indexed-name evidence for every stage, found ${nativeIndexedNameStages}/${namingEvidenceStages}.`)
if (namingEvidenceMissing !== 0 || namingEvidenceBoundaryViolations !== 0) throw new Error(`FreeCAD naming evidence boundary is incomplete: missing=${namingEvidenceMissing}, violations=${namingEvidenceBoundaryViolations}.`)
console.log(JSON.stringify({ status: 'freecad-composite-history-elementmap-pass', cases: report.summary.cases, parsedResources, parsedStringHasherResources, namingEvidenceStages, nativeEvidenceValidatedStages, nativeIndexedNameStages, nativeMappedNameStages, indexedOnlyStages: nativeIndexedNameStages - nativeMappedNameStages, privateTokenEvidenceCompleteStages, namingEvidenceMissing, namingEvidenceBoundaryViolations, internalBuilderEvidenceStages, internalBuilderEvidenceMissingStages, nativeDesktopResaveCases: resaveReport.summary.nativeDesktopResaveCases, resaveNameDrift: resaveReport.summary.resaveNameDrift }, null, 2))
console.log(JSON.stringify({ status: 'freecad-composite-history-elementmap-pass', cases: report.summary.cases, categoryCases: mutationEvidence.categoryCases, mutationCases: mutationEvidence.mutationCases, mutationPassed: mutationEvidence.mutationPassed, mutationStageRecords: mutationEvidence.mutationStageRecords, mutationPhaseStageRecords: mutationEvidence.mutationPhaseStageRecords, mutationFinalPropagationFailures: mutationEvidence.mutationFinalPropagationFailures, mutationStageRestoreFailures: mutationEvidence.mutationStageRestoreFailures, mutationNamingRestoreDriftCases: mutationEvidence.mutationNamingRestoreDriftCases, mutationNamingRestoreDriftStages: mutationEvidence.mutationNamingRestoreDriftStages, stageCorrelations, relationRecords, wrongBindings, unexplainedRelations, parsedResources, parsedStringHasherResources, namingEvidenceStages, nativeEvidenceValidatedStages, nativeIndexedNameStages, nativeMappedNameStages, indexedOnlyStages: nativeIndexedNameStages - nativeMappedNameStages, privateTokenEvidenceCompleteStages, namingEvidenceMissing, namingEvidenceBoundaryViolations, internalBuilderEvidenceStages, internalBuilderEvidenceMissingStages, nativeDesktopResaveCases: resaveReport.summary.nativeDesktopResaveCases, resaveNameDrift: resaveReport.summary.resaveNameDrift }, null, 2))

View File

@@ -62,6 +62,8 @@ if (!report) fail('34-module desktop probe report is missing; run probe:freecad-
if (report.baselineId !== config.baselineId || report.freecadVersion !== '1.1.1' || report.guiUp !== true || report.moduleCount !== 34 || report.modules?.length !== 34) {
fail('desktop probe report is not a GUI-up 34-module FreeCAD 1.1.1 report.')
}
if (report.determinism?.schemaVersion !== 1 || report.determinism.normalizedProcessFields?.join(',') !== 'pointer-address,uuid,freecad-document-cache-run') fail('desktop probe does not declare deterministic process-field normalization.')
if (report.guiCommands?.stateProbe !== 'native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard' || report.guiCommands.stateCases?.join(',') !== 'noDocument,documentNoSelection,selectedPartBox' || report.guiCommands.directIsActiveBoundary !== 'Std_Expressions-only-qaction-guard-for-locked-1.1.1-null-qaction-crash' || report.guiCommands.stateCommandCount !== report.guiCommands.commands?.length) fail('desktop probe GUI state boundary is incomplete.')
const invalid = report.modules.filter((module) => !['compiled-importable', 'compiled-import-failure', 'gui-only-unprobeable', 'not-built'].includes(module.runtimeStatus))
if (invalid.length) fail(`probe contains invalid module statuses: ${invalid.map((module) => module.name).join(', ')}`)
const expectedObjects = ['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Feature', 'PartDesign::Feature', 'Sketcher::SketchObject']
@@ -74,6 +76,11 @@ const propertyFlags = report.objects.flatMap((object) => object.properties || []
if (!propertyFlags.some((status) => status.includes('Hidden')) || !propertyFlags.some((status) => status.includes('Output'))) {
fail('runtime property fixtures must include Hidden and Output status flags.')
}
for (const command of report.guiCommands?.commands ?? []) {
if (!Array.isArray(command.registeredWorkbenches) || command.registeredWorkbenches.length === 0 || !Array.isArray(command.observations) || command.observations.length !== 1 || command.observations.some((observation) => !Object.hasOwn(observation, 'noDocument') || !Object.hasOwn(observation, 'documentNoSelection') || !Object.hasOwn(observation, 'selectedPartBox'))) {
fail(`GUI command ${command.id || '<unknown>'} lacks no-document/document/selection observations.`)
}
}
const runtimeObjects = report.runtimeObjects
if (!runtimeObjects?.available || runtimeObjects.candidateSource !== 'Document.supportedTypes' || !Array.isArray(runtimeObjects.types) || runtimeObjects.candidateCount !== runtimeObjects.types.length) {
fail('complete Document.supportedTypes runtime object evidence is missing.')

View File

@@ -2,16 +2,22 @@ import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { createElementMap2NameToken, elementMap2NameTokenToReference, elementMap2SemanticDigest, parseElementMap2, validateElementMap2, writeElementMap2 } from '../src/facade/elementMap2.ts'
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from '../src/facade/stringHasher.ts'
import { validateCompositeMutationEvidence } from './freecad-composite-mutation-evidence.mjs'
const root = resolve(new URL('..', import.meta.url).pathname)
const load = async (path) => JSON.parse(await readFile(resolve(root, path), 'utf8'))
const gate = await load('config/freecad-exact-history-elementmap-gate.json')
const oracle = await load('config/freecad-composite-history-elementmap-oracle.json')
const roundtrip = await load('config/freecad-fcstd-roundtrip-verification.json')
const resave = await load('config/freecad-composite-history-resave-verification.json')
const stageCorrelation = await load('config/freecad-tsn-stage-correlation-oracle.json')
if (oracle.baselineId !== gate.baselineId || oracle.freecadVersion !== gate.requiredFreecadVersion || oracle.gitCommit !== gate.requiredFreecadCommit || oracle.status !== 'pass' || oracle.summary?.cases !== gate.requiredCases || oracle.summary?.passed !== gate.requiredCases) throw new Error('Exact history/ElementMap2 gate baseline is not the locked FreeCAD 1.1.1 30-case report.')
const mutationEvidence = validateCompositeMutationEvidence(oracle)
if (mutationEvidence.mutationCases !== gate.requirements.mutationCases || mutationEvidence.mutationPassed !== gate.requirements.mutationCases - gate.requirements.mutationFailures || mutationEvidence.mutationStageRecords !== gate.requirements.mutationStageRecords || mutationEvidence.mutationStageRestoreFailures !== gate.requirements.mutationStageRestoreFailures || mutationEvidence.mutationFinalPropagationFailures !== gate.requirements.mutationFinalPropagationFailures || mutationEvidence.mutationNamingRestoreDriftCases !== gate.requirements.mutationNamingRestoreDriftCases || mutationEvidence.mutationNamingRestoreDriftStages !== gate.requirements.mutationNamingRestoreDriftStages) throw new Error('Exact history/ElementMap2 mutation requirements do not match per-case evidence.')
let wrongBindings = 0
let unexplainedRelations = 0
let roundtripNameDrift = 0
let resaveNameDrift = 0
let elementMap2ParseFailures = 0
let elementMap2SemanticFailures = 0
let elementMap2TokenWriterFailures = 0
@@ -31,7 +37,22 @@ let internalBuilderEvidenceStages = 0
let internalBuilderEvidenceMissingStages = 0
const builderStageType = /^(Part::(Fuse|Cut|Common|Extrusion|Revolution|Loft|Sweep|Fillet|Chamfer)|PartDesign::(Pad|Pocket|Revolution|Groove|AdditiveLoft|SubtractiveLoft|AdditivePipe|SubtractivePipe|Fillet|Chamfer|Draft|Thickness|Mirrored|MultiTransform|LinearPattern|PolarPattern|Hole))$/
let resources = 0
let caseReconciliations = 0
const resaveById = new Map((resave.cases || []).map((fixture) => [fixture.id, fixture]))
if (resaveById.size !== gate.requiredCases || resave.cases?.length !== gate.requiredCases) throw new Error('Exact gate resave evidence is not joined one-to-one with the locked corpus.')
const zeroMetrics = () => ({ wrongBindings, unexplainedRelations, roundtripNameDrift, resaveNameDrift, elementMap2ParseFailures, elementMap2SemanticFailures, elementMap2TokenWriterFailures, stringHasherParseFailures, stringHasherSemanticFailures, stringHasherEvidenceFailures, namingEvidenceMissing, namingEvidenceBoundaryViolations })
for (const fixture of oracle.cases) {
const metricsBefore = zeroMetrics()
const resaveFixture = resaveById.get(fixture.id)
if (!resaveFixture || resaveFixture.roundtripNameDrift !== 0 || resaveFixture.resaveNameDrift !== 0 || resaveFixture.nativeDesktopResaveCovered !== true) throw new Error(`Exact gate case ${fixture.id} lacks passing native save/reopen/resave evidence.`)
if (!['initial', 'reopened', 'resaved'].every((phase) => Array.isArray(resaveFixture.stageCorrelations?.[phase]) && resaveFixture.stageCorrelations[phase].length === fixture.stages.length)) throw new Error(`Exact gate case ${fixture.id} lacks complete stage correlations.`)
for (const [ordinal, stage] of fixture.stages.entries()) {
const expectedKey = `${fixture.id}:${String(ordinal).padStart(3, '0')}:${stage.name}`
for (const phase of ['initial', 'reopened', 'resaved']) {
const record = resaveFixture.stageCorrelations[phase][ordinal]
if (record.key !== expectedKey || record.ordinal !== ordinal || record.name !== stage.name || record.typeId !== stage.typeId || record.relationDigest !== stage.relationDigest || record.semanticNameDigest !== stage.semanticNameDigest) throw new Error(`Exact gate case ${expectedKey} changed identity or naming evidence during ${phase}.`)
}
}
if (fixture.roundtripNameDrift !== 0) roundtripNameDrift += 1
const knownObjects = new Set(fixture.stages.map((stage) => stage.name))
for (const stage of fixture.stages) {
@@ -96,10 +117,20 @@ for (const fixture of oracle.cases) {
elementMap2ParseFailures += 1
}
}
const metricsAfter = zeroMetrics()
const caseFailures = Object.fromEntries(Object.keys(metricsAfter).map((key) => [key, metricsAfter[key] - metricsBefore[key]]).filter(([, value]) => value !== 0))
if (Object.keys(caseFailures).length > 0) throw new Error(`Exact gate case ${fixture.id} failed closed: ${JSON.stringify(caseFailures)}.`)
caseReconciliations += 1
}
for (const scenario of roundtrip.scenarios || []) {
if (scenario.status !== 'pass' || (scenario.differences || []).length !== 0) roundtripNameDrift += 1
}
if (resave.status !== 'pass' || resave.summary?.nativeDesktopResaveCases !== gate.requiredCases || resave.summary?.roundtripNameDrift !== 0 || resave.summary?.resaveNameDrift !== 0 || caseReconciliations !== gate.requiredCases) resaveNameDrift += 1
if (stageCorrelation.status !== 'pass' || stageCorrelation.nativeDesktopResaveCovered !== true || stageCorrelation.summary?.mutationCases !== 5 || stageCorrelation.summary?.mutationPassed !== 5) throw new Error('Five-stage desktop correlation/mutation evidence is incomplete.')
wrongBindings += stageCorrelation.summary?.wrongBindings ?? 1
unexplainedRelations += stageCorrelation.summary?.unexplainedRelations ?? 1
roundtripNameDrift += stageCorrelation.summary?.roundtripNameDrift ?? 1
resaveNameDrift += stageCorrelation.summary?.resaveNameDrift ?? 1
if (indexedNameOnlyStages + privateTokenEvidenceRequiredStages !== nativeEvidenceStages || privateTokenEvidenceCompleteStages !== privateTokenEvidenceRequiredStages || internalBuilderEvidenceStages !== 42 || internalBuilderEvidenceMissingStages !== 0) throw new Error(`Exact native naming evidence is incomplete: indexedOnly=${indexedNameOnlyStages}, tokenRequired=${privateTokenEvidenceRequiredStages}, tokenComplete=${privateTokenEvidenceCompleteStages}, builder=${internalBuilderEvidenceStages}, builderMissing=${internalBuilderEvidenceMissingStages}.`)
if (roundtrip.status !== 'verified' || wrongBindings !== gate.requirements.wrongBindings || unexplainedRelations !== gate.requirements.unexplainedRelations || roundtripNameDrift !== gate.requirements.roundtripNameDrift || elementMap2ParseFailures !== gate.requirements.elementMap2ParseFailures || elementMap2SemanticFailures !== gate.requirements.elementMap2SemanticFailures || elementMap2TokenWriterFailures !== gate.requirements.elementMap2TokenWriterFailures || stringHasherParseFailures !== gate.requirements.stringHasherParseFailures || stringHasherSemanticFailures !== gate.requirements.stringHasherSemanticFailures || stringHasherEvidenceFailures !== gate.requirements.stringHasherEvidenceFailures || namingEvidenceMissing !== gate.requirements.namingEvidenceMissing || namingEvidenceBoundaryViolations !== gate.requirements.namingEvidenceBoundaryViolations) throw new Error(`Exact gate failed: wrongBindings=${wrongBindings}, unexplainedRelations=${unexplainedRelations}, roundtripNameDrift=${roundtripNameDrift}, elementMap2ParseFailures=${elementMap2ParseFailures}, elementMap2SemanticFailures=${elementMap2SemanticFailures}, elementMap2TokenWriterFailures=${elementMap2TokenWriterFailures}, stringHasherParseFailures=${stringHasherParseFailures}, stringHasherSemanticFailures=${stringHasherSemanticFailures}, stringHasherEvidenceFailures=${stringHasherEvidenceFailures}, namingEvidenceMissing=${namingEvidenceMissing}, namingEvidenceBoundaryViolations=${namingEvidenceBoundaryViolations}.`)
console.log(JSON.stringify({ status: 'freecad-exact-history-elementmap-gate-pass', exactPromotionReady: false, cases: oracle.summary.cases, resources, stringHasherResources, nativeEvidenceStages, nativeIndexedNameStages, nativeMappedNameStages, indexedOnlyStages: indexedNameOnlyStages, privateTokenEvidenceRequiredStages, privateTokenEvidenceCompleteStages, internalBuilderEvidenceStages, internalBuilderEvidenceMissingStages, wrongBindings, unexplainedRelations, roundtripNameDrift, elementMap2ParseFailures, elementMap2SemanticFailures, elementMap2TokenWriterFailures, stringHasherParseFailures, stringHasherSemanticFailures, stringHasherEvidenceFailures, namingEvidenceMissing, namingEvidenceBoundaryViolations }, null, 2))
if (roundtrip.status !== 'verified' || wrongBindings !== gate.requirements.wrongBindings || unexplainedRelations !== gate.requirements.unexplainedRelations || roundtripNameDrift !== gate.requirements.roundtripNameDrift || resaveNameDrift !== gate.requirements.resaveNameDrift || elementMap2ParseFailures !== gate.requirements.elementMap2ParseFailures || elementMap2SemanticFailures !== gate.requirements.elementMap2SemanticFailures || elementMap2TokenWriterFailures !== gate.requirements.elementMap2TokenWriterFailures || stringHasherParseFailures !== gate.requirements.stringHasherParseFailures || stringHasherSemanticFailures !== gate.requirements.stringHasherSemanticFailures || stringHasherEvidenceFailures !== gate.requirements.stringHasherEvidenceFailures || namingEvidenceMissing !== gate.requirements.namingEvidenceMissing || namingEvidenceBoundaryViolations !== gate.requirements.namingEvidenceBoundaryViolations) throw new Error(`Exact gate failed: wrongBindings=${wrongBindings}, unexplainedRelations=${unexplainedRelations}, roundtripNameDrift=${roundtripNameDrift}, resaveNameDrift=${resaveNameDrift}, elementMap2ParseFailures=${elementMap2ParseFailures}, elementMap2SemanticFailures=${elementMap2SemanticFailures}, elementMap2TokenWriterFailures=${elementMap2TokenWriterFailures}, stringHasherParseFailures=${stringHasherParseFailures}, stringHasherSemanticFailures=${stringHasherSemanticFailures}, stringHasherEvidenceFailures=${stringHasherEvidenceFailures}, namingEvidenceMissing=${namingEvidenceMissing}, namingEvidenceBoundaryViolations=${namingEvidenceBoundaryViolations}.`)
console.log(JSON.stringify({ status: 'freecad-exact-history-elementmap-gate-pass', exactPromotionReady: false, cases: oracle.summary.cases, caseReconciliations, categoryCases: mutationEvidence.categoryCases, mutationCases: mutationEvidence.mutationCases, mutationPassed: mutationEvidence.mutationPassed, mutationStageRecords: mutationEvidence.mutationStageRecords, mutationPhaseStageRecords: mutationEvidence.mutationPhaseStageRecords, mutationStageRestoreFailures: mutationEvidence.mutationStageRestoreFailures, mutationFinalPropagationFailures: mutationEvidence.mutationFinalPropagationFailures, mutationNamingRestoreDriftCases: mutationEvidence.mutationNamingRestoreDriftCases, mutationNamingRestoreDriftStages: mutationEvidence.mutationNamingRestoreDriftStages, nativeDesktopResaveCases: resave.summary.nativeDesktopResaveCases + 1, crossFeatureMutationCases: stageCorrelation.summary.mutationCases, crossFeatureMutationPassed: stageCorrelation.summary.mutationPassed, resources, stringHasherResources, nativeEvidenceStages, nativeIndexedNameStages, nativeMappedNameStages, indexedOnlyStages: indexedNameOnlyStages, privateTokenEvidenceRequiredStages, privateTokenEvidenceCompleteStages, internalBuilderEvidenceStages, internalBuilderEvidenceMissingStages, wrongBindings, unexplainedRelations, roundtripNameDrift, resaveNameDrift, elementMap2ParseFailures, elementMap2SemanticFailures, elementMap2TokenWriterFailures, stringHasherParseFailures, stringHasherSemanticFailures, stringHasherEvidenceFailures, namingEvidenceMissing, namingEvidenceBoundaryViolations }, null, 2))

View File

@@ -2,23 +2,42 @@ import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const inventory = JSON.parse(await readFile(resolve(root, 'config/freecad-gui-command-inventory.json'), 'utf8'))
const [inventory, contextBoundaries] = await Promise.all([
readFile(resolve(root, 'config/freecad-gui-command-inventory.json'), 'utf8').then(JSON.parse),
readFile(resolve(root, 'config/freecad-gui-command-context-boundaries.json'), 'utf8').then(JSON.parse),
])
const fail = (message) => { throw new Error(`FreeCAD GUI command inventory: ${message}`) }
if (inventory.schemaVersion !== 1 || inventory.baseline?.freecadVersion !== '1.1.1' || inventory.baseline?.commit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') fail('baseline mismatch.')
if (!Number.isInteger(inventory.commandCount) || inventory.commandCount < 900 || !Array.isArray(inventory.commands) || inventory.commands.length !== inventory.commandCount) fail('runtime command inventory is incomplete.')
if (inventory.contextBoundaries !== 'config/freecad-gui-command-context-boundaries.json') fail('context-only command boundary ledger is not linked.')
if (inventory.runtimeEvidence?.report !== '.cache/freecad/reference-desktop-gui-commands.json' || !/^[0-9a-f]{64}$/.test(inventory.runtimeEvidence.reportSha256 || '')) fail('dedicated runtime GUI oracle provenance is incomplete.')
if (inventory.runtimeEvidence?.stateProbe !== 'native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard' || inventory.runtimeEvidence.stateCases?.join(',') !== 'noDocument,documentNoSelection,selectedPartBox' || inventory.runtimeEvidence.directIsActiveBoundary !== 'Std_Expressions-only-qaction-guard-for-locked-1.1.1-null-qaction-crash') fail('runtime command state boundary is incomplete.')
const ids = new Set()
for (const command of inventory.commands) {
if (!command.id || ids.has(command.id)) fail(`duplicate or empty command '${command.id}'.`)
ids.add(command.id)
if (!Array.isArray(command.sourceModules) || command.sourceModules.length === 0 || !Array.isArray(command.workbenches) || command.workbenches.length === 0) fail(`${command.id} is missing source/workbench ownership.`)
for (const caseName of ['emptySelection', 'selectedPartBox']) {
for (const caseName of ['noDocument', 'documentNoSelection', 'selectedPartBox']) {
const probeCase = command.cases?.[caseName]
if (!probeCase?.observed || !Array.isArray(probeCase.states) || probeCase.states.length === 0) fail(`${command.id} is missing ${caseName} evidence.`)
if (probeCase.states.some((state) => !['true', 'false', 'error', 'no-action'].includes(String(state)))) fail(`${command.id} has an invalid ${caseName} state.`)
}
}
if (!Array.isArray(inventory.sourceOnlyCommands)) fail('sourceOnlyCommands must be an array.')
const sourceOnlyIds = new Set()
for (const command of inventory.sourceOnlyCommands) {
if (!command.id || !Array.isArray(command.sourceModules) || !command.reason) fail('source-only command entry is incomplete.')
if (ids.has(command.id) || sourceOnlyIds.has(command.id)) fail(`source-only command '${command.id}' is duplicated or also runtime-probed.`)
if (!['module-not-built', 'source-registration-not-loaded'].includes(command.reason)) fail(`source-only command '${command.id}' has an unknown generator reason.`)
sourceOnlyIds.add(command.id)
}
console.log(JSON.stringify({ status: 'gui-command-inventory-pass', commandCount: inventory.commandCount, sourceOnlyCount: inventory.sourceOnlyCommands.length, workbenchCount: inventory.runtimeEvidence?.workbenchCount }, null, 2))
if (contextBoundaries.schemaVersion !== 1 || contextBoundaries.baseline?.freecadVersion !== '1.1.1' || contextBoundaries.baseline.commit !== inventory.baseline.commit) fail('context-only command boundary baseline mismatch.')
const isolated = contextBoundaries.isolatedWorkbenchOracle
if (isolated?.report !== inventory.runtimeEvidence.report || isolated.reportSha256 !== inventory.runtimeEvidence.reportSha256 || isolated.commandCount !== inventory.commandCount || isolated.commandUniverseSha256 !== '82901c576ef4b4cea1182f6505f5282a70b84ee8420105e83e58f65c0fb9685f' || isolated.shardCount !== 100 || isolated.bimFirstTimeWelcome !== 'suppressed-before-workbench-activation') fail('isolated workbench oracle boundary is incomplete.')
const contextCommands = contextBoundaries.contextOnlyCommands
if (!Array.isArray(contextCommands) || contextCommands.map((command) => command.id).join(',') !== 'Import_ReadBREP,NaviCubeDraggableCmd') fail('context-only command set changed without review.')
for (const command of contextCommands) {
if (ids.has(command.id) || sourceOnlyIds.has(command.id)) fail(`context-only command '${command.id}' leaked into a startup inventory.`)
if (!['explicit-module-import-command', 'context-menu-lazy-command'].includes(command.classification) || !command.runtimeExpectation || !command.exactTask || !Array.isArray(command.sourceEvidence) || command.sourceEvidence.length === 0) fail(`context-only command '${command.id}' lacks a complete boundary.`)
}
console.log(JSON.stringify({ status: 'gui-command-inventory-pass', commandCount: inventory.commandCount, sourceOnlyCount: inventory.sourceOnlyCommands.length, contextOnlyCount: contextCommands.length, workbenchCount: inventory.runtimeEvidence?.workbenchCount, documentSensitiveCount: inventory.commands.filter((command) => command.documentSensitive).length, selectionSensitiveCount: inventory.commands.filter((command) => command.selectionSensitive).length }, null, 2))

View File

@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const [report, plan] = await Promise.all([
readFile(resolve(root, 'config/freecad-gui-workflow-oracle.json'), 'utf8').then(JSON.parse),
readFile(resolve(root, 'config/freecad-gui-workflow-plan.json'), 'utf8').then(JSON.parse),
])
const fail = (message) => { throw new Error(`FreeCAD GUI workflow oracle: ${message}`) }
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
if (report.schemaVersion !== 1 || report.baseline?.freecadVersion !== '1.1.1' || report.baseline?.commit !== lockedCommit || report.baseline.profile !== 'desktop-xvfb-isolated-config') fail('baseline mismatch.')
if (report.family?.id !== plan.family?.id || report.family.primaryCommand !== plan.family.primaryCommand || report.family.exactTask !== plan.family.primaryExactTask) fail('selected family mapping is stale.')
if (report.generatedBy !== './npmw run probe:freecad-gui-workflow' || report.checkedBy !== './npmw run check:freecad-gui-workflow') fail('probe/check provenance is incomplete.')
const success = report.workflows?.success
if (success?.workflowId !== report.family.id || success.commandId !== report.family.primaryCommand || success.state !== 'success' || success.success !== true) fail('success workflow identity is invalid.')
if (success.before?.activeWorkbench !== 'PartDesignWorkbench' || success.before.commandRegistered !== true || success.before.commandActive !== true || success.before.actionCount < 1 || success.before.selection?.join(',') !== 'Sketch') fail('native command preconditions are incomplete.')
if (success.taskPanel?.opened?.activeDialog !== true || success.taskPanel.opened.inEdit !== 'Pad' || success.taskPanel.opened.fieldCount < 1 || success.taskPanel.preview?.name !== 'Pad' || success.taskPanel.preview.length !== 10 || success.taskPanel.preview.volume !== 120) fail('native Pad Task preview evidence is incomplete.')
if (success.after?.acceptClicked !== true || success.taskPanel?.closed?.activeDialog !== false || success.taskPanel.closed.inEdit !== '') fail('native Pad Task accept/close evidence is incomplete.')
const pad = success.after?.pad
if (success.after?.bodyTip !== 'Pad' || success.after.bodyGroup?.join(',') !== 'Sketch,Pad' || pad?.typeId !== 'PartDesign::Pad' || pad.length !== 10 || pad.shapeValid !== true || pad.solidCount !== 1 || pad.faceCount !== 6 || pad.edgeCount !== 12 || pad.vertexCount !== 8 || pad.volume !== 120 || pad.profile !== 'Sketch' || pad.state?.join(',') !== 'Up-to-date') fail('native Pad result semantics are incomplete.')
if ((!Number.isInteger(success.after.undoCount) || success.after.undoCount < 1) && (!Array.isArray(success.after.undoNames) || success.after.undoNames.length < 1)) fail('native Pad transaction evidence is missing.')
if (JSON.stringify(report.remainingStates) !== JSON.stringify(['disabled', 'failure', 'cancel', 'recovery']) || report.exactPromotionReady !== false) fail('remaining workflow boundary is incorrect.')
console.log(JSON.stringify({
status: 'freecad-gui-workflow-pass',
family: report.family.id,
command: report.family.primaryCommand,
capturedStates: Object.keys(report.workflows),
remainingStates: report.remainingStates,
bodyTip: success.after.bodyTip,
volume: pad.volume,
undoNames: success.after.undoNames,
}, null, 2))

View File

@@ -0,0 +1,60 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
const [workflow, inventory, exactPlan] = await Promise.all([
load('config/freecad-gui-workflow-plan.json'),
load('config/freecad-gui-command-inventory.json'),
load('config/freecad-web-exact-parity-plan.json'),
])
const fail = (message) => { throw new Error(`FreeCAD GUI workflow plan: ${message}`) }
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
if (workflow.schemaVersion !== 1 || workflow.baseline?.freecadVersion !== '1.1.1' || workflow.baseline?.commit !== lockedCommit) fail('baseline mismatch.')
if (inventory.baseline?.commit !== lockedCommit || exactPlan.baseline?.freecadCommit !== lockedCommit) fail('input baseline mismatch.')
const family = workflow.family
if (!family?.id || !family.title || !family.workbench || !family.primaryCommand || !family.primaryExactTask || !family.selectionReason) fail('selected family metadata is incomplete.')
if (!Array.isArray(family.commandIds) || family.commandIds.length === 0 || !family.commandIds.includes(family.primaryCommand)) fail('selected command family is incomplete.')
if (!Array.isArray(family.relatedExactTasks) || family.relatedExactTasks.length === 0) fail('related exact tasks are missing.')
const exactTaskIds = new Set(exactPlan.programs.flatMap((program) => program.tasks.map((task) => task.id)))
for (const taskId of [family.primaryExactTask, ...family.relatedExactTasks]) if (!exactTaskIds.has(taskId)) fail(`selected family references unknown exact task ${taskId}.`)
const commandById = new Map(inventory.commands.map((command) => [command.id, command]))
for (const commandId of family.commandIds) if (!commandById.has(commandId)) fail(`selected command ${commandId} is absent from the runtime inventory.`)
const primary = commandById.get(family.primaryCommand)
const sensitivity = workflow.sensitivityEvidence
if (sensitivity?.source !== 'config/freecad-gui-command-inventory.json' || sensitivity.commandId !== family.primaryCommand) fail('sensitivity provenance is incomplete.')
if (sensitivity.documentSensitive !== primary.documentSensitive || sensitivity.selectionSensitive !== primary.selectionSensitive) fail('selected sensitivity flags do not match the runtime inventory.')
for (const caseName of ['noDocument', 'documentNoSelection', 'selectedPartBox']) {
if (JSON.stringify(sensitivity.cases?.[caseName]) !== JSON.stringify(primary.cases?.[caseName]?.states)) fail(`${caseName} sensitivity evidence is stale.`)
}
const stateNames = ['success', 'disabled', 'failure', 'cancel', 'recovery']
for (const stateName of stateNames) {
const recipe = workflow.stateRecipes?.[stateName]
if (!recipe?.setup || !recipe.action || !recipe.expected) fail(`${stateName} workflow recipe is incomplete.`)
}
const expectedTaskIds = Array.from({ length: 7 }, (_, index) => `ORA-GUI-WF-${String(index).padStart(3, '0')}`)
if (!Array.isArray(workflow.progress) || workflow.progress.map((entry) => entry.taskId).join(',') !== expectedTaskIds.join(',')) fail('workflow progress must contain ordered ORA-GUI-WF-000 through 006 tasks.')
const statuses = workflow.progress.map((entry) => entry.status)
if (statuses.filter((status) => status === 'in_progress').length !== 1 || statuses.some((status) => !['completed', 'in_progress', 'pending'].includes(status))) fail('workflow progress must have exactly one in-progress task.')
const inProgressIndex = statuses.indexOf('in_progress')
if (statuses.slice(0, inProgressIndex).some((status) => status !== 'completed') || statuses.slice(inProgressIndex + 1).some((status) => status !== 'pending')) fail('workflow progress is not a contiguous serial queue.')
for (const [index, entry] of workflow.progress.entries()) {
if (!Array.isArray(entry.evidence)) fail(`${entry.taskId} evidence must be an array.`)
if (index < inProgressIndex && entry.evidence.length === 0) fail(`${entry.taskId} is completed without evidence.`)
}
console.log(JSON.stringify({
status: 'freecad-gui-workflow-plan-pass',
family: family.id,
primaryCommand: family.primaryCommand,
primaryExactTask: family.primaryExactTask,
completed: statuses.filter((status) => status === 'completed').length,
inProgress: workflow.progress[inProgressIndex].taskId,
pending: statuses.filter((status) => status === 'pending').length,
}, null, 2))

View File

@@ -5,16 +5,16 @@ import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const report = JSON.parse(await readFile(resolve(root, 'config/freecad-native-property-semantics.json'), 'utf8'))
const fail = (message) => { throw new Error(`FreeCAD native property semantics check failed: ${message}`) }
if (report.schemaVersion !== 1 || report.status !== 'pass' || report.baseline?.freecadVersion !== '1.1.1' || report.baseline?.commit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || report.exactPromotionReady !== false || report.exactBlocker !== 'complete native document and property semantics') fail('report boundary is invalid')
if (report.schemaVersion !== 1 || report.status !== 'pass' || report.baseline?.freecadVersion !== '1.1.1' || report.baseline?.commit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || report.exactPromotionReady !== false || report.exactBlocker !== '58 runtime property types and 523 records remain opaque-only; complete native document and property semantics') fail('report boundary is invalid')
if (report.runtime?.registeredObjectTypes !== 352 || report.runtime.instantiableObjectTypes !== 348 || report.runtime.unavailableObjectTypes !== 4 || report.runtime.propertyRecords !== 5510 || report.runtime.propertyTypes !== 85 || report.runtime.unavailableObjects?.length !== 4) fail('runtime inventory counts changed')
const support = report.supportSummary
if (support?.['native-editable-codec']?.typeCount !== 18 || support['native-editable-codec'].recordCount !== 4135 || support?.['native-specialized-codec']?.typeCount !== 5 || support['native-specialized-codec'].recordCount !== 683 || support?.['opaque-fcstd-proxy']?.typeCount !== 62 || support['opaque-fcstd-proxy'].recordCount !== 692) fail('property support partition is stale')
if (support?.['native-editable-codec']?.typeCount !== 22 || support['native-editable-codec'].recordCount !== 4304 || support?.['native-specialized-codec']?.typeCount !== 5 || support['native-specialized-codec'].recordCount !== 683 || support?.['opaque-fcstd-proxy']?.typeCount !== 58 || support['opaque-fcstd-proxy'].recordCount !== 523) fail('property support partition is stale')
if (report.types?.length !== 85 || new Set(report.types.map(({ typeId }) => typeId)).size !== 85 || report.types.reduce((total, entry) => total + entry.recordCount, 0) !== 5510) fail('per-TypeId inventory is invalid')
if (report.propertyStatus?.unknownNumericBits?.length !== 0 || report.propertyStatus.proxyOnlyRecordCount !== 0 || report.propertyStatus.proxyOnly?.length !== 0 || report.propertyStatus.facadeNative?.join(',') !== report.propertyStatus.observed?.join(',')) fail('property status representation coverage is stale')
if (report.propertyStatus.behaviorNative?.join(',') !== 'Hidden,Immutable,NoModify,Ordered,Output,PropHidden,PropNoPersist,PropNoRecompute,PropOutput,PropReadOnly,PropTransient,ReadOnly,Transient' || report.propertyStatus.preservedOnly?.join(',') !== 'LockDynamic,PartialTrigger' || report.propertyStatus.preservedOnlyRecordCount !== 70) fail('property status behavior boundary is stale')
if (report.propertyStatus.behaviorNative?.join(',') !== 'Hidden,Immutable,LockDynamic,NoModify,Ordered,Output,PartialTrigger,PropHidden,PropNoPersist,PropNoRecompute,PropOutput,PropReadOnly,PropTransient,ReadOnly,Transient' || report.propertyStatus.preservedOnly?.length !== 0 || report.propertyStatus.preservedOnlyRecordCount !== 0) fail('property status behavior boundary is stale')
for (const locked of [report.source, report.harness]) {
const path = resolve(root, locked?.path ?? '')
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
if (bytes !== locked.bytes || createHash('sha256').update(content).digest('hex') !== locked.sha256) fail(`report is stale for ${locked.path}`)
}
console.log(JSON.stringify({ status: 'freecad-native-property-semantics-pass', propertyTypes: 85, propertyRecords: 5510, nativeCodecRecords: 4818, opaqueProxyRecords: 692, representedStatusRecords: 5510, preservedOnlyStatusRecords: 70, exactPromotionReady: false }, null, 2))
console.log(JSON.stringify({ status: 'freecad-native-property-semantics-pass', propertyTypes: 85, propertyRecords: 5510, nativeCodecRecords: 4987, opaqueProxyRecords: 523, representedStatusRecords: 5510, preservedOnlyStatusRecords: 0, exactPromotionReady: false }, null, 2))

View File

@@ -3,15 +3,21 @@ import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
const [sourceInventory, typeInventory, guiInventory, reference] = await Promise.all([
const [sourceInventory, typeInventory, guiInventory, reference, guiReference, boundaries, exactPlan] = await Promise.all([
load('config/freecad-source-inventory.json'),
load('config/freecad-type-property-inventory.json'),
load('config/freecad-gui-command-inventory.json'),
load('.cache/freecad/reference-desktop.json'),
load('.cache/freecad/reference-desktop-gui-commands.json'),
load('config/freecad-oracle-boundaries.json'),
load('config/freecad-web-exact-parity-plan.json'),
])
const fail = (message) => { throw new Error(`FreeCAD oracle coverage: ${message}`) }
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
if (reference.schemaVersion !== 1 || reference.freecadVersion !== '1.1.1' || reference.gitCommit !== lockedCommit) fail('reference desktop report is not the locked FreeCAD 1.1.1 baseline.')
if (guiReference.schemaVersion !== 1 || guiReference.freecadVersion !== '1.1.1' || guiReference.gitCommit !== lockedCommit || guiReference.guiUp !== true || guiReference.probeScope !== 'gui-commands') fail('GUI command report is not the dedicated locked FreeCAD 1.1.1 oracle.')
if (guiReference.determinism?.schemaVersion !== 1 || guiReference.determinism.normalizedProcessFields?.join(',') !== 'pointer-address,uuid,freecad-document-cache-run') fail('GUI command report lacks deterministic process-field normalization.')
if (boundaries.schemaVersion !== 1 || boundaries.baseline?.freecadVersion !== '1.1.1' || boundaries.baseline?.commit !== lockedCommit) fail('oracle boundary decisions do not match the locked baseline.')
if (sourceInventory.moduleCount !== 34 || reference.moduleCount !== 34 || reference.modules?.length !== 34) fail('source and desktop oracle must both enumerate 34 modules.')
if (typeInventory.moduleCount !== 34 || typeInventory.modules?.length !== 34) fail('type/property inventory must enumerate all 34 modules.')
@@ -26,17 +32,32 @@ for (const name of sourceModules.keys()) {
const missingReferenceModules = [...referenceModules.keys()].filter((name) => !sourceModules.has(name))
if (missingReferenceModules.length > 0) fail(`desktop oracle has unregistered modules: ${missingReferenceModules.join(', ')}.`)
const referenceCommands = new Map((reference.guiCommands?.commands ?? []).map((command) => [command.id, command]))
const referenceCommands = new Map((guiReference.guiCommands?.commands ?? []).map((command) => [command.id, command]))
const inventoryCommands = new Map(guiInventory.commands.map((command) => [command.id, command]))
if (referenceCommands.size !== guiInventory.commandCount || inventoryCommands.size !== guiInventory.commandCount) fail('GUI command counts or IDs are not unique and aligned.')
for (const [id, command] of inventoryCommands) {
const referenceCommand = referenceCommands.get(id)
if (!referenceCommand) fail(`GUI command ${id} is missing from the desktop oracle.`)
if (!Array.isArray(referenceCommand.observations) || referenceCommand.observations.length === 0) fail(`GUI command ${id} has no desktop observations.`)
if (referenceCommand.observations.some((observation) => !Object.hasOwn(observation, 'noDocument') || !Object.hasOwn(observation, 'documentNoSelection') || !Object.hasOwn(observation, 'selectedPartBox'))) fail(`GUI command ${id} lacks the complete document/selection state matrix.`)
if (command.status !== 'runtime-probed') fail(`GUI command ${id} is not marked runtime-probed.`)
for (const state of ['noDocument', 'documentNoSelection', 'selectedPartBox']) if (command.cases?.[state]?.observed !== true || !Array.isArray(command.cases[state].states) || command.cases[state].states.length === 0) fail(`GUI command ${id} inventory lacks ${state} evidence.`)
}
for (const id of referenceCommands.keys()) if (!inventoryCommands.has(id)) fail(`desktop GUI command ${id} is missing from the generated inventory.`)
for (const command of guiInventory.sourceOnlyCommands) if (!command.reason || !Array.isArray(command.sourceModules)) fail(`source-only command ${command.id} lacks an explicit reason.`)
const sourceOnlyCommands = new Map(guiInventory.sourceOnlyCommands.map((command) => [command.id, command]))
const sourceOnlyCommandDecisions = new Map((boundaries.sourceOnlyCommands ?? []).map((command) => [command.id, command]))
if (sourceOnlyCommandDecisions.size !== (boundaries.sourceOnlyCommands ?? []).length) fail('source-only command decisions contain duplicate IDs.')
const expectedSourceOnlyCommandIds = [...sourceOnlyCommands.keys()].sort()
const decidedSourceOnlyCommandIds = [...sourceOnlyCommandDecisions.keys()].sort()
if (JSON.stringify(expectedSourceOnlyCommandIds) !== JSON.stringify(decidedSourceOnlyCommandIds)) fail(`source-only command decisions drifted: expected ${expectedSourceOnlyCommandIds.join(', ')}, received ${decidedSourceOnlyCommandIds.join(', ')}.`)
const allowedCommandClassifications = new Set(['legacy-lazy-command', 'optional-addon-command-group', 'dynamic-command-template', 'optional-preference-command-group', 'optional-executable-command', 'context-lazy-command', 'source-template-command'])
const exactTaskIds = new Set(exactPlan.programs.flatMap((program) => program.tasks).map((task) => task.id))
for (const [id, decision] of sourceOnlyCommandDecisions) {
const inventoryCommand = sourceOnlyCommands.get(id)
if (!allowedCommandClassifications.has(decision.classification) || !decision.runtimeExpectation || !exactTaskIds.has(decision.exactTask) || !Array.isArray(decision.sourceEvidence) || decision.sourceEvidence.length === 0) fail(`source-only command ${id} lacks a complete classification.`)
if (JSON.stringify(decision.sourceModules) !== JSON.stringify(inventoryCommand.sourceModules)) fail(`source-only command ${id} module ownership differs from the generated inventory.`)
}
const runtimeObjects = reference.runtimeObjects
if (!runtimeObjects?.available || runtimeObjects.candidateSource !== 'Document.supportedTypes' || !Array.isArray(runtimeObjects.types)) fail('desktop oracle is missing the complete Document.supportedTypes runtime inventory.')
@@ -55,8 +76,16 @@ const runtimeObjectCount = runtimeObjects.availableCount
const sourceUsageTypeIds = new Set((typeInventory.documentObjectUsages ?? []).map((record) => record.typeId))
if (sourceUsageTypeIds.size === 0 || !Array.isArray(typeInventory.typeIdDeclarations)) fail('source TypeId declaration and addObject usage indexes are missing.')
const sourceUsageMissingRuntime = [...sourceUsageTypeIds].filter((typeId) => !runtimeTypeIds.has(typeId)).sort()
const expectedNotBuilt = reference.modules.filter((module) => module.runtimeStatus === 'not-built').map((module) => module.name).sort()
const decidedNotBuilt = (boundaries.notBuiltModules ?? []).map((module) => module.name).sort()
if (JSON.stringify(expectedNotBuilt) !== JSON.stringify(decidedNotBuilt)) fail(`not-built module decisions drifted: expected ${expectedNotBuilt.join(', ')}, received ${decidedNotBuilt.join(', ')}.`)
for (const module of boundaries.notBuiltModules) if (!module.buildOption || !['proxy', 'browser-reimplementation', 'source-template'].includes(module.decision) || !module.exactTask || !module.boundary || !Array.isArray(module.sourceEvidence) || module.sourceEvidence.length === 0) fail(`not-built module ${module.name} lacks a complete decision.`)
const decidedSourceOnlyTypeIds = (boundaries.sourceOnlyTypeIds ?? []).map((entry) => entry.typeId).sort()
if (JSON.stringify(sourceUsageMissingRuntime) !== JSON.stringify(decidedSourceOnlyTypeIds)) fail(`source-only TypeId decisions drifted: expected ${sourceUsageMissingRuntime.join(', ')}, received ${decidedSourceOnlyTypeIds.join(', ')}.`)
for (const entry of boundaries.sourceOnlyTypeIds) if (!entry.classification || !entry.runtimeExpectation || !Array.isArray(entry.sourceEvidence) || entry.sourceEvidence.length === 0) fail(`source-only TypeId ${entry.typeId} lacks a complete classification.`)
const staticObjectCandidateCount = sourceUsageTypeIds.size
const moduleStatusCounts = Object.fromEntries([...new Set(reference.modules.map((module) => module.runtimeStatus))].sort().map((status) => [status, reference.modules.filter((module) => module.runtimeStatus === status).length]))
const sourceOnlyCommandClassificationCounts = Object.fromEntries([...allowedCommandClassifications].sort().map((classification) => [classification, boundaries.sourceOnlyCommands.filter((command) => command.classification === classification).length]).filter(([, count]) => count > 0))
console.log(JSON.stringify({
status: 'freecad-oracle-coverage-pass',
baseline: { freecadVersion: reference.freecadVersion, commit: reference.gitCommit },
@@ -65,6 +94,8 @@ console.log(JSON.stringify({
guiCommands: guiInventory.commandCount,
guiCommandsRuntimeAligned: true,
sourceOnlyCommands: guiInventory.sourceOnlyCommands.length,
sourceOnlyCommandsClassified: sourceOnlyCommandDecisions.size,
sourceOnlyCommandClassificationCounts,
registeredObjectTypeCount: runtimeObjects.candidateCount,
runtimeObjectCount,
unavailableObjectCount: runtimeObjects.unavailableCount,
@@ -73,7 +104,10 @@ console.log(JSON.stringify({
sourceDocumentObjectUsageCount: staticObjectCandidateCount,
sourceUsageMissingRuntimeCount: sourceUsageMissingRuntime.length,
sourceUsageMissingRuntime: sourceUsageMissingRuntime,
notBuiltModuleDecisions: boundaries.notBuiltModules.map(({ name, decision, exactTask }) => ({ name, decision, exactTask })),
sourceOnlyTypeIdDecisions: boundaries.sourceOnlyTypeIds.map(({ typeId, classification }) => ({ typeId, classification })),
sourceOnlyCommandDecisions: boundaries.sourceOnlyCommands.map(({ id, classification, exactTask }) => ({ id, classification, exactTask })),
staticObjectCandidateCount,
exactPromotionReady: false,
remaining: ['not-built modules require an explicit build/proxy decision', 'source-declared TypeId/property candidates outside the registered document-object set require a native API inventory'],
remaining: ['task-scoped proxy/browser implementations remain open', 'runtime GUI task, failure, cancel and recovery states require per-command workflow oracles'],
}, null, 2))

View File

@@ -7,7 +7,7 @@ const fail = (message) => { throw new Error(`FreeCAD Property status oracle: ${m
const byId = new Map((oracle.cases ?? []).map((entry) => [entry.id, entry]))
const changed = (id) => byId.get(id)?.changed
if (oracle.schemaVersion !== 1 || oracle.baselineId !== 'freecad-1.1.1-property-status-oracle' || oracle.freecadVersion !== '1.1.1' || oracle.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || oracle.status !== 'pass') fail('baseline is invalid.')
if (oracle.schemaVersion !== 2 || oracle.baselineId !== 'freecad-1.1.1-property-status-oracle' || oracle.freecadVersion !== '1.1.1' || oracle.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || oracle.status !== 'pass') fail('baseline is invalid.')
if (byId.size !== 5 || ['ordinary', 'runtime-output', 'type-output', 'runtime-no-recompute', 'type-no-recompute'].some((id) => !byId.has(id))) fail('change matrix is incomplete.')
if (changed('ordinary')?.documentTouched !== true || byId.get('ordinary')?.recomputeCount !== 1) fail('ordinary input change did not touch and recompute its owner.')
if (changed('runtime-output')?.mustExecute !== false || changed('runtime-output')?.documentTouched !== false) fail('runtime Output changed its owner recompute state.')
@@ -15,8 +15,17 @@ if (changed('type-output')?.mustExecute !== false || changed('type-output')?.doc
if (changed('runtime-no-recompute')?.documentTouched !== true || byId.get('runtime-no-recompute')?.recomputeCount !== 1) fail('runtime NoRecompute no longer matches the locked implementation behavior.')
if (changed('type-no-recompute')?.documentTouched !== true || byId.get('type-no-recompute')?.recomputeCount !== 0) fail('Prop_NoRecompute did not touch without recomputing its owner.')
if (oracle.lockDynamic?.propertyStillPresent !== true || oracle.lockDynamic.renamedPropertyPresent !== false || oracle.lockDynamic.failures?.remove !== null || !oracle.lockDynamic.failures?.rename) fail('LockDynamic did not preserve FreeCAD remove-false/rename-error behavior.')
if (oracle.lockDynamic.mutableRenameAndRemove !== true || oracle.lockDynamic.undoAvailable !== true) fail('Dynamic property mutation is not transactionally undoable.')
const dynamicEvents = (oracle.lockDynamic.observerEvents ?? []).map((event) => `${event.type}:${event.property ?? event.name ?? ''}`)
if (dynamicEvents.join(',') !== 'property.added:MutableValue,transaction.opened:dynamic-property-mutation,property.before-change:MutableValue,property.changed:MutableValue,property.removed:RenamedValue,transaction.committed:') fail('Dynamic property observer/transaction order drifted.')
const partialTrigger = oracle.partialTrigger
if (partialTrigger?.reportedStatus?.join(',') !== 'PartialTrigger' || partialTrigger.value !== true || partialTrigger.recomputeCount !== 1 || partialTrigger.undoAvailable !== true || partialTrigger.state?.documentTouched !== false) fail('PartialTrigger native mutation behavior drifted.')
const partialEvents = (partialTrigger.observerEvents ?? []).map((event) => `${event.type}:${event.property ?? event.name ?? ''}`)
if (partialEvents.join(',') !== 'property.before-change:PartialLoad,transaction.opened:partial-trigger-mutation,property.changed:PartialLoad,property.changed:Shape,transaction.committed:') fail('PartialTrigger observer/transaction order drifted.')
const xml = oracle.fcstd?.xml
const reopened = oracle.fcstd?.reopened
if (xml?.persistedProperty !== true || xml.runtimeTransientProperty !== true || xml.typeTransientProperty !== true || xml.noPersistProperty !== false || xml.runtimeTransientHasValue !== false || xml.typeTransientHasValue !== false) fail('FCStd Property persistence partition drifted.')
if (JSON.stringify(reopened?.properties) !== JSON.stringify(['Persisted', 'RuntimeTransient', 'TypeTransient']) || reopened.values?.Persisted !== 11 || reopened.values.RuntimeTransient !== 0 || reopened.values.TypeTransient !== 0) fail('FCStd reopen did not restore persisted/transient defaults exactly.')
console.log(JSON.stringify({ status: 'freecad-property-status-oracle-pass', cases: byId.size, outputSuppressesTouch: true, noRecomputePartition: true, lockDynamic: true, fcstdRoundtrip: true }, null, 2))
if (xml.floatConstraintNative !== true || xml.quantityConstraintNative !== true || xml.precisionNative !== true || xml.hiddenLinkNative !== true) fail('Native high-frequency App Property XML codecs drifted.')
if (reopened.codecValues?.FloatConstraint !== 0.25 || reopened.codecValues.QuantityConstraint !== 12.5 || reopened.codecValues.Precision !== 0.001 || reopened.codecValues.HiddenLink !== 'Target') fail('Native high-frequency App Property codecs did not reopen losslessly.')
console.log(JSON.stringify({ status: 'freecad-property-status-oracle-pass', cases: byId.size, outputSuppressesTouch: true, noRecomputePartition: true, lockDynamic: true, partialTrigger: true, observerTransactions: true, nativeCodecTypes: 4, fcstdRoundtrip: true }, null, 2))

View File

@@ -0,0 +1,92 @@
import { createHash } from 'node:crypto'
import { readFile, stat } from 'node:fs/promises'
import { resolve } from 'node:path'
import { validateCompositeMutationEvidence } from './freecad-composite-mutation-evidence.mjs'
const root = resolve(new URL('..', import.meta.url).pathname)
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
const fail = (message) => { throw new Error(`FreeCAD TSN stage correlation: ${message}`) }
const [desktop, production, productionRegistry, composite, resave, mutations] = await Promise.all([
load('config/freecad-tsn-stage-correlation-oracle.json'),
load('config/freecad-isomorphic-provenance-verification.json'),
load('config/chrome-freecad-naming-production-verification.json'),
load('config/freecad-composite-history-elementmap-oracle.json'),
load('config/freecad-composite-history-resave-verification.json'),
load('config/freecad-parameter-mutation-report.json'),
])
const operations = ['cut', 'rotate', 'fillet', 'mirrored', 'linear-pattern']
if (desktop.schemaVersion !== 1 || desktop.baselineId !== 'freecad-1.1.1-tsn-stage-correlation' || desktop.freecadVersion !== '1.1.1' || desktop.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || desktop.status !== 'pass') fail('desktop oracle is not locked to FreeCAD 1.1.1.')
const harnessPath = resolve(root, desktop.harness?.path ?? '')
const [harnessContent, harnessBytes] = await Promise.all([readFile(harnessPath), stat(harnessPath).then(({ size }) => size)])
if (desktop.harness?.path !== 'scripts/freecad-tsn-stage-correlation-oracle.py' || desktop.harness.bytes !== harnessBytes || desktop.harness.sha256 !== createHash('sha256').update(harnessContent).digest('hex')) fail('desktop oracle is stale for its executable harness.')
if (JSON.stringify(desktop.operations) !== JSON.stringify(operations) || JSON.stringify(production.cases?.longChain?.operations) !== JSON.stringify(operations)) fail('desktop and production five-stage operation order differs.')
if (desktop.stages?.length !== 5 || desktop.reopenedStages?.length !== 5 || desktop.resavedStages?.length !== 5) fail('desktop save/reopen/resave stage count is incomplete.')
for (let index = 0; index < operations.length; index += 1) {
const before = desktop.stages[index]
const reopened = desktop.reopenedStages[index]
const resaved = desktop.resavedStages[index]
if (before.operation !== operations[index] || reopened.operation !== before.operation || resaved.operation !== before.operation || !before.shape?.valid || before.mappedNameCount < 1 || before.historyRelations < 1) fail(`desktop stage ${operations[index]} has no valid topology association.`)
if (before.mappedNameDigest !== reopened.mappedNameDigest || before.mappedNameDigest !== resaved.mappedNameDigest) fail(`desktop stage ${operations[index]} changed mapped names after save/reopen/resave.`)
}
if (desktop.summary?.wrongBindings !== 0 || desktop.summary?.unexplainedRelations !== 0 || desktop.summary?.roundtripNameDrift !== 0 || desktop.summary?.resaveNameDrift !== 0) fail(`desktop zero-difference metrics failed: ${JSON.stringify(desktop.summary)}`)
if (desktop.nativeDesktopResaveCovered !== true || desktop.summary?.mutationCases !== 5 || desktop.summary?.mutationPassed !== 5 || desktop.mutations?.length !== 5) fail('desktop cross-feature mutation/resave corpus is incomplete.')
for (const [index, mutation] of desktop.mutations.entries()) if (mutation.operation !== operations[index] || mutation.status !== 'pass' || mutation.parameterChanged !== true || mutation.downstreamShapeChanged !== true || mutation.restoredExactly !== true) fail(`desktop ${operations[index]} mutation did not edit, recompute and restore the chain.`)
if (composite.summary?.cases !== 30 || composite.summary?.passed !== 30 || resave.summary?.nativeDesktopResaveCases !== 30 || resave.summary?.roundtripNameDrift !== 0 || resave.summary?.resaveNameDrift !== 0) fail('the locked 30-case native desktop resave corpus is incomplete.')
const compositeMutation = validateCompositeMutationEvidence(composite)
const resaveById = new Map((resave.cases || []).map((fixture) => [fixture.id, fixture]))
let compositeCaseReconciliations = 0
if (resaveById.size !== compositeMutation.mutationCases || resave.cases?.length !== compositeMutation.mutationCases) fail('the 30-case resave report is not joined one-to-one with mutation evidence.')
for (const fixture of composite.cases) {
const resaveFixture = resaveById.get(fixture.id)
if (!resaveFixture || resaveFixture.roundtripNameDrift !== 0 || resaveFixture.resaveNameDrift !== 0 || resaveFixture.nativeDesktopResaveCovered !== true) fail(`${fixture.id} lacks a passing native resave transaction.`)
for (const phase of ['initial', 'reopened', 'resaved']) {
const records = resaveFixture.stageCorrelations?.[phase]
if (!Array.isArray(records) || records.length !== fixture.stages.length) fail(`${fixture.id}/${phase} lacks all stage correlations.`)
for (const [ordinal, stage] of fixture.stages.entries()) {
const record = records[ordinal]
const key = `${fixture.id}:${String(ordinal).padStart(3, '0')}:${stage.name}`
if (record.key !== key || record.ordinal !== ordinal || record.name !== stage.name || record.typeId !== stage.typeId || record.relationDigest !== stage.relationDigest || record.semanticNameDigest !== stage.semanticNameDigest) fail(`${key}/${phase} changed its locked stage evidence.`)
}
}
compositeCaseReconciliations += 1
}
if (compositeCaseReconciliations !== 30 || compositeMutation.mutationStageRecords !== 219 || compositeMutation.mutationStageRestoreFailures !== 0 || compositeMutation.mutationFinalPropagationFailures !== 0) fail('the 30-case mutation corpus is not fully reconciled.')
const mutationFamilyByOperation = new Map(mutations.families.flatMap((family) => (family.operationTypes || []).map((operation) => [operation, family])))
const nativeMutationFamilies = { cut: 'Part::Cut', fillet: 'Part::Fillet', mirrored: 'PartDesign::Mirrored', 'linear-pattern': 'PartDesign::LinearPattern' }
const nativeMutationCorrelation = operations.map((operation) => {
const requiredFamily = nativeMutationFamilies[operation]
const family = requiredFamily ? mutations.families.find(({ id }) => id === requiredFamily) : mutationFamilyByOperation.get(operation)
return { operation, familyId: family?.id ?? null, complete: family?.complete === true }
})
const missingNativeMutationOperations = nativeMutationCorrelation.filter(({ complete }) => !complete).map(({ operation }) => operation)
const proxyStageOperations = desktop.stages.filter(({ nativeBuilder }) => nativeBuilder !== true).map(({ operation }) => operation)
if (JSON.stringify(proxyStageOperations) !== '["rotate","mirrored","linear-pattern"]' || desktop.summary?.nativeBuilderStages !== 2 || desktop.summary?.proxyStages !== 3 || desktop.exactCorrelationReady !== false) fail('desktop native-builder/proxy boundary drifted.')
const registeredOperations = productionRegistry.operations || []
const coveredTransitions = operations.slice(1).map((operation, index) => `${operations[index]}->${operation}`)
const orderedOperationPairs = registeredOperations.length * registeredOperations.length
const blockers = [
`Native desktop builder stages missing from the sequential chain: ${proxyStageOperations.join(', ')}`,
`Native parameter-mutation family missing for production operation: ${missingNativeMutationOperations.join(', ')}`,
`${desktop.summary.mutationRestoreTopologyDriftCases}/5 edit/restore cases changed ${desktop.summary.mutationRestoreTopologyDriftStages} downstream semantic topology digests without wrong binding`,
`${compositeMutation.mutationNamingRestoreDriftCases}/30 native case mutations changed ${compositeMutation.mutationNamingRestoreDriftStages} recovered naming-stage digests while all 219 geometry stages restored`,
`Only ${coveredTransitions.length}/${orderedOperationPairs} ordered production operation pairs are classified and replayed; ${orderedOperationPairs - coveredTransitions.length} remain unclassified for type compatibility`,
]
console.log(JSON.stringify({
status: 'freecad-tsn-stage-correlation-fail-closed',
exactPromotionReady: false,
productionStages: production.cases.longChain.stages.length,
desktopStages: desktop.summary.stages,
nativeBuilderStages: desktop.summary.nativeBuilderStages,
proxyStageOperations,
compositeMutations: { cases: compositeMutation.mutationCases, passed: compositeMutation.mutationPassed, caseReconciliations: compositeCaseReconciliations, categoryCases: compositeMutation.categoryCases, stageRecords: compositeMutation.mutationStageRecords, phaseStageRecords: compositeMutation.mutationPhaseStageRecords, finalPropagationFailures: compositeMutation.mutationFinalPropagationFailures, geometryRestoreFailures: compositeMutation.mutationStageRestoreFailures, namingRestoreDriftCases: compositeMutation.mutationNamingRestoreDriftCases, namingRestoreDriftStages: compositeMutation.mutationNamingRestoreDriftStages },
mutations: { cases: desktop.summary.mutationCases, passed: desktop.summary.mutationPassed, nativeCorrelated: nativeMutationCorrelation.filter(({ complete }) => complete).length, missingNativeMutationOperations, restoreTopologyDriftCases: desktop.summary.mutationRestoreTopologyDriftCases, restoreTopologyDriftStages: desktop.summary.mutationRestoreTopologyDriftStages },
transitions: { registeredOperations: registeredOperations.length, orderedOperationPairs, covered: coveredTransitions.length, unclassified: orderedOperationPairs - coveredTransitions.length, coveredTransitions },
nativeDesktopResaveCases: resave.summary.nativeDesktopResaveCases + 1,
wrongBindings: desktop.summary.wrongBindings,
unexplainedRelations: desktop.summary.unexplainedRelations,
roundtripNameDrift: desktop.summary.roundtripNameDrift,
resaveNameDrift: desktop.summary.resaveNameDrift,
blockers,
}, null, 2))

View File

@@ -1,6 +1,7 @@
import hashlib
import json
import os
import re
import tempfile
import zipfile
@@ -34,8 +35,32 @@ def square_sketch(body, name, half_size=2.0, z=0.0):
def shape_summary(shape):
if shape is None or shape.isNull():
return {"valid": False, "solids": 0, "faces": 0, "edges": 0, "volume": 0.0}
return {"valid": bool(shape.isValid()), "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "volume": float(shape.Volume)}
return {"valid": False, "solids": 0, "faces": 0, "edges": 0, "vertices": 0, "volume": 0.0, "area": 0.0, "brepSha256": None}
brep = shape.exportBrepToString()
bounds = shape.BoundBox
geometry = {
"solids": len(shape.Solids),
"faces": len(shape.Faces),
"edges": len(shape.Edges),
"vertices": len(shape.Vertexes),
"volume": round(float(shape.Volume), 9),
"area": round(float(shape.Area), 9),
"bounds": [round(float(value), 9) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)],
"vertexPoints": sorted([round(float(vertex.Point.x), 9), round(float(vertex.Point.y), 9), round(float(vertex.Point.z), 9)] for vertex in shape.Vertexes),
"edgeLengths": sorted(round(float(edge.Length), 9) for edge in shape.Edges),
"faceAreas": sorted(round(float(face.Area), 9) for face in shape.Faces),
}
return {
"valid": bool(shape.isValid()),
"solids": len(shape.Solids),
"faces": len(shape.Faces),
"edges": len(shape.Edges),
"vertices": len(shape.Vertexes),
"volume": float(shape.Volume),
"area": float(shape.Area),
"brepSha256": hashlib.sha256(brep.encode("utf-8")).hexdigest(),
"geometryDigest": digest_json(geometry),
}
def history_entry(obj, name):
@@ -59,6 +84,81 @@ def relation_from_mapped_name(mapped_name):
return "preserved"
def normalize_mapped_name(mapped_name):
# StringHasher IDs are process-local. Preserve the token grammar while
# removing only the volatile hash-table slot assigned in this process.
return re.sub(r":H[0-9a-fA-F]+", ":H#", mapped_name or "")
def source_element_name(mapped_name):
match = re.search(r"(?:^|[;(])((?:Face|Edge|Vertex)\d+)", mapped_name or "")
if match:
return match.group(1)
local_token = re.search(r"(?:^|[;(])(#[a-zA-Z0-9]+:[0-9a-fA-F]+)", mapped_name or "")
if local_token:
return local_token.group(1)
sketch_token = re.search(r"(?:^|[;(])(g\d+(?:v\d+)?;SKT)", mapped_name or "")
if sketch_token:
return sketch_token.group(1)
indexed_token = re.search(r"(?:^|[;(])(#[0-9a-fA-F]+)", mapped_name or "")
return indexed_token.group(1) if indexed_token else ""
def linked_objects(obj):
result = set()
def collect(value):
if hasattr(value, "Name") and getattr(value, "Document", None) is obj.Document:
result.add(value.Name)
elif isinstance(value, (list, tuple)):
for item in value:
collect(item)
for property_name in obj.PropertiesList:
try:
property_type = obj.getTypeIdOfProperty(property_name)
except Exception:
continue
if "PropertyLink" not in property_type:
continue
try:
collect(getattr(obj, property_name))
except Exception:
pass
result.discard(obj.Name)
return sorted(result)
def relation_records(obj, names):
records = []
for entry in names:
history = entry.get("history") or []
sources = []
for item in history:
if item.get("object") == obj.Name or not item.get("object"):
continue
sources.append({
"sourceObject": item.get("object"),
"sourceTypeId": item.get("typeId"),
"sourceElement": source_element_name(item.get("mappedName")),
"mappedName": normalize_mapped_name(item.get("mappedName")),
"children": sorted(normalize_mapped_name(child) for child in (item.get("children") or [])),
})
records.append({
"resultName": entry["name"],
"mappedName": normalize_mapped_name(entry.get("mappedName")),
"indexedName": entry.get("indexedName") or "",
"relation": relation_from_mapped_name(entry.get("mappedName")),
"sources": sorted(sources, key=lambda source: (source["sourceObject"], source["sourceElement"], source["mappedName"])),
})
return records
def digest_json(value):
payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def native_mapping(obj, kind, index, name, mapped_name, mapped_ids, indexed_name, indexed_ids, history):
reference_name = mapped_name or indexed_name or name
source_refs = []
@@ -132,6 +232,7 @@ def stage_report(obj):
"history": history,
})
native_mapped_names.append(native_mapping(obj, kind, index, name, mapped, mapped_ids, indexed, indexed_ids, history))
relations = relation_records(obj, names)
native_evidence = {
"schemaVersion": 1,
"stageId": obj.Name,
@@ -159,10 +260,167 @@ def stage_report(obj):
"elementMapVersion": getattr(shape, "ElementMapVersion", None),
"elementMapSize": int(getattr(shape, "ElementMapSize", 0) or 0),
"names": names,
"linkedObjects": linked_objects(obj),
"relations": relations,
"relationDigest": digest_json(relations),
"semanticNameDigest": digest_json([{
"name": entry["name"],
"mappedName": normalize_mapped_name(entry.get("mappedName")),
"indexedName": entry.get("indexedName") or "",
} for entry in names]),
"nativeEvidence": native_evidence,
}
def correlation_snapshot(case_id, stages):
return [{
"key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]),
"ordinal": ordinal,
"name": stage["name"],
"typeId": stage["typeId"],
"linkedObjects": stage["linkedObjects"],
"relationCount": len(stage["relations"]),
"relationDigest": stage["relationDigest"],
"semanticNameDigest": stage["semanticNameDigest"],
} for ordinal, stage in enumerate(stages)]
def parameter_value(value):
if hasattr(value, "Value"):
return float(value.Value)
if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
return [float(value.x), float(value.y), float(value.z)]
if isinstance(value, (bool, int, float, str)) or value is None:
return value
return str(value)
def mutation_stage_snapshot(case_id, stages):
return [{
"key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]),
"ordinal": ordinal,
"name": stage["name"],
"typeId": stage["typeId"],
"shape": stage["shape"],
"relationDigest": stage["relationDigest"],
"semanticNameDigest": stage["semanticNameDigest"],
} for ordinal, stage in enumerate(stages)]
def mutation_geometry_fingerprint(stage):
return stage["shape"]["geometryDigest"]
def mutation_naming_fingerprint(stage):
return (stage["relationDigest"], stage["semanticNameDigest"])
def collect_named_stages(document, stage_names):
stages = []
for stage_name in stage_names:
obj = document.getObject(stage_name)
stage = stage_report(obj) if obj is not None else None
if stage is None:
raise RuntimeError("mutation removed or invalidated stage %s" % stage_name)
stages.append(stage)
return stages
def capture_mutation(case_id, document, construction, before_stages):
final = construction["final"]
target = construction["mutationTarget"]
property_name = construction["mutationProperty"]
edited_value = construction["mutationValue"]
stage_names = [stage["name"] for stage in before_stages]
stage_ordinals = {stage_name: ordinal for ordinal, stage_name in enumerate(stage_names)}
if target.Name not in stage_ordinals or final.Name not in stage_ordinals:
raise RuntimeError("mutation target or final object has no captured Shape stage")
if property_name not in target.PropertiesList:
raise RuntimeError("mutation property %s.%s is not registered" % (target.Name, property_name))
editor_modes = list(target.getEditorMode(property_name))
property_type = target.getTypeIdOfProperty(property_name)
native_editable = "ReadOnly" not in editor_modes and "Hidden" not in editor_modes
original_value = getattr(target, property_name)
before_value = parameter_value(original_value)
try:
setattr(target, property_name, edited_value)
document.recompute()
edited_value_readback = parameter_value(getattr(target, property_name))
edited_stages = collect_named_stages(document, stage_names)
finally:
setattr(target, property_name, original_value)
document.recompute()
restored_value = parameter_value(getattr(target, property_name))
restored_stages = collect_named_stages(document, stage_names)
before_geometry = [mutation_geometry_fingerprint(stage) for stage in before_stages]
edited_geometry = [mutation_geometry_fingerprint(stage) for stage in edited_stages]
restored_geometry = [mutation_geometry_fingerprint(stage) for stage in restored_stages]
before_naming = [mutation_naming_fingerprint(stage) for stage in before_stages]
edited_naming = [mutation_naming_fingerprint(stage) for stage in edited_stages]
restored_naming = [mutation_naming_fingerprint(stage) for stage in restored_stages]
changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_geometry, edited_geometry)) if before != edited]
restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_geometry, restored_geometry)) if before != restored]
naming_changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_naming, edited_naming)) if before != edited]
naming_restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_naming, restored_naming)) if before != restored]
final_ordinal = stage_ordinals[final.Name]
target_ordinal = stage_ordinals[target.Name]
property_changed = before_value != edited_value_readback
final_shape_changed = before_geometry[final_ordinal] != edited_geometry[final_ordinal]
final_brep_changed = before_stages[final_ordinal]["shape"]["brepSha256"] != edited_stages[final_ordinal]["shape"]["brepSha256"]
property_restored = before_value == restored_value
all_stages_restored = len(restoration_drift_ordinals) == 0
edited_shapes_valid = all(stage["shape"]["valid"] for stage in edited_stages)
status = "pass" if native_editable and property_changed and final_shape_changed and property_restored and all_stages_restored and edited_shapes_valid else "failed"
return {
"schemaVersion": 1,
"category": construction["category"],
"contract": {
"targetObject": target.Name,
"targetTypeId": target.TypeId,
"targetStageOrdinal": target_ordinal,
"propertyPath": property_name,
"propertyType": property_type,
"editorModes": editor_modes,
"nativeEditable": native_editable and property_type.startswith("App::Property"),
"finalObject": final.Name,
"finalStageOrdinal": final_ordinal,
"requiresFinalPropagation": True,
"requiresAllStageRestore": True,
},
"values": {
"before": before_value,
"edited": edited_value_readback,
"restored": restored_value,
},
"phases": {
"before": mutation_stage_snapshot(case_id, before_stages),
"edited": mutation_stage_snapshot(case_id, edited_stages),
"restored": mutation_stage_snapshot(case_id, restored_stages),
},
"metrics": {
"propertyChanged": property_changed,
"finalShapeChanged": final_shape_changed,
"finalBrepChanged": final_brep_changed,
"propertyRestored": property_restored,
"allStagesRestored": all_stages_restored,
"allStageGeometryRestored": all_stages_restored,
"allStageNamingRestored": len(naming_restoration_drift_ordinals) == 0,
"editedShapesValid": edited_shapes_valid,
"changedStageOrdinals": changed_ordinals,
"changedStages": len(changed_ordinals),
"restorationDriftOrdinals": restoration_drift_ordinals,
"restorationDriftStages": len(restoration_drift_ordinals),
"namingChangedStageOrdinals": naming_changed_ordinals,
"namingChangedStages": len(naming_changed_ordinals),
"namingRestorationDriftOrdinals": naming_restoration_drift_ordinals,
"namingRestorationDriftStages": len(naming_restoration_drift_ordinals),
"stageRecords": len(before_stages),
"phaseStageRecords": len(before_stages) * 3,
},
"status": status,
}
def make_boolean(doc, operation, index):
base = doc.addObject("Part::Box", "Box%02d" % index)
base.Length, base.Width, base.Height = 10.0, 10.0, 10.0
@@ -176,7 +434,14 @@ def make_boolean(doc, operation, index):
else:
result = doc.addObject("Part::Cut", "Cut%02d" % index)
result.Base, result.Tool = base, tool
return result
return {
"category": "boolean",
"final": result,
"base": base,
"mutationTarget": tool,
"mutationProperty": "Radius",
"mutationValue": float(tool.Radius.Value) + 0.5,
}
def make_partdesign(doc, mode, index):
@@ -210,18 +475,37 @@ def make_partdesign(doc, mode, index):
elif mode == "pocket-twoside":
pocket.Length2 = 1.0
doc.recompute()
return pocket
return pad
return {
"category": "partdesign",
"final": pocket,
"mutationTarget": pad,
"mutationProperty": "Length",
"mutationValue": float(pad.Length.Value) + 0.75,
}
return {
"category": "partdesign",
"final": pad,
"mutationTarget": pad,
"mutationProperty": "Length",
"mutationValue": float(pad.Length.Value) + 0.75,
}
def make_composite(doc, index):
first = make_boolean(doc, "fuse" if index % 2 else "cut", index)
first_construction = make_boolean(doc, "fuse" if index % 2 else "cut", index)
first = first_construction["final"]
second = doc.addObject("Part::Box", "SecondBox%02d" % index)
second.Length, second.Width, second.Height = 4.0, 4.0, 4.0
second.Placement.Base = App.Vector(3.0, 3.0, 3.0)
final = doc.addObject("Part::Cut", "CompositeCut%02d" % index)
final.Base, final.Tool = first, second
return final
return {
"category": "composite-boolean",
"final": final,
"mutationTarget": first_construction["base"],
"mutationProperty": "Length",
"mutationValue": float(first_construction["base"].Length.Value) + 0.5,
}
def make_dressup(doc, mode, index):
@@ -236,17 +520,27 @@ def make_dressup(doc, mode, index):
feature = body.newObject("PartDesign::Chamfer", "Chamfer%02d" % index)
feature.Base, feature.Size = (box, ["Edge1"]), 1.0
doc.recompute()
return feature
return {
"category": "dress-up",
"final": feature,
"mutationTarget": feature,
"mutationProperty": "Radius" if mode == "fillet" else "Size",
"mutationValue": 1.25,
}
def collect_case(case_id, factory, index, directory):
name = "ElementMapOracle%02d" % index
doc = App.newDocument(name)
try:
final = factory(doc, index)
construction = factory(doc, index)
final = construction["final"]
doc.recompute()
stages = [entry for obj in doc.Objects if (entry := stage_report(obj)) is not None]
before_names = {(stage["name"], entry["name"]): entry.get("mappedName") for stage in stages for entry in stage["names"]}
mutation = capture_mutation(case_id, doc, construction, stages)
stages = collect_named_stages(doc, [stage["name"] for stage in stages])
before_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in stages for entry in stage["names"]}
initial_correlations = correlation_snapshot(case_id, stages)
final_name = getattr(final, "Name", "")
path = os.path.join(directory, "%s.FCStd" % case_id)
doc.saveAs(path)
@@ -275,7 +569,8 @@ def collect_case(case_id, factory, index, directory):
reopened = App.openDocument(path)
reopened.recompute()
after_stages = [entry for obj in reopened.Objects if (entry := stage_report(obj)) is not None]
after_names = {(stage["name"], entry["name"]): entry.get("mappedName") for stage in after_stages for entry in stage["names"]}
after_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in after_stages for entry in stage["names"]}
reopened_correlations = correlation_snapshot(case_id, after_stages)
roundtrip_drift = sum(1 for key, value in before_names.items() if after_names.get(key) != value)
resaved_path = os.path.join(directory, "%s-resaved.FCStd" % case_id)
reopened.saveAs(resaved_path)
@@ -283,10 +578,11 @@ def collect_case(case_id, factory, index, directory):
resaved = App.openDocument(resaved_path)
resaved.recompute()
resaved_stages = [entry for obj in resaved.Objects if (entry := stage_report(obj)) is not None]
resaved_names = {(stage["name"], entry["name"]): entry.get("mappedName") for stage in resaved_stages for entry in stage["names"]}
resaved_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in resaved_stages for entry in stage["names"]}
resaved_correlations = correlation_snapshot(case_id, resaved_stages)
resave_name_drift = sum(1 for key, value in before_names.items() if resaved_names.get(key) != value)
App.closeDocument(resaved.Name)
return {"id": case_id, "finalObject": final_name, "stages": stages, "elementMapResources": resources, "stringHasherResource": string_hasher_resource, "roundtripNameDrift": roundtrip_drift, "resaveNameDrift": resave_name_drift, "nativeDesktopResaveCovered": True, "status": "pass"}
return {"id": case_id, "category": construction["category"], "finalObject": final_name, "stages": stages, "mutation": mutation, "stageCorrelations": {"initial": initial_correlations, "reopened": reopened_correlations, "resaved": resaved_correlations}, "elementMapResources": resources, "stringHasherResource": string_hasher_resource, "roundtripNameDrift": roundtrip_drift, "resaveNameDrift": resave_name_drift, "nativeDesktopResaveCovered": True, "status": "pass" if mutation["status"] == "pass" else "failed"}
except Exception as error:
return {"id": case_id, "finalObject": "", "stages": [], "elementMapResources": {}, "status": "failed", "error": str(error)}
finally:
@@ -312,11 +608,30 @@ with tempfile.TemporaryDirectory(prefix="freecad-elementmap-oracle-") as directo
version = App.Version()
report = {
"schemaVersion": 1,
"mutationContractVersion": 1,
"baselineId": "freecad-1.1.1-composite-history-elementmap2",
"freecadVersion": version_text(),
"gitCommit": FREECAD_COMMIT,
"status": "pass" if len(cases) == 30 and all(case["status"] == "pass" for case in cases) else "failed",
"cases": cases,
"summary": {"cases": len(cases), "passed": sum(1 for case in cases if case["status"] == "pass"), "failed": sum(1 for case in cases if case["status"] != "pass"), "roundtripNameDrift": sum(case.get("roundtripNameDrift", 0) for case in cases), "resaveNameDrift": sum(case.get("resaveNameDrift", 0) for case in cases), "nativeDesktopResaveCases": sum(1 for case in cases if case.get("nativeDesktopResaveCovered") is True)},
"summary": {
"cases": len(cases),
"passed": sum(1 for case in cases if case["status"] == "pass"),
"failed": sum(1 for case in cases if case["status"] != "pass"),
"categoryCases": {category: sum(1 for case in cases if case.get("category") == category) for category in ("boolean", "partdesign", "composite-boolean", "dress-up")},
"stageCorrelations": sum(len(case.get("stages", [])) for case in cases),
"relationRecords": sum(sum(len(stage.get("relations", [])) for stage in case.get("stages", [])) for case in cases),
"mutationCases": sum(1 for case in cases if case.get("mutation")),
"mutationPassed": sum(1 for case in cases if case.get("mutation", {}).get("status") == "pass"),
"mutationStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("stageRecords", 0) for case in cases),
"mutationPhaseStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("phaseStageRecords", 0) for case in cases),
"mutationFinalPropagationFailures": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("finalShapeChanged") is not True),
"mutationStageRestoreFailures": sum(case.get("mutation", {}).get("metrics", {}).get("restorationDriftStages", 0) for case in cases),
"mutationNamingRestoreDriftCases": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) > 0),
"mutationNamingRestoreDriftStages": sum(case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) for case in cases),
"roundtripNameDrift": sum(case.get("roundtripNameDrift", 0) for case in cases),
"resaveNameDrift": sum(case.get("resaveNameDrift", 0) for case in cases),
"nativeDesktopResaveCases": sum(1 for case in cases if case.get("nativeDesktopResaveCovered") is True),
},
}
print("FREECAD_COMPOSITE_HISTORY_ELEMENTMAP_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))

View File

@@ -0,0 +1,94 @@
const CASES = [
...['fuse', 'cut', 'common'].flatMap((operation) => [1, 2, 3].map((variant) => [`boolean-${operation}-${variant}`, 'boolean'])),
...['plain', 'midplane', 'reverse', 'taper', 'twoside', 'pocket', 'pocket-through', 'pocket-midplane', 'pocket-twoside', 'pocket-taper', 'pocket-up-to-face'].map((mode) => [`partdesign-${mode}`, 'partdesign']),
...[1, 2, 3, 4, 5, 6].map((variant) => [`composite-${String(variant).padStart(2, '0')}`, 'composite-boolean']),
...['fillet', 'chamfer'].flatMap((operation) => [1, 2].map((variant) => [`dressup-${operation}-${variant}`, 'dress-up'])),
]
export const expectedCompositeMutationCases = new Map(CASES)
export const expectedCompositeMutationCategoryCases = {
boolean: 9,
partdesign: 11,
'composite-boolean': 6,
'dress-up': 4,
}
const fail = (message) => { throw new Error(`FreeCAD composite mutation evidence: ${message}`) }
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, entry]) => [key, canonical(entry)])) : value
const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right))
const sha256 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)
const stageFingerprint = (stage) => [stage?.shape?.geometryDigest, stage?.relationDigest, stage?.semanticNameDigest]
const geometryFingerprint = (stage) => stage?.shape?.geometryDigest
const namingFingerprint = (stage) => [stage?.relationDigest, stage?.semanticNameDigest]
const validatePhaseRecord = (fixture, nominalStage, ordinal, phase, record) => {
const expectedKey = `${fixture.id}:${String(ordinal).padStart(3, '0')}:${nominalStage.name}`
if (record?.key !== expectedKey || record.ordinal !== ordinal || record.name !== nominalStage.name || record.typeId !== nominalStage.typeId) fail(`${fixture.id}/${phase}/${ordinal} changed its locked stage identity.`)
if (record.shape?.valid !== true || !sha256(record.shape?.brepSha256) || !sha256(record.shape?.geometryDigest) || !sha256(record.relationDigest) || !sha256(record.semanticNameDigest)) fail(`${fixture.id}/${phase}/${nominalStage.name} lacks valid geometry or naming digests.`)
}
export const validateCompositeMutationEvidence = (report) => {
if (report?.mutationContractVersion !== 1 || !Array.isArray(report.cases) || report.cases.length !== CASES.length) fail('the report does not declare the locked 30-case mutation contract.')
if (!same(report.cases.map(({ id }) => id), CASES.map(([id]) => id))) fail('case identities or ordering differ from the locked constructor inventory.')
const categoryCases = Object.fromEntries(Object.keys(expectedCompositeMutationCategoryCases).map((category) => [category, 0]))
let mutationCases = 0
let mutationPassed = 0
let mutationStageRecords = 0
let mutationPhaseStageRecords = 0
let mutationFinalPropagationFailures = 0
let mutationStageRestoreFailures = 0
let mutationNamingRestoreDriftCases = 0
let mutationNamingRestoreDriftStages = 0
const cases = []
for (const fixture of report.cases) {
const expectedCategory = expectedCompositeMutationCases.get(fixture.id)
if (fixture.category !== expectedCategory || fixture.status !== 'pass') fail(`${fixture.id} is not a passing ${expectedCategory} case.`)
categoryCases[fixture.category] += 1
const mutation = fixture.mutation
const contract = mutation?.contract
const metrics = mutation?.metrics
const phases = mutation?.phases
if (mutation?.schemaVersion !== 1 || mutation.category !== fixture.category || mutation.status !== 'pass' || !contract || !metrics || !phases) fail(`${fixture.id} has no passing mutation transaction.`)
if (contract.nativeEditable !== true || !Array.isArray(contract.editorModes) || contract.editorModes.includes('ReadOnly') || contract.editorModes.includes('Hidden') || !/^App::Property/.test(contract.propertyType || '') || typeof contract.propertyPath !== 'string' || !contract.propertyPath) fail(`${fixture.id} did not mutate a native editable FreeCAD property.`)
if (contract.finalObject !== fixture.finalObject || contract.requiresFinalPropagation !== true || contract.requiresAllStageRestore !== true) fail(`${fixture.id} changed its propagation or restoration contract.`)
if (!Number.isInteger(contract.targetStageOrdinal) || !Number.isInteger(contract.finalStageOrdinal) || contract.targetStageOrdinal < 0 || contract.finalStageOrdinal < 0 || contract.targetStageOrdinal >= fixture.stages.length || contract.finalStageOrdinal >= fixture.stages.length) fail(`${fixture.id} has invalid target/final stage ordinals.`)
if (fixture.stages[contract.targetStageOrdinal]?.name !== contract.targetObject || fixture.stages[contract.targetStageOrdinal]?.typeId !== contract.targetTypeId || fixture.stages[contract.finalStageOrdinal]?.name !== fixture.finalObject) fail(`${fixture.id} mutation targets are not bound to captured stages.`)
if (same(mutation.values?.before, mutation.values?.edited) || !same(mutation.values?.before, mutation.values?.restored)) fail(`${fixture.id} property edit or restoration readback is invalid.`)
if (!['before', 'edited', 'restored'].every((phase) => Array.isArray(phases[phase]) && phases[phase].length === fixture.stages.length)) fail(`${fixture.id} lacks a full three-phase stage matrix.`)
for (let ordinal = 0; ordinal < fixture.stages.length; ordinal += 1) {
const nominal = fixture.stages[ordinal]
for (const phase of ['before', 'edited', 'restored']) validatePhaseRecord(fixture, nominal, ordinal, phase, phases[phase][ordinal])
if (!same(stageFingerprint(phases.restored[ordinal]), stageFingerprint(nominal))) fail(`${fixture.id}/${nominal.name} recovered state differs from the saved nominal stage.`)
}
const changedStageOrdinals = fixture.stages.flatMap((_stage, ordinal) => geometryFingerprint(phases.before[ordinal]) === geometryFingerprint(phases.edited[ordinal]) ? [] : [ordinal])
const restorationDriftOrdinals = fixture.stages.flatMap((_stage, ordinal) => geometryFingerprint(phases.before[ordinal]) === geometryFingerprint(phases.restored[ordinal]) ? [] : [ordinal])
const namingChangedStageOrdinals = fixture.stages.flatMap((_stage, ordinal) => same(namingFingerprint(phases.before[ordinal]), namingFingerprint(phases.edited[ordinal])) ? [] : [ordinal])
const namingRestorationDriftOrdinals = fixture.stages.flatMap((_stage, ordinal) => same(namingFingerprint(phases.before[ordinal]), namingFingerprint(phases.restored[ordinal])) ? [] : [ordinal])
if (!same(metrics.changedStageOrdinals, changedStageOrdinals) || metrics.changedStages !== changedStageOrdinals.length || !changedStageOrdinals.includes(contract.finalStageOrdinal)) fail(`${fixture.id} did not propagate its native edit to the final object.`)
if (!same(metrics.restorationDriftOrdinals, restorationDriftOrdinals) || metrics.restorationDriftStages !== restorationDriftOrdinals.length || restorationDriftOrdinals.length !== 0) fail(`${fixture.id} did not restore every stage exactly.`)
if (!same(metrics.namingChangedStageOrdinals, namingChangedStageOrdinals) || metrics.namingChangedStages !== namingChangedStageOrdinals.length || !same(metrics.namingRestorationDriftOrdinals, namingRestorationDriftOrdinals) || metrics.namingRestorationDriftStages !== namingRestorationDriftOrdinals.length || metrics.allStageNamingRestored !== (namingRestorationDriftOrdinals.length === 0)) fail(`${fixture.id} naming edit/restoration metrics do not match its native phase evidence.`)
if (metrics.propertyChanged !== true || metrics.finalShapeChanged !== true || metrics.finalBrepChanged !== true || metrics.propertyRestored !== true || metrics.allStagesRestored !== true || metrics.allStageGeometryRestored !== true || metrics.editedShapesValid !== true) fail(`${fixture.id} mutation acceptance metrics are incomplete.`)
if (metrics.stageRecords !== fixture.stages.length || metrics.phaseStageRecords !== fixture.stages.length * 3) fail(`${fixture.id} mutation stage record counts are inconsistent.`)
mutationCases += 1
mutationPassed += 1
mutationStageRecords += metrics.stageRecords
mutationPhaseStageRecords += metrics.phaseStageRecords
mutationFinalPropagationFailures += metrics.finalShapeChanged === true ? 0 : 1
mutationStageRestoreFailures += metrics.restorationDriftStages
mutationNamingRestoreDriftCases += metrics.namingRestorationDriftStages > 0 ? 1 : 0
mutationNamingRestoreDriftStages += metrics.namingRestorationDriftStages
cases.push({ id: fixture.id, category: fixture.category, stages: fixture.stages.length, changedStages: metrics.changedStages, restoredStages: metrics.stageRecords, namingRestoreDriftStages: metrics.namingRestorationDriftStages })
}
const summary = { mutationCases, mutationPassed, mutationStageRecords, mutationPhaseStageRecords, mutationFinalPropagationFailures, mutationStageRestoreFailures, mutationNamingRestoreDriftCases, mutationNamingRestoreDriftStages, categoryCases }
for (const [key, value] of Object.entries(summary)) {
if (!same(report.summary?.[key], value)) fail(`summary ${key} differs from per-case evidence.`)
}
if (!same(categoryCases, expectedCompositeMutationCategoryCases) || mutationCases !== 30 || mutationPassed !== 30 || mutationStageRecords !== 219 || mutationPhaseStageRecords !== 657 || mutationFinalPropagationFailures !== 0 || mutationStageRestoreFailures !== 0) fail(`locked totals are incomplete: ${JSON.stringify(summary)}`)
return { ...summary, cases }
}

View File

@@ -0,0 +1,300 @@
import json
import os
import sys
import FreeCAD as App
import FreeCADGui as Gui
import Part
import Sketcher
from PySide import QtWidgets
MARKER = "FREECAD_GUI_WORKFLOW_RESULT="
def progress(phase):
print("FREECAD_GUI_WORKFLOW_PROGRESS=" + phase, file=sys.stderr, flush=True)
def emit(value, status=0):
result_file = os.environ.get("FREECAD_GUI_WORKFLOW_RESULT_FILE", "")
if result_file:
parent = os.path.dirname(result_file)
if parent:
os.makedirs(parent, exist_ok=True)
with open(result_file, "w", encoding="utf-8") as stream:
json.dump(value, stream, sort_keys=True, separators=(",", ":"))
stream.write("\n")
os._exit(status)
payload = (MARKER + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
offset = 0
while offset < len(payload):
offset += os.write(sys.stdout.fileno(), payload[offset:])
os._exit(status)
def flush_gui():
Gui.updateGui()
QtWidgets.QApplication.processEvents()
def active_edit_name():
gui_document = Gui.activeDocument()
in_edit = gui_document.getInEdit() if gui_document else None
obj = getattr(in_edit, "Object", None) if in_edit else None
return str(getattr(obj, "Name", ""))
def task_panel_state(phase):
main_window = Gui.getMainWindow()
focus = QtWidgets.QApplication.focusWidget()
buttons = []
for box in main_window.findChildren(QtWidgets.QDialogButtonBox):
if not box.isVisible():
continue
for button in box.buttons():
buttons.append({
"text": str(button.text()).replace("&", ""),
"objectName": str(button.objectName()),
"enabled": bool(button.isEnabled()),
"default": bool(button.isDefault()),
"role": str(box.buttonRole(button)),
})
known_button_ids = {id(button) for box in main_window.findChildren(QtWidgets.QDialogButtonBox) for button in box.buttons()}
for button in main_window.findChildren(QtWidgets.QPushButton):
if id(button) in known_button_ids or not button.isVisible():
continue
buttons.append({
"text": str(button.text()).replace("&", ""),
"objectName": str(button.objectName()),
"enabled": bool(button.isEnabled()),
"default": bool(button.isDefault()),
"role": "direct-push-button",
})
fields = []
field_types = (
QtWidgets.QLineEdit,
QtWidgets.QComboBox,
QtWidgets.QSpinBox,
QtWidgets.QDoubleSpinBox,
QtWidgets.QCheckBox,
)
seen = set()
for field_type in field_types:
for widget in main_window.findChildren(field_type):
identity = id(widget)
if identity in seen or not widget.isVisible():
continue
seen.add(identity)
fields.append({
"className": str(widget.metaObject().className()),
"objectName": str(widget.objectName()),
"enabled": bool(widget.isEnabled()),
"focus": widget is focus,
})
return {
"phase": phase,
"activeDialog": bool(Gui.Control.activeDialog()),
"inEdit": active_edit_name(),
"focus": None if focus is None else {
"className": str(focus.metaObject().className()),
"objectName": str(focus.objectName()),
},
"buttons": buttons,
"fieldCount": len(fields),
"fieldClasses": sorted({field["className"] for field in fields}),
}
def click_accept():
main_window = Gui.getMainWindow()
def finish_click(button):
button.click()
flush_gui()
if Gui.Control.activeDialog() or active_edit_name():
Gui.activeDocument().resetEdit()
flush_gui()
return True
candidates = []
for box in main_window.findChildren(QtWidgets.QDialogButtonBox):
if not box.isVisible():
continue
for button in box.buttons():
if box.buttonRole(button) == QtWidgets.QDialogButtonBox.AcceptRole and button.isEnabled():
candidates.append(button)
if candidates:
return finish_click(candidates[0])
for button in main_window.findChildren(QtWidgets.QPushButton):
text = str(button.text()).replace("&", "").strip().lower()
if button.isVisible() and button.isEnabled() and text in ("ok", "accept", "done"):
return finish_click(button)
dialog = Gui.Control.activeDialog()
if dialog and hasattr(dialog, "accept"):
dialog.accept()
flush_gui()
return True
return False
def selection_names():
return [str(item.ObjectName) for item in Gui.Selection.getSelectionEx()]
def available_transactions(document, method_name):
method = getattr(document, method_name, None)
if not callable(method):
return []
try:
return [str(value) for value in method()]
except Exception:
return []
def profile_name(feature):
value = getattr(feature, "Profile", None)
if isinstance(value, tuple) and value:
return str(getattr(value[0], "Name", ""))
return str(getattr(value, "Name", ""))
def counter(document, name):
try:
return int(getattr(document, name))
except Exception:
return None
try:
progress("activate-workbench:start")
Gui.activateWorkbench("PartDesignWorkbench")
flush_gui()
progress("activate-workbench:done")
document = App.newDocument("PadGuiSuccess")
document.UndoMode = 1
body = document.addObject("PartDesign::Body", "Body")
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
points = [
App.Vector(0, 0, 0),
App.Vector(4, 0, 0),
App.Vector(4, 3, 0),
App.Vector(0, 3, 0),
]
for index in range(4):
sketch.addGeometry(Part.LineSegment(points[index], points[(index + 1) % 4]), False)
document.recompute()
Gui.activeView().setActiveObject("pdbody", body)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(document.Name, sketch.Name)
flush_gui()
progress("fixture:done")
command = Gui.Command.get("PartDesign_Pad")
actions = list(command.getAction()) if command else []
before = {
"activeWorkbench": str(Gui.activeWorkbench().name()),
"commandRegistered": command is not None,
"commandActive": bool(command.isActive()) if command else False,
"actionCount": len(actions),
"actionEnabled": bool(actions[0].isEnabled()) if actions else None,
"selection": selection_names(),
"objectNames": [str(obj.Name) for obj in document.Objects],
"bodyTip": str(getattr(body.Tip, "Name", "")) if body.Tip else "",
"undoNames": available_transactions(document, "getAvailableUndoNames"),
}
progress("run-command:start")
Gui.runCommand("PartDesign_Pad", 0)
flush_gui()
progress("run-command:done")
pad = document.getObject("Pad")
opened = task_panel_state("opened")
if pad is None:
raise RuntimeError("PartDesign_Pad did not create a Pad preview object")
pad.Length = 10.0
document.recompute()
flush_gui()
progress("preview:done")
preview = {
"name": str(pad.Name),
"typeId": str(pad.TypeId),
"length": round(float(pad.Length.Value), 9),
"shapeValid": bool(pad.Shape.isValid()),
"solidCount": len(pad.Shape.Solids),
"volume": round(float(pad.Shape.Volume), 9),
"bodyTip": str(getattr(body.Tip, "Name", "")) if body.Tip else "",
"profile": profile_name(pad),
}
progress("accept:start")
acceptClicked = click_accept()
progress("accept:done")
document.recompute()
flush_gui()
closed = task_panel_state("closed")
pad = document.getObject("Pad")
after = {
"acceptClicked": acceptClicked,
"selection": selection_names(),
"objectNames": [str(obj.Name) for obj in document.Objects],
"bodyGroup": [str(obj.Name) for obj in body.Group],
"bodyTip": str(getattr(body.Tip, "Name", "")) if body.Tip else "",
"pad": {
"name": str(pad.Name),
"typeId": str(pad.TypeId),
"length": round(float(pad.Length.Value), 9),
"shapeValid": bool(pad.Shape.isValid()),
"solidCount": len(pad.Shape.Solids),
"faceCount": len(pad.Shape.Faces),
"edgeCount": len(pad.Shape.Edges),
"vertexCount": len(pad.Shape.Vertexes),
"volume": round(float(pad.Shape.Volume), 9),
"profile": profile_name(pad),
"state": [str(value) for value in pad.State],
},
"undoCount": counter(document, "UndoCount"),
"redoCount": counter(document, "RedoCount"),
"undoNames": available_transactions(document, "getAvailableUndoNames"),
"redoNames": available_transactions(document, "getAvailableRedoNames"),
}
version = App.Version()
result = {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1",
"freecadVersion": ".".join(str(value) for value in version[:3]),
"gitCommit": str(version[7]) if len(version) > 7 else "",
"workflowId": "partdesign-pad-task",
"commandId": "PartDesign_Pad",
"state": "success",
"before": before,
"taskPanel": {
"opened": opened,
"preview": preview,
"closed": closed,
},
"after": after,
"success": bool(
opened["activeDialog"]
and opened["inEdit"] == "Pad"
and acceptClicked
and not closed["activeDialog"]
and not closed["inEdit"]
and after["bodyTip"] == "Pad"
and after["pad"]["shapeValid"]
and after["pad"]["solidCount"] == 1
and abs(after["pad"]["volume"] - 120.0) < 1e-7
),
}
progress("result:done")
App.closeDocument(document.Name)
emit(result, 0 if result["success"] else 2)
except Exception as error:
progress("error:" + type(error).__name__)
emit({
"schemaVersion": 1,
"baselineId": "freecad-1.1.1",
"workflowId": "partdesign-pad-task",
"commandId": "PartDesign_Pad",
"state": "success",
"success": False,
"error": {"type": type(error).__name__, "message": str(error)},
}, 2)

View File

@@ -2,6 +2,7 @@ import json
import os
import tempfile
import zipfile
import xml.etree.ElementTree as ET
import FreeCAD as App
@@ -9,6 +10,43 @@ import FreeCAD as App
FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
class PropertyObserver:
def __init__(self, document_name):
self.document_name = document_name
self.events = []
def clear(self):
self.events = []
def slotOpenTransaction(self, document, name):
if document.Name == self.document_name:
self.events.append({"type": "transaction.opened", "name": str(name)})
def slotCommitTransaction(self, document):
if document.Name == self.document_name:
self.events.append({"type": "transaction.committed"})
def slotAbortTransaction(self, document):
if document.Name == self.document_name:
self.events.append({"type": "transaction.aborted"})
def slotBeforeChangeObject(self, obj, prop):
if obj.Document and obj.Document.Name == self.document_name:
self.events.append({"type": "property.before-change", "object": obj.Name, "property": str(prop)})
def slotChangedObject(self, obj, prop):
if obj.Document and obj.Document.Name == self.document_name:
self.events.append({"type": "property.changed", "object": obj.Name, "property": str(prop)})
def slotAppendDynamicProperty(self, obj, prop):
if getattr(obj, "Document", None) and obj.Document.Name == self.document_name:
self.events.append({"type": "property.added", "object": obj.Name, "property": str(prop)})
def slotRemoveDynamicProperty(self, obj, prop):
if getattr(obj, "Document", None) and obj.Document.Name == self.document_name:
self.events.append({"type": "property.removed", "object": obj.Name, "property": str(prop)})
def object_state(obj):
return {
"state": [str(value) for value in obj.State],
@@ -48,10 +86,17 @@ def change_case(case_id, attr=0, runtime_status=None):
def locked_dynamic_case():
document = App.newDocument("PropertyStatus_LockDynamic")
observer = None
try:
document.UndoMode = 1
obj = document.addObject("App::FeaturePython", "Feature")
obj.addProperty("App::PropertyInteger", "LockedValue", "Oracle")
obj.setPropertyStatus("LockedValue", "LockDynamic")
observer = PropertyObserver(document.Name)
App.addDocumentObserver(observer)
document.openTransaction("dynamic-property-mutation")
obj.addProperty("App::PropertyInteger", "MutableValue", "Oracle")
obj.MutableValue = 3
failures = {}
for operation, callback in {
"remove": lambda: obj.removeProperty("LockedValue"),
@@ -62,13 +107,48 @@ def locked_dynamic_case():
failures[operation] = None
except Exception as error:
failures[operation] = {"type": type(error).__name__, "message": str(error)}
obj.renameProperty("MutableValue", "RenamedValue")
removed_mutable = bool(obj.removeProperty("RenamedValue"))
document.commitTransaction()
return {
"reportedStatus": [str(value) for value in obj.getPropertyStatus("LockedValue")],
"propertyStillPresent": "LockedValue" in obj.PropertiesList,
"renamedPropertyPresent": "RenamedValue" in obj.PropertiesList,
"failures": failures,
"mutableRenameAndRemove": removed_mutable and "MutableValue" not in obj.PropertiesList and "RenamedValue" not in obj.PropertiesList,
"observerEvents": observer.events,
"undoAvailable": document.UndoCount == 1,
}
finally:
if observer:
App.removeDocumentObserver(observer)
App.closeDocument(document.Name)
def partial_trigger_case():
document = App.newDocument("PropertyStatus_PartialTrigger")
observer = PropertyObserver(document.Name)
App.addDocumentObserver(observer)
try:
document.UndoMode = 1
obj = document.addObject("PartDesign::SubShapeBinder", "Binder")
document.recompute()
document.purgeTouched()
observer.clear()
document.openTransaction("partial-trigger-mutation")
obj.PartialLoad = True
recomputed = int(document.recompute())
document.commitTransaction()
return {
"reportedStatus": [str(value) for value in obj.getPropertyStatus("PartialLoad")],
"value": bool(obj.PartialLoad),
"recomputeCount": recomputed,
"state": object_state(obj),
"observerEvents": observer.events,
"undoAvailable": document.UndoCount == 1,
}
finally:
App.removeDocumentObserver(observer)
App.closeDocument(document.Name)
@@ -78,6 +158,7 @@ def archive_case():
document = App.newDocument("PropertyStatusArchive")
try:
obj = document.addObject("App::FeaturePython", "Feature")
target = document.addObject("App::FeaturePython", "Target")
obj.addProperty("App::PropertyInteger", "Persisted", "Oracle")
obj.Persisted = 11
obj.setPropertyStatus("Persisted", "Output")
@@ -88,6 +169,14 @@ def archive_case():
obj.TypeTransient = 13
obj.addProperty("App::PropertyInteger", "NoPersist", "Oracle", "", int(App.PropertyType.Prop_NoPersist))
obj.NoPersist = 14
obj.addProperty("App::PropertyFloatConstraint", "FloatConstraint", "Oracle")
obj.FloatConstraint = 0.25
obj.addProperty("App::PropertyQuantityConstraint", "QuantityConstraint", "Oracle")
obj.QuantityConstraint = 12.5
obj.addProperty("App::PropertyPrecision", "Precision", "Oracle")
obj.Precision = 0.001
obj.addProperty("App::PropertyLinkHidden", "HiddenLink", "Oracle")
obj.HiddenLink = target
document.recompute()
document.saveAs(path)
finally:
@@ -95,6 +184,16 @@ def archive_case():
with zipfile.ZipFile(path, "r") as archive:
document_xml = archive.read("Document.xml").decode("utf-8")
xml_root = ET.fromstring(document_xml)
xml_properties = {
element.attrib.get("name"): element
for element in xml_root.findall("./ObjectData/Object/Properties/Property")
}
def native_float_property(name, expected):
element = xml_properties.get(name)
child = element.find("Float") if element is not None else None
return bool(element is not None and element.attrib.get("type") == "App::Property" + name and child is not None and abs(float(child.attrib["value"]) - expected) < 1e-12)
reopened = App.openDocument(path)
try:
@@ -107,6 +206,10 @@ def archive_case():
"noPersistProperty": '<Property name="NoPersist"' in document_xml,
"runtimeTransientHasValue": '<Integer value="12"' in document_xml,
"typeTransientHasValue": '<Integer value="13"' in document_xml,
"floatConstraintNative": native_float_property("FloatConstraint", 0.25),
"quantityConstraintNative": native_float_property("QuantityConstraint", 12.5),
"precisionNative": native_float_property("Precision", 0.001),
"hiddenLinkNative": xml_properties.get("HiddenLink") is not None and xml_properties["HiddenLink"].attrib.get("type") == "App::PropertyLinkHidden" and xml_properties["HiddenLink"].find("Link") is not None and xml_properties["HiddenLink"].find("Link").attrib.get("value") == "Target",
},
"reopened": {
"properties": sorted(str(value) for value in obj.PropertiesList if value in {"Persisted", "RuntimeTransient", "TypeTransient", "NoPersist"}),
@@ -119,6 +222,12 @@ def archive_case():
name: [str(value) for value in obj.getPropertyStatus(name)]
for name in ["Persisted", "RuntimeTransient", "TypeTransient"]
},
"codecValues": {
"FloatConstraint": float(obj.FloatConstraint),
"QuantityConstraint": float(obj.QuantityConstraint.Value),
"Precision": float(obj.Precision),
"HiddenLink": obj.HiddenLink.Name,
},
},
}
finally:
@@ -133,13 +242,14 @@ cases = [
change_case("type-no-recompute", attr=int(App.PropertyType.Prop_NoRecompute)),
]
report = {
"schemaVersion": 1,
"schemaVersion": 2,
"baselineId": "freecad-1.1.1-property-status-oracle",
"freecadVersion": ".".join(str(value) for value in App.Version()[:3]),
"gitCommit": FREECAD_COMMIT,
"status": "pass",
"cases": cases,
"lockDynamic": locked_dynamic_case(),
"partialTrigger": partial_trigger_case(),
"fcstd": archive_case(),
}
print("FREECAD_PROPERTY_STATUS_ORACLE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))

View File

@@ -1,16 +1,41 @@
import importlib
import importlib.util
import faulthandler
import hashlib
import json
import os
import re
import sys
import FreeCAD as App
PROBE_SCOPE = os.environ.get("FREECAD_REFERENCE_SCOPE", "full")
if PROBE_SCOPE not in {"full", "gui-commands"}:
raise RuntimeError("Unknown FreeCAD reference probe scope: " + PROBE_SCOPE)
TRACEBACK_SECONDS = int(os.environ.get("FREECAD_REFERENCE_TRACEBACK_SECONDS", "0"))
if TRACEBACK_SECONDS > 0:
faulthandler.dump_traceback_later(TRACEBACK_SECONDS, repeat=True, file=2)
ISOLATED_GUI_CONFIG = os.environ.get("FREECAD_ORACLE_ISOLATED_CONFIG") == "1"
if PROBE_SCOPE == "gui-commands" and ISOLATED_GUI_CONFIG:
App.ParamGet("User parameter:BaseApp/Preferences/Mod/BIM").SetBool("FirstTime", False)
def probe_progress(message):
print("FREECAD_REFERENCE_PROGRESS=" + message, file=sys.stderr, flush=True)
def property_metadata(obj):
def stable_text(value):
text = re.sub(r"0x[0-9a-fA-F]+", "<address>", str(value))
text = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "<uuid>", text)
return re.sub(r"(FreeCAD_Doc_<uuid>_[^/_]+_)\d+", r"\1<run>", text)
def json_value(value):
if value is None or isinstance(value, (bool, int, float, str)):
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return stable_text(value)
if isinstance(value, (list, tuple)):
return [json_value(item) for item in value]
if isinstance(value, dict):
@@ -18,7 +43,7 @@ def property_metadata(obj):
# FreeCAD quantities and placements keep a stable textual form while
# the report remains valid JSON for every registered property type.
try:
return str(value)
return stable_text(value)
except Exception:
return "<unserializable>"
@@ -168,7 +193,7 @@ def probe_module(name):
return result
def probe_gui_commands(document, selected_object):
def probe_gui_commands():
if not bool(getattr(App, "GuiUp", False)):
return {"available": False, "workbenches": [], "commands": []}
try:
@@ -180,61 +205,147 @@ def probe_gui_commands(document, selected_object):
workbench_names = sorted(Gui.listWorkbenches().keys())
except Exception:
workbench_names = []
command_observations = {}
workbench_filter = os.environ.get("FREECAD_GUI_WORKBENCH_FILTER")
if workbench_filter:
if workbench_filter not in workbench_names:
raise RuntimeError("Unknown GUI workbench filter: " + workbench_filter)
workbench_names = [workbench_filter]
command_registrations = {}
def action_enabled(command_id):
def command_active(command_id):
try:
command = Gui.Command.get(command_id)
if command is None:
return None
actions = command.getAction()
if not actions:
return None
return bool(actions[0].isEnabled())
# StdCmdExpression dereferences its private QAction in isActive().
# Other commands must use their native predicate; QAction enabled
# state is only a cached presentation value and misses context changes.
if command_id == "Std_Expressions":
actions = command.getAction()
return bool(actions[0].isEnabled()) if actions else None
return bool(command.isActive())
except Exception as error:
return {"error": str(error)}
workbenches = []
for workbench_name in workbench_names:
probe_progress("workbench:start:" + workbench_name)
try:
Gui.activateWorkbench(workbench_name)
Gui.updateGui()
command_ids = sorted(set(Gui.listCommands()))
Gui.Selection.clearSelection()
empty_selection = {command_id: action_enabled(command_id) for command_id in command_ids}
Gui.Selection.addSelection(document.Name, selected_object.Name)
selected_selection = {command_id: action_enabled(command_id) for command_id in command_ids}
Gui.Selection.clearSelection()
observations = {
command_id: {
"emptySelection": empty_selection.get(command_id),
"selectedPartBox": selected_selection.get(command_id),
}
for command_id in command_ids
}
for command_id, observation in observations.items():
command_observations.setdefault(command_id, []).append({"workbench": workbench_name, **observation})
for command_id in command_ids:
command_registrations.setdefault(command_id, []).append(workbench_name)
workbenches.append({"name": workbench_name, "commandCount": len(command_ids), "status": "probed"})
probe_progress("workbench:done:" + workbench_name)
except Exception as error:
try:
Gui.Selection.clearSelection()
except Exception:
pass
workbenches.append({"name": workbench_name, "commandCount": 0, "status": "probe-failure", "error": str(error)})
probe_progress("workbench:failed:" + workbench_name)
# Command registration is workbench-scoped, but document/selection state is
# intrinsic to each command. Probe the state matrix once after all providers
# are loaded instead of repeating the cumulative command set per workbench.
for document_name in list(App.listDocuments().keys()):
App.closeDocument(document_name)
command_ids = sorted(command_registrations.keys())
command_universe_sha256 = hashlib.sha256("\n".join(command_ids).encode("utf-8")).hexdigest()
if os.environ.get("FREECAD_GUI_REGISTRATION_ONLY") == "1":
return {
"available": True,
"probeMode": "registration-only",
"stateProbe": "not-probed",
"stateCases": [],
"directIsActiveBoundary": "not-applicable",
"commandUniverseCount": len(command_ids),
"commandUniverseSha256": command_universe_sha256,
"stateCommandCount": 0,
"shard": None,
"workbenches": workbenches,
"commands": [
{
"id": command_id,
"registeredWorkbenches": command_registrations[command_id],
}
for command_id in command_ids
],
}
shard = None
state_command_ids = command_ids
shard_spec = os.environ.get("FREECAD_GUI_COMMAND_SHARD")
if shard_spec:
try:
shard_index, shard_count = [int(value) for value in shard_spec.split("/", 1)]
except Exception:
raise RuntimeError("FREECAD_GUI_COMMAND_SHARD must use zero-based INDEX/COUNT syntax")
if shard_count < 1 or shard_index < 0 or shard_index >= shard_count:
raise RuntimeError("FREECAD_GUI_COMMAND_SHARD is outside its declared range")
shard_start = len(command_ids) * shard_index // shard_count
shard_end = len(command_ids) * (shard_index + 1) // shard_count
state_command_ids = command_ids[shard_start:shard_end]
shard = {
"index": shard_index,
"count": shard_count,
"start": shard_start,
"end": shard_end,
}
Gui.Selection.clearSelection()
Gui.updateGui()
probe_progress("state:start:noDocument")
no_document = {command_id: command_active(command_id) for command_id in state_command_ids}
probe_progress("state:done:noDocument")
context_document = App.newDocument("CommandContext")
selected_object = context_document.addObject("Part::Box", "CommandProbeBox")
Gui.updateGui()
probe_progress("state:start:documentNoSelection")
document_no_selection = {command_id: command_active(command_id) for command_id in state_command_ids}
probe_progress("state:done:documentNoSelection")
Gui.Selection.addSelection(context_document.Name, selected_object.Name)
Gui.updateGui()
probe_progress("state:start:selectedPartBox")
selected_selection = {command_id: command_active(command_id) for command_id in state_command_ids}
probe_progress("state:done:selectedPartBox")
Gui.Selection.clearSelection()
App.closeDocument(context_document.Name)
return {
"available": True,
"probeMode": "state-matrix",
"stateProbe": "native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard",
"stateCases": ["noDocument", "documentNoSelection", "selectedPartBox"],
"directIsActiveBoundary": "Std_Expressions-only-qaction-guard-for-locked-1.1.1-null-qaction-crash",
"commandUniverseCount": len(command_ids),
"commandUniverseSha256": command_universe_sha256,
"stateCommandCount": len(state_command_ids),
"shard": shard,
"workbenches": workbenches,
"commands": [
{"id": command_id, "observations": observations}
for command_id, observations in sorted(command_observations.items())
{
"id": command_id,
"registeredWorkbenches": command_registrations[command_id],
"observations": [{
"workbench": "all-registered-runtime-context",
"noDocument": no_document.get(command_id),
"documentNoSelection": document_no_selection.get(command_id),
"selectedPartBox": selected_selection.get(command_id),
}],
}
for command_id in state_command_ids
],
}
document = App.newDocument("ReferenceProbe")
box_object = document.addObject("Part::Box", "CommandProbeBox")
modules = [probe_module(name) for name in ALL_REFERENCE_MODULES]
gui_commands = probe_gui_commands(document, box_object)
runtime_objects = probe_registered_objects(document)
modules = []
if PROBE_SCOPE == "full":
document = App.newDocument("ReferenceProbe")
# Creating one native Part object loads the same runtime type registry used
# by the locked desktop baseline before supportedTypes() is enumerated.
document.addObject("Part::Box", "TypeRegistryProbeBox")
modules = [probe_module(name) for name in ALL_REFERENCE_MODULES]
App.closeDocument(document.Name)
gui_commands = probe_gui_commands()
version = App.Version()
result = {
"schemaVersion": 1,
@@ -245,20 +356,34 @@ result = {
"gitCommit": str(version[7]) if len(version) > 7 else "",
"guiUp": bool(getattr(App, "GuiUp", False)),
"buildPurpose": "desktop-reference-oracle" if bool(getattr(App, "GuiUp", False)) else "headless-reference-oracle",
"moduleCount": len(ALL_REFERENCE_MODULES),
"probeScope": PROBE_SCOPE,
"oracleSetup": {
"isolatedConfig": ISOLATED_GUI_CONFIG,
"bimFirstTimeWelcome": "suppressed-before-workbench-activation" if ISOLATED_GUI_CONFIG else "native-user-config",
},
"determinism": {
"schemaVersion": 1,
"normalizedProcessFields": ["pointer-address", "uuid", "freecad-document-cache-run"],
},
"moduleCount": len(modules),
"modules": modules,
"objects": [probe_object(document, type_id) for type_id in [
"Part::Box", "Part::Cylinder", "Part::Sphere", "Part::Cone",
"Part::Feature", "PartDesign::Feature", "Sketcher::SketchObject",
]],
"runtimeObjects": runtime_objects,
"guiCommands": gui_commands,
}
if PROBE_SCOPE == "full":
# Workbench activation registers additional GUI-owned document types.
# Enumerate them only in the independent object/property scope.
document = App.newDocument("RuntimeObjectProbe")
document.addObject("Part::Box", "TypeRegistryProbeBox")
result["runtimeObjects"] = probe_registered_objects(document)
result["objects"] = [probe_object(document, type_id) for type_id in [
"Part::Box", "Part::Cylinder", "Part::Sphere", "Part::Cone",
"Part::Feature", "PartDesign::Feature", "Sketcher::SketchObject",
]]
App.closeDocument(document.Name)
result["moduleStatusSummary"] = {
status: sum(1 for module in result["modules"] if module["runtimeStatus"] == status)
for status in sorted({module["runtimeStatus"] for module in result["modules"]})
}
App.closeDocument(document.Name)
print("FREECAD_REFERENCE_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":")))
sys.stdout.flush()
sys.exit(0)

View File

@@ -0,0 +1,368 @@
import hashlib
import json
import os
import tempfile
import FreeCAD as App
import Part
import Sketcher
FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
OPERATIONS = ["cut", "rotate", "fillet", "mirrored", "linear-pattern"]
def version_text():
return ".".join(str(value) for value in App.Version()[:3])
def add_rectangle(sketch, x_min, y_min, x_max, y_max):
points = [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)]
for index, start in enumerate(points):
end = points[(index + 1) % len(points)]
sketch.addGeometry(Part.LineSegment(App.Vector(start[0], start[1], 0), App.Vector(end[0], end[1], 0)), False)
def shape_snapshot(feature):
shape = feature.Shape
if shape.isNull():
raise RuntimeError("{} produced a null Shape".format(feature.Name))
brep = shape.exportBrepToString().encode("utf-8")
bounds = shape.BoundBox
return {
"valid": bool(shape.isValid()),
"solids": len(shape.Solids),
"faces": len(shape.Faces),
"edges": len(shape.Edges),
"vertices": len(shape.Vertexes),
"volume": float(shape.Volume),
"area": float(shape.Area),
"brepSha256": hashlib.sha256(brep).hexdigest(),
"bounds": [float(bounds.XMin), float(bounds.YMin), float(bounds.ZMin), float(bounds.XMax), float(bounds.YMax), float(bounds.ZMax)],
}
def mapped_names(feature):
shape = feature.Shape
result = {}
for kind, count in (("Face", len(shape.Faces)), ("Edge", len(shape.Edges)), ("Vertex", len(shape.Vertexes))):
for index in range(1, count + 1):
name = "{}{}".format(kind, index)
try:
mapped, _ = shape.getElementMappedName(name, True)
except Exception:
mapped = ""
try:
indexed, _ = shape.getElementIndexedName(name, True)
except Exception:
indexed = ""
result[name] = mapped or indexed or name
return result
def mapped_name_digest(feature):
payload = json.dumps(mapped_names(feature), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def relation_kind(mapped_name):
if ":G" in mapped_name:
return "generated"
if ":M" in mapped_name:
return "modified"
return "preserved"
def semantic_topology_digest(feature):
relations = {}
for name in mapped_names(feature):
try:
history = feature.getElementHistory(name)
except Exception:
history = []
relations[name] = sorted({
"{}:{}:{}".format(getattr(source, "Name", ""), getattr(source, "TypeId", ""), relation_kind(mapped or ""))
for source, mapped, _children in history
if getattr(source, "Name", "")
})
payload = json.dumps(relations, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def history_sources(feature):
sources = set()
relations = 0
for name in mapped_names(feature):
try:
history = feature.getElementHistory(name)
except Exception:
history = []
relations += len(history)
for source, _mapped, _children in history:
if getattr(source, "Name", ""):
sources.add(source.Name)
return sorted(sources), relations
def create_chain(document):
body = document.addObject("PartDesign::Body", "Body")
base = body.newObject("PartDesign::AdditiveBox", "Base")
base.Length, base.Width, base.Height = 20.0, 10.0, 4.0
document.recompute()
pocket_sketch = body.newObject("Sketcher::SketchObject", "PocketSketch")
add_rectangle(pocket_sketch, 7.0, 3.0, 11.0, 7.0)
pocket_sketch.Placement.Base.z = 4.0
pocket = body.newObject("PartDesign::Pocket", "CutStage")
pocket.Profile = pocket_sketch
pocket.Length = 2.0
document.recompute()
rotated = body.newObject("PartDesign::Feature", "RotateStage")
rotated.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
rotated.addProperty("App::PropertyAngle", "Angle", "Correlation")
rotated.SourceFeature = pocket
rotated.Angle = 15.0
rotated_shape = pocket.Shape.copy()
transformed = rotated_shape.rotate(App.Vector(10, 5, 0), App.Vector(0, 0, 1), float(rotated.Angle))
if transformed is not None:
rotated_shape = transformed
rotated.Shape = rotated_shape
document.recompute()
fillet = body.newObject("PartDesign::Fillet", "FilletStage")
fillet.Base = (rotated, ["Edge1"])
fillet.Radius = 0.2
document.recompute()
mirrored = body.newObject("PartDesign::Feature", "MirroredStage")
mirrored.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
mirrored.addProperty("App::PropertyVector", "PlaneNormal", "Correlation")
mirrored.SourceFeature = fillet
mirrored.PlaneNormal = App.Vector(1, 0, 0)
mirrored_shape = fillet.Shape.copy()
transformed = mirrored_shape.mirror(App.Vector(0, 0, 0), mirrored.PlaneNormal)
if transformed is not None:
mirrored_shape = transformed
mirrored.Shape = mirrored_shape
document.recompute()
pattern = body.newObject("PartDesign::Feature", "LinearPatternStage")
pattern.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
pattern.addProperty("App::PropertyInteger", "Occurrences", "Correlation")
pattern.addProperty("App::PropertyLength", "Length", "Correlation")
pattern.SourceFeature = mirrored
pattern.Occurrences = 2
pattern.Length = 24.0
copies = []
for index in range(pattern.Occurrences):
copy = mirrored.Shape.copy()
transformed = copy.translate(App.Vector(float(pattern.Length) * index, 0, 0))
if transformed is not None:
copy = transformed
copies.append(copy)
pattern.Shape = Part.makeCompound(copies)
document.recompute()
return [pocket, rotated, fillet, mirrored, pattern]
def rebuild_derived(document, features):
pocket, rotated, fillet, mirrored, pattern = features
document.recompute()
rotated_shape = pocket.Shape.copy()
transformed = rotated_shape.rotate(App.Vector(10, 5, 0), App.Vector(0, 0, 1), float(rotated.Angle))
if transformed is not None:
rotated_shape = transformed
rotated.Shape = rotated_shape
document.recompute()
mirrored_shape = fillet.Shape.copy()
transformed = mirrored_shape.mirror(App.Vector(0, 0, 0), mirrored.PlaneNormal)
if transformed is not None:
mirrored_shape = transformed
mirrored.Shape = mirrored_shape
copies = []
for index in range(pattern.Occurrences):
copy = mirrored.Shape.copy()
transformed = copy.translate(App.Vector(float(pattern.Length) * index, 0, 0))
if transformed is not None:
copy = transformed
copies.append(copy)
pattern.Shape = Part.makeCompound(copies)
document.recompute()
def topology_snapshot(features):
return [{
"operation": operation,
"name": feature.Name,
"shape": shape_snapshot(feature),
"mappedNameCount": len(mapped_names(feature)),
"mappedNameDigest": mapped_name_digest(feature),
"semanticTopologyDigest": semantic_topology_digest(feature),
} for operation, feature in zip(OPERATIONS, features)]
def parameter_value(value):
if hasattr(value, "Value"):
return float(value.Value)
if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
return [float(value.x), float(value.y), float(value.z)]
return value
def geometry_signature(snapshot):
shape = snapshot["shape"]
return [shape["valid"], shape["solids"], shape["faces"], shape["edges"], shape["vertices"], round(shape["volume"], 7), round(shape["area"], 7)]
def capture_mutation(document, features, operation, feature, property_name, edited_value):
original_value = getattr(feature, property_name)
before_parameter = parameter_value(original_value)
before = topology_snapshot(features)
try:
setattr(feature, property_name, edited_value)
rebuild_derived(document, features)
edited_parameter = parameter_value(getattr(feature, property_name))
edited = topology_snapshot(features)
finally:
setattr(feature, property_name, original_value)
rebuild_derived(document, features)
restored_parameter = parameter_value(getattr(feature, property_name))
restored = topology_snapshot(features)
before_digests = [stage["semanticTopologyDigest"] for stage in before]
restored_digests = [stage["semanticTopologyDigest"] for stage in restored]
before_final = before[-1]["shape"]["brepSha256"]
edited_final = edited[-1]["shape"]["brepSha256"]
restored_geometrically = [geometry_signature(stage) for stage in before] == [geometry_signature(stage) for stage in restored]
restored_topology_exactly = before_digests == restored_digests
passed = before_parameter != edited_parameter and before_final != edited_final and before_parameter == restored_parameter and restored_geometrically
return {
"operation": operation,
"featureName": feature.Name,
"typeId": feature.TypeId,
"propertyPath": property_name,
"beforeParameter": before_parameter,
"editedParameter": edited_parameter,
"restoredParameter": restored_parameter,
"parameterChanged": before_parameter != edited_parameter,
"downstreamShapeChanged": before_final != edited_final,
"restoredExactly": before_parameter == restored_parameter and restored_geometrically,
"restoredGeometrically": restored_geometrically,
"restoredTopologyExactly": restored_topology_exactly,
"restoreTopologyDriftStages": sum(1 for before_digest, restored_digest in zip(before_digests, restored_digests) if before_digest != restored_digest),
"beforeTopologyDigests": before_digests,
"editedTopologyDigests": [stage["semanticTopologyDigest"] for stage in edited],
"restoredTopologyDigests": restored_digests,
"status": "pass" if passed else "failed",
}
def stage_report(operation, feature):
sources, relations = history_sources(feature)
names = mapped_names(feature)
return {
"operation": operation,
"name": feature.Name,
"typeId": feature.TypeId,
"nativeBuilder": feature.TypeId != "PartDesign::Feature",
"shape": shape_snapshot(feature),
"mappedNameCount": len(names),
"mappedNameDigest": hashlib.sha256(json.dumps(names, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
"historySources": sources,
"historyRelations": relations,
"status": str(feature.getStatusString()),
}
def collect():
document = App.newDocument("TsnStageCorrelationOracle")
try:
features = create_chain(document)
rebuild_derived(document, features)
pocket, rotated, fillet, mirrored, pattern = features
mutations = [
capture_mutation(document, features, "cut", pocket, "Length", 2.5),
capture_mutation(document, features, "rotate", rotated, "Angle", 22.5),
capture_mutation(document, features, "fillet", fillet, "Radius", 0.35),
capture_mutation(document, features, "mirrored", mirrored, "PlaneNormal", App.Vector(0, 0, 1)),
capture_mutation(document, features, "linear-pattern", pattern, "Occurrences", 3),
]
stages = [stage_report(operation, feature) for operation, feature in zip(OPERATIONS, features)]
with tempfile.TemporaryDirectory(prefix="freecad-tsn-stage-correlation-") as directory:
initial_path = os.path.join(directory, "tsn-stage-correlation.FCStd")
resaved_path = os.path.join(directory, "tsn-stage-correlation-resaved.FCStd")
document.saveAs(initial_path)
App.closeDocument(document.Name)
reopened = App.openDocument(initial_path)
reopened.recompute()
reopened_stages = [stage_report(operation, reopened.getObject(stage["name"])) for operation, stage in zip(OPERATIONS, stages)]
reopened.saveAs(resaved_path)
App.closeDocument(reopened.Name)
resaved = App.openDocument(resaved_path)
resaved.recompute()
resaved_stages = [stage_report(operation, resaved.getObject(stage["name"])) for operation, stage in zip(OPERATIONS, stages)]
App.closeDocument(resaved.Name)
roundtrip_name_drift = sum(1 for before, after in zip(stages, reopened_stages) if before["mappedNameDigest"] != after["mappedNameDigest"])
resave_name_drift = sum(1 for before, after in zip(stages, resaved_stages) if before["mappedNameDigest"] != after["mappedNameDigest"])
known_sources = {"Base", "PocketSketch"}
wrong_bindings = 0
unexplained_relations = 0
for stage in stages:
known_sources.add(stage["name"])
unexplained_relations += sum(1 for source in stage["historySources"] if source not in known_sources)
if stage["mappedNameCount"] <= 0:
wrong_bindings += 1
with open(__file__, "rb") as harness_file:
harness_content = harness_file.read()
return {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1-tsn-stage-correlation",
"freecadVersion": version_text(),
"gitCommit": FREECAD_COMMIT,
"operations": OPERATIONS,
"stages": stages,
"reopenedStages": reopened_stages,
"resavedStages": resaved_stages,
"mutations": mutations,
"summary": {
"stages": len(stages),
"nativeBuilderStages": sum(1 for stage in stages if stage["nativeBuilder"]),
"proxyStages": sum(1 for stage in stages if not stage["nativeBuilder"]),
"mutationCases": len(mutations),
"mutationPassed": sum(1 for mutation in mutations if mutation["status"] == "pass"),
"mutationRestoreTopologyDriftCases": sum(1 for mutation in mutations if mutation["restoredTopologyExactly"] is not True),
"mutationRestoreTopologyDriftStages": sum(mutation["restoreTopologyDriftStages"] for mutation in mutations),
"wrongBindings": wrong_bindings,
"unexplainedRelations": unexplained_relations,
"roundtripNameDrift": roundtrip_name_drift,
"resaveNameDrift": resave_name_drift,
},
"nativeDesktopResaveCovered": True,
"exactCorrelationReady": all(stage["nativeBuilder"] for stage in stages) and all(mutation["status"] == "pass" for mutation in mutations) and wrong_bindings == 0 and unexplained_relations == 0 and roundtrip_name_drift == 0 and resave_name_drift == 0,
"harness": {
"path": "scripts/freecad-tsn-stage-correlation-oracle.py",
"bytes": len(harness_content),
"sha256": hashlib.sha256(harness_content).hexdigest(),
},
"status": "pass",
}
finally:
for name in list(App.listDocuments().keys()):
App.closeDocument(name)
try:
report = collect()
except Exception as error:
report = {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1-tsn-stage-correlation",
"freecadVersion": version_text(),
"gitCommit": FREECAD_COMMIT,
"operations": OPERATIONS,
"status": "failed",
"errorType": type(error).__name__,
"error": str(error),
}
print("FREECAD_TSN_STAGE_CORRELATION_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))

View File

@@ -0,0 +1,120 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const outputPath = resolve(root, 'config/freecad-active-work-queue.json')
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
const [gui, workflowPlan, composite, correlation, production] = await Promise.all([
load('.cache/freecad/reference-desktop-gui-commands.json'),
load('config/freecad-gui-workflow-plan.json'),
load('config/freecad-composite-history-elementmap-oracle.json'),
load('config/freecad-tsn-stage-correlation-oracle.json'),
load('config/chrome-freecad-naming-production-verification.json'),
])
const guiShardTasks = gui.guiCommands.mergedShards.map((shard, index) => ({
id: `ORA-GUI-SHARD-${String(index).padStart(3, '0')}`,
title: `Probe GUI command indexes [${shard.start}, ${shard.end})`,
status: 'completed',
dependencies: index === 0 ? ['ORA-GUI-SETUP-002'] : [`ORA-GUI-SHARD-${String(index - 1).padStart(3, '0')}`],
evidence: [shard.path, shard.sha256],
exit: `${shard.end - shard.start} commands have three native state observations`,
}))
const workflowTitles = [
'Select one GUI command family from the sensitivity report',
'Capture the native success workflow',
'Capture the native disabled workflow',
'Capture the native failure workflow',
'Capture the native cancel workflow',
'Capture recovery after failure or cancel',
'Update the exact blocker and focused checks',
]
const workflowProgress = new Map(workflowPlan.progress.map((entry) => [entry.taskId, entry]))
const workflowTasks = workflowTitles.map((title, index) => ({
id: `ORA-GUI-WF-${String(index).padStart(3, '0')}`,
title,
status: workflowProgress.get(`ORA-GUI-WF-${String(index).padStart(3, '0')}`).status,
dependencies: [index === 0 ? 'ORA-GUI-CONTEXT-001' : `ORA-GUI-WF-${String(index - 1).padStart(3, '0')}`],
...(index === 0 ? { family: workflowPlan.family.id, primaryCommand: workflowPlan.family.primaryCommand, exactTask: workflowPlan.family.primaryExactTask } : {}),
...(workflowProgress.get(`ORA-GUI-WF-${String(index).padStart(3, '0')}`).evidence.length > 0 ? { evidence: workflowProgress.get(`ORA-GUI-WF-${String(index).padStart(3, '0')}`).evidence } : {}),
exit: index === 0 ? 'one command family and its exact task are recorded' : 'one native workflow fixture and its focused assertion pass',
}))
const driftCases = composite.cases.filter((fixture) => fixture.mutation.metrics.namingRestorationDriftStages > 0)
const driftTasks = driftCases.map((fixture, index) => ({
id: `TSN-DRIFT-${String(index).padStart(3, '0')}`,
title: `Classify recovered naming evolution for ${fixture.id}`,
status: 'pending',
dependencies: index === 0 ? ['ORA-GUI-WF-006'] : [`TSN-DRIFT-${String(index - 1).padStart(3, '0')}`],
caseId: fixture.id,
driftStages: fixture.mutation.metrics.namingRestorationDriftStages,
driftOrdinals: fixture.mutation.metrics.namingRestorationDriftOrdinals,
exit: 'the case is classified as stable semantics, allowed evolution, or an implementation defect',
}))
const productionDriftTasks = correlation.mutations
.filter((mutation) => mutation.restoreTopologyDriftStages > 0)
.map((mutation, index) => ({
id: `TSN-PROD-DRIFT-${String(index).padStart(3, '0')}`,
title: `Classify production restore topology drift for ${mutation.operation}`,
status: 'pending',
dependencies: [index === 0 ? driftTasks.at(-1).id : `TSN-PROD-DRIFT-${String(index - 1).padStart(3, '0')}`],
operation: mutation.operation,
driftStages: mutation.restoreTopologyDriftStages,
exit: 'every changed downstream stage has a native comparison and classification',
}))
const coveredTransitions = new Set(['cut->rotate', 'rotate->fillet', 'fillet->mirrored', 'mirrored->linear-pattern'])
const transitionTasks = production.operations.flatMap((from) => production.operations.map((to) => {
const pair = `${from}->${to}`
return {
id: `TSN-PAIR-${from}-${to}`,
title: `Classify ordered operation pair ${pair}`,
status: coveredTransitions.has(pair) ? 'completed' : 'pending',
dependencies: coveredTransitions.has(pair) ? [] : [productionDriftTasks.at(-1).id],
pair,
exit: 'native acceptance or rejection is recorded; accepted pairs include builder, mutation, naming, and resave evidence',
}
}))
const setupTasks = [
{ id: 'ORA-GUI-SETUP-000', title: 'Split GUI state probing from object/property probing', status: 'completed' },
{ id: 'ORA-GUI-SETUP-001', title: 'Diagnose the BIM first-run modal block', status: 'completed', dependencies: ['ORA-GUI-SETUP-000'] },
{ id: 'ORA-GUI-SETUP-002', title: 'Isolate FreeCAD config and suppress BIM first-run welcome before probing', status: 'completed', dependencies: ['ORA-GUI-SETUP-001'] },
]
const closureTasks = [
{ id: 'ORA-GUI-MERGE-000', title: 'Merge and verify all 100 GUI command shards', status: 'completed', dependencies: [guiShardTasks.at(-1).id] },
{ id: 'ORA-GUI-CONTEXT-000', title: 'Classify Import_ReadBREP as explicit module-import context', status: 'completed', dependencies: ['ORA-GUI-MERGE-000'] },
{ id: 'ORA-GUI-CONTEXT-001', title: 'Classify NaviCubeDraggableCmd as context-menu lazy registration', status: 'completed', dependencies: ['ORA-GUI-CONTEXT-000'] },
]
const milestones = [
{ id: 'ORA-GUI-BASELINE', exactTask: 'EX-ORA-01', status: 'completed', tasks: [...setupTasks, ...guiShardTasks, ...closureTasks] },
{ id: 'ORA-GUI-WORKFLOWS', exactTask: 'EX-ORA-01', status: 'in_progress', tasks: workflowTasks },
{ id: 'TSN-RECOVERY-DRIFT', exactTask: 'EX-TSN-04', status: 'pending', tasks: [...driftTasks, ...productionDriftTasks] },
{ id: 'TSN-ORDERED-PAIRS', exactTask: 'EX-TSN-04', status: 'pending', tasks: transitionTasks },
]
const allTasks = milestones.flatMap((milestone) => milestone.tasks)
const count = (status) => allTasks.filter((task) => task.status === status).length
const nextTask = allTasks.find((task) => task.status === 'in_progress')?.id
if (!nextTask) throw new Error('FreeCAD active work queue requires one in-progress task.')
const queue = {
schemaVersion: 1,
baseline: { freecadVersion: '1.1.1', commit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' },
updatedAt: '2026-08-14',
generatedBy: 'scripts/generate-freecad-active-work-queue.mjs',
policy: {
maxInProgress: 1,
unit: 'one artifact, one focused check, one explicit exit condition',
transitionUnit: 'one ordered operation pair',
driftUnit: 'one native case',
},
nextTask,
summary: { tasks: allTasks.length, completed: count('completed'), inProgress: count('in_progress'), pending: count('pending') },
milestones,
}
const content = `${JSON.stringify(queue, null, 2)}\n`
if (process.argv.includes('--check')) {
const current = await readFile(outputPath, 'utf8').catch(() => '')
if (current !== content) throw new Error('FreeCAD active work queue is stale; run generate:freecad-active-work-queue.')
console.log(JSON.stringify({ status: 'freecad-active-work-queue-pass', nextTask: queue.nextTask, ...queue.summary }, null, 2))
} else {
await writeFile(outputPath, content)
console.log(JSON.stringify({ status: 'freecad-active-work-queue-generated', output: outputPath, nextTask: queue.nextTask, ...queue.summary }, null, 2))
}

View File

@@ -3,12 +3,13 @@ import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const reportPath = resolve(root, process.env.FREECAD_GUI_COMMAND_REPORT || '.cache/freecad/reference-desktop.json')
const reportPath = resolve(root, process.env.FREECAD_GUI_COMMAND_REPORT || '.cache/freecad/reference-desktop-gui-commands.json')
const outputPath = resolve(root, process.env.FREECAD_GUI_COMMAND_INVENTORY || 'config/freecad-gui-command-inventory.json')
const sourceInventory = JSON.parse(await readFile(resolve(root, 'config/freecad-source-inventory.json'), 'utf8'))
const reportBytes = await readFile(reportPath)
const report = JSON.parse(reportBytes)
if (report.baselineId !== 'freecad-1.1.1' || report.freecadVersion !== '1.1.1' || report.guiUp !== true) throw new Error('A verified desktop FreeCAD GUI report is required.')
if (report.probeScope !== 'gui-commands') throw new Error('The dedicated gui-commands oracle report is required.')
if (!report.guiCommands?.available || !Array.isArray(report.guiCommands.commands)) throw new Error('Desktop report does not contain GUI command observations.')
const sourceCommandModules = new Map()
@@ -21,17 +22,21 @@ const normalizeState = (state) => typeof state === 'boolean' ? state : state &&
const unique = (values) => [...new Set(values)].sort()
const commands = report.guiCommands.commands.map((command) => {
const observations = command.observations || []
const emptyStates = unique(observations.map((entry) => normalizeState(entry.emptySelection)))
const noDocumentStates = unique(observations.map((entry) => normalizeState(entry.noDocument)))
const documentNoSelectionStates = unique(observations.map((entry) => normalizeState(entry.documentNoSelection)))
const selectedStates = unique(observations.map((entry) => normalizeState(entry.selectedPartBox)))
const selectionSensitive = observations.some((entry) => typeof entry.emptySelection === 'boolean' && typeof entry.selectedPartBox === 'boolean' && entry.emptySelection !== entry.selectedPartBox)
const documentSensitive = observations.some((entry) => typeof entry.noDocument === 'boolean' && typeof entry.documentNoSelection === 'boolean' && entry.noDocument !== entry.documentNoSelection)
const selectionSensitive = observations.some((entry) => typeof entry.documentNoSelection === 'boolean' && typeof entry.selectedPartBox === 'boolean' && entry.documentNoSelection !== entry.selectedPartBox)
return {
id: command.id,
sourceModules: unique(sourceCommandModules.get(command.id) || ['GuiCore']),
workbenches: unique(observations.map((entry) => entry.workbench)),
workbenches: unique(command.registeredWorkbenches || observations.map((entry) => entry.workbench)),
cases: {
emptySelection: { states: emptyStates, observed: true },
noDocument: { states: noDocumentStates, observed: true },
documentNoSelection: { states: documentNoSelectionStates, observed: true },
selectedPartBox: { states: selectedStates, observed: true },
},
documentSensitive,
selectionSensitive,
status: 'runtime-probed',
}
@@ -51,10 +56,14 @@ const inventory = {
baseline: { freecadVersion: '1.1.1', commit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' },
generatedBy: 'scripts/generate-freecad-gui-command-inventory.mjs',
sourceInventory: 'config/freecad-source-inventory.json',
contextBoundaries: 'config/freecad-gui-command-context-boundaries.json',
runtimeEvidence: {
report: '.cache/freecad/reference-desktop.json',
report: '.cache/freecad/reference-desktop-gui-commands.json',
reportSha256: createHash('sha256').update(reportBytes).digest('hex'),
workbenchCount: report.guiCommands.workbenches.length,
stateProbe: report.guiCommands.stateProbe,
stateCases: report.guiCommands.stateCases,
directIsActiveBoundary: report.guiCommands.directIsActiveBoundary,
},
commandCount: commands.length,
commands,

View File

@@ -1,53 +1,113 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { createServer as createViteServer } from 'vite'
import { promisify } from 'node:util'
import { createServer as createViteServer, preview as createVitePreview } from 'vite'
import { chromium } from '../cnc_wams_gpt6/linuxcnc-master/web/node_modules/playwright/index.mjs'
const root = resolve(new URL('..', import.meta.url).pathname)
const reportPath = resolve(root, 'config/chrome-cam-linuxcnc-machine-verification.json')
const artifactManifestPath = resolve(root, 'config/linuxcnc-wasm-machine-artifact.json')
const linuxcncRoot = resolve(root, 'cnc_wams_gpt6/linuxcnc-master')
const linuxcncWebRoot = resolve(linuxcncRoot, 'web')
const executable = process.env.CHROME_BIN || '/home/mes123456/.local/bin/google-chrome'
if (!existsSync(executable)) throw new Error(`Chrome executable is unavailable: ${executable}`)
const vite = await createViteServer({
configFile: false,
root,
server: {
host: '127.0.0.1',
port: 5201,
strictPort: false,
watch: null,
proxy: {
'/linuxcnc-machine': {
target: process.env.LINUXCNC_WASM_URL || 'https://localhost:5190',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/linuxcnc-machine/, ''),
},
},
},
})
await vite.listen()
const address = vite.httpServer?.address()
if (!address || typeof address === 'string') throw new Error('CAM machine harness server did not expose a port.')
const browser = await chromium.launch({ headless: true, executablePath: executable, args: ['--no-sandbox', '--disable-dev-shm-usage', '--enable-unsafe-swiftshader'] })
let linuxcnc
let vite
let browser
let report
try {
if (!existsSync(executable)) throw new Error(`Chrome executable is unavailable: ${executable}`)
const manifest = JSON.parse(await readFile(artifactManifestPath, 'utf8'))
if (manifest.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(manifest.worktreeRevision) || !Array.isArray(manifest.artifacts) || manifest.artifacts.length !== 7 || manifest.machineConfigPath !== manifest.artifacts.find(({ role }) => role === 'machine-config')?.path) {
throw new Error('LinuxCNC WASM machine artifact manifest is invalid.')
}
const actualRevision = (await promisify(execFile)('git', ['-C', linuxcncRoot, 'rev-parse', 'HEAD'])).stdout.trim()
if (actualRevision !== manifest.worktreeRevision) throw new Error(`LinuxCNC worktree revision mismatch: expected ${manifest.worktreeRevision}, got ${actualRevision}.`)
const artifactIdentity = []
for (const artifact of manifest.artifacts) {
const path = resolve(root, manifest.distRoot, artifact.path)
const [bytes, metadata] = await Promise.all([readFile(path), stat(path)])
const sha256 = createHash('sha256').update(bytes).digest('hex')
if (metadata.size !== artifact.bytes || sha256 !== artifact.sha256) throw new Error(`LinuxCNC WASM artifact mismatch: ${artifact.path}.`)
artifactIdentity.push({ role: artifact.role, path: artifact.path, runtimeLoaded: artifact.runtimeLoaded, bytes: metadata.size, sha256 })
}
// Own the LinuxCNC server lifecycle so a missing external preview cannot be
// misreported as a parser/controller timeout.
linuxcnc = await createVitePreview({
root: linuxcncWebRoot,
configFile: resolve(linuxcncWebRoot, 'vite.config.js'),
preview: { host: '127.0.0.1', port: 0, strictPort: false },
})
const linuxcncAddress = linuxcnc.httpServer.address()
if (!linuxcncAddress || typeof linuxcncAddress === 'string') throw new Error('LinuxCNC WASM preview did not expose a port.')
const linuxcncUrl = `https://127.0.0.1:${linuxcncAddress.port}`
vite = await createViteServer({
configFile: false,
root,
server: {
host: '127.0.0.1',
port: 5201,
strictPort: false,
watch: null,
proxy: {
'/linuxcnc-machine': {
target: linuxcncUrl,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/linuxcnc-machine/, ''),
},
},
},
})
await vite.listen()
const address = vite.httpServer?.address()
if (!address || typeof address === 'string') throw new Error('CAM machine harness server did not expose a port.')
browser = await chromium.launch({ headless: true, executablePath: executable, args: ['--no-sandbox', '--disable-dev-shm-usage', '--enable-unsafe-swiftshader'] })
const browserVersion = await browser.version()
if (browserVersion !== manifest.chromeVersion) throw new Error(`Chrome version mismatch: expected ${manifest.chromeVersion}, got ${browserVersion}.`)
const context = await browser.newContext({ ignoreHTTPSErrors: true })
const page = await context.newPage()
const networkStatuses = new Map()
page.on('response', (response) => {
const pathname = new URL(response.url()).pathname
if (!pathname.startsWith('/linuxcnc-machine/')) return
const relativePath = pathname.slice('/linuxcnc-machine/'.length) || 'index.html'
networkStatuses.set(relativePath, response.status())
})
await page.goto(`http://127.0.0.1:${address.port}/chrome-cam-linuxcnc-machine-harness.html`, { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__bitbybitCamLinuxcncMachineReport), null, { timeout: 240_000 })
// The harness performs independent dry-run and run submissions, each with a
// 180 second controller budget, plus initial machine startup.
await page.waitForFunction(() => Boolean(window.__bitbybitCamLinuxcncMachineReport), null, { timeout: 420_000 })
report = await page.evaluate(() => window.__bitbybitCamLinuxcncMachineReport)
report = { ...report, browserId: 'chrome', browser: { product: await browser.version(), userAgent: await page.evaluate(() => navigator.userAgent) } }
if (report.machine?.machineCase !== manifest.machineCase || report.machine?.dryRunMachineCase !== manifest.machineCase) throw new Error(`LinuxCNC browser loaded unexpected machine cases: run=${report.machine?.machineCase || '<missing>'}, dry-run=${report.machine?.dryRunMachineCase || '<missing>'}.`)
const loadedArtifacts = manifest.artifacts.filter(({ runtimeLoaded }) => runtimeLoaded).map(({ role, path }) => ({ role, path, status: networkStatuses.get(path) ?? 0 }))
const missingArtifacts = loadedArtifacts.filter(({ status }) => status !== 200)
if (missingArtifacts.length > 0) throw new Error(`Chrome did not load pinned LinuxCNC runtime artifacts: ${missingArtifacts.map(({ role, path, status }) => `${role}:${path}:${status}`).join(', ')}.`)
report = {
...report,
runtime: {
server: 'managed-vite-preview',
worktreeRevision: actualRevision,
machineCase: report.machine.machineCase,
artifacts: artifactIdentity,
loadedArtifacts,
},
browserId: 'chrome',
browser: { product: browserVersion, userAgent: await page.evaluate(() => navigator.userAgent) },
}
await context.close()
if (report.status !== 'pass') process.exitCode = 1
} catch (error) {
report = { schemaVersion: 1, status: 'failed', browserId: 'chrome', error: error instanceof Error ? error.stack || error.message : String(error) }
process.exitCode = 1
} finally {
await Promise.allSettled([browser?.close(), vite?.close(), linuxcnc?.close()])
await mkdir(resolve(root, 'config'), { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
await browser.close()
await vite.close()
}

View File

@@ -35,7 +35,7 @@ const resaveReport = {
freecadVersion: report.freecadVersion,
gitCommit: report.gitCommit,
status: report.status,
cases: report.cases.map(({ id, roundtripNameDrift, resaveNameDrift, nativeDesktopResaveCovered }) => ({ id, roundtripNameDrift, resaveNameDrift, nativeDesktopResaveCovered })),
cases: report.cases.map(({ id, stageCorrelations, roundtripNameDrift, resaveNameDrift, nativeDesktopResaveCovered }) => ({ id, stageCorrelations, roundtripNameDrift, resaveNameDrift, nativeDesktopResaveCovered })),
summary: {
cases: report.summary.cases,
passed: report.summary.passed,
@@ -43,12 +43,15 @@ const resaveReport = {
roundtripNameDrift: report.summary.roundtripNameDrift,
resaveNameDrift: report.summary.resaveNameDrift,
nativeDesktopResaveCases: report.summary.nativeDesktopResaveCases,
stageCorrelations: report.summary.stageCorrelations,
relationRecords: report.summary.relationRecords,
},
}
await writeFile(resaveOutputPath, `${JSON.stringify(resaveReport, null, 2)}\n`)
for (const caseReport of report.cases) {
delete caseReport.resaveNameDrift
delete caseReport.nativeDesktopResaveCovered
delete caseReport.stageCorrelations
}
delete report.summary.roundtripNameDrift
delete report.summary.resaveNameDrift

View File

@@ -0,0 +1,81 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const shardCount = Number(process.env.FREECAD_GUI_COMMAND_SHARD_COUNT || 100)
if (!Number.isInteger(shardCount) || shardCount < 2 || shardCount > 256) throw new Error('FREECAD_GUI_COMMAND_SHARD_COUNT must be an integer from 2 through 256.')
const mergeOnly = process.argv.includes('--merge-only')
const shardArgument = process.argv.find((argument) => argument.startsWith('--shard='))
const requestedShard = shardArgument ? Number(shardArgument.slice('--shard='.length)) : null
if (mergeOnly && shardArgument) throw new Error('--merge-only and --shard cannot be used together.')
if (shardArgument && (!Number.isInteger(requestedShard) || requestedShard < 0 || requestedShard >= shardCount)) throw new Error(`--shard must be an integer from 0 through ${shardCount - 1}.`)
if (!mergeOnly) {
const shardIndexes = requestedShard == null ? Array.from({ length: shardCount }, (_, index) => index) : [requestedShard]
for (const index of shardIndexes) {
console.log(`[freecad-gui-command-shards] execute ${index + 1}/${shardCount}`)
const execution = spawnSync(process.execPath, ['scripts/run-freecad-reference-probe.mjs'], {
cwd: root,
env: {
...process.env,
FREECAD_ORACLE_PROFILE: 'desktop',
FREECAD_REFERENCE_SCOPE: 'gui-commands',
FREECAD_GUI_COMMAND_SHARD: `${index}/${shardCount}`,
},
stdio: 'inherit',
})
if (execution.status !== 0) throw new Error(`FreeCAD GUI command shard ${index}/${shardCount} failed with exit ${execution.status}.`)
}
}
if (requestedShard != null) {
console.log(JSON.stringify({ status: 'freecad-gui-command-shard-pass', shard: requestedShard, shardCount }, null, 2))
process.exit(0)
}
const shardReports = []
for (let index = 0; index < shardCount; index += 1) {
const relativePath = `.cache/freecad/reference-desktop-gui-commands-${index}-of-${shardCount}.json`
const bytes = await readFile(resolve(root, relativePath))
shardReports.push({ relativePath, bytes, report: JSON.parse(bytes) })
}
const first = shardReports[0].report
const universeCount = first.guiCommands?.commandUniverseCount
const universeSha256 = first.guiCommands?.commandUniverseSha256
const commands = []
let expectedStart = 0
for (const [index, { report }] of shardReports.entries()) {
const gui = report.guiCommands
if (report.baselineId !== 'freecad-1.1.1' || report.freecadVersion !== '1.1.1' || report.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || report.probeScope !== 'gui-commands') throw new Error(`GUI command shard ${index} has the wrong baseline.`)
if (gui?.shard?.index !== index || gui.shard.count !== shardCount || gui.shard.start !== expectedStart || gui.shard.end <= gui.shard.start) throw new Error(`GUI command shard ${index} has a non-contiguous range.`)
if (gui.commandUniverseCount !== universeCount || gui.commandUniverseSha256 !== universeSha256) throw new Error(`GUI command shard ${index} has a different command universe.`)
if (gui.stateCommandCount !== gui.commands?.length || gui.stateCommandCount !== gui.shard.end - gui.shard.start) throw new Error(`GUI command shard ${index} has an incomplete state range.`)
if (JSON.stringify(report.modules) !== JSON.stringify(first.modules) || JSON.stringify(gui.workbenches) !== JSON.stringify(first.guiCommands.workbenches)) throw new Error(`GUI command shard ${index} changed module or workbench registration evidence.`)
commands.push(...gui.commands)
expectedStart = gui.shard.end
}
if (expectedStart !== universeCount || commands.length !== universeCount || new Set(commands.map((command) => command.id)).size !== universeCount) throw new Error('Merged GUI command shards do not cover the command universe exactly once.')
const mergedUniverseSha256 = createHash('sha256').update(commands.map((command) => command.id).join('\n')).digest('hex')
if (mergedUniverseSha256 !== universeSha256) throw new Error('Merged GUI command order does not match the declared command universe hash.')
const merged = {
...first,
guiCommands: {
...first.guiCommands,
stateCommandCount: commands.length,
shard: null,
commands,
mergedShards: shardReports.map(({ relativePath, bytes, report }) => ({
path: relativePath,
sha256: createHash('sha256').update(bytes).digest('hex'),
start: report.guiCommands.shard.start,
end: report.guiCommands.shard.end,
})),
},
}
const output = resolve(root, '.cache/freecad/reference-desktop-gui-commands.json')
await writeFile(output, `${JSON.stringify(merged, null, 2)}\n`)
console.log(JSON.stringify({ status: 'freecad-gui-command-shards-merged', shardCount, commandCount: commands.length, commandUniverseSha256: universeSha256, output }, null, 2))

View File

@@ -0,0 +1,81 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const executable = resolve(root, '.cache/freecad/install-desktop/bin/FreeCAD')
const probe = resolve(root, 'scripts/freecad-gui-workflow-oracle.py')
const sysroot = resolve(root, '.cache/freecad/sysroot')
const outputDirectory = resolve(root, '.cache/freecad/gui-workflow-oracle')
const resultFile = resolve(outputDirectory, 'partdesign-pad-success-result.json')
const reportPath = resolve(root, 'config/freecad-gui-workflow-oracle.json')
const configDirectory = resolve(outputDirectory, 'config')
const userConfig = resolve(configDirectory, 'user.cfg')
const systemConfig = resolve(configDirectory, 'system.cfg')
const marker = 'FREECAD_GUI_WORKFLOW_RESULT='
const timeoutMs = Number(process.env.FREECAD_GUI_WORKFLOW_TIMEOUT_MS || 120_000)
const fail = (message) => { throw new Error(`FreeCAD GUI workflow oracle: ${message}`) }
await mkdir(outputDirectory, { recursive: true })
mkdirSync(configDirectory, { recursive: true })
const emptyConfig = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n<FCParameters><FCParamGroup Name="Root"/></FCParameters>\n'
writeFileSync(userConfig, emptyConfig)
writeFileSync(systemConfig, emptyConfig)
const runtimeEnv = {
...process.env,
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
MPLBACKEND: 'Agg',
FREECAD_GUI_WORKFLOW_RESULT_FILE: resultFile,
}
const execution = spawnSync('xvfb-run', ['-a', executable, '--user-cfg', userConfig, '--system-cfg', systemConfig, '--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), probe], {
cwd: root,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
timeout: timeoutMs,
env: runtimeEnv,
})
const output = `${execution.stdout ?? ''}\n${execution.stderr ?? ''}`
const markerLine = output.split(/\r?\n/).find((line) => line.startsWith(marker))
const resultBytes = (() => { try { return readFileSync(resultFile, 'utf8') } catch { return '' } })()
if (execution.error || (execution.status !== 0 && !resultBytes && !markerLine)) fail(`native probe exited with ${execution.status}: ${[execution.error?.message, output.trim()].filter(Boolean).join('\n')}`)
const result = JSON.parse(resultBytes || markerLine.slice(marker.length))
if (result.freecadVersion !== '1.1.1' || result.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') fail(`native baseline mismatch: ${JSON.stringify(result)}`)
if (result.workflowId !== 'partdesign-pad-task' || result.commandId !== 'PartDesign_Pad' || result.state !== 'success' || result.success !== true) fail(`Pad success workflow failed: ${JSON.stringify(result)}`)
const report = {
schemaVersion: 1,
baseline: {
freecadVersion: result.freecadVersion,
commit: result.gitCommit,
profile: 'desktop-xvfb-isolated-config',
},
generatedBy: './npmw run probe:freecad-gui-workflow',
checkedBy: './npmw run check:freecad-gui-workflow',
family: {
id: result.workflowId,
primaryCommand: result.commandId,
exactTask: 'EX-UI-03',
source: '.cache/freecad/FreeCAD/src/Mod/PartDesign/Gui/Command.cpp',
},
workflows: {
success: result,
},
remainingStates: ['disabled', 'failure', 'cancel', 'recovery'],
exactPromotionReady: false,
}
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({
status: 'freecad-gui-workflow-generated',
family: report.family.id,
command: report.family.primaryCommand,
state: result.state,
bodyTip: result.after.bodyTip,
volume: result.after.pad.volume,
taskOpened: result.taskPanel.opened.activeDialog,
taskClosed: !result.taskPanel.closed.activeDialog,
report: 'config/freecad-gui-workflow-oracle.json',
}, null, 2))

View File

@@ -16,10 +16,10 @@ if (!runtime?.available || runtime.candidateCount !== 352 || runtime.availableCo
const editableTypes = new Set([
'App::PropertyAngle', 'App::PropertyBool', 'App::PropertyDistance', 'App::PropertyEnumeration',
'App::PropertyFloat', 'App::PropertyFloatList', 'App::PropertyInteger', 'App::PropertyIntegerConstraint',
'App::PropertyIntegerList', 'App::PropertyLength', 'App::PropertyLink', 'App::PropertyLinkList',
'App::PropertyFloat', 'App::PropertyFloatConstraint', 'App::PropertyFloatList', 'App::PropertyInteger', 'App::PropertyIntegerConstraint',
'App::PropertyIntegerList', 'App::PropertyLength', 'App::PropertyLink', 'App::PropertyLinkHidden', 'App::PropertyLinkList',
'App::PropertyLinkSub', 'App::PropertyLinkSubList', 'App::PropertyPlacement', 'App::PropertyString',
'App::PropertyStringList', 'App::PropertyVector',
'App::PropertyPrecision', 'App::PropertyQuantityConstraint', 'App::PropertyStringList', 'App::PropertyVector',
])
const specializedTypes = new Set([
'App::PropertyExpressionEngine', 'Part::PropertyGeometryList', 'Part::PropertyPartShape',
@@ -30,8 +30,9 @@ const numericStatusNames = new Map([
[25, 'PropTransient'], [26, 'PropHidden'], [27, 'PropOutput'],
])
const facadeBehaviorStatusNames = new Set([
'Hidden', 'Immutable', 'NoModify', 'Ordered', 'Output', 'PropHidden', 'PropNoPersist',
'PropNoRecompute', 'PropOutput', 'PropReadOnly', 'PropTransient', 'ReadOnly', 'Transient',
'Hidden', 'Immutable', 'LockDynamic', 'NoModify', 'Ordered', 'Output', 'PartialTrigger',
'PropHidden', 'PropNoPersist', 'PropNoRecompute', 'PropOutput', 'PropReadOnly', 'PropTransient',
'ReadOnly', 'Transient',
])
const normalizeStatus = (status) => status.map((entry) => typeof entry === 'number' ? (numericStatusNames.get(entry) ?? `UnknownBit${entry}`) : entry)
const properties = runtime.types.flatMap((object) => (object.properties ?? []).map((property) => ({ ...property, objectTypeId: object.typeId, status: normalizeStatus(property.status) })))
@@ -81,7 +82,7 @@ const report = {
},
harness: { path: 'scripts/run-freecad-native-property-semantics.mjs', bytes: (await stat(harnessPath)).size, sha256: createHash('sha256').update(harnessContent).digest('hex') },
exactPromotionReady: false,
exactBlocker: 'complete native document and property semantics',
exactBlocker: '58 runtime property types and 523 records remain opaque-only; complete native document and property semantics',
generatedAt: new Date().toISOString(),
}
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`)

View File

@@ -24,4 +24,4 @@ const line = output.split(/\r?\n/).find((candidate) => candidate.includes(marker
if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD Property status oracle failed with status ${execution.status}: ${execution.error?.message || output.trim()}`)
const report = JSON.parse(line.slice(line.indexOf(marker) + marker.length))
await writeFile(resolve(root, 'config/freecad-property-status-oracle.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ status: report.status, baselineId: report.baselineId, cases: report.cases.length, lockDynamic: report.lockDynamic.propertyStillPresent, fcstdRoundtrip: true }, null, 2))
console.log(JSON.stringify({ status: report.status, baselineId: report.baselineId, cases: report.cases.length, lockDynamic: report.lockDynamic.propertyStillPresent, partialTrigger: report.partialTrigger.value, observerTransactions: true, fcstdRoundtrip: true }, null, 2))

View File

@@ -1,4 +1,5 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -6,6 +7,33 @@ import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profile = process.env.FREECAD_ORACLE_PROFILE || 'headless'
if (!['headless', 'desktop'].includes(profile)) throw new Error(`Unknown FreeCAD oracle profile: ${profile}`)
const scope = process.env.FREECAD_REFERENCE_SCOPE || 'full'
if (!['full', 'gui-commands'].includes(scope)) throw new Error(`Unknown FreeCAD oracle scope: ${scope}`)
if (scope === 'gui-commands' && profile !== 'desktop') throw new Error('The gui-commands oracle scope requires the desktop profile.')
const shardToken = process.env.FREECAD_GUI_COMMAND_SHARD
const shardMatch = shardToken?.match(/^(\d+)\/(\d+)$/)
if (shardToken && (!shardMatch || scope !== 'gui-commands')) throw new Error('FREECAD_GUI_COMMAND_SHARD requires gui-commands scope and zero-based INDEX/COUNT syntax.')
const requestedShard = shardMatch ? { index: Number(shardMatch[1]), count: Number(shardMatch[2]) } : null
if (requestedShard && (requestedShard.count < 1 || requestedShard.index >= requestedShard.count)) throw new Error('FREECAD_GUI_COMMAND_SHARD is outside its declared range.')
const workbenchFilter = process.env.FREECAD_GUI_WORKBENCH_FILTER
if (workbenchFilter && scope !== 'gui-commands') throw new Error('FREECAD_GUI_WORKBENCH_FILTER requires gui-commands scope.')
const registrationOnly = process.env.FREECAD_GUI_REGISTRATION_ONLY === '1'
if (registrationOnly && (!workbenchFilter || requestedShard)) throw new Error('FREECAD_GUI_REGISTRATION_ONLY requires one workbench filter and cannot use a command shard.')
const timeoutMs = Number(process.env.FREECAD_REFERENCE_TIMEOUT_MS || (profile === 'desktop' ? 300000 : 120000))
if (!Number.isInteger(timeoutMs) || timeoutMs < 10000 || timeoutMs > 900000) throw new Error('FREECAD_REFERENCE_TIMEOUT_MS must be an integer from 10000 through 900000.')
const configIdentity = requestedShard
? `gui-commands-${requestedShard.index}-of-${requestedShard.count}`
: workbenchFilter ? `gui-workbench-${workbenchFilter.replace(/[^A-Za-z0-9_.-]/g, '_')}`
: `${profile}-${scope}`
const oracleConfigDirectory = resolve(root, '.cache/freecad/oracle-config')
const oracleUserConfig = resolve(oracleConfigDirectory, `${configIdentity}-user.cfg`)
const oracleSystemConfig = resolve(oracleConfigDirectory, `${configIdentity}-system.cfg`)
if (profile === 'desktop') {
const emptyConfig = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n<FCParameters><FCParamGroup Name="Root"/></FCParameters>\n'
mkdirSync(oracleConfigDirectory, { recursive: true })
writeFileSync(oracleUserConfig, emptyConfig)
writeFileSync(oracleSystemConfig, emptyConfig)
}
const localOracle = resolve(root, profile === 'desktop' ? '.cache/freecad/install-desktop/bin/FreeCAD' : '.cache/freecad/install-native/bin/FreeCADCmd')
const fallbackOracle = profile === 'desktop' ? ['FreeCAD', 'freecad'] : ['FreeCADCmd', 'freecadcmd']
const referenceCommand = process.env.FREECAD_REFERENCE_CMD || process.env.FREECAD_CMD
@@ -22,7 +50,7 @@ if (!command) throw new Error('FreeCADCmd 1.1.1 is unavailable. Set FREECAD_CMD
// stdin loop and prevents a deterministic probe exit.
const sysroot = resolve(root, '.cache/freecad/sysroot')
const commandArgs = profile === 'desktop'
? ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resolve(root, 'scripts/freecad-reference-probe.py')]
? ['--user-cfg', oracleUserConfig, '--system-cfg', oracleSystemConfig, '--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resolve(root, 'scripts/freecad-reference-probe.py')]
: [resolve(root, 'scripts/freecad-reference-probe.py')]
const runtimeEnv = profile === 'desktop'
? {
@@ -31,6 +59,7 @@ const runtimeEnv = profile === 'desktop'
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
MPLBACKEND: 'Agg',
FREECAD_ORACLE_ISOLATED_CONFIG: '1',
}
: process.env
const desktopNeedsVirtualDisplay = profile === 'desktop' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
@@ -39,62 +68,82 @@ const launchArgs = desktopNeedsVirtualDisplay ? ['-a', command, ...commandArgs]
const execution = spawnSync(launchCommand, launchArgs, {
cwd: root,
encoding: 'utf8',
timeout: 120000,
timeout: timeoutMs,
maxBuffer: 20 * 1024 * 1024,
env: runtimeEnv,
})
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
const resultLine = output.split(/\r?\n/).find((line) => line.startsWith('FREECAD_REFERENCE_RESULT='))
if (execution.error || execution.status !== 0 || !resultLine) {
throw new Error(`FreeCAD reference probe failed with status ${execution.status}: ${execution.error?.message || output.trim()}`)
throw new Error(`FreeCAD reference probe failed with status ${execution.status}: ${[execution.error?.message, output.trim()].filter(Boolean).join('\n')}`)
}
const result = JSON.parse(resultLine.slice('FREECAD_REFERENCE_RESULT='.length))
if (result.freecadVersion !== '1.1.1') throw new Error(`Expected FreeCAD 1.1.1, received ${result.freecadVersion}`)
if (result.determinism?.schemaVersion !== 1 || result.determinism.normalizedProcessFields?.join(',') !== 'pointer-address,uuid,freecad-document-cache-run') throw new Error('Reference probe lacks deterministic process-field normalization.')
if (result.probeScope !== scope) throw new Error(`Reference probe returned scope ${result.probeScope || '<missing>'}; expected ${scope}.`)
if (profile === 'desktop' && (result.oracleSetup?.isolatedConfig !== true || result.oracleSetup.bimFirstTimeWelcome !== 'suppressed-before-workbench-activation')) throw new Error('Desktop reference probe lacks the isolated modal-dialog boundary.')
if (profile === 'desktop' && !registrationOnly && (result.guiCommands?.probeMode !== 'state-matrix' || result.guiCommands.stateProbe !== 'native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard' || result.guiCommands.stateCases?.join(',') !== 'noDocument,documentNoSelection,selectedPartBox' || result.guiCommands.stateCommandCount !== result.guiCommands.commands?.length)) throw new Error('Desktop reference probe lacks the locked GUI state matrix.')
if (registrationOnly && (result.guiCommands?.probeMode !== 'registration-only' || result.guiCommands.stateCommandCount !== 0 || result.guiCommands.workbenches?.length !== 1 || result.guiCommands.workbenches[0].name !== workbenchFilter)) throw new Error('Desktop reference probe returned an invalid single-workbench registration report.')
if (profile === 'desktop' && (!Number.isInteger(result.guiCommands?.commandUniverseCount) || result.guiCommands.commandUniverseCount < result.guiCommands.stateCommandCount || !/^[0-9a-f]{64}$/.test(result.guiCommands.commandUniverseSha256 || ''))) throw new Error('Desktop reference probe lacks a stable GUI command universe.')
if (requestedShard && (result.guiCommands?.shard?.index !== requestedShard.index || result.guiCommands.shard.count !== requestedShard.count)) throw new Error('Desktop reference probe returned the wrong GUI command shard.')
if (!requestedShard && result.guiCommands?.shard != null) throw new Error('Unsharded desktop reference probe returned shard metadata.')
const moduleStatuses = new Set(['not-built', 'compiled-importable', 'compiled-import-failure', 'gui-only-unprobeable'])
if (!Array.isArray(result.modules) || result.modules.length !== 34 || result.moduleCount !== 34) throw new Error('Reference probe must report the 34 configured FreeCAD modules.')
for (const module of result.modules) {
if (typeof module.name !== 'string' || typeof module.compiled !== 'boolean' || typeof module.importable !== 'boolean' || typeof module.guiRequired !== 'boolean' || typeof module.guiAvailable !== 'boolean' || !moduleStatuses.has(module.runtimeStatus)) {
throw new Error(`Reference probe returned an invalid module status for ${module.name || '<unknown>'}.`)
if (scope === 'full') {
if (!Array.isArray(result.modules) || result.modules.length !== 34 || result.moduleCount !== 34) throw new Error('Full reference probe must report the 34 configured FreeCAD modules.')
for (const module of result.modules) {
if (typeof module.name !== 'string' || typeof module.compiled !== 'boolean' || typeof module.importable !== 'boolean' || typeof module.guiRequired !== 'boolean' || typeof module.guiAvailable !== 'boolean' || !moduleStatuses.has(module.runtimeStatus)) {
throw new Error(`Reference probe returned an invalid module status for ${module.name || '<unknown>'}.`)
}
}
} else if (result.moduleCount !== 0 || result.modules?.length !== 0) {
throw new Error('GUI command probe must not include the module oracle scope.')
}
const runtimeObjects = result.runtimeObjects
if (!runtimeObjects?.available || runtimeObjects.candidateSource !== 'Document.supportedTypes' || !Array.isArray(runtimeObjects.types)) {
throw new Error('Reference probe must include the complete Document.supportedTypes runtime inventory.')
}
if (runtimeObjects.candidateCount !== runtimeObjects.types.length || runtimeObjects.availableCount + runtimeObjects.unavailableCount !== runtimeObjects.candidateCount) {
throw new Error('Reference probe returned inconsistent runtime object counts.')
}
const runtimeTypeIds = new Set()
for (const candidate of runtimeObjects.types) {
if (typeof candidate.typeId !== 'string' || !candidate.typeId || runtimeTypeIds.has(candidate.typeId) || !['available', 'unavailable'].includes(candidate.probeStatus)) {
throw new Error(`Reference probe returned an invalid runtime object candidate: ${candidate.typeId || '<unknown>'}.`)
if (scope === 'full') {
if (!runtimeObjects?.available || runtimeObjects.candidateSource !== 'Document.supportedTypes' || !Array.isArray(runtimeObjects.types)) {
throw new Error('Reference probe must include the complete Document.supportedTypes runtime inventory.')
}
runtimeTypeIds.add(candidate.typeId)
if (candidate.available) {
if (candidate.probeStatus !== 'available' || typeof candidate.runtimeTypeId !== 'string' || !candidate.runtimeTypeId || !Array.isArray(candidate.properties)) {
throw new Error(`Reference probe returned incomplete metadata for ${candidate.typeId}.`)
if (runtimeObjects.candidateCount !== runtimeObjects.types.length || runtimeObjects.availableCount + runtimeObjects.unavailableCount !== runtimeObjects.candidateCount) {
throw new Error('Reference probe returned inconsistent runtime object counts.')
}
const runtimeTypeIds = new Set()
for (const candidate of runtimeObjects.types) {
if (typeof candidate.typeId !== 'string' || !candidate.typeId || runtimeTypeIds.has(candidate.typeId) || !['available', 'unavailable'].includes(candidate.probeStatus)) {
throw new Error(`Reference probe returned an invalid runtime object candidate: ${candidate.typeId || '<unknown>'}.`)
}
for (const property of candidate.properties) {
if (typeof property.name !== 'string' || typeof property.typeId !== 'string' || !Array.isArray(property.status) || !Object.hasOwn(property, 'default')) {
throw new Error(`Reference probe returned invalid property metadata for ${candidate.typeId}.`)
runtimeTypeIds.add(candidate.typeId)
if (candidate.available) {
if (candidate.probeStatus !== 'available' || typeof candidate.runtimeTypeId !== 'string' || !candidate.runtimeTypeId || !Array.isArray(candidate.properties)) {
throw new Error(`Reference probe returned incomplete metadata for ${candidate.typeId}.`)
}
for (const property of candidate.properties) {
if (typeof property.name !== 'string' || typeof property.typeId !== 'string' || !Array.isArray(property.status) || !Object.hasOwn(property, 'default')) {
throw new Error(`Reference probe returned invalid property metadata for ${candidate.typeId}.`)
}
}
} else if (candidate.probeStatus !== 'unavailable' || typeof candidate.error !== 'string' || !candidate.error) {
throw new Error(`Reference probe did not explain why ${candidate.typeId} is unavailable.`)
}
} else if (candidate.probeStatus !== 'unavailable' || typeof candidate.error !== 'string' || !candidate.error) {
throw new Error(`Reference probe did not explain why ${candidate.typeId} is unavailable.`)
}
}
await writeFile(resolve(root, `.cache/freecad/reference-${profile}.json`), `${JSON.stringify(result, null, 2)}\n`)
const reportName = requestedShard
? `reference-desktop-gui-commands-${requestedShard.index}-of-${requestedShard.count}.json`
: workbenchFilter ? `reference-desktop-gui-commands-${workbenchFilter.replace(/[^A-Za-z0-9_.-]/g, '_')}.json`
: scope === 'gui-commands' ? 'reference-desktop-gui-commands.json' : `reference-${profile}.json`
await writeFile(resolve(root, '.cache/freecad', reportName), `${JSON.stringify(result, null, 2)}\n`)
console.log(JSON.stringify({
command: launchCommand === command ? command : `${launchCommand} ${command}`,
profile,
scope,
shard: requestedShard,
freecadVersion: result.freecadVersion,
moduleStatusSummary: result.moduleStatusSummary,
guiCommandCount: result.guiCommands?.commands?.length ?? 0,
runtimeObjectSummary: {
candidateCount: runtimeObjects.candidateCount,
availableCount: runtimeObjects.availableCount,
unavailableCount: runtimeObjects.unavailableCount,
propertyCount: runtimeObjects.types.reduce((total, candidate) => total + (candidate.properties?.length ?? 0), 0),
candidateCount: runtimeObjects?.candidateCount ?? 0,
availableCount: runtimeObjects?.availableCount ?? 0,
unavailableCount: runtimeObjects?.unavailableCount ?? 0,
propertyCount: runtimeObjects?.types?.reduce((total, candidate) => total + (candidate.properties?.length ?? 0), 0) ?? 0,
},
report: `.cache/freecad/reference-${profile}.json`,
report: `.cache/freecad/${reportName}`,
}, null, 2))

View File

@@ -0,0 +1,33 @@
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
const sysroot = resolve(root, '.cache/freecad/sysroot')
const script = resolve(root, 'scripts/freecad-tsn-stage-correlation-oracle.py')
const outputPath = resolve(root, 'config/freecad-tsn-stage-correlation-oracle.json')
if (!existsSync(executable)) throw new Error(`FreeCAD TSN stage-correlation executable is missing: ${executable}`)
const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), script], {
cwd: root,
encoding: 'utf8',
timeout: 180_000,
maxBuffer: 40 * 1024 * 1024,
env: {
...process.env,
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
MPLBACKEND: 'Agg',
},
})
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
const marker = 'FREECAD_TSN_STAGE_CORRELATION_RESULT='
const markerIndex = output.indexOf(marker)
if (execution.error || execution.status !== 0 || markerIndex < 0) throw new Error(`FreeCAD TSN stage-correlation oracle failed with status ${execution.status}: ${execution.error?.message || output.trim()}`)
const resultLine = output.slice(markerIndex + marker.length).split(/\r?\n/, 1)[0]
const report = JSON.parse(resultLine)
if (report.status !== 'pass') throw new Error(`FreeCAD TSN stage-correlation oracle reported ${report.status}: ${report.error || 'unknown failure'}`)
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ status: 'freecad-tsn-stage-correlation-generated', ...report.summary, exactCorrelationReady: report.exactCorrelationReady }, null, 2))

View File

@@ -88,6 +88,7 @@ const lanes = {
'probe:freecad-partdesign-revolution-groove',
'probe:freecad-part-builders',
'probe:freecad-composite-history-elementmap',
'probe:freecad-tsn-stage-correlation',
'test:freecad-fcstd-native',
'generate:freecad-parameter-mutations',
'check:freecad-oracle-coverage',
@@ -98,6 +99,7 @@ const lanes = {
'check:freecad-sketcher-constraints',
'check:freecad-fcstd-roundtrip',
'check:freecad-composite-history-elementmap',
'check:freecad-tsn-stage-correlation',
'check:freecad-exact-history-elementmap-gate',
'check:freecad-native-naming-evidence',
],