feat: expand oracle and topology evidence gates

This commit is contained in:
2026-08-10 18:27:56 -04:00
parent d727375561
commit 3907f50ff1
16 changed files with 17629 additions and 364 deletions

View File

@@ -39,7 +39,7 @@ jobs:
- run: ./npmw ci --ignore-scripts
- run: ./npmw run ci:real -- --lane=oracle
env:
FREECAD_ORACLE_PROFILE: headless
FREECAD_ORACLE_PROFILE: desktop
wasm:
runs-on: [self-hosted, linux, cad]

View File

@@ -13,7 +13,22 @@
"fuse",
"cut",
"common",
"pad"
"rotate",
"pad",
"pocket",
"loft",
"pipe",
"revolution",
"groove",
"fillet",
"chamfer",
"hole",
"draft",
"thickness",
"linear-pattern",
"polar-pattern",
"mirrored",
"multi-transform"
],
"transport": "step-text"
},

View File

@@ -24,6 +24,7 @@
"fuse",
"cut",
"common",
"rotate",
"pad",
"pocket",
"loft",

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@
`--lane=all` 会按 oracle、WASM、Chrome 顺序执行全部 lane。任一命令退出非零都会使整个 lane 失败;运行摘要写入 `.cache/ci/real-verification.json`,该文件不作为仓库基线证据提交。
oracle lane 默认会重新抓取锁定的 FreeCAD commit 并初始化子模块。仅在本机已经具备该 commit、且需要离线复现时才可显式使用 `FREECAD_SOURCE_OFFLINE=1`;该模式会拒绝未锁定的 checkout并跳过网络相关子模块更新不会替代 CI 的真实 fetch。
## Runner 要求
工作流使用标签为 `self-hosted, linux, cad` 的 runner因为 FreeCAD 原生编译和外围 CAM harness 需要较大的本地工具链。runner 必须提供:

View File

@@ -1755,3 +1755,11 @@ PartDesign transform oracle 由 4 项扩为 10 项:补入 PolarPattern `4000`
新增 `config/freecad-parameter-mutation-plan.json``config/freecad-parameter-mutation-report.json``scripts/generate-freecad-parameter-mutation-report.mjs`。矩阵把每个 35 个 feature family 分成 nominal 参数、非法边界、两个以上参数快照、编辑-重算-恢复事务、FCStd 往返五项;报告从现有 FreeCAD/Chrome 黄金证据抽取,不把 outcome-only 记录当参数快照。当前为 `1/35` family 完整、`35/35` nominal、`35/35` invalid boundary、`15/35` 参数变体、`6/35` 编辑恢复、`34/35` 往返;门禁输出 `freecad-parameter-mutation-fail-closed`,并已通过 `check:execution-plan` 接入默认 `verify`
运行 `./npmw run generate:freecad-parameter-mutations` 可在新增原生黄金模型后更新报告;`./npmw run check:freecad-parameter-mutations` 会拒绝陈旧报告。只有每个 family 都有真实参数快照及编辑后重算/恢复证据,且阶段 builder、MappedNameRef/StringHasher 和同构来源边界同时闭合,才允许继续评估 `EX-REL-01`,不允许通过修改 claim 或能力矩阵字段绕过。
## 227. 2026-08-10 ORA 运行时全量探针、TSN 阶段证据与真实 oracle lane
桌面 FreeCAD 1.1.1 已通过真实 GUI 入口完成全量注册对象探针:`352` 个注册文档对象 TypeId`348` 个可实例化,`4` 个明确记录原生不可用,累计 `5510` 个运行时属性元数据及默认值。`check:freecad-desktop-oracle``check:freecad-oracle-coverage` 均验证通过;源代码索引包含 `766` 个 TypeId 声明、`202` 个文档对象使用点,其中 `6` 个使用点尚无对应运行时注册对象,因此 exact 仍保持 fail-closed。
TSN 阶段门禁已接入默认执行计划。Pad 证据为 `25` 条跨 modified/generated 记录Pocket 为 `42` 条记录,含普通两阶段和 two-sided 四阶段的 source/result/topology/relation/cleanup 校验;当前原生 provider 只捕获 `cut/fuse/pad` 三类操作19 类目标操作仍缺 `16` 类逐阶段证据,且 `MappedNameRef/StringHasher` 私有 token 仍是明确阻塞项。
真实 oracle lane 已在固定桌面安装上完成 `27` 个命令,包含 ORA、Part/PartDesign/Sketcher、FCStd roundtrip 与 exact naming gatesWASM lane 已重跑 Chrome Pad/Pocket history harness。源代码网络抓取在本地因 GitHub 超时,验证使用显式 `FREECAD_SOURCE_OFFLINE=1` 的同一 pinned checkoutCI 默认路径仍执行真实 tag/submodule fetch不会把离线缓存当作成功证据。

