architecture
The boundary audit missed an edge because a regex ate a digit
A hand audit of a 16-workspace import graph concluded the backend adapters were clean. It was wrong twice — once because `[a-z-]` doesn't match a `2`, once because `from '…'` can't see a dynamic import. The 166-line CI gate built FROM that audit found the missed edge on its first run.
By the X-GIS team 5 min read
While auditing whether our backend adapters really depend on nothing but the RHI interface, this bash one-liner summarized the two adapter packages’ imports:
grep -rhoE "from '@xgis/[a-z-]+" rhi-webgl2/src rhi-webgpu/src | sort | uniq -c# 7 from '@xgis/compiler# 5 from '@xgis/engine# 8 from '@xgis/rhi# 5 from '@xgis/rhi-webgl ← this line# 3 from '@xgis/shader-dslThat fourth line says rhi-webgl, not rhi-webgl2. The character class
[a-z-] has no digits, so the match stopped one character short of the real
package name. Reading the output, the truncated name looked like the
rhi-webgl2 package importing itself — self-import noise, safe to ignore. The
audit moved on, an architecture issue was filed from its findings, and the
finding it should have contained — one backend adapter importing another
backend adapter — wasn’t in it.
The wrong first move, twice
The tempting conclusion is “use a better regex,” and that was in fact the
second wrong move. The follow-up audit script fixed the class to
[a-z0-9-]+ and parsed every workspace’s sources properly:
for m in re.finditer(r"from\s+['\"](@xgis/[a-z0-9-]+)", text):This one produced a clean graph — and still showed rhi-webgpu → rhi-webgl2 as a test-only edge, absent from production source. Both audits
were wrong for different reasons, and the second miss is the instructive one:
the actual coupling was a lazy boot fallback, and lazy code doesn’t say
from:
// rhi-webgpu/src/backend-providers.ts — the WebGPU→WebGL2 fallback providercreate: async (canvas) => { const { WebGl2Device } = await import('@xgis/rhi-webgl2') return initGPUForcedWebGL2(canvas, (gl) => new WebGl2Device(gl))},A from\s+['"]… pattern structurally cannot see import('…'). The exact
mechanism that makes the dependency cheap at runtime — dynamic import, so only
a WebGL2 boot pays for the WebGL2 package — is what made it invisible to both
audits. The edge wasn’t hiding; it was written in a dialect the tooling didn’t
speak.
What actually happened
The audit’s conclusions became a CI gate: a 166-line vitest test that scans
every workspace’s src/**/*.ts, extracts @xgis/* imports, and fails on any
edge outside a declared allowed graph. Because it was going to run forever
rather than once, its extraction pattern got the five minutes of care the
one-liner never did:
const IMPORT_RE = /(?:from\s*|import\s*\(\s*|import\s+)['"]@xgis\/([a-z0-9-]+)['"]?/gStatic from, bare side-effect import '…', and dynamic import('…') — all
three forms. Its very first local run, before it had ever gated anything:
- Expected []+ Received [+ "rhi-webgpu -> @xgis/rhi-webgl2 (e.g. rhi-webgpu/src/backend-providers.ts)",+ ]The gate found, in its first execution, the edge two rounds of hand auditing had missed. Chasing it to the file header revealed it wasn’t even a bug: the coupling is a documented transitional home — both boot providers produce the same WebGPU-typed context until a planned context-neutralization milestone relocates them. Legitimate, deliberate, and absent from the architecture issue that was supposed to enumerate exactly this kind of thing.
The fix
The edge went into the gate’s baseline — pinned, not allowed — with the rationale attached to the pin:
const BASELINE: ReadonlyArray<readonly [pkg: string, dep: string]> = [ ['rhi-webgl2', 'compiler'], ['rhi-webgpu', 'compiler'], ['rhi-webgpu', 'engine'], // Documented transitional home (#834 M5): both boot providers produce the // WebGPU-typed GPUContext, so the webgl2 provider lives in rhi-webgpu until // GPUContext neutralizes — then this edge burns down. ['rhi-webgpu', 'rhi-webgl2'],]The baseline is shrink-only in both directions: a new edge outside
ALLOWED ∪ BASELINE fails, and a baseline edge that no longer exists in the
code also fails until its entry is deleted — so every burn-down is locked in
the same commit that achieves it, and the baseline can never quietly go stale.
One more scar from the morning’s lesson made it into the test. If a regex can silently under-match, a gate built on a regex can silently pass on nothing. So the gate asserts its own scanner sees edges that must exist:
it('scanner sanity — permanent structural edges are seen', () => { // engine→rhi and map→engine are permanent; if the scan ever stops seeing // them the walker/regex broke, not the codebase, and the gate would be // silently passing on nothing. expect(edges.has('engine -> rhi')).toBe(true) expect(edges.has('map -> engine')).toBe(true)})That canary is the difference between the gate and the audits it replaced: the one-liner’s failure mode was an output that looked complete; the gate’s failure mode is a red build.
How we know it holds
Both directions of the ratchet fired for real within the same pull request.
The undocumented edge made the gate red before baselining (above), proving
detection. Then the burn-down that removed the adapters’ engine-policy imports
deleted the rhi-webgl2 → engine edge from the code — and the gate stayed red
on the stale baseline entry until that line was deleted in the same commit,
proving the shrink lock. Five baseline entries became four, mechanically.
What generalizes
The audit and the gate ran the same kind of scan. The difference wasn’t technique — it was that the audit’s tooling was treated as throwaway and the gate’s was treated as production. A one-shot audit script gets no code review, no test, no second look at its character classes; yet its output turns directly into filed issues and architectural decisions, which makes it load-bearing exactly once — at the moment nobody is checking it. If a scan’s output will become a decision, either give the scan the same rigor as shipped code, or ship it: turn it into the permanent gate first and read the gate’s first run as the audit. The gate caught in milliseconds what the eyeball audit missed twice, and it did so before it ever gated a single commit.
References
- “Slicing a 700-reference coupling into moves you can verify” — the earlier milestone in the same boundary program; this post’s gate now pins the graph that migration produced.
Read next
ci
5 min read
The second ratchet: one invariant with two authorities, and a gate we could not run
An icon-padding feature grew three compiler files past their LOC ceiling. The PR raised the ratchet it knew about and went green — then CI failed on a SECOND ratchet tracking the same three files with the same numbers. A repo that preaches single-authority had let one invariant grow two authorities, and a local gate nobody knew existed is a gate nobody can run.
architecture
8 min read
Slicing a 700-reference coupling into moves you can verify
@xgis/map named backend types in ~700 places. You cannot cut that in one commit and stay honest. The method: sort every reference into layers by how it's actually used, find the one clean move, and prove each slice compiles to identical bytes before the next.
refactoring
5 min read
The import edges grep cannot see
Relocating a module is editing the dependency graph, and text search sees only some of its edges. It misses dynamic imports and re-exports, and a regex-dialect gap can make a guard silently lie. Grep narrows; the compiler decides.