ADR 0030: src/io/xlsx/ stays flat; the read/write directory split is rejected
Status: Accepted (2026-08-08) · module layout · refines the layering rules of ADR 0029-era tooling (scripts/check-layering.ts)
Context
src/io/xlsx/ holds ~32 non-test modules in one directory, the largest flat directory in the tree. It also spelled the read/write split three different ways: styles.ts/read-styles.ts (prefix), pivot.ts/pivot-read.ts (suffix), shared-strings.ts/shared-strings-read.ts (suffix), while seven other features handled both directions in a single unprefixed file. That reads like drift, and the obvious remedy is xlsx/read/ plus xlsx/write/ plus xlsx/shared/, with a check-layering.ts rule forbidding a read module from importing a write module. The rule would turn a naming convention into a mechanism, which is what this project prefers (check-layering.ts says so in its own header).
We classified every module by reachability from the two entry pairs (read.ts/read-rows.ts and write.ts/write-stream.ts) instead of by its name. The result: 10 read-only, 9 write-only, and 13 reachable from both.
Decision
The directory stays flat. The three-way split is rejected. Two findings drove it:
The "shared" bucket is mostly not shared infrastructure. It is seven feature modules that carry both directions.
comments.ts,tables.ts,images.ts,hyperlinks.ts,data-validation.ts,conditional-formatting.tsandthreaded-comments.tseach export aparseXbeside anxXml. That co-location is load-bearing: the two halves share one feature's element vocabulary and must agree with each other, and a round-trip case is the assertion that they do. A read/write split would cut each in half and file the two functions that have to stay in step under different directories, a strictly worse arrangement reached in the name of tidiness.The directory names would lie.
conditional-formatting.tsimports theStyleRegistryfromstyles.ts, andread.tsimportsconditional-formatting.ts. So the read pipeline depends on the write-side registry transitively no matter which directory anything sits in. Directories namedread/andwrite/would advertise a separation the graph does not have.
The invariant we do keep is narrower and true: no read-pipeline module imports a write-pipeline module directly. Making that hold required extracting parseColor and colorAttrs into color-xml.ts, because reading and writing <color> are one concern with two directions, and parseColor had been sitting in the write-side style table, which made read-styles.ts and read-worksheet.ts import the writer to decode a colour. That was the only such import in the codec.
We do not gate that invariant, and this is the part most likely to be revisited, so here is why: a gate for it cannot be written honestly today.
check-layering.tsmatchesforbiddenentries as directory prefixes (target.startsWith(layer + '/')), so a file-level rule silently never fires. ItsRULES.findalso stops at the first matching rule, so per-file rules cannot stack.- A rule that derives the write pipeline from reachability defeats itself. The moment a read module imports a write module, that module becomes reachable from the read roots and therefore classifies as shared, which is exactly the label that makes the check pass. Verified, not assumed.
- A declared list of write-pipeline modules would work and would go stale in silence, since a new write module simply would not be on it. Silent staleness is the failure mode these checkers exist to prevent; adding one that has it would cost more credibility than the rule is worth.
So the convention is documented in docs/architecture.md, which now describes the three kinds of module and why the tidy-up is wrong, and enforced by review.
Enforced by review means enforced by nobody in particular, and it has already slipped once.rich-runs.ts was read-only and unprefixed for as long as it existed, and is read-rich-runs.ts since 2026-09-04. The reason a checker is still the wrong answer is above and unchanged; the reason to say this here is that a convention a reader assumes is gated is a convention they stop checking.
Consequences
- Good: the seven bidirectional feature modules stay whole. The one real read-to-write dependency is gone. The naming is uniform:
pivot-read.tsbecameread-pivot.tsandshared-strings-read.tsbecameread-shared-strings.ts, soread-is the prefix everywhere. - Good:
<color>serialisation and parsing now sit together, where the round-trip agreement they owe each other is visible in one file. - Bad: the read/write direction rests on a documented convention, not a gate, which is the weaker of the two kinds of guarantee this project trusts. A reader borrowing a helper out of a write module would pass CI.
docs/architecture.mdnamescolor-xml.tsas the precedent for where such a helper goes. - Neutral: the directory stays large. Size alone was never the problem; the mixed-concern module was, and that one is fixed.
Revisit if
check-layering.tsgrows file-levelforbiddenmatching and multi-rule stacking, at which point the direction rule becomes expressible without a stale-prone list. That is the cheapest path to gating this.- A feature module's two halves stop sharing anything but a filename, which would make splitting that module (not the directory) correct.