feat: align FreeCAD property status semantics
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-14 17:56:41 -04:00
parent f64b78865c
commit e3373c9d6c
28 changed files with 811 additions and 61 deletions

View File

@@ -10,10 +10,11 @@ if (report.runtime?.registeredObjectTypes !== 352 || report.runtime.instantiable
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 (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 !== 2639 || report.propertyStatus.facadeNative?.join(',') !== 'Hidden,ReadOnly' || report.propertyStatus.proxyOnly?.length !== 13) fail('property status coverage is stale')
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')
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, proxyOnlyStatusRecords: 2639, exactPromotionReady: false }, null, 2))
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))

View File

@@ -0,0 +1,22 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const oracle = JSON.parse(await readFile(resolve(root, 'config/freecad-property-status-oracle.json'), 'utf8'))
const fail = (message) => { throw new Error(`FreeCAD Property status oracle: ${message}`) }
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 (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.')
if (changed('type-output')?.mustExecute !== false || changed('type-output')?.documentTouched !== false) fail('Prop_Output changed its owner recompute state.')
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.')
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))

View File

@@ -0,0 +1,145 @@
import json
import os
import tempfile
import zipfile
import FreeCAD as App
FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
def object_state(obj):
return {
"state": [str(value) for value in obj.State],
"mustExecute": bool(obj.MustExecute),
"documentTouched": bool(obj.Document.isTouched()),
}
def change_case(case_id, attr=0, runtime_status=None):
document = App.newDocument("PropertyStatus_" + case_id)
try:
obj = document.addObject("App::FeatureTest", "Feature")
obj.addProperty("App::PropertyInteger", "Value", "Oracle", "Status probe", attr)
if runtime_status:
obj.setPropertyStatus("Value", runtime_status)
document.recompute()
document.purgeTouched()
before = object_state(obj)
obj.Value = 7
changed = object_state(obj)
recomputed = int(document.recompute())
after_recompute = object_state(obj)
return {
"id": case_id,
"attr": int(attr),
"runtimeStatus": runtime_status,
"reportedStatus": [str(value) for value in obj.getPropertyStatus("Value")],
"reportedType": [str(value) for value in obj.getTypeOfProperty("Value")],
"before": before,
"changed": changed,
"recomputeCount": recomputed,
"afterRecompute": after_recompute,
}
finally:
App.closeDocument(document.Name)
def locked_dynamic_case():
document = App.newDocument("PropertyStatus_LockDynamic")
try:
obj = document.addObject("App::FeaturePython", "Feature")
obj.addProperty("App::PropertyInteger", "LockedValue", "Oracle")
obj.setPropertyStatus("LockedValue", "LockDynamic")
failures = {}
for operation, callback in {
"remove": lambda: obj.removeProperty("LockedValue"),
"rename": lambda: obj.renameProperty("LockedValue", "RenamedValue"),
}.items():
try:
callback()
failures[operation] = None
except Exception as error:
failures[operation] = {"type": type(error).__name__, "message": str(error)}
return {
"reportedStatus": [str(value) for value in obj.getPropertyStatus("LockedValue")],
"propertyStillPresent": "LockedValue" in obj.PropertiesList,
"renamedPropertyPresent": "RenamedValue" in obj.PropertiesList,
"failures": failures,
}
finally:
App.closeDocument(document.Name)
def archive_case():
with tempfile.TemporaryDirectory(prefix="freecad-property-status-") as temp_dir:
path = os.path.join(temp_dir, "PropertyStatus.FCStd")
document = App.newDocument("PropertyStatusArchive")
try:
obj = document.addObject("App::FeaturePython", "Feature")
obj.addProperty("App::PropertyInteger", "Persisted", "Oracle")
obj.Persisted = 11
obj.setPropertyStatus("Persisted", "Output")
obj.addProperty("App::PropertyInteger", "RuntimeTransient", "Oracle")
obj.RuntimeTransient = 12
obj.setPropertyStatus("RuntimeTransient", "Transient")
obj.addProperty("App::PropertyInteger", "TypeTransient", "Oracle", "", int(App.PropertyType.Prop_Transient))
obj.TypeTransient = 13
obj.addProperty("App::PropertyInteger", "NoPersist", "Oracle", "", int(App.PropertyType.Prop_NoPersist))
obj.NoPersist = 14
document.recompute()
document.saveAs(path)
finally:
App.closeDocument(document.Name)
with zipfile.ZipFile(path, "r") as archive:
document_xml = archive.read("Document.xml").decode("utf-8")
reopened = App.openDocument(path)
try:
obj = reopened.getObject("Feature")
return {
"xml": {
"persistedProperty": '<Property name="Persisted"' in document_xml,
"runtimeTransientProperty": '<Property name="RuntimeTransient"' in document_xml,
"typeTransientProperty": '<Property name="TypeTransient"' in document_xml,
"noPersistProperty": '<Property name="NoPersist"' in document_xml,
"runtimeTransientHasValue": '<Integer value="12"' in document_xml,
"typeTransientHasValue": '<Integer value="13"' in document_xml,
},
"reopened": {
"properties": sorted(str(value) for value in obj.PropertiesList if value in {"Persisted", "RuntimeTransient", "TypeTransient", "NoPersist"}),
"values": {
"Persisted": int(obj.Persisted),
"RuntimeTransient": int(obj.RuntimeTransient),
"TypeTransient": int(obj.TypeTransient),
},
"statuses": {
name: [str(value) for value in obj.getPropertyStatus(name)]
for name in ["Persisted", "RuntimeTransient", "TypeTransient"]
},
},
}
finally:
App.closeDocument(reopened.Name)
cases = [
change_case("ordinary"),
change_case("runtime-output", runtime_status="Output"),
change_case("type-output", attr=int(App.PropertyType.Prop_Output)),
change_case("runtime-no-recompute", runtime_status="NoRecompute"),
change_case("type-no-recompute", attr=int(App.PropertyType.Prop_NoRecompute)),
]
report = {
"schemaVersion": 1,
"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(),
"fcstd": archive_case(),
}
print("FREECAD_PROPERTY_STATUS_ORACLE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))

View File

@@ -29,7 +29,10 @@ const numericStatusNames = new Map([
[13, 'Ordered'], [22, 'PropNoPersist'], [23, 'PropNoRecompute'], [24, 'PropReadOnly'],
[25, 'PropTransient'], [26, 'PropHidden'], [27, 'PropOutput'],
])
const facadeStatusNames = new Set(['Hidden', 'ReadOnly'])
const facadeBehaviorStatusNames = new Set([
'Hidden', 'Immutable', 'NoModify', 'Ordered', 'Output', '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) })))
if (properties.length !== 5510 || properties.some((property) => !property.name || !property.typeId || !Array.isArray(property.status))) fail('runtime property records are incomplete')
@@ -54,8 +57,8 @@ const supportSummary = Object.fromEntries(['native-editable-codec', 'native-spec
return [support, { typeCount: selected.length, recordCount: selected.reduce((total, entry) => total + entry.recordCount, 0) }]
}))
const observedStatuses = [...new Set(properties.flatMap((property) => property.status))].sort()
const unsupportedStatusNames = observedStatuses.filter((status) => !facadeStatusNames.has(status))
const unsupportedStatusRecordCount = properties.filter((property) => property.status.some((status) => unsupportedStatusNames.includes(status))).length
const preservedOnlyStatusNames = observedStatuses.filter((status) => !facadeBehaviorStatusNames.has(status))
const preservedOnlyStatusRecordCount = properties.filter((property) => property.status.some((status) => preservedOnlyStatusNames.includes(status))).length
const unavailableObjects = runtime.types.filter(({ available }) => !available).map(({ typeId, error }) => ({ typeId, error }))
const harnessContent = await readFile(harnessPath)
const report = {
@@ -68,9 +71,12 @@ const report = {
types,
propertyStatus: {
observed: observedStatuses,
facadeNative: [...facadeStatusNames].sort(),
proxyOnly: unsupportedStatusNames,
proxyOnlyRecordCount: unsupportedStatusRecordCount,
facadeNative: observedStatuses,
behaviorNative: [...facadeBehaviorStatusNames].sort(),
preservedOnly: preservedOnlyStatusNames,
preservedOnlyRecordCount: preservedOnlyStatusRecordCount,
proxyOnly: [],
proxyOnlyRecordCount: 0,
unknownNumericBits: observedStatuses.filter((status) => status.startsWith('UnknownBit')),
},
harness: { path: 'scripts/run-freecad-native-property-semantics.mjs', bytes: (await stat(harnessPath)).size, sha256: createHash('sha256').update(harnessContent).digest('hex') },

View File

@@ -0,0 +1,27 @@
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')
if (!existsSync(executable)) throw new Error(`FreeCAD Property status oracle executable is missing: ${executable}`)
const sysroot = resolve(root, '.cache/freecad/sysroot')
const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resolve(root, 'scripts/freecad-property-status-oracle.py')], {
cwd: root,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 16 * 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}` : ''}`,
},
})
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
const marker = 'FREECAD_PROPERTY_STATUS_ORACLE_RESULT='
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))