What We Built
The brief was small: two interfaces called ResolvedPositionStyle had
drifted. One listed three marker shapes, the other five. One called the
label field label, the other labelText. Make them one. A half-day job.
The /speckit.review pass made it a week. If the interface had drifted,
what about the enum it points at? The resolver that produces it? The
renderer switch that consumes it? The VS Code tool that takes a shape as a
parameter? We went and looked. The answer was drift in every direction.
So the work expanded along a single axis — marker shapes — from the LinkML
schema all the way to the MCP tool input schema. Every link in that chain
now points at one source of truth: PointShapeEnum in
shared/schemas/src/linkml/common.yaml. Change a value there, rerun
pnpm --filter @debrief/schemas build, and the type widens or narrows
everywhere that uses it, automatically.
The moving parts that landed together:
- One interface.
ResolvedPositionStylelives in@debrief/utils.symbol: PointShape(template-literal derivation of the schema enum),labelText: string | null. The components-side duplicate is gone; its file now re-exports. - One resolver.
resolvePositionStyleandcomputeAllPositionStylesare single-implementation in@debrief/utils. The components-side copy — which had subtly different null-override semantics — is gone. The winner:nullmeans “no override, use the cascaded default”, matching the LinkML attribute description. - One exhaustive switch. Every renderer switch on
symbolnow has anassertNever(shape)default branch. Adding a 6th shape in LinkML breaks the build in every renderer file that needs updating, until it does. - A real runtime guard. Before: a JSON payload with
symbol: "star"silently drew a circle. After: it throwsInvalidPointShapeErrorwith the offending value and the valid set; the renderer catches, logs, and skips the symbol — without crashing the rest of the track. - Narrowed generator output. A post-process step in
scripts/generate.pyrewritessymbol: string,tosymbol: PointShape,on the two enum-ranged attributes aftergen-typescriptruns. No more “stringin the type, enum in the comment” gap. - Pinned enum parity. A new schema-adherence test keeps
PointShapeEnumandMarkerSymbolEnumfrom silently drifting again — the ADR from feature #091 is honoured, the drift surface is closed.
Numbers
| Before | After | |
|---|---|---|
interface ResolvedPositionStyle declarations |
2 | 1 |
resolvePositionStyle / computeAllPositionStyles implementations |
2 each | 1 each |
| Hand-typed shape unions across the codebase | 3 | 0 |
| Renderer switches silently falling through on unknown shapes | 2 | 0 |
| Enum-parity adherence tests | 0 | 1 |
| Compile-time shape-drift warnings after a LinkML enum edit | 0 | depends on which renderer doesn’t handle the new case |
Lessons Learned
The /speckit.review pass is where the scope expansion happens. A
spec that only said “consolidate two interfaces” would have shipped the
half-day fix and left the underlying drift alive. The review asked
why have the interfaces drifted at all? and the answer forced six
sibling sites into the same refactor.
R-011 was the one real risk. gen-typescript emits string for
enum-ranged LinkML attributes, which is why the hand-typed unions exist
in the first place. We time-boxed a prototype for the
post-process mechanism — the existing generate.py already runs seven
post-process steps on the gen-typescript output for GeoJSON
coordinates, so the eighth was additive. Tractable.
Never silently default on schema mismatch. The old resolver’s
symbol as 'circle' | 'square' | 'triangle' cast let unknown values
fall through to a default circle. That’s an Article I.3 silent-failure
violation that pre-dated this feature; catching it here, via a typed
error and a try/catch in the renderer, was the shortest path to closing
it. Users with broken data now learn their data is broken.
What’s Next
Backlog #206 tracks the broader audit for other hand-typed unions along other axes (named colours, line caps, line joins, label locations, etc). The mechanism proven here — schema-derived template-literal type + generator post-process + assertNever defaults + enum-parity test — can carry over, one axis at a time.