feat: advance FreeCAD exact parity evidence
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
32
scripts/check-freecad-gui-workflow-oracle.mjs
Normal file
32
scripts/check-freecad-gui-workflow-oracle.mjs
Normal 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))
|
||||
60
scripts/check-freecad-gui-workflow-plan.mjs
Normal file
60
scripts/check-freecad-gui-workflow-plan.mjs
Normal 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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
92
scripts/check-freecad-tsn-stage-correlation.mjs
Normal file
92
scripts/check-freecad-tsn-stage-correlation.mjs
Normal 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))
|
||||
@@ -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=(",", ":")))
|
||||
|
||||
94
scripts/freecad-composite-mutation-evidence.mjs
Normal file
94
scripts/freecad-composite-mutation-evidence.mjs
Normal 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 }
|
||||
}
|
||||
300
scripts/freecad-gui-workflow-oracle.py
Normal file
300
scripts/freecad-gui-workflow-oracle.py
Normal 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)
|
||||
@@ -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=(",", ":")))
|
||||
|
||||
@@ -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)
|
||||
|
||||
368
scripts/freecad-tsn-stage-correlation-oracle.py
Normal file
368
scripts/freecad-tsn-stage-correlation-oracle.py
Normal 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=(",", ":")))
|
||||
120
scripts/generate-freecad-active-work-queue.mjs
Normal file
120
scripts/generate-freecad-active-work-queue.mjs
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
81
scripts/run-freecad-gui-command-shards.mjs
Normal file
81
scripts/run-freecad-gui-command-shards.mjs
Normal 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))
|
||||
81
scripts/run-freecad-gui-workflow-oracle.mjs
Normal file
81
scripts/run-freecad-gui-workflow-oracle.mjs
Normal 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))
|
||||
@@ -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`)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
33
scripts/run-freecad-tsn-stage-correlation-oracle.mjs
Normal file
33
scripts/run-freecad-tsn-stage-correlation-oracle.mjs
Normal 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))
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user