fix(rest): state the exceljs Buffer-shim assertion once, in a typed test loader - #13425
Conversation
…est loader
`exceljs@4.4.0`'s `index.d.ts` opens with `declare interface Buffer extends
ArrayBuffer { }`. That file has 106 top-level exports, so it is a module and
this `Buffer` is module-local: it shadows Node's global `Buffer` inside every
exceljs signature, including `load(buffer: Buffer, ...)` at `index.d.ts:1490`.
A Node `Buffer` is a `Uint8Array`, so no Node `Buffer` value can satisfy that
parameter. The defect is in the published declaration, not at any call site.
`packages/rest` paid it at 6 anonymous `as any` sites and left a 7th as a
ledgered `TS2345`. This adds `src/xlsx-test-loader.ts` — one `loadXlsxWorkbook`
that performs the assertion once, against the parameter type read off the
dependency's own signature (`Parameters<Xlsx['load']>[0]`) rather than spelled
by hand — and migrates all 7 test sites onto it. Runtime is untouched: the same
Node `Buffer` reaches exceljs, which has always accepted it.
`src/import-prepare.ts:143` is production source and is deliberately unchanged;
it types its dynamic exceljs import as `any` so CSV/JSON imports do not pay for
the dependency, and a test-layer helper cannot serve it.
Ledger: `src/rest.test.ts` 3 -> 2, regenerated with
`pnpm --filter @objectstack/rest gen:test-typecheck-debt`. The two that remain
are the `IHttpRequest` pair; the authored `_note` is updated to point at the
card that holds them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…celjs-typed-loader
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fa7f32b21ad5967bc6ad6fc4497f7b5e7602b8ef && git checkout fa7f32b21ad5967bc6ad6fc4497f7b5e7602b8ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c813e7ac78ac73a451025764280b3d893684cb5d 6268cca54df977de0c230195b2b27b641692bba1 && git checkout -B drift-repro c813e7ac78ac73a451025764280b3d893684cb5d && git merge --no-ff 6268cca54df977de0c230195b2b27b641692bba1
node scripts/docs-audit/affected-docs.mjs --json c813e7ac78ac73a451025764280b3d893684cb5d |
PM 复核:ACCEPT-on-green,已 undraft。附一条裁定(dev 明确要求的)。
⭐ 选项 C 是测过并否掉的,不是跳过派发令要求先查 C 再落 A,这一步做得比要求的更实: ⭐ 控制组是关键的一笔: 实现比裁定要求的更好一点派发令说"把断言做一次"。实际做法是把参数类型从依赖自己的签名读出来( 围栏:实测通过从 diff 自己读,change set 恰好 5 个文件。
⇒ 6 个
⚖️ 裁定:
|
Fixes #13378
exceljs@4.4.0'sindex.d.tsopens, at line 1, withdeclare interface Buffer extends ArrayBuffer { }. That file carries 106 top-levelexportdeclarations, so it is a module — which makes thisBuffermodule-local, and it therefore shadows Node's globalBufferinside every exceljs signature, including theXlsx.loadatindex.d.ts:1490. A NodeBufferis aUint8Array, so no NodeBuffervalue can satisfy that parameter. The defect is in the published declaration, not at any call site.packages/restwas paying it at 6 anonymousas anysites and leaving a 7th as a ledgeredTS2345.STEP ONE — option C (upgrade upstream) was checked first, and it does not exist
Measured 2026-08-30 against the npm registry, zero repo changes:
dist-tags.latest4.4.1-prerelease.0(2024-12-20)4.4.1-prerelease.0index.d.tsline 1declare interface Buffer extends ArrayBuffer { }— identicalexportcountloadsignatureindex.d.ts:1490,load(buffer: Buffer, options?: Partial(XlsxReadOptions)): Promise(Workbook)— identical line, identical texttime.modified4.3.0line 1⇒ There is no later exceljs line to pin to. C is measured and rejected. Option A was implemented, as ruled.
What this does — option A
New
packages/rest/src/xlsx-test-loader.ts: oneloadXlsxWorkbook(bytes)that performs the assertion once and hands back aWorkbook. The assertion targets the parameter type read off the dependency's own signature —Parameters(Xlsx['load'])[0]— rather than a hand-spelledany, so if exceljs ever drops the shim the alias resolves to Node'sBufferand the assertion becomes a no-op instead of a lie. The file's header states the whole defect, quotes the declaration and the tsc error, and records the option-C reading above, so the next reader does not read it as laziness.All 7 test sites migrated — retiring the 6
as anyis the point of the shape, not a bonus:src/export-integration.test.tswb.xlsx.load(getBuffer() as any)src/export-business-timezone.test.tswb.xlsx.load(getBuffer() as any)src/rest.test.tswb.xlsx.load(buf)— the ledgeredTS2345All three files also drop their now-unused
import ExcelJS from 'exceljs'; the dependency is named in exactly one test-layer file now.grep -rn "xlsx.load(" --include=*.ts packages/rest/srcafterwards reports 2: the helper, andsrc/import-prepare.ts:143.src/import-prepare.ts:143— deliberately NOT changedIt is production source, not a test, and it is not broken: it types its dynamic import as
any(const ExcelJS: any = (await import('exceljs')).default ?? …) precisely so CSV/JSON imports do not pay for exceljs, the same patternsrc/rest-server.ts:612uses. Three reasons it stays:src/import-prepare.tswould be a real defect, not a repair;Buffer | ArrayBuffer, so which arm the assertion belongs on is a decision), and it drags exceljs's types into a production module that deliberately keeps them out. That is a bigger change than this card carries.Ledger
Regenerated with the repo's own tool (
pnpm --filter @objectstack/rest gen:test-typecheck-debt), never by hand:src/rest.test.tsdoes not reach zero, so its entry is re-recorded rather than deleted — measured, not assumed. The 2 that remain are theIHttpRequestpair, and #13377 is the card that holds them; they are untouched here. The authored_noteis updated to point at that card and to record that the exceljs entry is gone; it stays card pointers, not readings, and a re-run of the generator confirmed it survives verbatim.rest.test.ts(2064,7)/(2089,7)are now at(2063,7)/(2088,7).Verification
Behaviour is unmoved, as a typing-only change requires: the same Node
Bufferreaches exceljs, which has always accepted it — that is what all 6as anysites were already doing.Anti-vacuity — the edited files really are inside the checked programs
tsc -p tsconfig.test.json --listFileslists 654 files, among them 159packages/rest/src/**/*.test.ts, and it names all four edited/added paths. Positive controlsrc/rest-server.tspresent (1); negative controlspackages/client/src/index.tsand a non-existentsrc/no-such-file.tsboth 0. The build program (tsconfig.json --listFiles, 452 files) containssrc/xlsx-test-loader.tsand, correctly, none of the test files — so the helper is checked by both programs, the strictNodeNextone included.Reverse verification — direction predicted first, then ablated
Both legs mutate, prove the mutation on disk by blob hash, measure, restore, and prove the restore by blob hash — never by exit code. No rebuild leg applies: both programs read
packages/rest/srcfrom source, so nodistartifact can serve either reading. Each leg carried atrap … EXIT INT TERMrestore with absolute paths.Ablation 1 — undo the
src/rest.test.ts:1267migration, keep the regenerated ledger at 2.Predicted: the EXACT ratchet reds as GREW, 3 vs 2. Observed, exactly that:
src/rest.test.ts— HEAD blob10020c00d886ca492dbc03e5368b4f12061d9991, mutated on disk to4e2fe9ae11cfb3aa7b9424d7146c8f4432afd0a0(differs ⇒ the mutation landed), restored to10020c00…(equals HEAD ⇒ restored). Text anchors:loadXlsxWorkbook(buf)0 occurrences,wb.xlsx.load(buf)1.Ablation 2 — drop the assertion inside the helper (
bytes as unknown as XlsxLoadInput→bytes).Predicted: the BUILD program reds with the same
TS2345class that used to sit atrest.test.ts(1267,26), plus aTS6196for the alias that goes unused. Observed, exactly that:src/xlsx-test-loader.ts— HEAD blobf3ce5889bf94a97da4ca6dc10e3295e1d69c96b6, mutated tof18d49068dc6be3874e514d2ebad407c18251c42, restored tof3ce5889…. Text anchors:as unknown as XlsxLoadInput0,wb.xlsx.load(bytes)1. Afterwardsgit diff HEADempty andgit status --porcelainempty.⇒ the assertion is load-bearing, and the migration is what moved the ledger. Neither is decoration.
Gates
origin/mainwas merged into this branch before deriving the union (d23dc08a0merge base), thennode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackwas re-derived on the merged tree. Its stderr banner confirms the answer is about this repo at commit6268cca54dand that the--repoassertion holds. All families it named were run at that same commit —git rev-parse --short HEAD=6268cca54d, the head this PR carries.22 path-matched + 4 convention-triggered families, all green. Verdict lines, quoted from each gate's own output:
check-type-check-coverage: OK — 66/78 workspace packages type-checked (plus the root), 12 in the DEBT ledger (372 frozen raw errors), 1 exempt.(this is bothcheck:type-check-coverageandcheck:type-check-debt, the latter run with the workspace closure built:turbo run build --filter=./packages/* --filter=./packages/*/*→70 successful, 70 total)✓ check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree.OK: 24 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.(check:cross-package-test-inputs, both the pnpm and thescripts/invocation)check-dispatcher-error-vocabulary: OK — 22 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry.✓ doc authoring guard: 48 published skill files clean/14064 customer-facing string(s) across 691 spec sources clean/sibling-package prose ids hold the baselineOK every log channel keeps its receiver: 2308 non-test TS file(s) walked, 0 detach(es).OK ObjectQL double 'limit' conformance holds: 293 double(s) graded.check-page-declaration-shape: OK — 34 page entries across 2319 sources.✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a 'files' whitelist that covers every entry point … and admits no test, test-harness config or build script✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new.check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through 'dist/'.check-type-source-resolution OK — 95 tsc program(s) across 77 packages scanned.✓ where-matcher conformance holds: 316 matcher(s) discovered, 316 answer the combinator battery correctly or refuse it loudly.OK: all 123 declared cross-package glob(s) (88 unique) are covered by 'core' or 'crosspkg'.(check-ci-filter-parity)OK check:comment-mask-adoption — 14 private comment-stripper(s) … all 14 recorded.✓ check:keyed-text-bounds: … 148 keyed text-family columns judged, 148 bounded.✓ check:plugin-teardown-shape: 64 Plugin implementation(s) across 5039 source(s).✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).✓ check:undeclared-dep-imports: 78 workspace packages … 2 ledger row(s), all evidence intact.✓ affected-docs self-test: 487 cases pass./✓ check-drift-comment: 56 cases pass across 5 fixture diff(s).✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new.check-engine-double-contract: OK — 709 pinned, 134 in the DEBT ledger, 3 exempt.check-nul-bytes: OK (scanned 7439 text file(s) … no raw ASCII control bytes).Plus the repo-wide scan, run in full rather than narrowed:
pnpm lint(eslint . --no-inline-config) exit 0, no output.NOT MEASURED — one family, recorded as a refusal and NOT folded into the green list:
node scripts/check-test-completeness.mjs—PREREQUISITE NOT MET(exit 3). It grades a savedturbo run testlog and cannot produce one; its own text says the local reading for the derived family is NOT MEASURED and that this is not a red. CI passes the log on every invocation, so CI's reading is unaffected.(
check:dual-build-cjs-loadsalso refused at first withPREREQUISITE NOT METon an unbuilt closure; it is listed green above because it was re-run after the full build and measured properly.)Changeset — judged, not assumed:
skip-changesetpackages/restpublishesfiles: ["dist", "README.md", "CHANGELOG.md"], and tsup builds a single entry (entry: ['src/index.ts']). Nothing insrc/index.tsreaches the new helper, so it is never emitted. Measured after the full build, not asserted:grep -c "loadXlsxWorkbook\|xlsx-test-loader"overpackages/rest/dist/index.jsanddist/index.cjsreturns 0 for both; positive controlRestServerreturns 36 indist/index.js, so the grep and the artifact are both real. The three edited files are*.test.ts(excluded from the build config and never emitted), andtest-typecheck-debt.jsonis not in thefileswhitelist.⇒ nothing published moves ⇒ no changeset,
skip-changesetlabel applied. Hadsrc/import-prepare.tsbeen touched the answer would have been the other one — which is part of why it was left alone.Generated by Claude Code