Skip to content

refactor(devx): share the regex-literal walk, keep the position rule a parameter - #15532

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-15487-regex-recogniser-shared
Sep 4, 2026
Merged

baozhoutao merged 2 commits into
mainfrom
claude/issue-15487-regex-recogniser-shared

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15487

Two modules each answered "does a / here open a RegularExpressionLiteral?" with
their own copy: a byte-identical 14-word REGEX_AFTER_KEYWORD set, an IDENT_CHAR
class, and a body walk. js-comment-mask.mjs's own header records what a drifting
copy costs — dropping return from that set passes all 23 of its self-test cases and
is caught only by the corpus sweep — so the copy that drifts drifts silently in
exactly the direction its owner warns about.

The choice: a FACTORY, and the line is drawn by number of right answers

The split is not "shared module vs caller"; it is how many right answers the
question has
.

The WALK — character class, backslash sequences, line-terminator refusal, flags,
and the keyword set a position rule reads — has exactly one right answer, ECMA-262's.
A second copy can therefore only drift. It now lives once, as walkRegexBody in
scripts/js-comment-mask.mjs, which already owns "which bytes are literal".

The POSITION RULE has two right answers, because the consumers fail in
deliberately opposite directions: a masker over-masks safely, a scanner must never
skip a span it invented. It stays a parameter.

makeRegexRecogniser({ mayBeginAt }) rather than
regexLiteralAt(src, at, { mayBeginAt })
: check-dispatcher-error-vocabulary.mjs
calls its recogniser from fifteen places, and an option threaded through fifteen call
sites is fifteen chances to pass the other consumer's rule — the drift this change
exists to end, re-created one layer up. Binding it once,

export const regexLiteralAt = makeRegexRecogniser({ mayBeginAt: regexMayBeginAt });

leaves all fifteen call sites untouched and makes "this module's rule is THIS one" a
fact about the module rather than a convention its call sites have to keep. There is
no default rule and omitting one throws — a default would be one of two opposite
failure directions handed silently to whichever caller forgot.

walkRegexBody returns an OUTCOME, not a verdict

This is the part a naive extraction gets wrong, and it is measured rather than
argued. An unclosed body is a third place the two directions part, and the card's
table does not list it:

  • the masker over-masks to the end of the line and reads on — 198 positions on this
    tree, every one a JSX closing tag
    (</div>, </Link>) in a .tsx file;
  • the scanner answers "not a regex at all", so it skips nothing.

So the shared half returns { end, closed } and each caller decides what closed: false means. A shared function returning only a verdict would have picked one of
those two, silently, for both.

Also measured while drawing the line, over the same population: \r / U+2028 /
U+2029 inside a walked body 0; escaped line terminator inside a body 0;
keyword-branch hits that are member names (map.delete / 2) 0. The tree has no
CRLF sources at all.

ZERO behaviour change, measured on both sides

The masker proof is a differential, not a re-run: scanSource at the merge base
and scanSource on this branch, over the same population (the corpus sweep's own
5929 files), comparing comment, literal, interpolation, maskComments and
stripComments byte for byte.

differential scanSource(BASE 5023630b1) vs scanSource(HEAD 63fb64b99) over 5929 corpus files:
  files whose comment/literal/interpolation/maskComments/stripComments differ: 0
  flag bytes differing in total: 0
  VERDICT: byte-identical on every array, every file.

A plain before/after snapshot of the tree would NOT have shown this: the population
contains scripts/js-comment-mask.mjs itself, whose own bytes changed, so it differs
for a reason that has nothing to do with behaviour. Running both implementations over
one fixed population is what removes that.

Gate side, byte-identical against main (cmp -s, all three):

reading verdict
check-dispatcher-error-vocabulary (normal) byte-identical
check-dispatcher-error-vocabulary --report byte-identical (86 lines)
check-dispatcher-error-vocabulary --self-test byte-identical — 322 assertions

⚠️ The dispatch named --sites / --json; this gate has neither flag. Its
site-listing reading is --report, which is what was captured.

#14742's two cross-fixture cases are KEPT, and still bite

Both were ablated rather than reasoned about. Each leg mutates, proves the
mutation reached disk (anchored grep -cF in both directions), runs, restores with
git checkout HEAD -- <absolute path>, and proves the restore by blob hash and
an empty git diff HEAD. No dist/ is involved: node scripts/js-comment-mask.mjs
resolves the source file by path, not through a package exports, so there is no
rebuild leg to forget.

