Repository navigation
[finding] The regex-literal recogniser now exists twice — js-comment-mask.mjs and check-dispatcher-error-vocabulary.mjs each carry the keyword set and a position rule that deliberately differ #15487
Description
Activity
Graded — PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk.domain:devx/priority:p3, queued. Two recognisers with a byte-identical keyword set and a deliberately different position rule (a masker fails safe by over-masking; a scanner must never skip a span it invented). Drift is guarded by PR #15486's cross-fixture self-test case; the card is about whether the shared half (REGEX_AFTER_KEYWORD, the class/escape/flag walk) should live once injs-comment-mask.mjswith the position rule as the one parameter. Serial oncheck-dispatcher-error-vocabulary.mjsbehind #15486 and onjs-comment-mask.mjs(checkcheck-comment-mask-corpus/-adoptionbefore dispatch). Dispatch after #15486 lands.
Generated by Claude Code
Claim: PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk, dispatching to anos-devsubagent on branchclaude/issue-15487-regex-recogniser-shared. P3, S/M. Ruling: the shared half (the keyword set, the class / escape / flag / line-terminator walk) lives ONCE inscripts/js-comment-mask.mjsand is exported; the position rule stays the one parameter each caller owns (the masker over-masks safely; the scanner never skips a span it invented) — a recogniser factory or a{ mayBeginAt }option, whichever the two call sites read best; ⛔ no behaviour change on either side, proven by byte-identicalcheck-comment-mask-corpus(5919 files) andcheck-dispatcher-error-vocabularyreadings plus its 322-assertion self-test, and PR #15486's cross-fixture case kept. Serial on both files: #15486 merged 18:41Z; no open PR touches either (scan across all open PRs at claim time). Main ata104ad2d7. Assignee field was empty at the read immediately before this write.
Generated by Claude Code
os-dev-report
{ "issue": 15487, "status": "done", "branch": "claude/issue-15487-regex-recogniser-shared", "pr": "https://github.com/objectstack-ai/objectstack/pull/15532", "premise_still_valid": true, "summary": "The regex-literal recogniser is now split by NUMBER OF RIGHT ANSWERS rather than by module. The WALK (character class, backslash sequences, line-terminator refusal, flags) plus REGEX_AFTER_KEYWORD and IDENT_CHAR live once in scripts/js-comment-mask.mjs as walkRegexBody and are exported; check-dispatcher-error-vocabulary.mjs deleted its byte-identical copies and imports them. The POSITION RULE stays each caller's: shipped as a FACTORY, makeRegexRecogniser({ mayBeginAt }), because the gate calls its recogniser from fifteen places and an option threaded through fifteen call sites is fifteen chances to pass the other consumer's rule - binding it once leaves every call site untouched. No default rule; omitting one throws. walkRegexBody returns { end, closed } rather than a verdict because an unclosed body is a THIRD place the two failure directions part, which the card's table does not list: the masker over-masks to end of line (measured: 198 positions on this tree, every one a JSX closing tag in a .tsx file) while the scanner answers -1. A shared function returning only a verdict would have picked one of those two for both, silently. js-comment-mask's own inline forward state machine keeps its rule; the gate keeps regexMayBeginAt unchanged.", "tests": "ZERO BEHAVIOUR CHANGE, masker side, by DIFFERENTIAL rather than re-run: scanSource at merge base 5023630b1 and scanSource at HEAD 63fb64b99 run over the SAME population (the corpus sweep's own 5929 files), comparing comment, literal, interpolation, maskComments and stripComments byte for byte - EXIT=0, 'files whose ... differ: 0, flag bytes differing in total: 0, VERDICT: byte-identical on every array, every file.' A plain before/after tree snapshot would NOT have shown this: the population contains js-comment-mask.mjs itself, whose own bytes changed. Gate side byte-identical vs main by cmp -s on all three readings: normal (EXIT=0, 'OK - 63 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry'), --report (EXIT=0, 86 lines), --self-test (EXIT=0, '10 shapes + 322 assertions OK'). NOTE: the dispatch named --sites/--json; this gate has neither flag, its site listing is --report, which is what was captured. node scripts/js-comment-mask.mjs --self-test EXIT=0 '44 cases pass (23 mask/strip corpus, 12 interpolation view, 9 shared recogniser)' - the 9 are the new section, driven in BOTH position modes, with a literal roster and floor of its own. node scripts/check-comment-mask-corpus.mjs EXIT=0 '5929 files, 0 disagree, 0 unparseable, 62.0s (comparator self-test: 12 cases pass)' under os-verify-lock VERDICT command-exit 0, held 62s waited 143s. check-comment-mask-adoption EXIT=0 and --self-test EXIT=0. pnpm -s check:pm-dispatch-gates EXIT=0 '1402 cases pass' (no case edited). pnpm check:nul-bytes EXIT=0. pnpm lint (eslint . --no-inline-config, whole repo) EXIT=0 under the lock, held 91s waited 400s. DERIVED FAMILY: dispatch-gates --commands --repo objectstack-ai/objectstack derived 32 commands, re-derived identical on the final commit; ALL 32 RAN AND ALL 32 EXITED 0. Three first reported PREREQUISITE NOT MET / exit 3 and were recorded as NOT MEASURED, never as passes, then built and re-run to a real reading: check:browser-reachable-entries (exit 1 self-declaring NOT MEASURED, unbuilt spec dist) then EXIT=0 - this is the gate that reads js-comment-mask.d.mts inside a tsc program, so its green also checks the declarations added there; check:doc-formula-expressions (exit 3) then EXIT=0; check:type-check-debt (exit 3, 29 unbuilt dependencies) then EXIT=0 after building the whole 71-task closure, '13 ledger entr(ies) re-measured in 130.3s, 143 raw tsc error(s), none above its recorded number'. ABLATION - 4 legs, each mutating, proving the mutation reached disk by anchored grep -cF in BOTH directions (deleted-text 1 to 0, injected-text 0 to 1), running, restoring with git checkout HEAD -- ABSOLUTE PATH under a trap, and proving the restore by blob hash equality against HEAD plus an empty git diff HEAD. No dist/build leg exists or was skipped: node scripts/js-comment-mask.mjs resolves the source by path, not through a package exports map, so there is no rebuild to forget. (A) makeRegexRecogniser ignores mayBeginAt - mask EXIT=1 on 'NEVER-SKIP mode reads the same / as division', gate EXIT=1; the parameter is load-bearing. (B) one row deleted from the new roster - EXIT=1 with 3 named floor failures. (C) the gate's rule reads } as a regex position - gate EXIT=1 on #14742's DIVERGENCE case, 'the documented DIVERGENCE between the two recognisers is gone'. (D) 'return' dropped from the SHARED keyword set - mask --self-test EXIT=0 (all 44 pass, exactly as the header says), corpus sweep EXIT=1 naming the same file the header predicts, 'scripts/check-test-source-alias.mjs fabricates=0 over-masks=17', so the documented backstop is INTACT on the shared copy; and NEW, the gate's --self-test now reds too on 'return /re/ is not read as a regex' plus #14742's AGREEMENT case, so consolidation gave the shared set a second faster guard it did not have as two copies. Legs C and D red a DIFFERENT one of #14742's two cases each, which is why both are kept and neither is redundant. Also measured while drawing the line, same population: CR / 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, and the tree has no CRLF sources at all. One of the nine new assertions failed on its first run - my expected closing index was wrong and the walk was right; the expectation was fixed, not the walk.", "mcp_calls": "0 - GitHub REST throughout via GH_TOKEN, per the dispatch; the REST list endpoint (6 pages, 562 open non-PR issues) served the dedup, no MCP call and no /search/issues", "open_questions": [], "out_of_scope_findings": [ "filed as #15534: scripts/js-comment-mask.d.mts declares selfTest(): void with the JSDoc 'there is no verdict for a caller to forget to read', but since #13798 the module returns SELF_TEST_VERDICT and its own CLI dispatch compares against it - the declaration states the opposite of the behaviour, in the sentence #13798 exists to refute. Unlabeled, unassigned, references this card. Adjacent to open #15398 (same file, SourceFlags omits the interpolation array) but a different field, so filed rather than folded in.", "NOT filed, already covered: the missing interpolation field in SourceFlags is open #15398, which this card's own body cites.", "NOT a finding, recorded for the PM: dispatch-gates printed a STALE TREE notice - this branch is cut at 5023630b1 and origin/main has moved to 615fac3a0, across which scripts/engine-double-contract.pinned.json changed. Not a path this PR touches; CI re-derives against the merge result." ] }
Generated by Claude Code
LANDED — PM seat
domain:devx @ objectstack(#6023), sessionsession_012zGPuVVX3deAx9LdjK8jCk.PR #15532 merged 2026-09-04T22:12:26Z (squash
19fa30c5d; rework headcc51a0aa8went green, enqueued 21:52Z, one queue run, no eviction). Probed onorigin/mainafter a re-fetch (HEAD3d3f60e01), textually:scripts/js-comment-mask.mjs:export function walkRegexBody:183,export function makeRegexRecogniser(options):231— the= {}default that reddedcheck:declaration-mirrorsis gone (arity 1, matchingjs-comment-mask.d.mts:106).scripts/check-dispatcher-error-vocabulary.mjs: importsmakeRegexRecogniserfrom./js-comment-mask.mjs(:229–:232), keeps the position ruleregexMayBeginAt:1514, andexport const regexLiteralAt = makeRegexRecogniser({ mayBeginAt: regexMayBeginAt }):1559.
Measured locally on main (exit codes read before any pipe; none of these imports
typescript):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 check-dispatcher-error-vocabulary --self-test: 10 shapes + 322 assertions OK (vocabulary + #9098 door typing) node scripts/check-dispatcher-error-vocabulary.mjs → exit 0 (0 fabricated / 0 missed against the TypeScript parser) node scripts/check-declaration-mirrors.mjs → exit 0Shift rework count stays at 2 (both CI-caught, both landed). The derivation gap that let the arity mismatch reach CI is #15553 (queued, serial on
dispatch-gates.mjsbehind PR #15570). Strippingpm:dispatched+ assignee;Fixes #15487closed this card.js-comment-mask.mjsis FREE: #15534+#15398 and #15557 are dispatchable.
Generated by Claude Code
Found while closing #14742 (PR #15486), which added a regex-literal recogniser to
scripts/check-dispatcher-error-vocabulary.mjs. Out of scope there: that card's scope is the scanner's blind spot and its ledgers, and consolidating two modules is a separate change with its own risk.What is duplicated
Two modules now answer "does a
/here open a RegularExpressionLiteral?", each with its own copy:scripts/js-comment-mask.mjs—REGEX_AFTER_KEYWORD,IDENT_CHAR, and the position rule insidescanSource;scripts/check-dispatcher-error-vocabulary.mjs—REGEX_AFTER_KEYWORD,IDENT_CHAR,regexMayBeginAt,regexLiteralAt(added by The shared textual scanners open a string on a quote inside a REGEX literal - 46 code: positions in 68 files stay unplaceable after #14626 #14742).The 14-word keyword set is byte-identical in both. That set is not decorative:
js-comment-mask.mjs's own header records that droppingreturnfrom it passes all 23 of its self-test cases and is caught only by the corpus sweep, onscripts/check-test-source-alias.mjs. So the copy that drifts drifts silently in exactly the direction the header warns about.What is deliberately NOT duplicated, and why a naive extraction is wrong
The two position rules differ on purpose, and the difference is a failure-direction argument rather than an oversight:
js-comment-mask.mjscheck-dispatcher-error-vocabulary.mjs),]}</>++/--A masker fails safe by over-masking (it under-reports loudly). A scanner that skips a span it invented desynchronises silently — which is the defect #14742 closed. ⇒ any extraction has to keep the position rule parameterised, not pick one of the two.
Mitigation already in place
#14742 added a
--self-testcase that runs both recognisers over the same fixtures and asserts they agree everywhere except that documented divergence, and a second case that asserts the divergence itself still exists. So a silent drift is caught today; what is left is the duplication, not an unguarded one.What a fix would look like
Export the keyword set and a position-rule-parameterised recogniser from one place (most likely⚠️
js-comment-mask.mjs, which already owns "which bytes are literal"), and have the gate consume it with its conservative rule passed in.js-comment-mask.mjshas a hand-written.d.mts(see #15398) and a corpus sweep in CI — both are part of the cost.Unassigned and unlabeled, for triage.
Generated by Claude Code