Skip to content

Commit c7f59ce

Browse files
BIMvoicelouistrue
andauthored
fix(export): exempt IfcComplexProperty's Name from anonymize over-scrub (#4049)
* fix(export): exempt IfcComplexProperty's Name from anonymize over-scrub isNonRootNameExempt's startsWith('IFCPROPERTY') check is meant to exempt every IfcProperty subtype's Name from pseudonymizeAllNames's non-root sweep, so property/quantity names stay legible under keepPropertySets. IfcComplexProperty is a direct IfcProperty subtype in both IFC4 and IFC4X3 whose own name doesn't start with IFCPROPERTY, so it fell through to the sweep and got pseudonymized instead of exempted. This is an over-scrub (a debuggability loss), not a leak: the exemption should have applied and didn't, so the output ends up more anonymized than intended, never less. Fixed with an explicit exact-type check rather than widening the existing prefix, so the fix doesn't reintroduce the same defect shape at a different edge, and pinned with anonymize-scrub.exp-derived.test.ts, which re-derives which entities are IfcProperty/IfcPhysicalQuantity subtypes directly from the IFC4_ADD2_TC1.exp and IFC4X3.exp schemas and asserts isNonRootNameExempt agrees for every one, plus a mutation-guard test proving a future widening of the new check back into a prefix (e.g. startsWith('IFCCOMPLEX')) is caught. isNonRootNameExempt's IFCPHYSICAL/IFCQUANTITY prefixes were independently re-verified against both schemas and are safe: every subtype of IfcPhysicalQuantity and IfcPhysicalSimpleQuantity in both IFC4 and IFC4X3 carries the matching prefix. apps/viewer/src/sdk/adapters/query-adapter.ts's isProductType, raised alongside this in the same issue, is confirmed NOT a defect: it gates on IfcTypeEnumFromString's hand-enumerated whitelist before its own prefix checks run, and every whitelist entry already matches its prefix by construction, so no type the prefix would miss is ever reachable there. Left unchanged. Should be converted to a proper isSubtypeOf schema-hierarchy check once @ifc-lite/codegen's exported schema hierarchy (#4041) lands; kept as a self-contained fix on main for now rather than stacking a dependency on that still-open PR for a cosmetic issue. pnpm --filter @ifc-lite/export test: 1157 passed / 34 skipped before (measured against a clean upstream/main checkout), 1166 passed / 34 skipped after (net +9 from 5 new tests plus lod1-generator.test.ts's pre-existing wasm-resolution failure clearing once the workspace was built). tsc --noEmit, check-module-size.mjs, check-test-wiring.mjs, check-source-text-assertions.mjs, and check-api-surface.mjs all pass. Closes #4042 Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436 * test(export): make the anonymize-scrub 'still scrubbed' control exercise isNonRootNameExempt 'control: a type that should still be scrubbed (IfcPropertySet, an IfcRoot) still is' didn't test what it claimed: IfcPropertySet IS an IfcRoot, so slotsFor's `IFC_ROOT_TYPES.has(typeUpper) || isNonRootNameExempt(typeUpper)` short-circuits on IFC_ROOT_TYPES before isNonRootNameExempt is ever consulted. Mutating isNonRootNameExempt to `return true` unconditionally left this specific test green (5 others in the file went red) -- CodeRabbit flagged this and it was not addressed. Adds a non-root IfcMaterial (#40) to the complex-property fixture and pins its Name is still pseudonymized, which genuinely depends on isNonRootNameExempt returning false for IFCMATERIAL -- proving the fix's new exact-type check for IfcComplexProperty didn't widen the exemption to cover unrelated non-root classes too. Verified RED/GREEN: mutating isNonRootNameExempt to `return true` failed the new control with "expected 'Concrete C30/37' to be 'IfcMaterial-1'" (plus 4 other, pre-existing tests); reverting the mutation returns all 24 tests in the file to green. pnpm --filter @ifc-lite/export test: 1166 passed / 34 skipped, unchanged (no lod1-generator wasm failure in this workspace -- packages/wasm/dist is present). Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436 * chore(export): keep exemption predicate reviewer-quotable --------- Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
1 parent f4a2642 commit c7f59ce

4 files changed

Lines changed: 324 additions & 3 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@ifc-lite/export": patch
3+
---
4+
5+
Fix an anonymized subset export over-scrubbing `IfcComplexProperty`'s `Name`. `isNonRootNameExempt`'s `startsWith('IFCPROPERTY')` check exempts `IfcProperty` subtypes' names from pseudonymization (so property/quantity names stay legible under `keepPropertySets`), but `IfcComplexProperty` is a direct `IfcProperty` subtype in both IFC4 and IFC4X3 whose own name doesn't start with `IFCPROPERTY`, so it fell through to the sweep and got pseudonymized instead of exempted. This is an over-scrub (a debuggability loss), not a data leak — the direction is the opposite: the export was more anonymized than intended.
6+
7+
Fixed with an explicit exact-type check rather than widening the string prefix, to avoid reintroducing the same defect shape at a different edge; pinned with a test that derives the answer from the EXPRESS schemas directly. Should be converted to a proper `isSubtypeOf` schema-hierarchy check once `@ifc-lite/codegen`'s exported schema hierarchy (#4041) lands.
8+
9+
Also tightens `anonymize-scrub.test.ts`'s "control: a type that should still be scrubbed" test, which used `IfcPropertySet` — an `IfcRoot`, so `slotsFor` never reaches `isNonRootNameExempt` for it at all (it short-circuits on `IFC_ROOT_TYPES` first) — and stayed green even when `isNonRootNameExempt` was mutated to `return true` unconditionally. The control now pins a non-root `IfcMaterial`, whose `Name` genuinely depends on `isNonRootNameExempt`'s answer.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
});