ablation expected direction observed
A · makeRegexRecogniser ignores mayBeginAt mask + gate red mask EXIT=1 (✗ NEVER-SKIP mode reads the same / as division), gate EXIT=1
B · one row deleted from the new roster floor red, naming the row EXIT=1, 3 named failures incl. "NEVER-SKIP mode…" ran but is not declared
C · the gate's rule reads } as a regex position the divergence case reds EXIT=1 — the documented DIVERGENCE between the two recognisers is gone
D · return dropped from the shared set see below mask EXIT=0, gate EXIT=1, corpus EXIT=1

Leg D is the interesting one, and it is the card's own lesson re-measured on the
consolidated copy:

  • js-comment-mask --self-test still passes — all 44 cases — exactly as its header
    says ("dropping return passes all 23 cases and is caught only by the corpus
    sweep"). Consolidation did not change that.
  • The corpus sweep still catches it, and still names the same file:
    ✗ comment-mask corpus sweep: 1 of 5929 files disagree … scripts/check-test-source-alias.mjs fabricates=0 over-masks=17.
    The documented backstop is intact on the shared copy.
  • New: the gate's --self-test now reds too —
    `return /re/` is not read as a regex — because the set it reads is now the
    masker's set. So the shared half gained a second, faster guard it did not have as
    two copies.

Leg C and leg D each red a different one of #14742's two cases, which is why both are
kept: they now assert that the two position rules still agree where they must and
still diverge where they must. Neither is redundant.

New self-test section, floored

js-comment-mask --self-test gains 9 assertions driven in BOTH position modes —
one recogniser built with an over-masking rule, one with a never-skip rule, the two
differing only at the character under test so nothing here goes stale when a real
rule is re-argued. An assertion in one mode alone would pass on a recogniser that
ignored its parameter entirely (leg A is that mutation).

The section declares a literal roster with a floor of its own, the way the cases
table does, so a deleted or renamed assertion names itself in the refusal. It does
not inherit the interpolation section's acknowledged un-floored debt.

✓ js-comment-mask self-test: 44 cases pass (23 mask/strip corpus, 12 interpolation view, 9 shared recogniser)

One of the nine failed on its first run — the expected closing index was mine, not
the walk's; the walk was right. Fixed the expectation, not the walk.

Verification — every exit code captured before any pipe

All on the final commit 63fb64b99, tree clean.

node scripts/check-comment-mask-corpus.mjs                                    EXIT=0
  ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 5929 files, 0 disagree,
    0 unparseable, 62.0s (comparator self-test: 12 cases pass).
  os-verify-lock: VERDICT command-exit 0 · held the lock 62s · waited 143s

node scripts/check-comment-mask-adoption.mjs                                  EXIT=0
node scripts/check-comment-mask-adoption.mjs --self-test                      EXIT=0

node scripts/check-dispatcher-error-vocabulary.mjs                            EXIT=0
  check-dispatcher-error-vocabulary: OK — 63 unregistered code-stamping site(s), all
  classified; 2 awaiting a ledger entry (#8846).      (byte-identical to main)

node scripts/check-dispatcher-error-vocabulary.mjs --self-test                EXIT=0
  check-dispatcher-error-vocabulary --self-test: 10 shapes + 322 assertions OK
  (vocabulary + #9098 door typing)                    (byte-identical to main)

node scripts/js-comment-mask.mjs --self-test                                  EXIT=0
  ✓ js-comment-mask self-test: 44 cases pass (23 mask/strip corpus, 12 interpolation
    view, 9 shared recogniser).

pnpm -s check:pm-dispatch-gates                                               EXIT=0
  ✓ dispatch-gates self-test: 1402 cases pass.        (no case edited)

pnpm check:nul-bytes                                                          EXIT=0
pnpm lint  (eslint . --no-inline-config, whole repo)                          EXIT=0
  os-verify-lock: VERDICT command-exit 0 · held the lock 91s · waited 400s

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
derived 32 commands for this changeset — this is the card whose blast radius is
the import graph, and the derivation was re-run on the final commit and returned the
same 32. All 32 ran and all 32 exited 0.

Three needed a build first and reported PREREQUISITE NOT MET / exit 3 rather than a
verdict — recorded as NOT MEASURED, then built and re-run to a real reading, never
counted as passes:

command first reading after building final
pnpm --filter @objectstack/spec run check:browser-reachable-entries exit 1, "NOT MEASURED — dist holds no bundles" --filter @objectstack/spec EXIT=0
pnpm --filter @objectstack/lint run check:doc-formula-expressions exit 3, PREREQUISITE NOT MET --filter @objectstack/lint EXIT=0
pnpm check:type-check-debt exit 3, 29 unbuilt dependencies whole package closure (71 tasks) EXIT=0 — 13 ledger entr(ies) re-measured in 130.3s, 143 raw tsc error(s), none above its recorded number

check:browser-reachable-entries is the gate that reads js-comment-mask.d.mts
inside a tsc program, so its green is also the check on the declarations added there.

⚠️ The derivation printed a STALE TREE notice: this branch is cut at 5023630b1
and origin/main has since moved to 615fac3a0, across which
scripts/engine-double-contract.pinned.json changed — not a path this PR touches.
The families above were derived from this tree; CI derives them again against the
merge result.

skip-changeset: scripts/ publishes nothing from any package. The label is set on
this PR.

Out of scope, filed separately

scripts/js-comment-mask.d.mts declares selfTest(): void while the module returns
SELF_TEST_VERDICT — the value its own CLI dispatch checks to catch a self-test that
returned without printing a verdict (#13798). Same file and same class as #15398 (the
missing interpolation field), which is open and covers only scanSource. Filed as
a new card rather than fixed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…a parameter

`scripts/js-comment-mask.mjs` and `scripts/check-dispatcher-error-vocabulary.mjs`
each carried a regex-literal recogniser: a byte-identical 14-word
`REGEX_AFTER_KEYWORD` set, an `IDENT_CHAR` class, and a body walk. The keyword
set is not decorative — js-comment-mask's own header records that dropping
`return` from it passes all 23 of its self-test cases and is caught only by the
corpus sweep, so the copy that drifts drifts silently in exactly the direction
its owner warns about.

Split by NUMBER OF RIGHT ANSWERS rather than by module:

  the WALK has one (ECMA-262's), so it lives once — `walkRegexBody`, exported
  from js-comment-mask.mjs alongside `IDENT_CHAR` and `REGEX_AFTER_KEYWORD`;

  the POSITION RULE has two, because the consumers fail in deliberately
  opposite directions — a masker over-masks safely, a scanner must never skip
  a span it invented — so it is a parameter (`makeRegexRecogniser`), with no
  default and a throw when one is omitted.

`walkRegexBody` returns `{ end, closed }` rather than a verdict: an unclosed
body is where the two directions part, so the shared half reports the outcome
and each caller decides. `scanSource` over-masks to `end` and reads on (198
positions on this tree, every one a JSX closing tag); the gate answers -1.

Zero behaviour change, measured both sides:

  scanSource(BASE) vs scanSource(HEAD) over the same 5929-file corpus
  population — comment, literal, interpolation, maskComments and stripComments
  all byte-identical: 0 files differ, 0 flag bytes differ.

  check-dispatcher-error-vocabulary normal, --report and --self-test output
  byte-identical, self-test still 10 shapes + 322 assertions.

#14742's two cross-fixture self-test cases are KEPT and still mean what they
said: the two POSITION RULES are still two, so pinning them against each other
on the agreeing fixtures and on the `}` divergence still guards a real thing.

js-comment-mask --self-test gains a 9-assertion shared-recogniser section
driven in BOTH position modes, with a literal roster and floor of its own:
23 mask/strip corpus + 12 interpolation view + 9 shared recogniser = 44.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@github-actions github-actions Bot added the size/m label Sep 4, 2026
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff origin/main...claude/issue-15487-regex-recogniser-shared (merge base 5023630b1, head 63fb64b99): 3 files, +356/−59, matching the PR file list; the claim's ruling (5545199621) taken as written — the WALK (walkRegexBody: class, escapes, line-terminator refusal, flags), REGEX_AFTER_KEYWORD and IDENT_CHAR live once in js-comment-mask.mjs and are exported (declarations added to js-comment-mask.d.mts); the gate deletes its byte-identical copies (the removed block) and binds its own position rule once through makeRegexRecogniser({ mayBeginAt: regexMayBeginAt }) — a factory rather than an option, because the gate calls the recogniser from fifteen sites; no default rule (omitting one throws). walkRegexBody returns { end, closed } so the unclosed-body case — a third divergence the card's table did not list (masker over-masks to end of line, 198 measured positions, all JSX closing tags; scanner answers −1) — stays each caller's. Zero behaviour change proven by DIFFERENTIAL: scanSource at merge base vs head over the corpus's 5929 files, every flag array byte-identical; gate readings (normal / --report / --self-test 322) byte-identical by cmp; check-comment-mask-corpus 5929 / 0 disagree; js-comment-mask --self-test 23 + 12 + 9 (the nine new in both position modes, rostered and floored). Four ablations each red a different guard, including the return-keyword backstop now caught by the gate's self-test as well as the corpus sweep. Derived family 32/32 green after building the three prerequisite closures (no NOT MEASURED left standing). Hot-file scan: #15532 is the only open PR on either file. skip-changeset is right.

Fixes #15487 closes the card on merge. #15534 (the .d.mts selfTest(): void declaration contradicting #13798) is graded on the card. Flipping ready and arming auto-merge now.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

REWORK (round 1 on this PR; the shift's second) — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

CI red on 63fb64b99: Lint & Repo Gates, step Hand-written declaration mirrors (check:declaration-mirrors, run 33914061581):

FAIL: a hand-written declaration disagrees with the module it mirrors.
  - scripts/js-comment-mask.d.mts:106 declares `makeRegexRecogniser` with 1 required parameter(s), but scripts/js-comment-mask.mjs implements 0.
    `Function.length` counts parameters before the first default or rest …

Diagnosis: the implementation is makeRegexRecogniser({ mayBeginAt } = {}) — the = {} default makes Function.length 0 while the declaration (correctly, since omitting the rule throws) requires the options object. The honest fix is on the module side: drop the = {} default so the parameter is required at runtime exactly as declared (the "omitting one throws" behaviour is unchanged — it now throws on destructuring undefined at the same call, or keep the explicit check and throw the same message). The dev is resumed on the same branch; auto-merge stays armed and re-runs on the push. Note for the derivation: this gate was not in the 32-command family the dev ran for a change set containing a .d.mts — checked below and filed if it is a population gap.


Generated by Claude Code

`check-declaration-mirrors` went red in CI:

  scripts/js-comment-mask.d.mts:106 declares `makeRegexRecogniser` with 1
  required parameter(s), but scripts/js-comment-mask.mjs implements 0.

`Function.length` counts the parameters BEFORE the first defaulted one, so
`makeRegexRecogniser({ mayBeginAt } = {})` reports zero required arguments. The
declaration is the honest side — there is no default position rule, so the
options really are required — and the module is what was wrong.

Dropped the `= {}` and read `mayBeginAt` longhand behind an `options == null`
guard, which keeps the existing TypeError message rather than letting a bare
destructuring TypeError replace it with one that explains nothing. No behaviour
change beyond the arity: both `makeRegexRecogniser()` and a non-function rule
still throw, with the same message.

The self-test row that already covered "omitting the rule throws" now pins all
three facts at once — both throws, the message, and
`makeRegexRecogniser.length === 1` — so the module's own `--self-test` reds on
this regression instead of only a gate one layer away. Ablated: restoring the
`= {}` gives `Function.length` 0, `js-comment-mask --self-test` EXIT=1 on that
row, and `check-declaration-mirrors` EXIT=1 with the exact CI message above.
The row name and count are unchanged, so the verdict still reads 44 cases
(23 mask/strip corpus, 12 interpolation view, 9 shared recogniser).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

check-declaration-mirrors red — fixed in cc51a0aa8

scripts/js-comment-mask.d.mts:106 declares `makeRegexRecogniser` with 1 required
parameter(s), but scripts/js-comment-mask.mjs implements 0.

Function.length counts the parameters before the first defaulted one, so
makeRegexRecogniser({ mayBeginAt } = {}) reported zero required arguments. The
declaration was the honest side — there is no default position rule, so the options
really are required — and the module was what was wrong.

Dropped the = {} and read mayBeginAt longhand behind an options == null guard,
so the existing TypeError message survives instead of a bare destructuring
TypeError replacing it with one that explains nothing. Nothing but the arity moved:
makeRegexRecogniser() and a non-function rule both still throw, same message.

The self-test row that already covered "omitting the rule throws" now pins all three
facts at once — both throws, the message, and makeRegexRecogniser.length === 1 —
so this regression reds in the module's own --self-test rather than only in a gate
one layer away. Row name and count unchanged, so the verdict still reads 44.

Ablated (mutate → prove on disk → run → restore → prove restore byte-identical):
restoring = {} gives Function.length 0, js-comment-mask --self-test EXIT=1
on that row, and check-declaration-mirrors EXIT=1 with the exact CI message
above.

Verification, all on cc51a0aa8, exit captured before any pipe

node scripts/check-declaration-mirrors.mjs --self-test                        EXIT=0
  All 23 self-test cases passed.
node scripts/check-declaration-mirrors.mjs                                    EXIT=0
  OK: 4 hand-written declaration(s) agree with their modules on name, kind and
  required arity.

node scripts/js-comment-mask.mjs --self-test                                  EXIT=0
  ✓ js-comment-mask self-test: 44 cases pass (23 mask/strip corpus, 12
    interpolation view, 9 shared recogniser).

node scripts/check-dispatcher-error-vocabulary.mjs --self-test                EXIT=0
  10 shapes + 322 assertions OK            (byte-identical to main)
node scripts/check-dispatcher-error-vocabulary.mjs                            EXIT=0
  (byte-identical to main; --report byte-identical too)

differential scanSource(5023630b1) vs scanSource(cc51a0aa8), same 5929-file
population: 0 files differ, 0 flag bytes differ.

node scripts/check-comment-mask-corpus.mjs                                    EXIT=0
  ✓ 5929 files, 0 disagree, 0 unparseable, 62.7s (comparator self-test: 12 cases)

pnpm -s check:pm-dispatch-gates                                               EXIT=0
  ✓ dispatch-gates self-test: 1402 cases pass.   (no case edited)
pnpm check:nul-bytes                                                          EXIT=0
pnpm lint                                                                     EXIT=0

Re-derived the family for the change set: the same 32 commands, and all 32 ran
and exited 0 again on this commit.

⚠️ check-declaration-mirrors IS a population gap, not the always-runs tail

Asked and answered mechanically:

  • grep -c declaration-mirrors scripts/pm/dispatch-gates.mjs → 0
  • the string appears 0 times in the full dispatch-gates derivation for a change
    set that includes scripts/js-comment-mask.d.mts — not among the 32 matched, not
    in the 37 artifact rosters, not in the pending-changeset or unreachable listings,
    and not in the always-runs tail.

So dispatch-gates does not know this gate exists in any capacity. That is a genuine
gap rather than a deliberate exclusion, and the gate is a particularly bad one to
miss: lint.yml's own comment says it discovers its corpus (every
scripts/**/*.d.mts) precisely "so a third mirror added tomorrow is covered by
existing rather than by anyone remembering to enrol it" — the derivation is then the
one place that still needs someone to remember. A .d.mts-touching card gets no hint
it owes this gate, which is exactly what happened here: 32 derived, 32 green, and the
red arrived from CI.

One plausible contributing cause worth checking with the fix: it is invoked as
node scripts/check-declaration-mirrors.mjs, not through a pnpm check:* alias
(lint.yml says that is deliberate, per its GATE INVOCATION IDIOM note) — but other
directly-invoked node scripts/... gates are derived, so that alone does not
explain it.

Not filed by me — the PM had already opened #15553 for exactly this from the same CI
red, with the mechanism pinned to the gate declaring its population as discovered by
extension with no path literal. My measurements above agree with it; nothing new to
add, so no duplicate card.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Rework round 1 verified — PM seat domain:devx @ objectstack (#6023). Commit cc51a0aa8 drops the = {} default and reads mayBeginAt longhand behind an options == null guard (module fixed, declaration kept): check-declaration-mirrors reads OK: 4 hand-written declaration(s) agree with their modules on name, kind and required arity, the js-comment-mask self-test's existing "omitting mayBeginAt throws" row now carries the arity pin (a fifth ablation reds it one layer before the gate), and every earlier reading is byte-identical again (322 assertions, 5929 / 0 disagree, differential 0). The ACCEPT above stands; auto-merge is armed and CI re-runs on this head. The derivation gap the dev confirmed (declaration-mirrors appears nowhere in the family for a .d.mts change set) is #15553.


Generated by Claude Code

@baozhoutao
baozhoutao added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 19fa30c Sep 4, 2026
31 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15487-regex-recogniser-shared branch September 4, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants