Skip to content

Commit 08a6c7a

Browse files
claude[bot]claude
andauthored
fix(scripts): make check-test-typecheck signatures path-independent (#18173)
Fixes #17739 ## The defect `normalizeMessage` in `scripts/check-test-typecheck.mts` keeps a quoted span verbatim when it is short and free of the structural punctuation that makes tsc print a whole shape. A **TS6059** message reads `File 'ABS' is not under 'rootDir' 'ABS'`, and an absolute filesystem path carries none of that punctuation — so **its LENGTH was the only thing deciding whether it survived**, and the identical diagnostic produced one signature in a short-path checkout and a different one in a long-path checkout. The ledgers were recorded from a long path, and CI's checkout path is long too, which is why this survived: it reds **only locally and in short-path agent worktrees**. ## The two readings — pristine `main`, same commit, same command Both worktrees at `origin/main` `7e58d177e`, both after `pnpm install` and the same `pnpm --workspace-concurrency=2 --filter '@objectstack/lint^...' build` dependency-closure build. Nothing but the checkout path differs. | worktree | `packages/lint/src` | `pnpm --filter @objectstack/lint typecheck` | |:---|---:|:---| | `/home/user/objectstack-issue-17739` | 52 chars | **exit 0** — `OK, 2 file(s) / 6 error(s) / 2 pinned signature(s)` | | `/tmp/w9` | 25 chars | **exit 1** — `4 problem(s)` | The short-path run reports the **same six already-ledgered TS6059 errors** as both ARRIVED (`... 'rootDir' '/tmp/w9/packages/lint/src'` — a signature the ledger does not record) and VANISHED (`... 'rootDir' '...'` — the recorded one absent), and its ARRIVED text instructs the reader to go repair errors that are not new. The card's headline is therefore **confirmed**, and confirmed on today's `main`, not only on the `431c757120` it was filed against. It also contradicted the function's own docblock: an absolute checkout path is not "a name, a dotted path, or a small literal type", and it changes when the **checkout** moves rather than when the error does. ## The repair A quoted span that is a filesystem-absolute path (POSIX, Windows drive letter with either separator, UNC) now collapses **at every length** — which is exactly what a long-path checkout already did. The change is one added condition plus its detector, `isAbsolutePathSpan`, exported so the self-test can prove it still reaches its subject. **Rejected alternative: relativising the path against the repo root.** It buys a sharper signature, but it re-spells the signature every existing ledger already holds in its collapsed form — churning a maintainer-gated shrink-only ratchet for discrimination no ledger has ever recorded. Measured across all 19 `test-typecheck-debt.json` ledgers **before** choosing: quoted spans beginning with `/` = **0**, drive-letter spans = **0**, against **89** capital-initial spans as the firing control. **Effect on the existing ledger: none, measured two ways.** 1. By construction, from the counts above: no recorded signature contains an absolute-path span, so none can be re-spelled. 2. Empirically, at the final head with the fix in place: `pnpm -r run check:test-typecheck` over **every** package that wires this gate — **36 packages, 36 `OK` lines, 0 problems**, and `git status --porcelain` empty afterwards (no ledger rewritten). Same lint verdict before and after the fix on the long path: `2 file(s) / 6 error(s) / 2 pinned signature(s)`. This does **not** weaken the gate: it collapses only what a long path already collapsed, so no discrimination any ledger or CI has ever relied on is given up. An over-masking control in the self-test pins that short **non**-path spans (a relative specifier, a named type) still survive verbatim. ## Tests A sixth self-test battery, `Path-independence: a signature must not move with the CHECKOUT (#17739)`, floor 7, registered in `SELF_TEST_BATTERIES` with `SELF_TEST_BATTERY_FLOOR` raised 5 to 6. It pins the property (one signature from two checkout paths), a control that the fixture really straddles the 32-character threshold the defect lived on, a zero-churn pin against the spelling the ledgers hold today, the Windows spellings, an over-masking control, and a detector-discrimination control. **Ablation** (one-shot, on the throwaway short-path worktree whose copy was proved byte-identical to this branch's blob `76c65e7e41f1230139d7cbdbdf48ce09a3f0b462` before the mutation, and restored to that same hash after): | leg | on-disk proof | result | |:---|:---|:---| | remove ` && !isAbsolutePathSpan(inner)` | marker count 1 to 0, blob hash `76c65e7e` to `d568fd90` | self-test **exit 1**, 3 named `#17739` failures — the property, the zero-churn pin, the Windows pin; `pnpm --filter @objectstack/lint typecheck` **exit 1** | | restore from the branch | `git hash-object` back to `76c65e7e41f1230139d7cbdbdf48ce09a3f0b462` | self-test exit 0 | The ablated property failure prints the defect verbatim: `got "TS6059: File '...' is not under 'rootDir' '/tmp/w9/packages/lint/src'. ..." vs "TS6059: File '...' is not under 'rootDir' '...'. ..."`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived **27** families for this one-path diff; all 27 run at the final head `f7c116ae2`, all **exit 0**, reconciled with `--ran` carrying exit codes: *27 derived, 27 run, 0 NOT-MEASURED (a DERIVED zero)*. `check:type-check-debt` first returned **exit 3 (PREREQUISITE NOT MET)** twice — first for an unbuilt workspace closure, then for an OOM under the caller's own `--max-old-space-size=4096`, which is tighter than the CI-shaped 6144 ceiling that gate pins. Neither exit 3 is recorded as a measurement; the closure was built (`turbo run build`, 72/72) and the gate re-run at 8192 to a real **exit 0**. Also run: the self-test through `tsx` directly, `eslint` on the changed file (0 errors / 0 warnings over 1 file, read from `--format json`; `eslint.config.mjs` enables no type-aware linting, so a one-file diff in `scripts/` cannot move any untouched file's verdict), and a manual control-character sweep over the changed file (0 hits) beside `check:nul-bytes`. ## Changeset `skip-changeset`. Measured on the publish surface rather than asserted: all **70** non-private packages declare `files[]`, the distinct entries are `CHANGELOG.md README.md api-surface dist json-schema liveness llms.txt prompts spec-changes.json src/**/*.zod.ts`, **0** of them escape their own package directory, and grepping the 69 built `dist` trees for the changed symbols gives `isAbsolutePathSpan` **0** and `STABLE_QUOTED_SPAN` **0** against a positive control of `ObjectKernel` **106**. The repo-root `scripts/` directory publishes nothing. ## Acceptance notes - `noted, not filed:` the card's declared scope limit still stands — only TS6059 was measured to carry an absolute path in a quoted span, and no sweep of other diagnostic codes was done. The repair does not need one (it is keyed on the span's shape, not on the TS code), so this is an observation rather than a finding. Carrier: whoever next reads `normalizeMessage`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fffe3e5 commit 08a6c7a

1 file changed

Lines changed: 143 additions & 5 deletions

File tree

‎scripts/check-test-typecheck.mts‎

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,16 +315,71 @@ const DIAGNOSTIC = /^(\S[^(]*)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
315315
/** Longest quoted span kept verbatim; beyond this it is a type blob, not a name. */
316316
const STABLE_QUOTED_SPAN = 32;
317317

318+
// ── Why an ABSOLUTE PATH collapses at every length (#17739) ──────────────
319+
//
320+
// The length test is the WHOLE decision for a span carrying none of `{ < ( [`,
321+
// and an absolute filesystem path carries none of them. So until this rule
322+
// existed, whether such a path survived verbatim was decided by how long the
323+
// CHECKOUT's path is — by nothing about the error at all:
324+
//
325+
// TS6059 reads `File '…' is not under 'rootDir' '<an ABSOLUTE path>'.`
326+
// /tmp/w9/packages/lint/src 25 chars → survived VERBATIM
327+
// /home/user/objectstack/packages/lint/src 40 chars → collapsed to '…'
328+
//
329+
// Measured on pristine `origin/main` @ 7e58d177e, same command, same commit:
330+
// `pnpm --filter @objectstack/lint typecheck` exits 0 from a 52-character
331+
// checkout and 1 from a 25-character one, where it reports the SAME six TS6059
332+
// errors as both ARRIVED (a signature the ledger does not record) and VANISHED
333+
// (the recorded one absent). The ledger was recorded from a long path, and CI's
334+
// path is long too, which is why this survived: it reds only in a short-path
335+
// checkout — and its ARRIVED text there instructs the reader to go repair
336+
// errors that are not new, which is worse than a gate that is merely noisy.
337+
//
338+
// It also contradicted the docblock below. An absolute checkout path is not "a
339+
// name, a dotted path, or a small literal type", and it changes when the
340+
// CHECKOUT moves, not when the error does — the one property that paragraph
341+
// says every surviving span has.
342+
//
343+
// THE REPAIR IS THE NARROWEST ONE THAT MAKES THE SIGNATURE PATH-INDEPENDENT: a
344+
// span that is an absolute path collapses like a type blob does, at every
345+
// length. Relativising it against the repo root instead was considered and
346+
// rejected — it would re-spell the signature every ledger already holds in its
347+
// collapsed form, churning a maintainer-gated shrink-only ratchet to buy
348+
// discrimination no ledger has ever recorded. Measured before choosing, across
349+
// all 19 `test-typecheck-debt.json` ledgers: quoted spans beginning `/` = 0,
350+
// drive-letter spans = 0, against 89 spans beginning with a capital letter as
351+
// the firing control. So this rule re-spells NO recorded signature; it only
352+
// stops a short-path checkout from producing a different one.
353+
//
354+
// ⚠️ It therefore collapses only what a long-path checkout already collapsed:
355+
// no discrimination any ledger, or CI, has ever relied on is given up.
356+
357+
/** A quoted span that is a filesystem-absolute path: POSIX, Windows drive, or UNC. */
358+
const ABSOLUTE_PATH_SPAN = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
359+
360+
/**
361+
* Whether a quoted span is an absolute filesystem path, and therefore moves
362+
* with the CHECKOUT rather than with the error (#17739). Exported so the
363+
* self-test can prove the detector still reaches its subject: a span shape that
364+
* stopped matching would make the path-independence pins pass vacuously.
365+
*/
366+
export function isAbsolutePathSpan(inner: string): boolean {
367+
return ABSOLUTE_PATH_SPAN.test(inner);
368+
}
369+
318370
/**
319371
* A diagnostic message with its churn-prone spans collapsed. A quoted span
320372
* survives only when it is short AND free of the structural punctuation that
321-
* makes tsc print a whole shape — i.e. when it is a name, a dotted path, or a
322-
* small literal type, all of which change only when the error does.
373+
* makes tsc print a whole shape AND not an absolute filesystem path — i.e. when
374+
* it is a name, a dotted path, or a small literal type, all of which change
375+
* only when the error does.
323376
*/
324377
export function normalizeMessage(message: string): string {
325378
return message
326379
.replace(/'([^']*)'/g, (whole, inner: string) =>
327-
inner.length <= STABLE_QUOTED_SPAN && !/[{<(\[]/.test(inner) ? whole : "'…'",
380+
inner.length <= STABLE_QUOTED_SPAN && !/[{<(\[]/.test(inner) && !isAbsolutePathSpan(inner)
381+
? whole
382+
: "'…'",
328383
)
329384
.replace(/\s+/g, ' ')
330385
.trim();
@@ -538,11 +593,12 @@ const SELF_TEST_BATTERIES: Readonly<Record<string, number>> = Object.freeze({
538593
'The ratchet-remedy authority convention (#8435)': 7,
539594
'The same authority rule, applied to the per-SIGNATURE offer (#13470)': 3,
540595
'The ledger\'s own prose (#12624)': 11,
596+
'Path-independence: a signature must not move with the CHECKOUT (#17739)': 7,
541597
});
542598

543599
// DELETING an entry silences that battery's floor exactly as effectively as
544600
// zeroing it, so the roster's own size is pinned too.
545-
const SELF_TEST_BATTERY_FLOOR = 5;
601+
const SELF_TEST_BATTERY_FLOOR = 6;
546602

547603
// The key an assertion is filed under when no battery is open. It is not a
548604
// declared battery, so it reds by the same set difference rather than silently
@@ -759,6 +815,86 @@ function selfTest(): string {
759815
);
760816
}
761817

818+
// ── A signature must not move with the CHECKOUT (#17739) ─────────────────
819+
//
820+
// TS6059 prints the offending file and the `rootDir` as ABSOLUTE paths, and
821+
// an absolute path carries none of the structural punctuation the collapse
822+
// rule keys on — so its LENGTH decided whether it survived, and the same
823+
// diagnostic had one signature in a short-path checkout and a different one
824+
// in a long one. The ledgers were recorded long, so a short-path checkout
825+
// reported six real, already-ledgered TS6059 errors as ARRIVED and VANISHED
826+
// at once and exited 1 on pristine `main`.
827+
//
828+
// The pins are paired the way the rest of this file pairs them: the property
829+
// (one signature from two checkout paths), a control proving the fixture
830+
// really straddles the length threshold the defect lived on — else the
831+
// equality could hold for a reason that is not the repair — a zero-churn pin
832+
// against the spelling every ledger already holds, and an over-masking
833+
// control proving short NON-path spans are left alone.
834+
battery('Path-independence: a signature must not move with the CHECKOUT (#17739)');
835+
{
836+
const SHORT_ROOT = '/tmp/w9/packages/lint/src';
837+
const LONG_ROOT = '/home/user/objectstack/packages/lint/src';
838+
const ts6059 = (rootDir: string): string =>
839+
`File '${rootDir}/../../../examples/app-showcase/src/app.tsx' is not under 'rootDir' `
840+
+ `'${rootDir}'. 'rootDir' is expected to contain all source files.`;
841+
/** The spelling packages/lint and packages/cli record TODAY. */
842+
const LEDGERED =
843+
"TS6059: File '…' is not under 'rootDir' '…'. 'rootDir' is expected to contain all source files.";
844+
845+
expect(
846+
`#17739 (control) — the fixture straddles the length threshold the defect lived on: the short `
847+
+ `rootDir is ${SHORT_ROOT.length} chars (<= ${STABLE_QUOTED_SPAN}, so it USED to survive `
848+
+ `verbatim) and the long one ${LONG_ROOT.length} (> ${STABLE_QUOTED_SPAN}, always collapsed). `
849+
+ 'Without this the equality below could hold for a reason that is not the repair',
850+
SHORT_ROOT.length <= STABLE_QUOTED_SPAN && LONG_ROOT.length > STABLE_QUOTED_SPAN,
851+
);
852+
expect(
853+
'#17739 (control) — and the two RAW messages really do differ, so the equality below is not one '
854+
+ 'input compared with itself',
855+
ts6059(SHORT_ROOT) !== ts6059(LONG_ROOT),
856+
);
857+
expect(
858+
`⭐ #17739 — the SAME diagnostic from two different checkout paths produces ONE signature. A `
859+
+ `signature that moved with the checkout made the identical error read as ARRIVED and `
860+
+ `VANISHED at once: got ${JSON.stringify(diagnosticSignature('TS6059', ts6059(SHORT_ROOT)))} `
861+
+ `vs ${JSON.stringify(diagnosticSignature('TS6059', ts6059(LONG_ROOT)))}`,
862+
diagnosticSignature('TS6059', ts6059(SHORT_ROOT)) === diagnosticSignature('TS6059', ts6059(LONG_ROOT)),
863+
);
864+
expect(
865+
'#17739 (zero churn) — and that one signature is the collapsed spelling the ledgers ALREADY '
866+
+ 'hold, so this repair re-records nothing in a maintainer-gated shrink-only ratchet: got '
867+
+ `${JSON.stringify(diagnosticSignature('TS6059', ts6059(SHORT_ROOT)))}`,
868+
diagnosticSignature('TS6059', ts6059(SHORT_ROOT)) === LEDGERED,
869+
);
870+
expect(
871+
'#17739 — the Windows spellings collapse too (drive letter with either separator, and UNC). A '
872+
+ 'rule that knew only POSIX would leave the same length-dependence standing on the other '
873+
+ 'platform this ledger is meant to read identically on',
874+
[String.raw`C:\checkouts\os\src`, 'C:/checkouts/os/src', String.raw`\\build\os\src`].every(
875+
(root) => normalizeMessage(`is not under 'rootDir' '${root}'.`) === "is not under 'rootDir' '…'.",
876+
),
877+
);
878+
expect(
879+
'#17739 (over-masking control) — a SHORT span that is not an absolute path still survives '
880+
+ 'verbatim, so the repair collapses PATHS rather than widening the mask: a relative specifier '
881+
+ 'and a named type both keep their text',
882+
normalizeMessage("Cannot find module './fixtures/a.js' or its type declarations.") ===
883+
"Cannot find module './fixtures/a.js' or its type declarations." &&
884+
normalizeMessage("not assignable to parameter of type 'IHttpRequest'.") ===
885+
"not assignable to parameter of type 'IHttpRequest'.",
886+
);
887+
expect(
888+
'#17739 (detector control) — isAbsolutePathSpan() DISCRIMINATES rather than answering true for '
889+
+ 'everything: it accepts a POSIX absolute path and rejects a relative specifier, a bare name, '
890+
+ "and a string-literal type whose own quotes wrap a slash-leading route",
891+
isAbsolutePathSpan('/etc/hosts') &&
892+
!isAbsolutePathSpan('./fixtures/a.js') &&
893+
!isAbsolutePathSpan('IHttpRequest') &&
894+
!isAbsolutePathSpan('"/api/v1"'),
895+
);
896+
}
897+
762898
// ── The ratchet-remedy authority convention (#8435) ────────────────────────
763899
//
764900
// Asserted against the text `evaluate()` REALLY emits rather than a copy of
@@ -1068,7 +1204,9 @@ function selfTest(): string {
10681204
+ 'signature that ARRIVED and the one that VANISHED; signatures are position-blind but '
10691205
+ 'identity-sharp; the ARRIVED offer is marked maintainer-only and VANISHED is not), the #8435 '
10701206
+ 'convention (the unledgered-file verdict keeps its ledger offer marked maintainer-only, and the '
1071-
+ 'SHRANK / GRADUATED / GREW verdicts stay unmarked) and the #12624 ledger-prose pins (the text '
1207+
+ 'SHRANK / GRADUATED / GREW verdicts stay unmarked), the #17739 path-independence pins (one '
1208+
+ 'signature from two checkout paths, in the collapsed spelling the ledgers already hold, with '
1209+
+ 'short non-path spans left verbatim) and the #12624 ledger-prose pins (the text '
10721210
+ '`--update` writes carries every mechanism sentence, states no refuted cause, passes the '
10731211
+ 'measured counts through unchanged, and preserves an authored `_note` verbatim in its own key) '
10741212
+ 'all hold.',

0 commit comments

Comments
 (0)