View File

@@ -14,7 +14,7 @@
"check:baseline": "node scripts/check-baseline.mjs",
"check:freecad-source": "node scripts/verify-freecad-source.mjs",
"check:bitbybit-history": "node scripts/check-bitbybit-history-surface.mjs",
"check:execution-plan": "node scripts/check-freecad-execution-plan.mjs && node scripts/check-freecad-web-exact-parity-plan.mjs && node scripts/check-freecad-web-exact-promotion.mjs && node scripts/check-freecad-oracle-coverage.mjs && node scripts/check-freecad-golden-coverage.mjs && node scripts/generate-freecad-parameter-mutation-report.mjs --check",
"check:execution-plan": "node scripts/check-freecad-execution-plan.mjs && node scripts/check-freecad-web-exact-parity-plan.mjs && node scripts/check-freecad-web-exact-promotion.mjs && node scripts/check-freecad-oracle-coverage.mjs && node scripts/check-freecad-golden-coverage.mjs && node scripts/generate-freecad-parameter-mutation-report.mjs --check && node scripts/check-freecad-tsn-stage-evidence.mjs",
"check:freecad-web-exact-parity-plan": "node scripts/check-freecad-web-exact-parity-plan.mjs",
"check:freecad-web-exact-promotion": "node scripts/check-freecad-web-exact-promotion.mjs",
"generate:freecad-inventory": "node scripts/generate-freecad-source-inventory.mjs",
@@ -101,6 +101,7 @@
"check:chrome-native-pad-history": "node scripts/check-chrome-native-pad-history.mjs",
"test:chrome-native-pocket-history": "node scripts/run-chrome-native-pocket-history.mjs",
"check:chrome-native-pocket-history": "node scripts/check-chrome-native-pocket-history.mjs",
"check:freecad-tsn-stage-evidence": "node scripts/check-freecad-tsn-stage-evidence.mjs",
"test:chrome-native-revolution-history": "node scripts/run-chrome-native-revolution-history.mjs",
"check:chrome-native-revolution-history": "node scripts/check-chrome-native-revolution-history.mjs",
"test:chrome-native-groove-history": "node scripts/run-chrome-native-groove-history.mjs",

View File

