Repository navigation
Commit 55fd5ee
fix(pm): dispatch-gates refuses an absent path unless --repo asserts whose tree it is (#18409)
Fixes #18383
A path named on argv that is not in this tree has two readings — a
surface of THIS repo that is not written yet, and a path belonging to
ANOTHER repo — and a repo-relative path cannot tell them apart. The tool
resolved that ambiguity silently toward the harmless reading: exit 0,
the absent path filed under "apply once this card's changeset exists",
the gate list derived from THIS repo's workflows. A seat read that exit
code and wrote "it refuses objectui paths by design" into five dispatch
texts.
Unasserted, the ambiguity is now NOT MEASURED: the run ends at exit 3
(`EXIT_PREREQUISITE_NOT_MET`, this tool's existing NOT MEASURED code)
with the absent paths listed and both resolving spellings printed
verbatim, before any family derivation runs.
## The three shapes, before and after
Measured on `does/not/exist.mjs`, exit codes captured before any pipe.
Before = `origin/main` `7358c1c5b`; after = this branch at `ab0864186`.
| shape | before | after |
| --- | --- | --- |
| (a) absent path, no `--repo` | **exit 0**, derived 7 commands | **exit
3**, NOT MEASURED, absent path named, stdout empty |
| (b) absent path, `--repo objectstack-ai/objectstack` | exit 0 | exit 0
— **byte-identical stderr+stdout to before** |
| (c) absent path, `--repo objectstack-ai/objectui` | exit 2 | exit 2 —
**byte-identical stderr+stdout to before**, same text |
The card's literal Reading, `--commands skills/objectui/SKILL.md
AGENTS.md` with no `--repo`, went from exit 0 to exit 3 naming
`skills/objectui/SKILL.md`.
Controls that must NOT move, all measured at `ab0864186`:
| control | exit |
| --- | --- |
| real path, no `--repo` (`--commands scripts/pm/dispatch-gates.mjs`) |
0 |
| real path, `--tier packages/spec/src/index.ts` | 0 |
| a glob (`--commands 'packages/*/src/index.ts'`) — a pattern is not an
absent file | 0 |
| `--changed` — a tree's own diff cannot be another repo's | 0 |
The refusal text:
```
dispatch-gates: NOT MEASURED — an absent path may be another repo's — assert --repo to derive as a not-yet-written path of THIS repo, or name the other repo to be refused.
1 of 1 named path(s) are absent, and no --repo says whose tree they are from: does/not/exist.mjs
Two readings, and nothing in a repo-relative path tells them apart. Pick one — copy a line:
--repo objectstack-ai/objectstack — they are paths of THIS repo that are not written yet; derive as before.
--repo owner/the-other-repo — they are another repo's; be refused with both repos named, from a checkout of that repo.
(Exit 3 = NOT MEASURED, distinct from the 2 a usage error and the wrong-repo refusal carry. Capture it BEFORE any pipe.)
```
## Shape of the change
- `absentDeclaredPaths()` extracted from `bannerLines`, so the banner's
count and the refusal's list are ONE reading and can never name
different sets.
- `absentPathVerdict()` — the verdict, mirroring
`repoAssertionVerdict`'s shape. Returns `ok` when there is nothing
absent, or when an assertion is present at all: the wrong-repo refusal
runs first and keeps its own exit 2 and its own text, so `--repo other`
is unreached by this branch.
- One dispatch branch, placed after the assertion block and before every
mode branch. Guarded by `argvPaths.length > 0`, so only paths NAMED on
argv reach it.
Applied uniformly across modes, `--tier` included, and that is a
deliberate call worth a reviewer's eye: `--tier` derives no family, but
the wrong-repo refusal above it ALREADY ends a `--tier` run over another
repo's slug (measured on `origin/main`: `--tier does/not/exist.mjs
--repo objectstack-ai/objectui` exits 2), so the repo an answer is about
is already load-bearing there. Exempting `--tier` would resolve the same
ambiguity silently toward the harmless reading in the one mode a claim
comment pastes from. Consequence, stated plainly: `--tier PATH` where
PATH is not yet written now exits 3 until `--repo` is added.
## The one in-tree caller this broke, and its repair
The self-test's pending-changeset probe hands the CLI a HYPOTHETICAL
changeset path — the whole point of it — with no assertion. The new
branch turned that case and the one below it red. The battery named
both; this was measured, not predicted. Asserting the repo is the whole
repair, and it is exactly the migration every dispatcher of a
not-yet-written path owes. Both cases now assert a shape in EACH branch,
so the no-readable-remote path pins the refusal rather than passing over
empty output.
## Caller census
`git grep -n "dispatch-gates.mjs" -- scripts .github .claude` = **284
hits across 108 files**. Almost all are prose: other gate scripts'
headers citing this file. Narrowed to real invocations:
- **CI / hooks: none passes a path at all.** `package.json` wires
`check:pm-dispatch-gates` = `node scripts/pm/check-dispatch-gates.mjs
--self-test && node scripts/pm/check-dispatch-gates.mjs`, which reaches
this tool only by `spawnSync(..., '--self-test')`.
`.github/workflows/lint.yml`'s eight hits are that one step plus
comments. The three `.claude/hooks/*` hits are allowlist fixtures naming
`--self-test` and `--repo objectstack-ai/objectstack`. So no CI or hook
invocation passes a possibly-absent path without `--repo`, and none is
changed here.
- **Two agent-facing prose lines instruct the shape that can now
refuse** — `.claude/skills/pm-dispatch/SKILL.md:478` and
`.claude/skills/checklist-test/SKILL.md:98`, both spelling `--tier
PATHS` with no `--repo`. A PM claiming a card for a not-yet-written file
gets exit 3 there until the flag is added. ⛔ Not changed in this PR
(file surface is this script only) — listed as a finding for the seat.
- `scripts/git-merge-regen.mjs:119` already spells `--commands --repo
objectstack-ai/objectstack`; unaffected.
## Gates
Derived in-worktree; `--commands PATH --repo objectstack-ai/objectstack`
and the no-path re-derivation off the real change set produced
**identical** 30-command lists, and the list was re-derived unchanged at
the final head. All 30 run, exit codes captured before any pipe, then
reconciled with `--ran`:
```
✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED
(a DERIVED zero — all 30 recorded an exit code and none of them is 3).
Run reconciliation — 30 derived, 30 run, 0 NOT-MEASURED, 0 UNRUN.
```
**All 30 exit 0** at `ab0864186`, including `pnpm
check:pm-dispatch-gates` — this tool's own battery, run exactly as
`lint.yml` step 「PM dispatch-gates self-test」 runs it (`pnpm
check:pm-dispatch-gates`, repo root, no extra env), detached per the
file header's foreground-cap rule with the exit code captured to disk:
```
✓ dispatch-gates self-test: 1746 cases pass.
check:pm-dispatch-gates: the battery took 631.6s on this box. exit 0
```
### The patch round, stated plainly
The first pushed head `284855e7f` went **red on CI at exactly this
step**, and it reproduced locally at that same commit: `✗ dispatch-gates
self-test: 7 of 1742 case(s) failed.` All seven were the two blocks that
drive the CLI over the pending-changeset probe path — hypothetical by
design, passed with no assertion — so the new refusal fired inside them
and the derivation they assert on never ran.
Fixed on the CASE side, which is what the card's contract prescribes:
those probes now assert the repo, spelled once in a `runCliHypothetical`
helper beside `runCli` so every probe of a not-yet-written path carries
it and the next one is written through the same door. ⛔ No case was
skipped, disabled or loosened; ⛔ no exemption was carved for the
self-test, for a fixture directory or for a `.changeset/` prefix; ⛔ the
refusal itself was not weakened.
The battery went 1742 → **1746** cases: the value-bearing block's inner
cases sit behind `if (doc && vbRows.length >= 1)`, so while the outer
CONTROL was red they never ran at all. Four more cases now execute — the
diagnostics-increase direction, not a rewrite.
## Acceptance notes
- `--json` needed no new shape: a refusal prints nothing on stdout and
exits non-zero, which is what `--repo other` already did (measured on
`origin/main`: `--json ABSENT --repo objectstack-ai/objectui` = exit 2,
0 bytes of stdout). The new refusal matches it.
- With this checkout's `origin` unreadable the refusal says the
assertion cannot be CHECKED either, rather than printing a slug it does
not have — noted, not filed: an unverifiable identity plus an absent
path is NOT MEASURED in both directions, which is the honest answer.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bz6hxDBqK62NP2W1LATvnt)_
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ecf3e3b commit 55fd5ee
1 file changed
Lines changed: 230 additions & 10 deletions
0 commit comments