Repository navigation
A nested template literal desynchronises the vocabulary gate's shared textual scanners, and every code: position after it in that file goes unplaced #14626
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 2, 2026 Triage — graded
p2,bug+tooling,pm:queue, routeddomain:devx. (Applied and read back first.)⭐ There are four scanners with this defect, not three
The card names
enclosingOpeners,sliceBalancedandscanTopLevel. Atorigin/mainbd4aa4e,scripts/check-dispatcher-error-vocabulary.mjscarries the identical quote-skip at four sites:function decl quote-skip scanTopLevel:1134:1141enclosingOpeners:1539:1549sliceBalanced:1558:1567splitTopLevel:1576:1585And
splitTopLevel's own docblock is the sharpest evidence in the whole card:/** Split on TOP-LEVEL commas — a nested call, generic or template keeps its own. */ export function splitTopLevel(args) {
It claims template-awareness — "a nested call, generic or template keeps its own" — and then implements the same seek-to-matching-backtick that cannot survive a nested template. A primitive whose comment asserts the property it lacks is worse than one that is merely wrong, because a reader checking whether templates are handled finds a "yes".
⚠️ So the fix owes four primitives and four self-test cases, not three, and the "re-derivation of the pinned counts" is correspondingly wider. Whoever claims this should re-derive the population with a grep rather than working from the card's list.Why
p2You are careful that nothing is wrong today: both unplaced
code:positions inautomation.tsare the identical validation-catalog ternary whose sibling the gate does see, so no verdict changes and no unregistered wire code hides.What earns
p2is the sentence you wrote, which is the finding:a count the gate prints as a total is really a floor, and nothing said so until now
and the one after it —
enclosingOpenersreturnsundefinedfor both "genuinely inside a string" (correct) and "the scanner lost its place 400 lines ago" (a defect), and the caller cannot tell which. 85 of 902code:tokens repo-wide come back unplaced; 33 of the 346 the #13790 anchor reaches. Nobody can currently say how many of those are the second kind.A gate that silently declines an unknown fraction of its own population is one bad template away from missing a real unregistered wire code, and it would miss it in exactly the way that produces no red. Not
p1— nothing is red and nothing is unregistered today.Scope ruled
The template-literal mode (tracking
${…}nesting rather than seeking a matching backtick) across all four primitives, plus:⛔ A
--self-testcase per primitive with a nested-template fixture and a positive control — the control is the half that proves the fixture would have failed before. Your card already specifies this; I am making it binding rather than suggested.⛔ Re-derive
OBJECT_LITERAL_CODE_HELPER_BLINDNESSandINLINE_LITERAL_EXPRESSION_CENSUSin the same PR. A fix that moves the primitives and leaves the pinned counts stale converts this defect into a wrong pin, which is harder to find than the current honest floor.⭐ Worth doing while in there: make the primitives distinguish their two
undefinedanswers. That is the root of the card — "genuinely in a string" and "scanner desynchronised" coming back identically is what made this invisible for as long asobjlithelperhas existed (#13233). A fix that handles nested templates but keeps the ambiguous return leaves the next desync class equally silent.⛔ Do not widen to
check:error-code-casing. You measured it unaffected — plain regexes, no bracket tracking — and that stands.On the filing
Filing under Prime Directive #10 rather than folding it into PR #14625 was right: this is a change to three (four) shared primitives that every shape and every census figure in the gate depends on, and it cannot ride a shape widening. Recording the reading as a declared lower bound (
positionsBehindScannerDesync) instead of rounding it away is the part I would hold up as the model — a census that names its own blind spot is worth more than one that looks clean.
Generated by Claude Code
Claim: session
session_01WLJQhde67SeTccsmnBVarV(domain:devx execution seat, seat post #6023) — R1 wave 9.- Branch:
claude/issue-14626-scanner-nested-template-mode - Worktree:
../objectstack-14626(dedicated per-task worktree, offorigin/main) - Domain:
domain:devxper triage 5514266725 (the vocabulary gate's shared textual scanners). - File surface:
scripts/check-dispatcher-error-vocabulary.mjsONLY — a template-literal mode (tracking${…}nesting instead of seeking the matching backtick) in all FOUR quote-skipping primitives (scanTopLevel:1134/:1141,enclosingOpeners:1539/:1549,sliceBalanced:1558/:1567,splitTopLevel:1576/:1585on the triage's base), a--self-testcase per primitive with a nested-template fixture AND a positive control, the twoundefinedanswers ("genuinely inside a string" vs "scanner desynchronised") made distinguishable, andOBJECT_LITERAL_CODE_HELPER_BLINDNESS+INLINE_LITERAL_EXPRESSION_CENSUSre-derived in the same PR (population re-derived by grep, not from the card's list). ⛔check:error-code-casinguntouched. ⛔ No shape semantics change beyond what the primitives' correctness implies — any verdict that moves is listed per site in the PR body (a newly PLACEDcode:position may surface a stamp; that is the point, and any new unregistered value is reported, not registered here). - Premise re-verified on
origin/main4d0d9445a: the gate (post-feat(devx): reach the INLINE literal expression at an object-literalcode:#14625,objlitexprat:404) still carries the seek-to-same-quote skip in the four primitives;positionsBehindScannerDesyncis recorded in the frozen census as a declared floor; no open PR touches the gate (27 open at 22:2xZ; [finding] An INLINE literal ternary at an object-literalcode:is reached by no vocabulary shape — the third stamp position, censused at zero #13790's PR feat(devx): reach the INLINE literal expression at an object-literalcode:#14625 merged 17:21Z). Premise holds. - Container & model: subagent lane (M — four shared primitives + pinned-count re-derivation), opus.
- Clause ②: not reached. Serial constraints: none. Changeset:
scripts/**⇒skip-changeset.
Generated by Claude Code
- Branch:
- added a commit that references this issue
on Sep 2, 2026 os-dev-report
{ "issue": 14626, "status": "done", "branch": "claude/issue-14626-scanner-nested-template-mode", "pr": "https://github.com/objectstack-ai/objectstack/pull/14739", "premise_still_valid": true, "summary": "The premise held: on base 4d0d9445a the quote-skip population re-derived by grep is exactly FOUR sites (scanTopLevel :1140/:1143, enclosingOpeners :1548/:1551, sliceBalanced :1566/:1569, splitTopLevel :1584/:1587), matching the triage. All four now call ONE shared skipStringLiteral(src, at) -> {end, closed}: a frame stack where ' and \" seek their close honouring backslash, a backtick enters TEMPLATE mode, and ${ pushes an INTERPOLATION frame tracked by brace depth in which a quote opens a NEW literal, recursing to any depth. The two undefined answers are now distinguishable: enclosingOpeners returns a TAGGED value for every requested index (bracket / top-level / inside-string / desync) and the other three take an optional scanReport(). The tag is CERTIFIED rather than asserted - inside-string is only claimed for a walk with no unterminated literal, no bracket underflow and an empty stack at EOF; otherwise every unvisited index is desync. Both censuses re-derived on this tree with tree drift separated from what the fix moved, and a new SCANNER_LITERAL_BLIND_SPOTS block records the residual class the sweep found. No verdict moved: deriveSites diffed key-by-key across the change is identical (sites 59 -> 59, unresolved 5 -> 5, added [], removed [], per-shape counts identical), so the escalation clause (an unregistered value at a newly placed position => blocked) was NOT reached and the runtime ledger is untouched. Two notes for the PM. (1) The issue was already assigned to baozhoutao when I picked it up; I wrote no assignee. (2) CHANNEL SWITCH declared: repo-scoped REST reads work from this container (issue, comments, labels, PR create/patch, issue create all 200/201), but GET /search/issues returns 403 - so the pre-filing duplicate search went through the sanctioned REST LIST route instead (GET /repos/.../issues?state=open&labels=tooling&per_page=100, 87 open issues, grepped locally for 'regex literal' / 'character class' / 'scanner' / 'desync'; 4 keyword hits, all inspected, none this class). MCP was not used at all.", "tests": "All at HEAD 160c99d51 (the final commit - the merge of origin/main after fix commit 0dc603cd2), every exit code captured by redirect BEFORE any pipe.\nGATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/check-dispatcher-error-vocabulary.mjs derived 18 commands from this tree at 160c99d51 (--repo asserted and held; the script took the changeset itself, no hand-fed diff list). All 18 run on the merged head under scripts/pm/os-verify-lock.sh (slot issue-14626-dev, ~3 waits behind other agents, polled in-turn, never parked on a monitor). 17 exit 0. The one non-zero is node scripts/check-test-completeness.mjs exit 3, which is its own declared NOT-MEASURED branch, in its own words: 'Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? ... There is no local log to hand it, so the local reading for this gate is NOT MEASURED. It is not a red' - read as NOT MEASURED, not as green and not as red. node scripts/pm/dispatch-gates.mjs --ran RECORD then prints its own verdict line: 'Run reconciliation - 18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN' and 'dispatch-gates --ran: 18 derived famil(ies) accounted for', exit 0.\nNAMED BY DISPATCH, OUTSIDE THE DERIVED FAMILY, RUN ANYWAY: pnpm check:error-code-casing exit 0 (the gate the card forbids widening - still green, and its file is untouched in the diff); pnpm check:nul-bytes exit 0, verdict line 'check-nul-bytes: OK (scanned 8042 text file(s) ... no raw ASCII control bytes)'.\nTHE GATE'S OWN VERDICT LINES: 'check-dispatcher-error-vocabulary --self-test: 10 shapes + 282 assertions OK (vocabulary + #9098 door typing)' - 277 before this card, so +5 net assertion groups; and 'check-dispatcher-error-vocabulary: OK - 59 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).'\nSELF-TEST PER PRIMITIVE, EACH WITH A POSITIVE CONTROL: the pre-fix legacySeekQuote is kept in test scope with all four primitives re-implemented around it differing in exactly the skip. Fixture asserts / control asserts: scanTopLevel 2 top-level ';' past the nested template / legacy sees 0; enclosingOpeners the code: after it is {-enclosed / legacy answers undefined; sliceBalanced the whole argument list / legacy truncates at the inner backtick; splitTopLevel 2 parts / legacy answers 1. Plus inside-string on a code: written inside a string, desync behind an unterminated template, the skipper's two interpolation cases, and scanReport on all three.\nMUTATION ON THE COMMITTED TREE (no build/dist involved - a .mjs run directly, so no dist preflight applies): deleting the ${-tracking branch from skipStringLiteral (the skipper falls back to seek-to-matching-backtick) reddens EXACTLY the four primitive cases and nothing else - 'scanTopLevel saw 0 top-level ; past a nested template, not 2', 'enclosingOpeners answered {\"kind\":\"desync\"} ... instead of the object-literal {', 'sliceBalanced returned a truncated slice', 'splitTopLevel cut ... into 1 parts, not 2'. The mutation was CONFIRMED ON DISK before the run, not inferred from an editor exit code: grep -c on the deleted text 1 -> 0, grep -c on the injected marker 0 -> 1, and the worktree blob hash moved 9d4b8492 -> 1dce95d3. Restore ran under a trap on EXIT/INT/TERM with an ABSOLUTE path via git checkout HEAD -- ABS_PATH (HEAD named explicitly, never a bare git checkout --, which reads the index), and is proven back by BOTH halves: blob hash 9d4b8492 again (equal to the HEAD blob, non-empty) and git diff HEAD --name-only empty.\nCENSUS INSTRUMENTS: the objlitexpr guard cascade and the objlithelper candidate population were counted by instrumented COPIES of the gate (the real branches, counters injected at each continue) driven through the real deriveSites, run once with the PRE-fix primitives (git show HEAD:...) and once with the POST-fix ones over the same 2187 files. Conformance: the pre-fix instrument reproduces #13790's pinned readings up to tree drift - 33 of 349 unplaced at the anchor (pinned 33 of 346), 86 of 906 unplaced repo-wide (card: 85 of 902), guard splits 41/47/9/0 identical, 2 live positions, 5 values. localTwinCensus used the #13478 replica (resolveConstant fallback ON through the real deriveSites) and reproduced 53/16/16/9 against the pinned 49/16/15/9, again drift-shaped; its headline reduce stays 0 at BOTH ends, backed by the positive control already pinned in --self-test. All instrument copies were deleted before the gate derivation, so nothing untracked entered the changeset (git status: 1 modified file).\nLINT, NARROWED AND THE NARROWING MEASURED (not 'not run'): population comes from eslint's own config and the diff is one file, so npx eslint --no-inline-config --format json on that file is the whole of it; the JSON reports 1 file linted, 0 errors, 0 warnings, exit 0; and eslint.config.* states it runs with no parserOptions.project and no typed @typescript-eslint rules, so type-aware linting is off and a one-file diff cannot move any untouched file's verdict. Repo-wide pnpm lint stays CI's run.\nSCOPE PROOF: git diff --stat = 1 file changed, 679 insertions, 80 deletions, scripts/check-dispatcher-error-vocabulary.mjs only. grep -c on the seek idiom over the changed file returns 1, and that one is legacySeekQuote inside --self-test. The five shape regexes (objlit, objlitconst, objlittemplate, objlithelper, objlitexpr) are byte-identical in the diff.", "mcp_calls": "0 - every GitHub read and write went through REST with $GITHUB_TOKEN and the CA bundle; MCP was not used at all", "open_questions": [], "out_of_scope_findings": [ "filed as #14742 (unassigned, no labels, left for PM triage): the same four primitives open a string on a quote or backtick inside a REGEX LITERAL character class - packages/rest/src/error-response.ts's RELATION_DOES_NOT_EXIST is the live one. Measured on this card's own sweep AFTER the template fix: 68 of 2187 files still fail the scan-health certificate, 46 code: positions repo-wide stay unplaceable and 11 of them sit at objlitexpr's anchor. Not fixed here because it is not mechanical (telling /re/ from division needs the preceding token's grammatical class, i.e. lexer state this deliberately textual scan does not carry), so #14626 counted it in SCANNER_LITERAL_BLIND_SPOTS and stopped. --self-test pins the declaration in the direction it can fail: a regex fixture must still come back desync, with the same source minus the regex as the positive control, so the block cannot outlive the blind spot it declares." ] }
Generated by Claude Code
ACCEPT — PR #14739 merged to
mainas41978ece3(out of the merge queue 00:49:25Z;added_to_merge_queue23:26:14Z). Sessionsession_01WLJQhde67SeTccsmnBVarV(domain:devx execution seat, seat post #6023), R1 wave 9.Close-out probes on
origin/main96b772330(verify worktree,41978ece3an ancestor):scripts/check-dispatcher-error-vocabulary.mjscarriesfunction skipStringLiteral(the one shared frame-stack skipper with template mode behind all four primitives) and theSCANNER_LITERAL_BLIND_SPOTSblock (the residual regex-literal class, declared and pinned in the failing direction);enclosingOpenersreturns tagged answers (bracket/top-level/inside-string/desync,inside-stringonly certified by a cleanscanReport()); both censuses re-derived;deriveSitesbyte-identical across the change (59/59 sites, 5/5 unresolved); the five shape regexes byte-identical; self-test 10 shapes + 282 assertions. One file,+679 −80;check-error-code-casing.mjsand the runtime ledger untouched.skip-changeset,size/l.Against triage 5514266725: four primitives (population by grep) — held; per-primitive fixture + positive control — held; pinned counts re-derived in the same PR — held; ⛔ no widening to
check:error-code-casing— held. Zero rework. Follow-up #14742 (regex-literal character-class blind spot; 46code:positions still unplaceable) awaits triage.pm:dispatchedremoved with this comment; the card closes via the PR'sFixes #14626.
Generated by Claude Code
Found while widening
scripts/check-dispatcher-error-vocabulary.mjsfor #13790 (PR #14625). Out of scope there: it is a defect in the gate's shared textual scanners, not in any one shape, and it costs theobjlithelpershape that shipped before it exactly the same way.The defect
enclosingOpeners,sliceBalancedandscanTopLevelall skip a string the same way — on seeing a quote character, advance to the next occurrence of that same character:A nested template literal breaks that. In
`Unknown key${n > 1 ? 's' : ''} ${keys.map((k) => `\`${k}\``).join(', ')} — the toggle body is { enabled?: boolean }`the scanner enters at the outer backtick and leaves at the first inner backtick, so from there on it is parsing string bytes as code and code bytes as string. The bracket stack desynchronises and stays desynchronised for the rest of the file.
What it costs, measured
packages/runtime/src/domains/automation.tscarries such a template at line 1285. Downstream of it,enclosingOpenersreturns no enclosing bracket forcode:positions it should answer for, so any shape guarded by that primitive silently declines them:code:positions in that file after line 1285 thatenclosingOpenerscannot place:1384,:1399)Both are
code: <x> === undefined ? 'required' : 'invalid_type'in the flow-clone door — the same shape, same values and same ADR-0112 D6 verdict as the instance at:1125that the gate does see. So today the under-count changes no verdict and hides no unregistered wire code. What it changes is that a count the gate prints as a total is really a floor, and nothing said so until now.Repo-wide, over
packages/**non-test source (2186 files),enclosingOpenersreturns no bracket for 85 of the 902code:tokens in the tree — 33 of the 346 that #13790's shape anchor reaches. Not all of those are desync victims: somecode:bytes really are inside a string, and that is the correct answer. The point is that the primitive cannot tell a caller which of the two it is answering — "genuinely in a string" and "the scanner lost its place 400 lines ago" come back identically, asundefined.Why it is not a one-shape problem
objlithelper(Widencodehelperto the object-literal stamp position — the blast radius is now measured, and 4 undischargeableunresolvedfindings are the blocker #13233) is guarded byenclosingOpeners. It has carried this since it landed.objlitexpr([finding] An INLINE literal ternary at an object-literalcode:is reached by no vocabulary shape — the third stamp position, censused at zero #13790) inherits it, and its census records the reading as a declared lower bound (positionsBehindScannerDesync) rather than silently rounding it away.check:error-code-casingis unaffected: its patterns are plain regexes with no bracket tracking.What a fix would owe
The scanners would need a template-literal mode that tracks
${…}nesting rather than seeking a matching backtick — a real change to three shared primitives that every shape and every census figure in the gate depends on. Which means it owes a re-derivation of the pinned counts inOBJECT_LITERAL_CODE_HELPER_BLINDNESSandINLINE_LITERAL_EXPRESSION_CENSUS, and a--self-testcase per primitive with a nested-template fixture and a positive control. That is a card, not a rider on a shape widening.Unassigned, for triage. Filed under Prime Directive #10 rather than folded into PR #14625.
Generated by Claude Code