@@ -74,6 +74,22 @@ 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.')
}
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.')
}
if (runtimeObjects.availableCount + runtimeObjects.unavailableCount !== runtimeObjects.candidateCount) fail('runtime object summary counts are inconsistent.')
const runtimeTypeIds = new Set()
for (const candidate of runtimeObjects.types) {
if (!candidate.typeId || runtimeTypeIds.has(candidate.typeId)) fail(`runtime object TypeId '${candidate.typeId || '<missing>'}' is invalid or duplicated.`)
runtimeTypeIds.add(candidate.typeId)
if (candidate.available) {
if (candidate.probeStatus !== 'available' || typeof candidate.runtimeTypeId !== 'string' || !candidate.runtimeTypeId || !Array.isArray(candidate.properties)) fail(`${candidate.typeId} lacks runtime property metadata.`)
if (candidate.properties.some((property) => !property.name || !property.typeId || !Array.isArray(property.status) || !Object.hasOwn(property, 'default'))) fail(`${candidate.typeId} has an incomplete property record.`)
} else if (candidate.probeStatus !== 'unavailable' || !candidate.error) fail(`${candidate.typeId} lacks an explicit instantiation failure.`)
}
for (const typeId of expectedObjects) if (!runtimeTypeIds.has(typeId)) fail(`core TypeId ${typeId} is absent from Document.supportedTypes.`)
const runtimePropertyCount = runtimeObjects.types.reduce((total, candidate) => total + (candidate.properties?.length ?? 0), 0)
console.log(JSON.stringify({
status: 'freecad-desktop-oracle-pass',
@@ -88,5 +104,9 @@ console.log(JSON.stringify({
guiUp: report.guiUp,
moduleStatusSummary: report.moduleStatusSummary,
objectFixtureCount: report.objects.length,
registeredObjectTypeCount: runtimeObjects.candidateCount,
runtimeObjectCount: runtimeObjects.availableCount,
unavailableObjectCount: runtimeObjects.unavailableCount,
runtimePropertyCount,
},
}, null, 2))

View File

@@ -38,8 +38,24 @@ for (const [id, command] of inventoryCommands) {
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 runtimeObjectCount = reference.objects?.filter((object) => object.available && object.runtimeTypeId).length ?? 0
const staticObjectCandidateCount = typeInventory.modules.reduce((total, module) => total + module.objects.length, 0)
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.')
if (runtimeObjects.candidateCount !== runtimeObjects.types.length || runtimeObjects.availableCount + runtimeObjects.unavailableCount !== runtimeObjects.candidateCount) fail('runtime object counts are inconsistent.')
const runtimeTypeIds = new Set()
for (const object of runtimeObjects.types) {
if (typeof object.typeId !== 'string' || !object.typeId || runtimeTypeIds.has(object.typeId)) fail(`runtime object TypeId '${object.typeId || '<unknown>'}' is duplicated or invalid.`)
runtimeTypeIds.add(object.typeId)
if (object.available && (typeof object.runtimeTypeId !== 'string' || !object.runtimeTypeId || !Array.isArray(object.properties))) fail(`runtime object ${object.typeId} lacks TypeId/property metadata.`)
if (!object.available && (!object.error || object.probeStatus !== 'unavailable')) fail(`runtime object ${object.typeId} lacks an explicit unavailable status.`)
}
for (const coreTypeId of ['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Feature', 'PartDesign::Feature', 'Sketcher::SketchObject']) {
if (!runtimeTypeIds.has(coreTypeId)) fail(`core runtime object ${coreTypeId} is absent from Document.supportedTypes.`)
}
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 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]))
console.log(JSON.stringify({
status: 'freecad-oracle-coverage-pass',
@@ -49,8 +65,15 @@ console.log(JSON.stringify({
guiCommands: guiInventory.commandCount,
guiCommandsRuntimeAligned: true,
sourceOnlyCommands: guiInventory.sourceOnlyCommands.length,
registeredObjectTypeCount: runtimeObjects.candidateCount,
runtimeObjectCount,
unavailableObjectCount: runtimeObjects.unavailableCount,
runtimePropertyCount: runtimeObjects.types.reduce((total, object) => total + (object.properties?.length ?? 0), 0),
sourceDeclaredTypeIdCount: typeInventory.typeIdDeclarations.length,
sourceDocumentObjectUsageCount: staticObjectCandidateCount,
sourceUsageMissingRuntimeCount: sourceUsageMissingRuntime.length,
sourceUsageMissingRuntime: sourceUsageMissingRuntime,
staticObjectCandidateCount,
exactPromotionReady: false,
remaining: ['not-built modules require an explicit build/proxy decision', 'static TypeId/property candidates require per-object runtime probes'],
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'],
}, null, 2))

View File

@@ -0,0 +1,38 @@
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 fail = (message) => { throw new Error(`FreeCAD TSN stage evidence: ${message}`) }
const [history, pad, pocket] = await Promise.all([
load('config/chrome-native-history-verification.json'),
load('config/chrome-native-pad-history-verification.json'),
load('config/chrome-native-pocket-history-verification.json'),
])
const requiredOperations = ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform']
if (history.status !== 'pass' || history.nativeCapabilities?.availability !== 'available' || history.nativeCapabilities.transport !== 'step-text') fail('the shared native history provider is not available.')
if (JSON.stringify(history.nativeCapabilities.operations) !== JSON.stringify(requiredOperations)) fail('the native history operation registry drifted from the locked 19-operation contract.')
if (pad.status !== 'pass' || pad.execution?.status !== 'completed' || pad.execution.recordCount <= 0 || pad.execution.crossKindRecords <= 0) fail('Pad has no completed cross-kind stage history.')
if (pad.execution.profileKinds?.sort().join(',') !== 'edge,face,vertex' || pad.execution.summary?.isValid !== true || pad.execution.summary?.solids !== 1 || pad.workerDisposed !== true) fail('Pad stage topology or worker ownership evidence is incomplete.')
if (pocket.status !== 'pass' || pocket.history?.stages?.length !== 2 || pocket.twoSidedHistory?.stages?.length !== 4) fail('Pocket staged history evidence is incomplete.')
const stageRecords = [...(pocket.history.stages ?? []), ...(pocket.twoSidedHistory.stages ?? [])]
for (const stage of stageRecords) {
if (!stage.stageId || !stage.operation || !Array.isArray(stage.inputObjectIds) || stage.inputObjectIds.length === 0 || !stage.resultObjectId || stage.topologyEntries <= 0 || stage.recordCount <= 0) fail(`stage ${stage.stageId || '<unknown>'} lacks source/result topology evidence.`)
}
if (pocket.history.relations?.modified <= 0 || pocket.history.relations?.deleted <= 0 || pocket.twoSidedHistory.structuralValid !== true || pocket.twoSidedHistory.solids !== 1) fail('Pocket relation or two-sided structural evidence is incomplete.')
if (pocket.afterRelease?.shapeCount !== 0 || pocket.afterRelease?.kernelReferenceCount !== 0 || pad.workerDisposed !== true) fail('TSN harnesses did not release native resources.')
const capturedOperations = [...new Set(stageRecords.map((stage) => stage.operation))].sort()
const missingOperations = requiredOperations.filter((operation) => !capturedOperations.includes(operation))
console.log(JSON.stringify({
status: 'freecad-tsn-stage-evidence-pass',
exactPromotionReady: false,
provider: history.nativeCapabilities.providerId,
requiredBuilderOperations: requiredOperations.length,
capturedStageOperations: capturedOperations,
capturedBuilderOperationCount: capturedOperations.length,
missingBuilderOperations: missingOperations,
pad: { records: pad.execution.recordCount, crossKindRecords: pad.execution.crossKindRecords, relations: pad.execution.relations },
pocket: { records: pocket.history.recordCount, stages: pocket.history.stages.length, twoSidedStages: pocket.twoSidedHistory.stages.length, relations: pocket.history.relations },
blockers: [`${missingOperations.length} of ${requiredOperations.length} builder operations still need explicit native stage captures`, 'FreeCAD private MappedNameRef/StringHasher token evidence remains unavailable for the browser provider'],
}, null, 2))

View File

@@ -15,10 +15,28 @@ for (const module of inventory.modules) {
for (const object of module.objects) {
if (typeof object.sourceFile !== 'string' || !object.sourceFile.startsWith('src/Mod/')) fail(`${module.name} has an invalid sourceFile.`)
for (const field of ['classes', 'typeIdSymbols', 'propertyRegistrations', 'propertyMembers', 'pythonProperties']) if (!Array.isArray(object[field])) fail(`${module.name}/${object.sourceFile}.${field} must be an array.`)
for (const value of [...object.classes, ...object.typeIdSymbols, ...object.propertyRegistrations]) if (typeof value !== 'string' || !value) fail(`${module.name}/${object.sourceFile} contains an invalid symbol.`)
for (const field of ['typeIdDeclarations', 'documentObjectUsages']) if (object[field] !== undefined && !Array.isArray(object[field])) fail(`${module.name}/${object.sourceFile}.${field} must be an array when present.`)
const declaredTypeIds = object.typeIdDeclarations ?? []
const documentObjectUsages = object.documentObjectUsages ?? []
for (const value of [...object.classes, ...object.typeIdSymbols, ...object.propertyRegistrations, ...declaredTypeIds, ...documentObjectUsages]) if (typeof value !== 'string' || !value) fail(`${module.name}/${object.sourceFile} contains an invalid symbol.`)
for (const value of [...object.propertyMembers, ...object.pythonProperties]) if (!value || typeof value.name !== 'string' || !value.name) fail(`${module.name}/${object.sourceFile} contains an invalid property.`)
}
}
const validateTypeIdIndex = (records, field, expectedCount) => {
if (!Array.isArray(records) || records.length !== expectedCount) fail(`${field} count does not match totals.`)
const typeIds = new Set()
for (const record of records) {
if (!record?.typeId || typeIds.has(record.typeId) || !/^[A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*$/.test(record.typeId)) fail(`${field} contains an invalid or duplicate TypeId.`)
typeIds.add(record.typeId)
if (!Array.isArray(record.sourceModules) || record.sourceModules.length === 0 || record.sourceModules.some((name) => !names.has(name))) fail(`${field}/${record.typeId} has invalid source modules.`)
if (!Array.isArray(record.sourceFiles) || record.sourceFiles.length === 0 || record.sourceFiles.some((path) => !path.startsWith('src/Mod/'))) fail(`${field}/${record.typeId} has invalid source files.`)
}
}
validateTypeIdIndex(inventory.typeIdDeclarations, 'typeIdDeclarations', inventory.totals?.typeIdDeclarationCount)
validateTypeIdIndex(inventory.documentObjectUsages, 'documentObjectUsages', inventory.totals?.documentObjectUsageCount)
const declarationEvidenceCount = inventory.modules.reduce((total, module) => total + module.objects.reduce((sum, object) => sum + (object.typeIdDeclarations?.length ?? 0), 0), 0)
const usageEvidenceCount = inventory.modules.reduce((total, module) => total + module.objects.reduce((sum, object) => sum + (object.documentObjectUsages?.length ?? 0), 0), 0)
if (declarationEvidenceCount !== inventory.totals.typeIdDeclarationEvidenceCount || usageEvidenceCount !== inventory.totals.documentObjectUsageEvidenceCount) fail('TypeId source evidence totals are inconsistent.')
const sourceRoot = process.env.FREECAD_SOURCE_DIR ? resolve(process.env.FREECAD_SOURCE_DIR) : resolve(root, inventory.baseline.sourcePath || '.cache/freecad/FreeCAD')
let sourceAvailable = false
try {

View File

@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { mkdir, stat } from 'node:fs/promises'
import { mkdir, rename, stat } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
@@ -9,24 +9,64 @@ const directoryArgument = process.argv.slice(2).find((argument) => argument.star
const target = resolve(root, directoryArgument?.slice('--directory='.length) || toolchain.sourceCheckout.defaultDirectory)
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 300000 }).trim()
const exists = await stat(target).then(() => true, () => false)
const offline = process.env.FREECAD_SOURCE_OFFLINE === '1'
if (!exists) {
if (offline) throw new Error(`FREECAD_SOURCE_OFFLINE=1 requires an existing pinned checkout: ${target}`)
await mkdir(dirname(target), { recursive: true })
git('clone', '--filter=blob:none', '--no-checkout', toolchain.source.repository, target)
} else {
const remote = git('-C', target, 'remote', 'get-url', 'origin')
if (remote !== toolchain.source.repository) throw new Error(`Existing checkout has unexpected origin: ${remote}`)
const dirty = git('-C', target, 'status', '--porcelain')
if (dirty) throw new Error(`Existing FreeCAD checkout is dirty: ${target}`)
const submodulePaths = git('-C', target, 'config', '--file', '.gitmodules', '--get-regexp', 'path')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => line.slice(line.indexOf(' ') + 1).trim())
const parentDirty = git('-C', target, 'status', '--porcelain')
const unexpectedDirty = parentDirty.split(/\r?\n/).filter((line) => line && !submodulePaths.some((path) => line.endsWith(` ${path}`) || line.endsWith(`\t${path}`)))
if (unexpectedDirty.length > 0) throw new Error(`Existing FreeCAD checkout is dirty outside submodules: ${target}`)
// A previous interrupted clone can leave a non-empty directory without a
// gitlink checkout. Move only those exact submodule targets into an ignored,
// recoverable cache before asking Git to initialize them again.
if (!offline) {
const staleRoot = resolve(root, '.cache/freecad/stale-submodules', String(Date.now()))
let moved = 0
for (const submodulePath of submodulePaths) {
const submodule = resolve(target, submodulePath)
const present = await stat(submodule).then(() => true, () => false)
if (!present) continue
const hasGitMetadata = await stat(resolve(submodule, '.git')).then(() => true, () => false)
const childDirty = hasGitMetadata
? git('-C', submodule, 'status', '--porcelain').length > 0
: true
if (!hasGitMetadata || childDirty) {
const destination = resolve(staleRoot, submodulePath)
await mkdir(dirname(destination), { recursive: true })
await rename(submodule, destination)
moved += 1
console.error(`Moved stale FreeCAD submodule ${submodulePath} to ${destination}`)
}
}
if (moved > 0) await mkdir(staleRoot, { recursive: true })
} else {
console.error('FREECAD_SOURCE_OFFLINE=1: preserving the pinned source checkout and skipping submodule repair.')
}
}
git('-C', target, 'fetch', '--depth=1', 'origin', `refs/tags/${toolchain.source.tag}`)
const fetched = git('-C', target, 'rev-parse', 'FETCH_HEAD')
if (fetched !== toolchain.source.commit) throw new Error(`Fetched FreeCAD tag resolved to ${fetched}, expected ${toolchain.source.commit}.`)
git('-C', target, 'checkout', '--detach', toolchain.source.commit)
const currentRevision = git('-C', target, 'rev-parse', 'HEAD')
if (offline && currentRevision === toolchain.source.commit) {
console.error(`FREECAD_SOURCE_OFFLINE=1: using pinned FreeCAD source ${currentRevision}.`)
} else {
git('-C', target, 'fetch', '--depth=1', 'origin', `refs/tags/${toolchain.source.tag}`)
const fetched = git('-C', target, 'rev-parse', 'FETCH_HEAD')
if (fetched !== toolchain.source.commit) throw new Error(`Fetched FreeCAD tag resolved to ${fetched}, expected ${toolchain.source.commit}.`)
git('-C', target, 'checkout', '--detach', toolchain.source.commit)
}
// The locked source graph includes GSL, OndselSolver, AddonManager and the
// test support checkout. Initialize every gitlink so a clean oracle build does
// not silently fall back to a partial module set.
git('-C', target, 'submodule', 'sync', '--recursive')
git('-C', target, 'submodule', 'update', '--init', '--recursive')
if (offline) console.error('FREECAD_SOURCE_OFFLINE=1: skipping network-dependent submodule update.')
else git('-C', target, 'submodule', 'update', '--init', '--recursive')
console.log(`FreeCAD source ready: ${target} @ ${toolchain.source.commit}`)

View File

@@ -8,6 +8,20 @@ import FreeCAD as App
def property_metadata(obj):
def json_value(value):
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, (list, tuple)):
return [json_value(item) for item in value]
if isinstance(value, dict):
return {str(key): json_value(item) for key, item in value.items()}
# FreeCAD quantities and placements keep a stable textual form while
# the report remains valid JSON for every registered property type.
try:
return str(value)
except Exception:
return "<unserializable>"
properties = []
for name in obj.PropertiesList:
try:
@@ -22,23 +36,61 @@ def property_metadata(obj):
status = list(obj.getPropertyStatus(name))
except Exception:
status = []
properties.append({"name": name, "typeId": type_id, "group": group, "status": status})
try:
default = json_value(getattr(obj, name))
except Exception:
default = "<unreadable>"
properties.append({"name": name, "typeId": type_id, "group": group, "status": status, "default": default})
return properties
def probe_object(document, type_id):
object_name = "Reference" + type_id.replace(":", "_")
try:
obj = document.addObject(type_id, "Reference" + type_id.replace(":", "_"))
obj = document.addObject(type_id, object_name)
except Exception as error:
return {"typeId": type_id, "available": False, "error": str(error)}
return {"typeId": type_id, "available": False, "probeStatus": "unavailable", "error": str(error)}
return {
"typeId": type_id,
"available": True,
"probeStatus": "available",
"name": obj.Name,
"runtimeTypeId": obj.TypeId,
"properties": property_metadata(obj),
}
def probe_registered_objects(document):
try:
registered = sorted(set(str(type_id) for type_id in document.supportedTypes()))
except Exception as error:
return {"available": False, "types": [], "error": str(error)}
records = []
for index, type_id in enumerate(registered):
record = probe_object(document, type_id)
record["candidateIndex"] = index
record["candidateSource"] = "Document.supportedTypes"
records.append(record)
# A few GUI-only TechDraw providers dereference their view during
# removeObject(). Keep those instances alive until FreeCAD exits so a
# probe cannot turn a valid metadata observation into a process crash.
if not bool(getattr(App, "GuiUp", False)) and record.get("available") and record.get("name"):
try:
document.removeObject(record["name"])
except Exception as error:
record["cleanupError"] = str(error)
return {
"available": True,
"candidateSource": "Document.supportedTypes",
"candidateCount": len(records),
"availableCount": sum(1 for record in records if record.get("available")),
"unavailableCount": sum(1 for record in records if not record.get("available")),
"types": records,
}
ALL_REFERENCE_MODULES = [
"AddonManager", "Assembly", "BIM", "CAM", "Cloud", "Draft", "Fem", "Help",
"Idf", "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh",
@@ -180,6 +232,9 @@ def probe_gui_commands(document, selected_object):
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)
version = App.Version()
result = {
"schemaVersion": 1,
@@ -191,12 +246,13 @@ result = {
"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),
"modules": [probe_module(name) for name in ALL_REFERENCE_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",
]],
"guiCommands": probe_gui_commands(document, box_object),
"runtimeObjects": runtime_objects,
"guiCommands": gui_commands,
}
result["moduleStatusSummary"] = {
status: sum(1 for module in result["modules"] if module["runtimeStatus"] == status)

View File

@@ -26,6 +26,8 @@ const propertyRegistrationPattern = /\bADD_PROPERTY(?:_TYPE)?\s*\(\s*([A-Za-z_][
const propertyMemberPattern = /\bApp::(Property[A-Za-z0-9_]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|,)/g
const pythonPropertyPattern = /\.addProperty\(\s*([^,\n]+),\s*["']([^"']+)["']/g
const typeIdPattern = /\b([A-Za-z_][A-Za-z0-9_]*)::(?:getClassTypeId|getTypeId)\s*\(/g
const typeDeclarationPattern = /\b(?:PROPERTY_SOURCE(?:_[A-Z_]+)?|TYPESYSTEM_SOURCE(?:_[A-Z_]+)?|EXTENSION_PROPERTY_SOURCE(?:_[A-Z_]+)?)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*)/g
const documentObjectUsagePattern = /\baddObject\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*)["']/g
const unique = (values) => [...new Set(values)].sort()
const moduleEntries = (await readdir(sourceMod, { withFileTypes: true })).filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))
@@ -37,6 +39,8 @@ for (const entry of moduleEntries) {
let propertyRegistrationCount = 0
let propertyMemberCount = 0
let pythonPropertyCount = 0
let typeIdDeclarationCount = 0
let documentObjectUsageCount = 0
for (const file of files) {
const text = await readFile(file, 'utf8')
const classes = unique([...text.matchAll(classPattern)].map((match) => match[1]))
@@ -44,18 +48,25 @@ for (const entry of moduleEntries) {
const members = unique([...text.matchAll(propertyMemberPattern)].map((match) => ({ type: match[1], name: match[2] })))
const pythonProperties = [...text.matchAll(pythonPropertyPattern)].map((match) => ({ typeExpression: match[1].trim(), name: match[2] }))
const typeIds = unique([...text.matchAll(typeIdPattern)].map((match) => match[1]))
const typeIdDeclarations = unique([...text.matchAll(typeDeclarationPattern)].map((match) => match[1]))
const documentObjectUsages = unique([...text.matchAll(documentObjectUsagePattern)].map((match) => match[1]))
propertyRegistrationCount += registrations.length
propertyMemberCount += members.length
pythonPropertyCount += pythonProperties.length
if (classes.length || registrations.length || members.length || pythonProperties.length || typeIds.length) {
objects.push({
typeIdDeclarationCount += typeIdDeclarations.length
documentObjectUsageCount += documentObjectUsages.length
if (classes.length || registrations.length || members.length || pythonProperties.length || typeIds.length || typeIdDeclarations.length || documentObjectUsages.length) {
const object = {
sourceFile: relative(sourceRoot, file),
classes,
typeIdSymbols: typeIds,
propertyRegistrations: registrations,
propertyMembers: members,
pythonProperties,
})
}
if (typeIdDeclarations.length > 0) object.typeIdDeclarations = typeIdDeclarations
if (documentObjectUsages.length > 0) object.documentObjectUsages = documentObjectUsages
objects.push(object)
}
}
modules.push({
@@ -66,22 +77,45 @@ for (const entry of moduleEntries) {
propertyRegistrationCount,
propertyMemberCount,
pythonPropertyCount,
typeIdDeclarationCount,
documentObjectUsageCount,
objects,
runtimeStatus: 'requires-freecad-reference-probe',
})
}
const indexTypeIds = (field) => {
const index = new Map()
for (const module of modules) for (const object of module.objects) for (const typeId of object[field] ?? []) {
const record = index.get(typeId) || { typeId, sourceModules: new Set(), sourceFiles: new Set() }
record.sourceModules.add(module.name)
record.sourceFiles.add(object.sourceFile)
index.set(typeId, record)
}
return [...index.values()]
.map((record) => ({ typeId: record.typeId, sourceModules: [...record.sourceModules].sort(), sourceFiles: [...record.sourceFiles].sort() }))
.sort((left, right) => left.typeId.localeCompare(right.typeId))
}
const typeIdDeclarations = indexTypeIds('typeIdDeclarations')
const documentObjectUsages = indexTypeIds('documentObjectUsages')
const inventory = {
schemaVersion: 1,
baseline: { freecadVersion: '1.1.1', commit: baselineCommit, sourcePath: '.cache/freecad/FreeCAD' },
generatedBy: 'scripts/generate-freecad-type-property-inventory.mjs',
moduleCount: modules.length,
typeIdDeclarations,
documentObjectUsages,
totals: {
sourceFileCount: modules.reduce((sum, module) => sum + module.sourceFileCount, 0),
objectSourceCount: modules.reduce((sum, module) => sum + module.objectSourceCount, 0),
propertyRegistrationCount: modules.reduce((sum, module) => sum + module.propertyRegistrationCount, 0),
propertyMemberCount: modules.reduce((sum, module) => sum + module.propertyMemberCount, 0),
pythonPropertyCount: modules.reduce((sum, module) => sum + module.pythonPropertyCount, 0),
typeIdDeclarationCount: typeIdDeclarations.length,
typeIdDeclarationEvidenceCount: modules.reduce((sum, module) => sum + module.typeIdDeclarationCount, 0),
documentObjectUsageCount: documentObjectUsages.length,
documentObjectUsageEvidenceCount: modules.reduce((sum, module) => sum + module.documentObjectUsageCount, 0),
},
modules,
}

View File

@@ -8,7 +8,8 @@ const profile = process.env.FREECAD_ORACLE_PROFILE || 'headless'
if (!['headless', 'desktop'].includes(profile)) throw new Error(`Unknown FreeCAD oracle profile: ${profile}`)
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 candidates = process.env.FREECAD_CMD ? [process.env.FREECAD_CMD] : [localOracle, ...fallbackOracle]
const referenceCommand = process.env.FREECAD_REFERENCE_CMD || process.env.FREECAD_CMD
const candidates = referenceCommand ? [referenceCommand] : [localOracle, ...fallbackOracle]
let command = null
for (const candidate of candidates) {
const probe = spawnSync(candidate, profile === 'desktop' ? ['--console', '--version'] : ['--version'], { encoding: 'utf8', timeout: 15000 })
@@ -32,7 +33,10 @@ const runtimeEnv = profile === 'desktop'
MPLBACKEND: 'Agg',
}
: process.env
const execution = spawnSync(command, commandArgs, {
const desktopNeedsVirtualDisplay = profile === 'desktop' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
const launchCommand = desktopNeedsVirtualDisplay ? 'xvfb-run' : command
const launchArgs = desktopNeedsVirtualDisplay ? ['-a', command, ...commandArgs] : commandArgs
const execution = spawnSync(launchCommand, launchArgs, {
cwd: root,
encoding: 'utf8',
timeout: 120000,
@@ -53,5 +57,44 @@ for (const module of result.modules) {
throw new Error(`Reference probe returned an invalid module status for ${module.name || '<unknown>'}.`)
}
}
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>'}.`)
}
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.`)
}
}
await writeFile(resolve(root, `.cache/freecad/reference-${profile}.json`), `${JSON.stringify(result, null, 2)}\n`)
console.log(JSON.stringify({ command, result }, null, 2))
console.log(JSON.stringify({
command: launchCommand === command ? command : `${launchCommand} ${command}`,
profile,
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),
},
report: `.cache/freecad/reference-${profile}.json`,
}, null, 2))

View File

@@ -40,6 +40,7 @@ const lanes = {
'configure:freecad-native',
'build:freecad-native',
'probe:freecad-reference',
'check:freecad-desktop-oracle',
'test:golden:freecad',
'test:golden:freecad:families',
'test:golden:freecad:failures',
@@ -61,6 +62,7 @@ const lanes = {
'check:freecad-composite-history-elementmap',
'check:freecad-exact-history-elementmap-gate',
'check:freecad-native-naming-evidence',
'test:freecad-fcstd-native',
],
wasm: [
...wasmBuilds,
@@ -71,10 +73,14 @@ const lanes = {
'check:planegcs-artifact',
'test:chrome-planegcs',
'test:chrome-native-history',
'test:chrome-native-pad-history',
'test:chrome-native-pocket-history',
'test:chrome-camotics-wasm',
'test:chrome-cam-native-simulation',
'check:chrome-planegcs',
'check:chrome-native-history',
'check:chrome-native-pad-history',
'check:chrome-native-pocket-history',
'check:chrome-camotics-wasm',
'check:chrome-cam-native-simulation',
],
@@ -83,13 +89,13 @@ const lanes = {
const selectedLanes = requestedLane === 'all' ? ['oracle', 'wasm', 'chrome'] : [requestedLane]
const results = []
function runScript(name) {
function runScript(name, laneEnvironment = {}) {
if (!scripts[name]) throw new Error(`Required npm script is missing: ${name}`)
const startedAt = Date.now()
console.log(`\n[real-verification] ${name}`)
const result = spawnSync(npmw, ['run', name], {
cwd: root,
env: { ...process.env, CI_REAL_VERIFICATION: '1' },
env: { ...process.env, CI_REAL_VERIFICATION: '1', ...laneEnvironment },
stdio: 'inherit',
})
const record = { script: name, status: result.status ?? 1, durationMs: Date.now() - startedAt }
@@ -100,7 +106,14 @@ function runScript(name) {
try {
for (const lane of selectedLanes) {
console.log(`\n[real-verification] lane=${lane}`)
for (const name of lanes[lane]) runScript(name)
const laneEnvironment = lane === 'oracle'
? {
FREECAD_ORACLE_PROFILE: 'desktop',
FREECAD_CMD: resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd'),
FREECAD_REFERENCE_CMD: resolve(root, '.cache/freecad/install-desktop/bin/FreeCAD'),
}
: {}
for (const name of lanes[lane]) runScript(name, laneEnvironment)
}
} finally {
const outputDir = resolve(root, '.cache', 'ci')