refactoring
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.
By the X-GIS team 5 min read
Moving a module looks like the most mechanical refactor there is: git mv the
file, then repoint the imports. Extracting the Camera cluster — six files — out
of @xgis/engine into @xgis/map, we repointed roughly ninety import sites from
@xgis/engine to the new home. “Roughly ninety” is already the tell: nobody
counts by hand, so the count comes from a grep, and the job is only as correct as
that grep is complete.
It is not complete. A module’s place in a build is a set of edges in a dependency graph, and text search sees only some of those edges. Three times on this one move, a grep said the coast was clear when it wasn’t — and each time the compiler, not the grep, was the thing that actually knew.
The guard that lied: \b is not a word boundary in git grep
Before moving Camera, the safety check was simple: nothing below @xgis/map
may import it, or the move inverts a dependency. So we asked git — does @xgis/data
(which sits below map) still name Camera?
git grep -nE "import[^;]*\bCamera\b[^;]*@xgis/engine" origin/main -- 'data/src/**'Empty. Great — data is Camera-free, the move is safe. Except data was not
Camera-free at all. data/src/tile-select.ts opens with
import type { Camera } from '@xgis/engine', plain as day.
The bug is the \b. git grep -E uses POSIX ERE, and in POSIX ERE \b is not a
word boundary — it’s unspecified, and here it matched nothing resembling a word,
so the guard returned a false “clean.” The identical query in ripgrep — whose Rust
regex engine implements \b as a real word boundary — found every occurrence
instantly.
We had nearly built the whole slice on a false premise: that two prerequisite PRs,
which strip Camera out of data and rhi-webgpu, were unnecessary. They were
not. The lesson is narrow and load-bearing: git grep and ripgrep do not share a
regex dialect. A guard that must not miss is one you run in an engine whose
regex you actually trust — and cross-check when the answer is “nothing,” because
“nothing” is exactly what a broken pattern also returns.
The edge with no from: dynamic import()
With the prerequisites really in place, the move went in and the build ran. It died in the engine package — the one we were removing geo from:
engine/src/projection/globe.test.ts(326,37): error TS2307: Cannot find module './camera'Every static scan had said the staying engine files were clean. They were. But
globe.test.ts reached Camera a different way:
const { Camera } = await import('./camera')A dynamic import(). It has no from clause, so a scan for import … from './camera' never sees it, and it’s easy to forget import() is even an import
when you’re sweeping a hundred static ones. The test builds a Camera to prove a
globe matrix matches its flat-2D framing — a genuine camera↔globe test that, in
the end state, belongs with Camera in @xgis/map. Lifting that one describe
out of the engine’s globe suite into the camera cluster made the engine build go
quiet.
The edge that points outward: export … from
The build got further and died again — this time in the site bundle, on the package one level above:
runtime/src/index.ts:3 export { Camera } from '@xgis/engine' "Camera" is not exported by "engine/src/index.ts"The sweep had rewritten import { Camera } from '@xgis/engine' everywhere. It had
not touched export { Camera } from '@xgis/engine' — a re-export in the runtime
barrel — because the pattern anchored on import. A re-export is an import edge
wearing a different keyword: it pulls the symbol in and pushes it back out, and
it breaks the same way when the source stops exporting. One character in the
search — import → export — found it.
An aside that cost twenty minutes: the pipe ate the exit code
Between the second and third failures, a build “passed” that hadn’t. The command
was bun run build | tail -50, backgrounded; the completion notice read exit
code 0. But tail exits 0 whether or not bun did — in a pipeline the shell
reports the last command’s status, and the real failure had scrolled into the
middle of the log, above the tail window. Re-running as a bare bun run build > log — no pipe — surfaced the true exit 1. When a build’s pass/fail is the thing
you’re checking, never read it through a pipe: redirect, and check the exit code
of the process you actually care about.
Grep narrows, the build decides
None of these three edges is exotic. Dynamic imports, re-exports, and type-only
imports are ordinary TypeScript. What they share is that an import … from '…'
text pattern — the mental model that “an import is a line that says import … from”
— cannot see them, and neither can a sed sweep built on that model. The dependency
graph has more kinds of edge than one keyword.
So the working method is not “grep, then trust the grep.” It’s:
- Grep to narrow and to plan. It gives you the shape and the count, and it’s how you sort the work. Just never let a “no matches” stand as proof — confirm it in a second engine, because a broken pattern and a clean tree return the identical empty set.
- Let the compiler enumerate.
tsc(viabun run build) is the only tool that walks every edge — static, dynamic, re-exported, type-only — and it fails loudly and precisely (file:line, “cannot find”, “not exported by”) on the ones the grep missed. A green full build isn’t a formality after a module move; it is the reference-completeness check.
The move was, in the end, exactly as mechanical as it looked — git mv plus
import edits, zero behavioural change, byte-identical render. But “mechanical”
doesn’t mean “grep-complete.” A file move is a graph edit, and the only tool that
sees the whole graph is the one that compiles it.
Read next
architecture
5 min read
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.
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.
debugging
5 min read
A failure with no witnesses: one query pipeline that silenced all three channels
A jq command over a saved MCP result printed nothing and exited 0 — no data, no error, no failure. Every one of those was manufactured by the caller: a wrong schema emptied stdout, a defensive 2>/dev/null ate the one diagnostic line, and | head laundered jq exit code 5 into 0. A Unix pipeline has three signal channels; habits acquired as defenses can close all three at once.