Skip to content

tooling(pm): refuse to render a tail the cut left unresolved - #15126

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15116-unbalanced-tail-refusal
Sep 4, 2026
Merged

tooling(pm): refuse to render a tail the cut left unresolved#15126
os-steve merged 1 commit into
mainfrom
claude/issue-15116-unbalanced-tail-refusal

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15116

scripts/pm/dispatch-gates.mjs only. One commit, verified at head b52fcc23.

The shape, reproduced at the base before any edit

At f594e70d (the merge base and this branch's base), a fixture workflow written in the self-test's own style:

- run: node scripts/check-x.mjs --base "$(git merge-base origin/main HEAD)"
- run: node scripts/check-y.mjs --gate 'a;b'

derives, through extractCheckInvocations:

{"check":"scripts/check-x.mjs --base \"$","argvVariables":[],"selfTest":false}
{"check":"scripts/check-y.mjs --gate 'a","argvVariables":[],"selfTest":false}

The key is scripts/check-x.mjs --base "$ — cut at the (, unclosed quote swallowed into one token by argvTokens, no WORKFLOW_VALUE_SOURCE hit, so zero variables: a LITERAL, which is what puts a row into --commands as a command a dev can paste. That is the truncated argv the file's own docblock names as the worst of the three outcomes.

The live tree, re-measured at this base

114 direct invocations across .github/workflows/*.yml. Zero carry an unclosed quote, an unclosed ${{ ... }} or a token ending in a bare $; zero direct matches are cut at a paren. The three paren hits in the workflow text are all comment lines:

lint.yml: # (`node scripts/pm/check-half-states.mjs`) reads a shared board over the
lint.yml: #   - `node scripts/check-aggregator-roster.mjs` (statically, in the lint
required-set-patrol.yml: # `node scripts/check-required-contexts.mjs --verify-required-set` (#9678).

This confirms the seat's reading at e98bfc87: the defect is latent, and no derived key moves.

Outcome taken: (i) classify the unresolved remainder as a VARIABLE

Measured against the file's existing invariants, outcome (ii) — refuse the argv half and keep the bare key — is the one the file already rules out. renderedArgv's docblock reserves null for "an invocation with no argv at all — that one keeps the bare path key, because bare is what CI runs", and the #15083 half of the same docblock says a bare key for a script CI only ever invokes with argv "is not a neutral omission — it is a command this tool made up". Refusing here would re-mint exactly that fabrication for a script whose only invocation carries a value. Outcome (i) leaves the invocation named, with its workflow named, on the NOT RUNNABLE LOCALLY line, and out of --commands: a missing lead, never a fabricated one.

On the four axes:

  • Real business need — measured, not assumed: the consumer is the PM dispatch loop, which pastes --commands output verbatim, and --ran reconciles against those same strings. A truncated key that renders runnable hands that loop a command that exits non-zero for a reason unrelated to the tree. The producer side is live too: $(git merge-base ...) is the ordinary spelling for the --base gates, and pr-automation.yml already pins a base — today through a step output rather than a substitution, which is why the shape is latent rather than active.
  • Long-term soundness — one predicate on the tokeniser's own unterminated branch, no per-script table, no widening of DIRECT_CHECK_INVOCATION's terminator set. The cut at ( stays the shell boundary it has always been; this reads what the cut left. No workaround, and nothing downstream re-derives the answer: the classification travels on the invocation as it already did.
  • Making it structurally harder for an AI to get wrong — this is contract tightening at the producer, not tolerance at the consumer. The alternative shape of this fix would have been a consumer-side fallback (a downstream reader skipping keys that "look odd"), which is the tolerance that hides batch mistakes. Instead an unresolvable value is refused loudly at the point of classification, and the row says which value it could not resolve.
  • Startup-stage focus, no surface sprawl — no new field, no new flag, no new output mode. argvVariables.length already drives notRunnable in discoverFamilies; the fix adds entries to a list that already exists. Net surface: one module-private function.

The fix

unresolvedRemainder(token) replays the tokeniser's own unterminated branch over the finished token and returns what it could not resolve, or null. renderedArgv names that remainder in variables alongside the WORKFLOW_VALUE_SOURCE hits.

Three spellings reach that state and they are one state, so they get one predicate rather than a list — the contract's "extend one guard to close the whole class":

  • an unclosed quote ("$ from a $(...) value; 'a from a terminator inside a quoted value like --gate 'a;b'),
  • an unclosed ${{ ... }} (a GitHub expression holding a terminator — ${{ inputs.x || 'y' }} is the live-looking spelling),
  • a token left ending in a bare $ (the same cut with the value unquoted).

The replay is faithful to argvTokens because that branch copies the remainder verbatim (text.slice(i)) and a closing delimiter is always inside the token that used it — so the replay reaches the same verdict the tokeniser did. The unbalanced state is therefore observable AFTER tokenisation (the mechanism question the dispatch asked): it did not have to be detected during it.

After the fix, the same fixture:

{"check":"scripts/check-x.mjs --base \"$","argvVariables":["\"$"],"selfTest":false}
{"check":"scripts/check-y.mjs --gate 'a","argvVariables":["'a"],"selfTest":false}
{"check":"scripts/check-z.mjs --gate 'Test Core' --shard ${{ matrix.shard }}","argvVariables":["${{ matrix.shard }}"],"selfTest":false}

The control is unchanged: the quoted literal still contributes no variable, the expression still contributes exactly one.

The pin, extended rather than replaced

The truncation pin kept both of its existing clauses (no continuation backslash; no redirection fd surviving as an argument) and gained a third, stated over every derived key as the contrapositive:

carrying a truncation and rendering as runnable are mutually exclusive

That is the form outcome (i) makes true. A flat "every key has balanced quotes" would be unsatisfiable beside a fixture that quotes the $(...) shape, because under outcome (i) the key keeps the truncation and is made harmless by the classification rather than by the key text.

The pin reads the key with a quote scanner independent of argvTokens — the shell's own outermost-quote rule, so a ' inside a "..." is text, not a delimiter. A parity count would not do: zero live keys nest a quote today, and the day one does a parity scan would call a balanced key truncated.

The same property is asserted on the live half (direct invocations read from the workflows). It is vacuous today by the measurement above, and says so in its comment; the non-vacuous subjects are in the fixture.

Fixtures

A written fixture — the first in that block that is not quoted from live workflow text, and it says so, because the tree does not have this shape:

  1. node scripts/check-x.mjs --base "$(git merge-base origin/main HEAD)" — the key really is scripts/check-x.mjs --base "$ (pinned, so the case cannot go vacuous) and it is NOT runnable, remainder "$ named.
  2. node scripts/check-y.mjs --gate 'a;b' — a terminator inside a quoted value lands the same way, remainder 'a named.
  3. CONTROL: node scripts/check-z.mjs --gate 'Test Core' --shard ${{ matrix.shard }} — tokenises and classifies exactly as today, one variable, the quoted literal contributing none.
  4. Shape-level over all three, non-vacuously: exactly 2 of the 3 carry a truncation, and none of those is runnable.

Unit-level cases were added to the existing renderedArgv and argvTokens pins on the same call rather than as new cases, including the closed-quote control on the same line.

--commands before and after — byte-identical

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands \
  .github/workflows/ci.yml .github/workflows/pr-automation.yml
tree md5 lines
base f594e70d 59cf418666b26b5fb80386b8f8ab54cc 37
head b52fcc23 59cf418666b26b5fb80386b8f8ab54cc 37

cmp reports the two byte-identical. No live key changes.

Reverse verification (ablation)

From the committed state, the naming of the remainder in renderedArgv was replaced with an inert binding. Landing proved on disk before measuring: removed-text hits 1 to 0, injected-marker hits 1, worktree blob 1be05934... different from the HEAD blob 36500aff.... No build or dist/ is involved — this script is run from source by node.

✗ dispatch-gates self-test: 4 of 1343 case(s) failed.
  ✗ ⭐ …and that truncation is NOT runnable: the unresolved remainder is named as the value the workflow supplies
  ✗ ⭐ a terminator inside a quoted value lands the same way — named, never rendered as a command a dev could paste
  ✗ ⛔ …and the shape-level statement over every key this fixture derives: carrying a truncation and rendering as runnable are mutually exclusive — non-vacuously, 2 of the 3 carry one
  ✗ renderedArgv renders every tail and reports which values come from the workflow

Direction was as predicted: turns red. Restore leg proven the same way — restored blob 36500aff... equal to the HEAD blob, git diff HEAD empty (0 bytes), marker hits back to 0, naming back to 1.

Gates — derived at head b52fcc23, each quoting its own verdict line

Re-derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths). The derivation at this head returns the dispatch list minus node scripts/pm/bare-root-worklist.mjs --self-test; it was run anyway, per the card's clause, and is green.

gate exit its own verdict line
node scripts/check-ci-filter-parity.mjs 0 OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg …
node scripts/check-closing-keyword-parity.mjs 0 check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords …)
node scripts/check-closing-keyword-parity.mjs --self-test 0 ✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs 0 ✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 40.5s
node scripts/check-self-test-wired.mjs 0 ✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test 0 4 live ledger row(s) verified … 7 declared batteries, 50 cases registered, every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs 0 ✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s) …
node scripts/check-whole-set-label-write.mjs --self-test 0 ✓ all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test 0 OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.
pnpm check:agent-test-spelling 0 ✓ check-agent-test-spelling: 0 violations — 437 file(s) …
pnpm check:bash32-floor 0 ✓ check-bash32-floor: 26 tracked shell file(s) … name no bash 4+ construct …
pnpm check:cli-command-ids 0 ✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s) … all resolve
pnpm check:cross-package-test-inputs 0 OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live 0 ✓ 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8233 tracked file(s).
pnpm check:entry-guard 0 ✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs …
pnpm check:nul-bytes 0 ✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)
pnpm check:parse-guard 0 ✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates 0 ✓ dispatch-gates self-test: 1343 cases pass.
pnpm check:pnpm-filter-targets 0 ✓ 142/181 --filter occurrence(s) across 33 file(s) resolve against 79 workspace package(s) …
pnpm check:refd-timer-probe 0 OK check-refd-timer-probe: 5848 source file(s) swept …
pnpm check:watch-hint-literal 0 ✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s) …
pnpm lint (repo-wide ESLint, through the lock) 0 os-verify-lock: VERDICT command-exit 0 · held the lock 59s · waited 187s — eslint printed no findings

Every exit code was captured by redirecting first (cmd > file 2>&1; EXIT=$?), never read across a pipe. The two long runs (--self-test, pnpm lint) went through scripts/pm/os-verify-lock.sh; their VERDICT lines are the readings quoted, and the lock's own disclaimer applies — those seconds are shared-box seconds.

check-test-completeness is a value-bearing family and is NOT MEASURED here, in its own words.

The script's own suites, owed separately from the derived families: its --self-test (1343 cases, green) and scripts/pm/check-dispatch-gates.mjs via pnpm check:pm-dispatch-gates (green). git grep dispatch-gates over *.test.ts returns five files; all five mention it in prose comments only and none pins this script.

scripts/pm/bare-root-worklist.mjs --self-test is green at this head — no finding to report there, as expected since no live key changes. scripts/pm/bare-root-worklist.mjs was not touched; #15091 remains a separate flight.

Nothing is published from any package, so this carries the skip-changeset label rather than a changeset.


Generated by Claude Code

`DIRECT_CHECK_INVOCATION` ends an argv tail at the first `;|&<>()`, which is
the shell boundary it has always drawn. A value written as a command
substitution is therefore cut INSIDE its own construct:
`--base "$(git merge-base origin/main HEAD)"` leaves the tail `--base "$`,
`argvTokens` swallows the unclosed quote into one token, and the token carries
no `WORKFLOW_VALUE_SOURCE` hit — so it classified as a LITERAL and the key
`scripts/check-x.mjs --base "$` rendered into `--commands` as a runnable
command. A truncated argv that looks runnable is the outcome this file's own
docblock names as the worst of the three.

`unresolvedRemainder` reads the tokeniser's own unterminated branch back off
the finished token and names what it could not resolve. That remainder is a
value the workflow supplies just as surely as `${{ … }}` is, so it rides the
channel the classification already has: any entry in `variables` marks the row
NOT RUNNABLE LOCALLY and keeps the key out of `--commands`. This is the file's
rule applied rather than a new one — a missing lead, never a fabricated one.
Refusing the argv half instead would hand back a BARE path key, and a bare run
is an invocation CI never makes: the fabrication the value-bearing render
retired.

One predicate covers the whole class, because the three spellings are one
state: an unclosed quote (`$(…)`, or a terminator inside a quoted value like
`--gate 'a;b'`), an unclosed `${{ … }}` (an expression holding a terminator),
and a token left ending in a bare `$`.

Measured at this commit: 114 direct invocations across `.github/workflows` and
zero carry any of them — the three paren hits in the workflow text are comment
lines. `--commands` for `ci.yml` and `pr-automation.yml` is byte-identical
before and after. The truncation pin now asserts, over every derived key, that
carrying a truncation and rendering as runnable are mutually exclusive, read
with a quote scanner independent of `argvTokens`; a written fixture carries the
non-vacuous subjects (the `$(…)` shape, the `'a;b'` shape, and the control that
`--gate 'Test Core'` and `--shard ${{ matrix.shard }}` classify exactly as
today), and the live half asserts the same property over the workflows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
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