|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ |
| 4 | + |
| 5 | +/** |
| 6 | + * `isNonRootNameExempt` (`anonymize-scrub.ts`) is a name-exemption: types it |
| 7 | + * matches are skipped by `pseudonymizeNonRootNames`'s sweep, so their |
| 8 | + * `Name`/`Description`/etc. stay legible. Three of its checks are |
| 9 | + * `startsWith` prefix tests standing in for "is a subtype of X": |
| 10 | + * |
| 11 | + * - `startsWith('IFCPROPERTY')` (+ the explicit `IFCCOMPLEXPROPERTY` |
| 12 | + * exact match added for #4042) for `IfcProperty` |
| 13 | + * - `startsWith('IFCPHYSICAL')` for `IfcPhysicalQuantity` |
| 14 | + * - `startsWith('IFCQUANTITY')` for `IfcPhysicalSimpleQuantity` |
| 15 | + * |
| 16 | + * #4042: `startsWith('IFCPROPERTY')` alone missed `IfcComplexProperty`, a |
| 17 | + * direct `IfcProperty` subtype whose own name doesn't start with |
| 18 | + * `IFCPROPERTY` — an over-scrub (the exemption should have applied and |
| 19 | + * didn't), not a leak. This test imports the REAL `isNonRootNameExempt` (not |
| 20 | + * a copy) and re-derives, directly from the EXPRESS schemas |
| 21 | + * (`packages/codegen/schemas/IFC4_ADD2_TC1.exp` and `IFC4X3.exp`), every |
| 22 | + * entity that is a proper subtype of `IfcProperty`, `IfcPhysicalQuantity`, |
| 23 | + * or `IfcPhysicalSimpleQuantity`, then asserts the code's answer against the |
| 24 | + * schema's in both directions (missing exemption, AND newly over-admitting |
| 25 | + * something that isn't in this family) for every non-root entity in both |
| 26 | + * schemas — so a future schema change, or a careless fix that widens the |
| 27 | + * new `IFCCOMPLEXPROPERTY` check back into a prefix, fails here instead of |
| 28 | + * silently mis-scrubbing again. |
| 29 | + */ |
| 30 | + |
| 31 | +import { readFileSync } from 'node:fs'; |
| 32 | +import { resolve } from 'node:path'; |
| 33 | +import { describe, expect, it } from 'vitest'; |
| 34 | +import { isNonRootNameExempt } from './anonymize-scrub.js'; |
| 35 | +import { IFC_ROOT_TYPES } from './subset-roots.js'; |
| 36 | + |
| 37 | +const SCHEMA_FILES = ['IFC4_ADD2_TC1.exp', 'IFC4X3.exp'] as const; |
| 38 | + |
| 39 | +// Vitest runs with `packages/export` as the working directory. |
| 40 | +const SCHEMA_DIR = resolve(process.cwd(), '../codegen/schemas'); |
| 41 | + |
| 42 | +function schemaText(name: string): string { |
| 43 | + return readFileSync(resolve(SCHEMA_DIR, name), 'utf8'); |
| 44 | +} |
| 45 | + |
| 46 | +/** Split the schema into one text block per `ENTITY <Name> … END_ENTITY;` |
| 47 | + * declaration, so each entity's own (possibly absent) `SUBTYPE OF` clause |
| 48 | + * is the only thing a per-entity regex can see. */ |
| 49 | +function splitEntityBlocks(text: string): Map<string, string> { |
| 50 | + const out = new Map<string, string>(); |
| 51 | + const re = /^ENTITY\s+(\w+)\b/gm; |
| 52 | + const starts: Array<{ name: string; index: number }> = []; |
| 53 | + let m: RegExpExecArray | null; |
| 54 | + while ((m = re.exec(text)) !== null) { |
| 55 | + starts.push({ name: m[1]!.toUpperCase(), index: m.index }); |
| 56 | + } |
| 57 | + for (let i = 0; i < starts.length; i++) { |
| 58 | + const end = i + 1 < starts.length ? starts[i + 1]!.index : text.length; |
| 59 | + out.set(starts[i]!.name, text.slice(starts[i]!.index, end)); |
| 60 | + } |
| 61 | + return out; |
| 62 | +} |
| 63 | + |
| 64 | +function parseEntityParents(text: string): Map<string, string> { |
| 65 | + const out = new Map<string, string>(); |
| 66 | + for (const [name, block] of splitEntityBlocks(text)) { |
| 67 | + const m = /SUBTYPE\s+OF\s+\((\w+)\)/.exec(block); |
| 68 | + if (m) out.set(name, m[1]!.toUpperCase()); |
| 69 | + } |
| 70 | + return out; |
| 71 | +} |
| 72 | + |
| 73 | +function parseAllEntityNames(text: string): string[] { |
| 74 | + return [...splitEntityBlocks(text).keys()]; |
| 75 | +} |
| 76 | + |
| 77 | +/** Entities declared `ABSTRACT SUPERTYPE` never appear as a real STEP |
| 78 | + * record's own type name (a file instantiates one of their concrete |
| 79 | + * subtypes instead) — e.g. `IfcSimpleProperty` is itself a direct |
| 80 | + * `IfcProperty` subtype, abstract, with every one of ITS subtypes already |
| 81 | + * starting `IFCPROPERTY`. No real entity is ever literally typed |
| 82 | + * `IFCSIMPLEPROPERTY`, so the predicate under test correctly never needs |
| 83 | + * to answer for it either way; excluded here the same way the reference |
| 84 | + * `is-non-rooted-classifiable-resource.exp-derived.test.ts` excludes an |
| 85 | + * ancestor matching itself. */ |
| 86 | +function parseAbstractEntityNames(text: string): Set<string> { |
| 87 | + const out = new Set<string>(); |
| 88 | + for (const [name, block] of splitEntityBlocks(text)) { |
| 89 | + if (/\bABSTRACT\s+SUPERTYPE\b/.test(block)) out.add(name); |
| 90 | + } |
| 91 | + return out; |
| 92 | +} |
| 93 | + |
| 94 | +/** Is `name` a PROPER descendant of `ancestor` (never `name === ancestor`)? */ |
| 95 | +function isDescendantOf( |
| 96 | + parents: Map<string, string>, |
| 97 | + name: string, |
| 98 | + ancestor: string, |
| 99 | +): boolean { |
| 100 | + let cur: string | undefined = parents.get(name); |
| 101 | + const seen = new Set<string>([name]); |
| 102 | + while (cur && !seen.has(cur)) { |
| 103 | + if (cur === ancestor) return true; |
| 104 | + seen.add(cur); |
| 105 | + cur = parents.get(cur); |
| 106 | + } |
| 107 | + return false; |
| 108 | +} |
| 109 | + |
| 110 | +/** The schema's answer: is `name` a proper subtype of `IfcProperty`, |
| 111 | + * `IfcPhysicalQuantity`, or `IfcPhysicalSimpleQuantity`? */ |
| 112 | +function schemaSaysExempt(parents: Map<string, string>, name: string): boolean { |
| 113 | + return ( |
| 114 | + isDescendantOf(parents, name, 'IFCPROPERTY') || |
| 115 | + isDescendantOf(parents, name, 'IFCPHYSICALQUANTITY') || |
| 116 | + isDescendantOf(parents, name, 'IFCPHYSICALSIMPLEQUANTITY') |
| 117 | + ); |
| 118 | +} |
| 119 | + |
| 120 | +describe('isNonRootNameExempt vs the EXPRESS schemas', () => { |
| 121 | + const perSchema = SCHEMA_FILES.map((file) => { |
| 122 | + const text = schemaText(file); |
| 123 | + return { |
| 124 | + file, |
| 125 | + parents: parseEntityParents(text), |
| 126 | + names: parseAllEntityNames(text), |
| 127 | + abstractNames: parseAbstractEntityNames(text), |
| 128 | + }; |
| 129 | + }); |
| 130 | + |
| 131 | + it('reaches the schemas and finds the families this predicate is about', () => { |
| 132 | + // Anti-vacuity: a silent parse failure would make the sweep below |
| 133 | + // iterate zero entities and trivially pass. |
| 134 | + for (const { file, parents, names, abstractNames } of perSchema) { |
| 135 | + expect(names.length, `${file}: no ENTITY declarations parsed`).toBeGreaterThan(500); |
| 136 | + expect(abstractNames.has('IFCSIMPLEPROPERTY'), `${file}: IfcSimpleProperty should parse as ABSTRACT`).toBe(true); |
| 137 | + expect( |
| 138 | + isDescendantOf(parents, 'IFCCOMPLEXPROPERTY', 'IFCPROPERTY'), |
| 139 | + `${file}: IfcComplexProperty should chain to IfcProperty`, |
| 140 | + ).toBe(true); |
| 141 | + expect( |
| 142 | + isDescendantOf(parents, 'IFCQUANTITYLENGTH', 'IFCPHYSICALQUANTITY'), |
| 143 | + `${file}: IfcQuantityLength should chain to IfcPhysicalQuantity`, |
| 144 | + ).toBe(true); |
| 145 | + } |
| 146 | + }); |
| 147 | + |
| 148 | + it('exempts every non-root IfcProperty/IfcPhysicalQuantity subtype in both IFC4 and IFC4X3', () => { |
| 149 | + // Restricted to non-`IFC_ROOT_TYPES` entities: `pseudonymizeNonRootNames` |
| 150 | + // only ever consults `isNonRootNameExempt` after `IFC_ROOT_TYPES.has` |
| 151 | + // already came back `false` (`slotsFor`'s |
| 152 | + // `IFC_ROOT_TYPES.has(typeUpper) || isNonRootNameExempt(typeUpper)`), so |
| 153 | + // this predicate's answer for a ROOT type (e.g. `IfcPropertySet`, which |
| 154 | + // happens to start with `IFCPROPERTY` but is not an `IfcProperty` |
| 155 | + // subtype) is never actually consulted — asserting it here would just |
| 156 | + // be testing dead code, not the module's real behaviour. Also excludes |
| 157 | + // ABSTRACT entities: no real STEP record is ever literally typed as one. |
| 158 | + // |
| 159 | + // Only the under-exemption direction is checked here: `isNonRootNameExempt` |
| 160 | + // exempts several non-`IfcProperty` entities for reasons OUTSIDE this |
| 161 | + // family entirely (`IFCAPPLICATION`/`IFCPERSON`/`IFCORGANIZATION`/ |
| 162 | + // `IFCPERSONANDORGANIZATION` are owner-history actors, not schema |
| 163 | + // subtypes of `IfcProperty`), and the pre-existing `startsWith('IFCPROPERTY')` |
| 164 | + // already over-admits a few `IfcPropertyAbstraction` siblings that are |
| 165 | + // NOT `IfcProperty` subtypes (`IfcPropertyEnumeration`, |
| 166 | + // `IfcPropertyDependencyRelationship`) — both out of scope for #4042, |
| 167 | + // which is specifically about `IfcComplexProperty` being MISSED. The |
| 168 | + // over-admission direction for the #4042 fix itself is covered by the |
| 169 | + // dedicated mutation-guard test below instead. |
| 170 | + const disagreements: string[] = []; |
| 171 | + for (const { file, parents, names, abstractNames } of perSchema) { |
| 172 | + for (const name of names) { |
| 173 | + if (abstractNames.has(name) || IFC_ROOT_TYPES.has(name)) continue; |
| 174 | + const expected = schemaSaysExempt(parents, name); |
| 175 | + const actual = isNonRootNameExempt(name); |
| 176 | + if (expected && !actual) { |
| 177 | + disagreements.push(`${file}: ${name} — schema says exempt, code says not exempt`); |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | + expect(disagreements).toEqual([]); |
| 182 | + }); |
| 183 | + |
| 184 | + it('exempts the specific entity #4042 found missing (regression pin)', () => { |
| 185 | + expect(isNonRootNameExempt('IFCCOMPLEXPROPERTY')).toBe(true); |
| 186 | + }); |
| 187 | + |
| 188 | + it('does not widen the #4042 fix into a prefix that over-admits a sibling (mutation guard)', () => { |
| 189 | + // `IfcComplexPropertyTemplate` is a subtype of `IfcPropertyTemplate` |
| 190 | + // (itself IfcRoot-derived, not an `IfcProperty` subtype at all) — it |
| 191 | + // shares the `IfcComplexProperty*` textual prefix but is a different |
| 192 | + // entity family. A fix that generalized the exact `IFCCOMPLEXPROPERTY` |
| 193 | + // match back into `startsWith('IFCCOMPLEX')` would wrongly exempt it. |
| 194 | + expect(isNonRootNameExempt('IFCCOMPLEXPROPERTYTEMPLATE')).toBe(false); |
| 195 | + }); |
| 196 | +}); |
0 commit comments