packages/export/src/anonymize-scrub.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,119 @@ describe('applyScrub: return value', () => {
426426
expect(content).not.toContain('Wall A');
427427
});
428428
});
429+
430+
/**
431+
* `IfcComplexProperty` (#4042): `isNonRootNameExempt`'s
432+
* `startsWith('IFCPROPERTY')` check is meant to exempt every `IfcProperty`
433+
* subtype's `Name` from `pseudonymizeAllNames`'s non-root sweep — the
434+
* exemption exists so property/quantity names stay legible under
435+
* `keepPropertySets` (see the doc above `isNonRootNameExempt`). But
436+
* `IfcComplexProperty` is a direct `IfcProperty` subtype in both the
437+
* IFC4 and IFC4X3 EXPRESS schemas (`ENTITY IfcProperty ABSTRACT SUPERTYPE
438+
* OF (ONEOF (IfcComplexProperty, IfcSimpleProperty))`) whose own name
439+
* does not start with `IFCPROPERTY`, so it falls through to the sweep and
440+
* gets pseudonymized instead of exempted — an over-scrub, not a leak: the
441+
* composite property's real name is lost from the debugging repro the
442+
* exemption exists for, but nothing extra escapes scrubbing.
443+
*
444+
* A minimal fixture with its own `IfcPropertySet` (kept via
445+
* `keepPropertySets`, reached through `IfcWallType.HasPropertySets` the
446+
* same way the shared fixture's #7/#8 are) holding one `IfcPropertySingleValue`
447+
* (the control: already correctly exempt) and one `IfcComplexProperty` (the
448+
* regression this issue is about).
449+
*/
450+
const COMPLEX_PROPERTY_MODEL = `ISO-10303-21;
451+
HEADER;
452+
FILE_DESCRIPTION((''),'2;1');
453+
FILE_NAME('complex-property-fixture.ifc','2024-01-01T00:00:00',(''),(''),'','','');
454+
FILE_SCHEMA(('IFC4'));
455+
ENDSEC;
456+
DATA;
457+
#1=IFCPROJECT('${guid(1)}',#10,'Project One',$,$,$,$,$,#61);
458+
#60=IFCMONETARYUNIT('NOK');
459+
#61=IFCUNITASSIGNMENT((#60));
460+
#4=IFCBUILDINGSTOREY('${guid(4)}',#10,'Storey One',$,$,$,$,$,$,0.);
461+
#5=IFCWALL('${guid(5)}',#10,'Wall A',$,$,$,$,'TAG-A');
462+
#6=IFCWALLTYPE('${guid(6)}',#10,'WallType A',$,$,(#7),$,$,$,.NOTDEFINED.);
463+
#7=IFCPROPERTYSET('${guid(7)}',#10,'Pset_Test',$,(#8,#9));
464+
#8=IFCPROPERTYSINGLEVALUE('SimpleProp',$,IFCLABEL('foo'),$);
465+
#9=IFCCOMPLEXPROPERTY('ComplexProp',$,'Usage',(#8));
466+
#40=IFCMATERIAL('Concrete C30/37',$,$);
467+
#10=IFCOWNERHISTORY(#13,#14,$,.NOCHANGE.,1700000001,$,$,1700000000);
468+
#11=IFCPERSON('IDENT-1','Doe','Jane',$,$,$,$,$);
469+
#12=IFCORGANIZATION($,'Acme Consulting','Structural Engineering',$,$);
470+
#13=IFCPERSONANDORGANIZATION(#11,#12,$);
471+
#14=IFCAPPLICATION(#12,'26.0.0 NOR FULL','ifc-lite','ifc-lite-export');
472+
#20=IFCRELCONTAINEDINSPATIALSTRUCTURE('${guid(20)}',#10,$,$,(#5),#4);
473+
#21=IFCRELDEFINESBYTYPE('${guid(21)}',#10,$,$,(#5),#6);
474+
#30=IFCRELAGGREGATES('${guid(30)}',#10,$,$,#1,(#4));
475+
ENDSEC;
476+
END-ISO-10303-21;`;
477+
478+
// #7/#8/#9 (the property set and its two properties) are included explicitly
479+
// rather than relied on to arrive through the exporter's own forward
480+
// closure — whether a `HasPropertySets` reference is followed into that
481+
// closure is an orthogonal `visibleOnly`-style concern this test is not
482+
// about; it only needs #7-#9 to be present in the export so their pseudonym
483+
// (or lack of one) can be asserted. #40 (`IfcMaterial`, unreferenced by
484+
// anything else in this fixture) is included the same way, purely so the
485+
// "still scrubbed" control below has a genuinely non-`IfcRoot` type to pin —
486+
// see that test.
487+
const COMPLEX_PROPERTY_INCLUDED_IDS = new Set([1, 4, 5, 6, 7, 8, 9, 20, 21, 30, 40]);
488+
489+
async function complexPropertyFixture() {
490+
const store = await parse(COMPLEX_PROPERTY_MODEL);
491+
const view = new MutablePropertyView(null, 'anonymize');
492+
const index = getEffectiveEntityIndex(store, view, true);
493+
return { store, view, index };
494+
}
495+
496+
function exportComplexPropertyFixture(store: IfcDataStore, view: MutablePropertyView): string {
497+
return decode(
498+
new StepExporter(store, view).export({
499+
schema: 'IFC4',
500+
subsetEntityIds: COMPLEX_PROPERTY_INCLUDED_IDS,
501+
author: '',
502+
organization: '',
503+
authorization: '',
504+
timeStamp: '2024-01-01T00:00:00',
505+
}).content,
506+
);
507+
}
508+
509+
describe('applyScrub: isNonRootNameExempt covers IfcComplexProperty (#4042)', () => {
510+
it('exempts IfcComplexProperty.Name from pseudonymization, same as IfcPropertySingleValue.Name', async () => {
511+
const { store, view, index } = await complexPropertyFixture();
512+
applyScrub(store, index, COMPLEX_PROPERTY_INCLUDED_IDS, view, {
513+
keepPropertySets: true,
514+
guidRandom: seededRandom(1),
515+
});
516+
const content = exportComplexPropertyFixture(store, view);
517+
518+
// Control: IfcPropertySingleValue.Name is already correctly exempt.
519+
expect(lineArgs(content, 8)[0]).toBe("'SimpleProp'");
520+
// Regression: IfcComplexProperty.Name must be exempt too, not pseudonymized.
521+
expect(lineArgs(content, 9)[0]).toBe("'ComplexProp'");
522+
});
523+
524+
it('control: a non-root type that should still be scrubbed (IfcMaterial) still is', async () => {
525+
const { store, view, index } = await complexPropertyFixture();
526+
applyScrub(store, index, COMPLEX_PROPERTY_INCLUDED_IDS, view, {
527+
keepPropertySets: true,
528+
guidRandom: seededRandom(1),
529+
});
530+
const content = exportComplexPropertyFixture(store, view);
531+
532+
// IfcMaterial is not an IfcRoot, so `slotsFor` reaches `isNonRootNameExempt`
533+
// rather than short-circuiting on `IFC_ROOT_TYPES` first (unlike
534+
// IfcPropertySet, which IS an IfcRoot and would stay pseudonymized under
535+
// the ROOT sweep no matter what isNonRootNameExempt returns — that
536+
// shorter-circuiting is exactly why an earlier version of this test using
537+
// IfcPropertySet passed even when isNonRootNameExempt was mutated to
538+
// `return true` unconditionally). IfcMaterial.Name isn't in the exempt
539+
// list, so it must still be pseudonymized: proves the fix's new
540+
// exact-type check for IfcComplexProperty didn't widen the exemption to
541+
// cover unrelated non-root classes too.
542+
expect(lineArgs(content, 40)[0]).toBe("'IfcMaterial-1'");
543+
});
544+
});

packages/export/src/anonymize-scrub.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,15 @@ const NON_ROOT_NAME_ATTRIBUTES = ['Name', 'LongName', 'Description', 'ProfileNam
6666
* (kept by decision — it is debugging signal), and property / quantity
6767
* names (`FireRating`, `Width`), which are only exported at all under
6868
* `keepPropertySets` and are what a property-debugging repro needs. */
69-
function isNonRootNameExempt(typeUpper: string): boolean {
69+
export function isNonRootNameExempt(typeUpper: string): boolean {
7070
return typeUpper === 'IFCAPPLICATION'
7171
// Owner-history actors are `scrubOwnerHistory`'s job (their whole
7272
// attribute list, not just Name), and must stay untouched when it is off.
7373
|| typeUpper === 'IFCPERSON'
7474
|| typeUpper === 'IFCORGANIZATION'
7575
|| typeUpper === 'IFCPERSONANDORGANIZATION'
76-
|| typeUpper.startsWith('IFCPROPERTY')
76+
// #4042: IfcComplexProperty is a direct subtype whose name lacks the prefix.
77+
|| typeUpper.startsWith('IFCPROPERTY') || typeUpper === 'IFCCOMPLEXPROPERTY'
7778
|| typeUpper.startsWith('IFCPHYSICAL')
7879
|| typeUpper.startsWith('IFCQUANTITY');
7980
}
@@ -105,7 +106,6 @@ export interface ScrubResult {
105106
* could not be decoded, left untouched by the affected sub-step. */
106107
warnings: string[];
107108
}
108-
109109
/**
110110
* Scrub `includedIds`' `IfcRoot` entities plus every owner-history entity in
111111
* `index`, queuing every change on `view` (never writing into `store`).

0 commit comments

Comments
 (0)