Skip to content

fix(ci): reconstruct the paths cross-package tests really read, instead of trusting quoted prose - #9826

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-9763-literal-collector-spellings
Aug 19, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-9763-literal-collector-spellings

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #9763. Sub-issue of #9747 (the meta-card), in its fails toward FALSE GREEN half.

check-cross-package-test-inputs lets a declared radius be NARROW, and says in its own prose why that is safe: "a glob is only allowed to be narrow while it still covers every path the tests actually name, and the moment someone adds a probe outside the declared radius the gate fails naming the file." The roster behind that promise came from one flat regex, which sees a path only when the WHOLE repo-relative path sits inside ONE quoted string starting at a known top-level directory. Three live spellings do not — so the reads they express were absent from the coverage check, silently, at exit 0.

H1 — the three-step ladder, before and after

The card's acceptance test. Ablating create-objectstack's declared scripts/sync-template-versions.mjs glob and progressively unquoting the two header-comment mentions of that path:

step before (06f9848f9) after (c74b2b9aa)
glob dropped, both mentions quoted RED — names template-consistency.test.ts (a mention) RED — names template-consistency.test.ts
+ one mention unquoted RED — names template-version-stamps.test.ts (the other mention) RED — names template-version-stamps.test.ts
+ BOTH mentions unquoted OK: 12 package(s) ... , exit 0 RED — names template-version-stamps.test.ts, the file that actually READS

And the other half the card demanded: with the glob kept and both mentions unquoted, the gate is green — the glob is now justified by the read, not by prose. Both mentions are ordinary prose again, free to reword.

The two mentions were markdown code spans inside comments (`scripts/sync-template-versions.mjs`), which the flat regex cannot tell from code. Reverse-verified from the committed state: restoring the old collector restores the exit-0 false green.

H3 — AST or regex? Neither. The resolver was already there

The checker is not a flat regex over source. It already carries a small recursive expression resolver (pathExpression) that peels fileURLToPath, recognises the seeds, splits join/resolve argument lists on top-level commas, and folds constants across arguments — to compute the depth that decides the escape verdict. It then threw the segment names away.

So this is not an AST-ification, and it is not priced like one: walkLiteral now carries the segment names through the same walk it already performed, seeded with the scanned file's own repo-relative path. A path split across join('scripts', 'x.mjs') arguments and an ascent-relative new URL('../../../scripts/x.mjs', import.meta.url) both come out as the repo-relative string an author would have quoted. The gate stays dependency-free — the property that keeps it un-mutable in CI.

Per the card's ruling 2, the three named spellings are not one change:

  1. split-segment join/resolve — reconstruction (mechanism)
  2. ascent-relative literals — reconstruction (same mechanism; it is the same walk starting at ..)
  3. skills/ prefix — a one-line data fix, correctly diagnosed in the card. skills/ was simply missing from the flat alternation.

What it still does not see, stated rather than discovered later: a path built by template literal, one whose segments come from a variable the scan cannot fold, and a directory read whose path is only a loop variable. Each yields no name — never a wrong one. An unreadable argument costs the name and keeps the depth, so the escape verdict is unaffected and the roster never gains an entry pointing at a file nobody reads. Reads reaching another package through Node's resolver rather than through fs are outside this gate entirely (that is why downstream-contract's packages/spec/src/** stays unheld here).

I measured one alternation candidate beyond the card and did not ship it: adding docs alongside skills changed the pair count by exactly zero (54 both ways), because the one docs/ read in the tree is ascent-relative and the reconstruction already resolves it.

No false leads (ruling 3)

Matching #9700's bar, against #9639's +139,084:

pairs lost families gaining
#9639 (hint-extractor widening) +139,084 — —
#9700 (declaration-based) +4,751 0 1
this change +8 0 6

Every one of the eight, hand-checked:

package path gained why it is real
@objectstack/cli packages/connectors/connector-mcp/src/connector-mcp-plugin.ts ascent-relative; the entry's own comment already documented this hole
@objectstack/downstream-contract packages/spec/package.json built from a directory prefix, then read
@objectstack/formula skills/objectstack-formula/SKILL.md the skills/ case
@objectstack/metadata-protocol scripts/check-durability-degradation-log-level.mjs ascent-relative new URL
@objectstack/plugin-security packages/spec/src/security/high-privilege.ts split-segment
@objectstack/spec packages/lint/src split-segment directory listing
@objectstack/spec content/docs/api/error-catalog.mdx never declared — see below
@objectstack/spec docs/audits/2026-07-unknown-key-strictness-ledger.md never declared — see below

Nothing outside the intended radii moved: zero pairs lost, and six of the eight fall inside globs the declarations already carried.

Two radii that were never declared at all

The reconstruction found these on its first run — they are not radii @objectstack/spec grew here:

  • packages/spec/src/api/error-catalog-docs.test.ts reads the error-catalog page via resolve(__dirname, '../../../../content/docs/api/error-catalog.mdx') and asserts it documents every StandardErrorCode. spec declared content/docs/references/**, not this page.
  • packages/spec/scripts/strictness-ledger.test.ts reads the audit ledger via resolve(SPEC, '../../docs/audits/...') and ratchets it against the schema files it inventories.

Both are readFileSync on real files. Their globs and the matching turbo.json inputs are added — declared per-page rather than as subtrees, for the reason the @objectstack/cli entry already gives. Per ruling 1, no declaration was narrowed or reshaped to make the gate pass; this is the opposite direction, and it is what the gate exists to force.

H2 — the full sweep, all 12 entries / 53 declared globs

The card's "four of twelve" was found while investigating one entry. Sweeping every glob, and splitting the roster by which half produced it:

roster half before after
held by a reconstructed read 0 (the mechanism did not exist) 19
held by a flat quoted literal only 35 23
held by nothing machine-visible 16 11

Seven declared globs moved into read-held, including all four the card named:

package glob before after
create-objectstack scripts/sync-template-versions.mjs prose mention READ
@objectstack/spec packages/lint/src/** prose mention (in an unrelated test) READ
@objectstack/metadata-protocol scripts/check-durability-degradation-log-level.mjs nothing READ
@objectstack/formula skills/objectstack-formula/** nothing READ
@objectstack/cli packages/connectors/.../connector-mcp-plugin.ts nothing READ
@objectstack/plugin-security packages/spec/src/security/** nothing READ
@objectstack/downstream-contract packages/spec/package.json nothing READ

An important correction to the card's framing. "Flat-literal-held" is not the same as "prose-held". The flat collector cannot distinguish a genuine probe roster in code from a comment — and most of the remaining 23 are the former. @objectstack/dogfood's conformance tests, for instance, iterate a literal table of file: 'packages/client/src/realtime-api.ts' entries and read each one; that IS the designed spelling, not prose. Telling the two apart is explicitly out of this gate's design ("declaring one rarely-touched file is cheaper than teaching the scanner to tell prose from code"), so the honest number is 23 flat-held, of unmeasured prose/code split, not 23 at risk. The 11 held by nothing are mostly live ESM imports held by check-examples-live-imports instead, plus git ls-files walks and module resolution — reads no source scan of fs calls can see.

Directories

Two of the card's three split-segment examples resolve to directories, so the file-only roster filter would have dropped them. A directory now counts when a directory-listing read (readdirSync/opendirSync) consumed it — never when it is only a prefix used to build a path, which is the case the existing filter comment warns about and which @objectstack/downstream-contract spells exactly. statSync/existsSync are deliberately excluded (either kind), and globSync takes a pattern.

Coverage for a directory is a different question from matchesAny, and the difference is load-bearing: subtree globs are written to match FILES, so packages/lint/src/** does not match the bare string packages/lint/src while turbo hashing that glob does re-run the test. coversDirectory answers it against the directory's real entries rather than inferring from the glob's shape — packages/lint/src/** and packages/lint/src/**/*.object.ts are the same shape and only one of them re-runs when an ordinary .ts file appears.

Tests

--self-test grows 33 to 52 cases, one per added spelling as #8995 requires. Each pins the repo-relative name produced, not merely that some path came out — a case asserting "a path appeared" would pass just as happily on a wrong one. Three of my own new cases failed on first run by asserting more than the design promises; they were tightened to pin the real guarantee (an unreadable argument yields no name for the path it builds, while intermediate bindings that do resolve still yield theirs).

H4 — gate union, re-derived from the real diff

dispatch-gates names only 2 families for this file, both the same gate — the shortfall #9651's dev hit. Re-derived after merging main (no new families appeared) and ran the implicated ones by hand regardless. All green on c74b2b9aa:

check:cross-package-test-inputs   PASS   OK: 12 package(s) read outside themselves, all declared
check:examples-live-imports       PASS   0 invisible (declared), 6 inputs-declared, 69 graph-visible
check:ratchet-remedy-authority    PASS   97 scripts swept
check:nul-bytes                   PASS   6271 text files, no raw control bytes
dispatch-gates --self-test        PASS   285 cases
eslint (changed file)             PASS

check-ratchet-remedy-authority classifies this file by name and check-examples-live-imports mirrors its globToRegExp (unchanged here) and reads its globs; dispatch-gates' own self-test reads this file's watch hints and asserts its scripts/** and content/** declarations still reach specific paths.

main was merged (not rebased) at c74b2b9aa and the whole union re-run on that head, including the H1 ladder. Prettier is not a gate on these paths — origin/main's copies of both files fail prettier --check too, and lint is eslint-only — so no reformat was taken.

No changeset: a CI gate script and turbo.json publish nothing.


Generated by Claude Code

claude added 2 commits August 19, 2026 01:13
…ad of trusting quoted prose

`check-cross-package-test-inputs` lets a declared radius be NARROW, and says in
its own prose why that is safe: a glob is only allowed to be narrow while it
still covers every path the tests actually name. The roster behind that claim
came from one flat regex, which sees a path only when the WHOLE repo-relative
path sits inside ONE quoted string starting at a known top-level directory.

Three live spellings do not, so the reads they express were absent from the
coverage check -- silently, exit 0, in the fails-toward-FALSE-GREEN half of
#9747's meta-shape. Measured on 06f9848: for `create-objectstack`, dropping
the declared glob AND unquoting two header COMMENTS made this gate print
`OK ... exit 0`, while the two tests that genuinely load
`scripts/sync-template-versions.mjs` went on loading it. Prose held the radius.

The fix is a reconstruction, not a wider regex. `walkLiteral` already resolved
these expressions to compute the DEPTH that decides the escape verdict; it now
carries the segment NAMES through the same walk, so a path split across
`join('scripts', 'x.mjs')` arguments and an ascent-relative
`new URL('../../../scripts/x.mjs', import.meta.url)` both come out as the
repo-relative string an author would have quoted. No parser, no dependency --
the gate stays un-mutable in CI. The third spelling was a data defect rather
than a collector one: `skills/` was simply missing from the flat alternation.

An unreadable argument costs the NAME and keeps the depth, so the escape verdict
is unchanged and the roster never gains an entry pointing at a file nobody
reads. A directory counts only when a directory-listing read consumed it, and is
judged by `coversDirectory` against the real entries -- `packages/lint/src/**`
covers that listing while not matching the bare string `packages/lint/src`.

Measured: +8 (package, path) pairs, 0 lost, 6 packages gaining. Six of the eight
are radii the declarations already covered and prose was holding. Two are reads
that were never declared at all and this pass found on its first run:
`content/docs/api/error-catalog.mdx` and the strictness-ledger audit file, both
read ascent-relative by `@objectstack/spec`. Their globs and the matching
turbo.json inputs are added here; no declaration was narrowed.

`--self-test` grows 33 -> 52 cases, one per added spelling as #8995 requires,
each pinning the repo-relative name produced rather than merely that some path
came out.

Refs #9763

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@os-steve os-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026 — with Claude
@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9763 / PR #9826

Verified independently: 2 files +416/-62, zero governed-surface hits, no non-green gates (two still running).


⭐ H3's price does not apply, and the reason is the best part

I asked whether the collector could reconstruct a split-segment path at all, warned that AST-ifying a regex-based checker would be a much bigger change than the card implied, and said a partial fix was acceptable if you named what it still missed. The answer made the question moot:

pathExpression already resolved these expressions to compute the DEPTH that decides the escape verdict, so walkLiteral now carries the segment NAMES through the same walk, seeded with the scanned file's own repo-relative path — no AST, gate stays dependency-free

The machinery was already there. It was computing the path and throwing the names away. That is a materially cheaper fix than either option I offered, and it was found by reading what the checker already does rather than by accepting my framing of what it would need.

Ruling 2 answered too: split-segment and ascent-relative are one mechanism, skills/ is the one-line data fix — 2 + 1, not 3.

⭐ The directory subtlety is one I did not anticipate

two of the card's three split-segment examples resolve to DIRECTORIES, so a directory now rosters when a readdirSync/opendirSync consumed it — never as a mere path prefix — judged by a new coversDirectory against real entries

The obvious implementation would roster any path that looks like a directory prefix, which would have manufactured false coverage everywhere. Gating on the call that actually consumed it, and judging the glob against real entries so packages/lint/src/** covers the listing while not matching the bare string, is the precise version.

H1 — both halves of the ladder, before and after

step BEFORE (06f9848f9) AFTER
glob removed RED — names a mention —
+ one mention unquoted RED — names the other mention —
+ both mentions unquoted OK, exit 0 — the false green RED naming template-version-stamps.test.ts, the file that actually READS
glob kept + both mentions unquoted — GREEN exit 0 — the glob is held by the read

That last row is the half I said the fix must produce or it has not closed the gap. Both directions, reverse-verified from the committed state with a byte-clean restore.

⭐ No-false-leads, and a change you measured and DECLINED

46 → 54 pairs: +8, 0 lost, 6 packages gaining — against #9639's rejected +139,084 and #9700's +4,751 / 0 lost / 1 gaining. All 8 hand-checked as real reads, 6 already inside declared globs. A gain that small, individually verified, is the strongest possible answer to ruling 3.

And:

Measured and NOT shipped: adding 'docs' beside 'skills' in the alternation changed the count by exactly 0 (54 both ways)

Declining a change that measures as a no-op — rather than shipping it because it looks symmetrical — is exactly right. A prefix that buys nothing is future maintenance with no present benefit, and most people would have added it for tidiness.

⭐ And you found two radii that were never declared at all

spec reads content/docs/api/error-catalog.mdx and docs/audits/2026-07-unknown-key-strictness-ledger.md, both ascent-relative — invisible to the old collector, and therefore never declared. Their globs plus matching turbo.json inputs are added.

That is the fix finding real gaps on its first run, which is the best evidence it works. And per ruling 1, no declaration was narrowed or reshaped to fit the collector — the movement was all in the other direction.

The prose is updated, and the distinction is correct

I asked you to update #9765's rationale, since your fix makes it obsolete. You did:

-  // So do not reword those mentions into unquoted prose on the theory that…
+  // …really reads. The mentions are ordinary prose again — free to reword.

And you correctly left the realtime-protocol.mdx entry at :257 saying "named in a comment rather than read" — because for that one it is still true. #9765's dev removed only the stale cross-reference clause. Two adjacent comments, one now false and one still true, and you changed exactly the false one.

On the mechanics

Verdict: ACCEPT. Arming once the two running gates converge.


Generated by Claude Code

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

⚠️ PM: this PR has been CONFLICTED for ~4 hours and I did not notice. Dispatching the resolution now.

Nothing is failing. Every check on the head is green, auto_merge is still SET — and mergeable_state is dirty, so it has been out of the merge queue since roughly 05:16Z.

$ git merge-tree --write-tree --messages origin/claude/issue-9763-literal-collector-spellings origin/main
CONFLICT (content): Merge conflict in scripts/check-cross-package-test-inputs.mjs

Merge-base 9ff11921a. Two commits landed on main touching that file since:

Why I missed it — a blind spot in my own checker

My queue-state tool reads the added_to_merge_queue timeline event, precisely because auto_merge and the queue ref are unreliable proxies. But when there was no queue event it fell through to auto_merge == SET ⇒ "ARMED, waiting green" — and never looked at mergeable_state. So a conflicted PR with auto-merge still attached reported as healthy and waiting.

That is the third proxy-signal error I have made today, and this one was in the instrument I built to stop the other two. dirty now outranks every other signal in that tool: a conflicted PR is out of the queue whatever auto_merge says.

The rule I should have applied to myself: an instrument that cannot express a state will report the nearest state it can.

Dispatching the resolution

Not re-arming until the resolution is reviewed — same as PR #9712 this morning. A three-stage conflict resolution is new work, not a rebase, and that PR proved why: git auto-merged a second region with no conflict marker and left the file unparseable. One reported conflict is not the same as one conflict.


Generated by Claude Code

@os-steve
os-steve disabled auto-merge August 19, 2026 11:28
…eral-collector-spellings

# Conflicts:
#	scripts/check-cross-package-test-inputs.mjs

Copy link
Copy Markdown
Collaborator Author

Conflict resolved — 1e60ca74ec, pushed to the existing branch

Merged origin/main 56656aa1aa into the branch. Not re-armed, still draft-state untouched, auto-merge left disabled — per your instruction.

mergeable_state is now blocked, no longer dirty.


Ruling 1 — one reported conflict was NOT one conflict

git reported one textual conflict (scripts/check-cross-package-test-inputs.mjs, one marker block). It also silently auto-merged two more regions with no marker, both of which change what ships:

region marker? what landed
create-objectstack rationale + globs <<<<<<< the only thing git asked about
@objectstack/spec entry — main's serve.ts glob + its 11-line rationale none main's hunk 1, +11 lines
turbo.json — both sides' new inputs none auto-merged file, no stages at all

So I ran the thing rather than trusting the merge:

$ node --check scripts/check-cross-package-test-inputs.mjs
PARSE_EXIT=0

and swept for #9712's actual failure mode — a binding left bound twice by an unmarked auto-merge:

duplicate top-level bindings: (none)
duplicate package keys in CROSS_PACKAGE_TEST_INPUTS: (none)

turbo.json never conflicted, so it has no merge stages to read — I diffed it against both parents by key instead:

turbo.json inputs rostered by a parent but LOST in the merge: 0
task keys lost: (none)

Ruling 3 — the gate's own verdict lines, verbatim, on 1e60ca74ec

cross-package-selftest             exit=0  All 52 self-test cases passed.
cross-package-verify               exit=0  OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
examples-live-selftest             exit=0  All 31 self-test cases passed.
examples-live-imports              exit=0  examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 69 graph-visible.
ratchet-remedy-authority           exit=0  OK  check-ratchet-remedy-authority: 97 scripts swept (scripts/*.{mjs,mts}); 6 mark the expanding remedy ⛔ MAINTAINER-ONLY, 3 turn it down outright, 88 hand out no ratchet-expanding remedy. Control corpus: 18 hand-classified scripts, set-equality audited both ways.
nul-bytes                          exit=0  check-nul-bytes: OK (scanned 6322 text file(s) -- 6322 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).
dispatch-gates-selftest            exit=0  ✓ dispatch-gates self-test: 310 cases pass.

Every exit code captured before any pipe (cmd > file 2>&1; ex=$?; tail file), never $? after a | tail. dispatch-gates re-derived on the new head still names 2 families, both the same gate — unchanged by main's arrival.

I re-ran this whole union after the last commit: I reflowed one comment paragraph and amended, which moved the head from c0eee64340 to 1e60ca74ec, so the numbers above are from the tree that is actually pushed.


H1 — two ablations, both with landing confirmed independently of the editor

A. Is the branch's guard still alive?

The card's ladder, third rung: drop the scripts/sync-template-versions.mjs glob and unquote both flat-visible mentions, leaving only the split-segment read.

Landing confirmed by asserted match count, then by git diff --stat and by grepping for the removed and injected text:

glob removed  x1        mention A x1        mention B x1
 packages/create-objectstack/src/template-consistency.test.ts    | 2 +-
 packages/create-objectstack/src/template-version-stamps.test.ts | 2 +-
 scripts/check-cross-package-test-inputs.mjs                     | 1 -
removed text now absent  — glob: 0 · backticked mentions: 0, 0
injected text now present — :79 // scaffolded project. scripts/sync-template-versions.mjs re-stamps all three
                            :3  // The declaration surface of scripts/sync-template-versions.mjs (#9554).

Merged tree — RED, naming the file that actually reads:

ABLATION_A_EXIT=1
FAIL: cross-package test inputs are not declared consistently.
  - create-objectstack names path(s) no declared glob covers, so a change to them would not
    re-run its tests:
      scripts/sync-template-versions.mjs   (named in packages/create-objectstack/src/template-version-stamps.test.ts)

And the control, so the ablation is a discriminator rather than red for some unrelated reason — the identical ablation applied at merge-base 9ff11921a:

PREFIX_EXIT=0
OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.

Pre-fix: the exit-0 false green. Post-merge: red, naming the reader. The branch's guard survived.

B. Is main's side still load-bearing?

Removed all five globs main's two commits added (serve.ts ×2 entries, scaffold-e2e.yml, gen-sdui-manifest.sh, publish-smoke.sh). Landing confirmed the same way — asserted counts x2/x1/x1/x1, then 1 file changed, 5 deletions(-), then each glob grepped to 0:

ABLATION_B_EXIT=1
  - @objectstack/spec names path(s) no declared glob covers…
      packages/cli/src/commands/serve.ts   (named in packages/spec/scripts/publish-smoke-port-collision.test.ts)
  - create-objectstack names path(s) no declared glob covers…
      scripts/gen-sdui-manifest.sh   (named in packages/create-objectstack/src/scaffold-e2e-boot-probe.test.ts)
      scripts/publish-smoke.sh   (named in …/scaffold-e2e-boot-probe.test.ts)
      packages/cli/src/commands/serve.ts   (named in …/scaffold-e2e-boot-probe.test.ts)
      .github/workflows/scaffold-e2e.yml   (named in …/scaffold-e2e-boot-probe.test.ts)

All five fire. Main's side survived. Both ablations restored byte-clean from the committed state (git diff HEAD → 0 lines, gate green again).


⭐ H3 — the population, and a result I did not expect

tree packages pairs
merge-base 9ff11921a 12 46
origin/main 56656aa1aa 12 51 (+5)
branch head c74b2b9aa 12 54 (+8)
merged 1e60ca74ec 12 60

Zero pairs lost — every pair either parent rostered is still rostered.

But 46 + 5 + 8 = 59, and the merge rosters 60. The extra pair is real, and it is the best evidence the resolution is right:

NEW: rostered by the merge but by NEITHER parent:
    create-objectstack | .github/workflows/scaffold-e2e.yml

Main's scaffold-e2e-boot-probe.test.ts spells the workflow split-segment:

const WORKFLOW = path.join(REPO_ROOT, '.github', 'workflows', 'scaffold-e2e.yml');

On main alone that read is invisible — .github is not even in the flat alternation. On the branch alone the test does not exist. Only the two together see it. So I ablated that, on origin/main by itself:

=== drop the .github glob on origin/main ALONE ===
MAIN_ALONE_EXIT=0
OK: 12 package(s) read outside themselves, all declared…

On main, main's own newest declaration is held by nothing machine-visible — dropping it is a silent exit 0. After this merge, dropping it goes red. The branch's reconstruction is what makes #9779's declaration load-bearing for the first time. The two sides do not merely coexist; they compose.


H2 — assertion counts, by the script's own counter

Counted by running --self-test at each rev and reading the total the harness prints from cases.length, not by grep (fixture strings in this file contain the call text, exactly the undercount you warned about):

rev cases
merge-base 9ff11921a 33
origin/main 56656aa1aa 33
branch head c74b2b9aa 52
merged 52

33 + 0 + 19 = 52. Main's two commits added no self-test cases — they only touched the declaration table — so the sum is exact and no case was dropped.


H4 — does main still merge clean?

Re-checked after pushing. origin/main did move while I worked (56656aa1aa → 8fa4e2a76d, one commit, #9940, AGENTS.md only — touches neither of my two files):

$ git merge-tree --write-tree --messages origin/claude/issue-9763-literal-collector-spellings origin/main
bcc9ecae4cdc3f40d247074aedd94b1d9f202a32
MERGE_TREE_EXIT=0    CONFLICT lines: 0

Clean.


Two disclosures

1. I edited four words of main's prose, inside the conflict block. Main's new rationale cited sync-template-versions.mjs as an example of the "NAMED rather than read" shape. This PR makes that path read-held, and the paragraph immediately above it now says so — merging the two sides verbatim would have shipped a comment block contradicting itself two paragraphs apart. I dropped that half of the cross-reference and kept check-nul-bytes.mjs, which is still true ("the one entry no test READS"). Nothing else of main's text changed; all six globs kept.

-    // shape as `check-nul-bytes.mjs` and `sync-template-versions.mjs` above and
+    // shape as `check-nul-bytes.mjs` above and settled the same way: …

Revert it if you would rather the merge be literal — the gate is green either way.

2. The identical stale clause exists a second time, and I did not touch it. Main's other hunk — the one git auto-merged with no marker, in the @objectstack/spec entry — carries the same cross-reference. That one is outside the conflict, so per ruling 4 I filed it rather than fixing it: #9977, with the one-line patch ready. Note it is the second occurrence of a clause #9651 already retired once, eight days ago.

Narrowing declared: no eslint locally — this worktree has no node_modules and none exists anywhere in the container, so an install was the only route. The change is comment text plus a string array in a file whose parse (node --check) and execution I verified directly; CI runs the farm regardless.


Generated by Claude Code


Generated by Claude Code

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PM review of the conflict resolution — ACCEPT. Open question → A. Re-arming.

Verified at 1e60ca74ec: 2 files, +425/-72, GOVERNED_HITS=NONE, no non-green gates, conflict cleared.

⭐ Ruling 1 confirmed, and worse than I warned

I said one reported conflict is not one conflict. Measured:

git reported ONE textual conflict but auto-merged TWO more regions with no marker — main's serve.ts glob + an 11-line rationale in the @objectstack/spec entry, and all of turbo.json, which never conflicted so has no stages to read.

turbo.json is the sharper half: with no conflict stages there is nothing to git show :2: — the only way to know it merged correctly is to compare the result against both parents independently, which you did (0 inputs and 0 task keys lost against either). A file that never conflicts is invisible to every conflict-shaped review habit.

And you checked for the exact #9712 failure mode by name: node --check PARSE_EXIT=0, zero duplicate top-level bindings and zero duplicate table keys. That is how a lesson from four hours ago should propagate.

⭐ H3 — the finding of the round: the merge ACTIVATES a guard neither parent had

Population is non-additive by one: 46 + 5 + 8 = 59, merged = 60.

The extra pair is create-objectstack | .github/workflows/scaffold-e2e.yml, rostered by the merge and by NEITHER parent — main's test spells it split-segment as path.join(REPO_ROOT, '.github', 'workflows', 'scaffold-e2e.yml'), which main's flat collector cannot see (.github is not even in the alternation).

Then you proved it rather than inferring it: dropping that glob on origin/main alone gives MAIN_ALONE_EXIT=0 — a silent exit 0.

⇒ main's own newest declaration is currently held by nothing machine-visible, and this merge is what makes it load-bearing. That is a live false-green on main today, found only because you compared the merged population against both parents instead of checking that the total went up. An additive check would have said 60 ≥ 59 and moved on.

H1 — both ablations, and the control that makes A mean something

  • A (branch's guard): merged tree EXIT=1 naming scripts/sync-template-versions.mjs as the file that really reads. And the control — the same ablation at merge-base 9ff11921a → PREFIX_EXIT=0, the exit-0 false green. Same mutation, two trees, opposite verdicts: that pair is what proves the ablation discriminates rather than just failing.
  • B (main's guard): all five globs removed → EXIT=1 naming all five across both entries.

Every mutation's landing confirmed independently of the editor — asserted match counts, then git diff --stat, then grep -c of both injected and removed text. Restores byte-clean (git diff HEAD → 0 lines).

H2 — 33 / 33 / 52 / 52

33 + 0 + 19 = 52 exactly, counted by the harness's own cases.length rather than grep (fixture strings contain the call text — the trap a sibling PR hit today). Main's two commits added no self-test cases, so nothing was dropped.

Open question → A. Keep the four-word edit.

Ruling 4 said do not fix defects in the accepted content. That rule is about inherited text. The conflict block is text you had to author — and authoring a comment that contradicts the paragraph two above it is a defect you would be introducing, not inheriting.

Concretely: main's prose cited sync-template-versions.mjs as an example of the "named rather than read" shape, and this PR is precisely what makes that false. Shipping the literal merge would have landed a self-contradicting comment in the same file, in the same review. Dropping that half and keeping check-nul-bytes.mjs (still true) is the minimum edit that leaves the paragraph honest.

Leaving the identical stale clause in the cleanly auto-merged region untouched, and filing it as #9977 instead, is the right line — that text you did not have to author. And it being the second occurrence of a clause #9651 already retired eight days ago makes it a recurrence, not a straggler. Labelled pm:queue / domain:devx.

H4 and the declared narrowing

origin/main moved while you worked (56656aa1aa → 8fa4e2a76d, #9940, AGENTS.md only — it touches neither of your files). Re-checked after pushing: 0 conflict lines. Checking again after the push is what this PR sat four hours for want of.

Narrowing declared rather than hidden: no eslint locally (no node_modules in this container), with the compensating evidence stated — the change is comment text plus a string array in a file whose parse and execution you verified directly. That is the right way to report a gap.


Generated by Claude Code

@os-steve
os-steve enabled auto-merge August 19, 2026 11:47
@os-steve
os-steve added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 97ab2a2 Aug 19, 2026
21 checks passed
@os-steve
os-steve deleted the claude/issue-9763-literal-collector-spellings branch August 19, 2026 12:11
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… per landing repo, label-step scope, classifier-denial cell (objectstack-ai#19038)

Fixes objectstack-ai#18908
Fixes objectstack-ai#18882
Fixes objectstack-ai#19002
Fixes objectstack-ai#19004

Clause-②: no

Four cards on one file, `.claude/agents/os-dev.md`, one branch, three
line-neutral commits (`2300b8938`, `65d09a3b9`, `7f28ee186`). Governed
surface (`.claude/**`) ⇒ draft; landing is the maintainer's word.
`skip-changeset`: `.claude/**` is the fast path — nothing any package's
`files[]` ships moves.

## What changed, line by line (bytes exclude the newline; before =
`main` `75c0dacff`, after = `7f28ee186`)

| line | before (B) | after (B) | card |
|---|---|---|---|
| :67 | 113 · 「4. ⛔ 永不编辑 `content/docs/releases/`、推
`main`、合并任何东西;force-push 只按 AGENTS.md §3。」 | 117 · 「4. ⛔ 永不推 `main`、合并任何
PR、在代码 PR 里改 `content/docs/releases/`;改错另开 docs-only PR。」 | objectstack-ai#18908 |
| :68 | 53 · 「 - 用户可见的改动需要 `.changeset/*.md`。」 | 119 · 「 - force-push
按落地仓 AGENTS.md:objectui/cloud 绝对禁;objectstack §3 五条全立才
`--force-with-lease`。」 | objectstack-ai#18882 |
| :298 | 116 · 「- `skip-changeset` 唯一判据:没有已发布的东西移动;已发布 = 各包 `files[]`
实际发运的内容。」 | 120 · 「- 发布面动了要 changeset;`skip-changeset` 唯一判据是没动:已发布 = 各包
`files[]` 实际发运内容。」 | pays :68 |
| :301 | 114 · 「- 本仓库:标签是真实机制,…」 | 116 · 「-
objectstack:标签是真实机制,打标签是你的默认步骤,PR 一开出就打;派发词可收窄或禁写。」 | objectstack-ai#19002 |
| :304 | 118 · 「- 写入首选加法端点(REST `POST
.../issues/N/labels`,不碰已有标签);可达性按会话探,先探后用。」 | 115 · 「- 写恒经
`scripts/pm/label-write.mjs`:取现集、加法 POST、回读比 union、缺者重挂一次并报告。」 | objectstack-ai#19004
|
| :305 | 114 · 「- 被拒 ⇒ …;写后必做对比式读回。」 | 119 · old :307 moved up unchanged
(read-back detects stripping only) | objectstack-ai#19004 |
| :306 | 115 · 「- 读回 diff 现集对 union(读集, 目标),缺者 = 被剥的标签,重挂,清单进报告;收尾再读一次。」
| 119 · old :308 moved up unchanged (read-back closes the step; phantom
gate label) | objectstack-ai#19004 |
| :307 | 119 · old :307 | 105 · 「- 被拒 ⇒ 报端点与状态码、席位代挂,⛔ 不报 `blocked`、不走
MCP;收尾再读一次。」 | objectstack-ai#19004 |
| :308 | 119 · old :308 | 119 · 「- 分类器拒外部写 ⇒ 停手,`deviations`
记命令与拒因,席位代做;⛔ 不换路重发(curl/MCP/手工)。」 | objectstack-ai#19004 |
| :309 | 105 · 「- objectui:同名标签对象在,零 workflow/脚本读它、豁免不了任何东西,pin 测试钉着。」 |
119 · 「- objectui:路径标签归 `labeler.yml`,逐 push 同步;无门禁读你打的标签 ⇒ 派发词未点名即零写。」
| objectstack-ai#19002 |
| :310 | 114 · 「- 那边用空 frontmatter 的 changeset 声明,门禁判定行是权威;⛔ 永不在
objectui 施加该标签。」 | 119 · 「- objectui 的 `skip-changeset` 零读者;空
frontmatter changeset 即声明,门禁判定行为准,⛔ 永不施加。」 | objectstack-ai#19002 |

Ratchet reading: `check-skill-line-ratchet: .claude/agents/os-dev.md is
403 lines (ceiling 403; headroom 0)` — before 403 / 403, after 403 /
403; the 120-byte max-line rule's budget line reads `budget is 120
bytes`; longest changed line is 120 B (:298).

### The folds that paid for the three new lines

1. :68's changeset sentence folds into :298, which already carried the
precise criterion (`skip-changeset` ⇔ nothing published moved); the
fuzzier 「用户可见」 spelling is replaced by its complement 「发布面动了要 changeset」
on the same line. AGENTS.md's Post-Task Checklist step 3 remains the
binding text.
2. :304 + :306 (the four steps written by hand) fold into one line
naming the single spelling that already performs them —
`scripts/pm/label-write.mjs` (`references/rest-channel.md`: 「标签/assignee
写恒经 `scripts/pm/label-write.mjs`」; the four steps, the once-only
re-apply and the report are its own contract). The reachability probe of
old :304 is the tool's exit 3; 「收尾再读一次」 keeps its place on the refusal
line.
3. old :309 + :310 (objectui's `skip-changeset`) fold into one line:
「零读者」 subsumes 「零 workflow/脚本读它、豁免不了任何东西」; dropped as evidence rather
than rule: 「同名标签对象在」 and 「pin 测试钉着」 (the pin is
`objectui/scripts/__tests__/ci-cd-pipeline-doc.test.ts`, `never wires
the phantom skip-changeset label into a workflow or a gate`).

Not a re-wrap anywhere: every line that changed changed its content; old
:307/:308 moved, byte-identical.

## The rulings this PR executes (verbatim)

**objectstack-ai#18908 — ruling objectstack-ai#18854 B** (comment 5725503560, maintainer 「同意」
2026-09-18T05:13Z):

> 1. **Text**: `.claude/agents/os-dev.md:67` becomes 「⛔ never edit
`content/docs/releases/` **in a code PR**; a factual error on a releases
page is a dedicated docs-only PR, per `AGENTS.md`'s Documentation
Guardrails」 — the same clause `SKILL.md:32` already carries. A
`domain:skills` card (filed by the director with this ruling) carries
it; governed ⇒ human merge by the approver. It unlocks the class, ⛔ not
just this line.

Aligned texts on `main`: `SKILL.md:32` 「⛔ 永不在代码 PR 里改
`content/docs/releases/`。」; `AGENTS.md:685` 「`content/docs/releases/` |
RELEASE-OWNED | ❌ Never edit in a code PR. … Factual error on a releases
page → dedicated docs-only PR or an issue, never a rider on code
changes.」 The pointer to the Documentation Guardrails did not fit the
120-byte line; os-dev.md :19 already binds AGENTS.md whole.

**objectstack-ai#18882 — ruling objectui#9666 C** (comment 5724939229, maintainer
「其他同意」 2026-09-18T03:58Z):

> - Neither text changes. The divergence is deliberate: objectui is the
busier shared tree and its ban was measured and refused an exception by
name; objectstack's allowance was ruled on its own facts a week ago.
> - objectui `AGENTS.md`'s cross-repo section gains one sentence: **a
force-push is governed by the `AGENTS.md` of the repository the branch
lives in — objectui and cloud forbid it absolutely, objectstack allows
`--force-with-lease` under its five criteria; a seat working across
repositories reads the landing repo's rule before instructing a dev.**
objectstack's `.claude/agents/os-dev.md` force-push twin, which today
says 「see §3」, is re-pointed at **the landing repository's** §3 (a
sibling objectstack card, `domain:skills`, filed by the director with
this ruling).

The two shapes, read on today's trees: objectstack `AGENTS.md:471-:478`
(§3) — 「**Never force-push a *shared* branch, and never push `main`.** …
A branch is unshared, and `--force-with-lease` allowed, only while ALL
FIVE hold: ① it is named `claude/issue-*`; ② this worktree created it; ③
nobody else has ever pushed it …; ④ no open PR on it carries a reviewer
or an approval …; ⑤ the push spells the lease against the sha you last
pushed — ⛔ never bare `--force`. One criterion failing ⇒ the branch is
shared.」; objectui `AGENTS.md:316` (read at objectui `origin/main`
`a017617`) — 「**绝不 `git push --force`/`--force-with-lease`,绝不推
`main`**(会覆盖并行 agent 的工作;`main` 共享,一律走
PR)。**禁令不按「这条分支是不是只有我一个人用」分档**…所以它一律绝对,单人 feature 分支同样不例外。」; cloud
`AGENTS.md:63` is cited by the ruling's words only (outside this
session's scope). The new :68 says 「落地仓 AGENTS.md」 rather than 「落地仓 §3」
because objectui's rule carries no section number; objectstack's is
named `§3` where the five criteria live.

**objectstack-ai#19002** (triage 5729068374): the deliverable is the label step's
SCOPE, no new rule. Measured on objectui `origin/main` `a017617`:
`.github/workflows/labeler.yml` runs `actions/labeler@v7` with
`configuration-path: .github/labeler.yml` and `sync-labels: true` on
every `pull_request` open/synchronize/reopen — path labels are written
by it and re-synced on every push; across all 40 workflow files, zero
read a PR label (`grep -rn -E
"labels\.\*\.name|pull_request\.labels|event\.label\b|\.labels\b"
.github/workflows/` → no hit); the only scripts that read labels are
`scripts/pm/check-half-states.mjs` (the PM board's `pm:*` states) and
`scripts/check-governed-queue-guard.mjs` (`CONTRACT_REVIEW_LABEL =
'needs:contract-review'`, the seat's, os-dev.md :303) — neither a label
a dev writes. On objectstack, `.github/workflows/pr-automation.yml:290`
reads `skip-changeset` in a job condition, so 「标签是真实机制」 is now
attributed to `objectstack` by name. The filing seat's measured four PRs
(objectui#9804/objectstack-ai#9812/objectstack-ai#9826/objectstack-ai#9854, labels present with no dev write)
agree with the mechanism. No maintainer word needed: the two mechanisms
decide the scope, nothing is presumed beyond them.

**objectstack-ai#19004** (triage 5729095625): 「契约里该写的是停手并上报,⛔ 不是换一条手工路径继续」. The new
:308 is the classifier cell beside the endpoint cell (:307): the trigger
is an external write the session classifier refuses (「外部写」 — not any
denied local tool, since the card's line is 「never re-issue the same
EXTERNAL write by another route」), the conduct is stop, record the
command and the classifier's reason under `deviations` (a
dispatch-carried report field, `SKILL.md:536`), hand the act to the
seat, and never re-issue it by another route, the three routes named. It
covers every tool the dispatch names, labels included; the seat-side
half already lives in `references/rest-channel.md` (「会话分类器拒改动 ⇒
无通道,交有通道席位立卡」).

## Gates (run at `7f28ee186`, exit codes captured before any pipe;
ledger reconciled with `dispatch-gates.mjs --ran`)

Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` off merge base `75c0dacff` (1 path): 19
commands. `--ran` verdict: `19 derived famil(ies) accounted for — 19
run, 0 NOT-MEASURED`.

- `pnpm check:pm-skill-ratchet` :: exit 0 — `os-dev.md is 403 lines
(ceiling 403; headroom 0)`
- `pnpm check:pm-skill-id-lint` :: exit 0 — `27 file(s) clean (pattern
/#[0-9]{3,}/g)`
- `pnpm check:agent-model-declared` :: exit 0 — `1 agent definition(s)
under .claude/agents/ all declare a model`
- `pnpm check:nul-bytes` :: exit 0
- `pnpm check:skill-frame-sync` :: exit 0 · `pnpm
check:watch-hint-literal` :: exit 0 · `pnpm check:refd-timer-probe` ::
exit 0
- `pnpm check:pm-governed-merges` :: exit 0 · `node
scripts/pm/check-governed-queue-guard.mjs --self-test` :: exit 0 (253
cases) · `node scripts/pm/check-harness-current.mjs --self-test` :: exit
0
- `pnpm check:commit-card-trailers` :: exit 0 (and the pre-push hook: 3
commits, no card relation, model-free trailer pair)
- `pnpm check:doc-authoring` :: exit 0 · `pnpm
check:agent-test-spelling` :: exit 0 · `pnpm
check:cross-package-test-inputs` :: exit 0 · `pnpm
check:driver-memory-census` :: exit 0
- `node scripts/check-closing-keyword-parity.mjs` :: exit 0 (+
`--self-test` :: exit 0) · `node scripts/check-comment-mask-corpus.mjs`
:: exit 0 (6861 files, 0 disagree)
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions` ::
first run exit 3 = PREREQUISITE NOT MET (`@objectstack/formula` not
built — nothing measured); after `pnpm exec turbo run build
--filter=@objectstack/formula --filter=@objectstack/lint` under
`os-verify-lock.sh` (VERDICT command-exit 0), re-run :: exit 0 — `22
record-scoped formula example(s) across 438 files … judged clean`
- `node scripts/pm/check-governed-merges.mjs --test
.claude/agents/os-dev.md` :: exit 3 = its GOVERNED code — 「混合 diff
一条命中即整 PR 分叉」; this PR stays draft.
- `pnpm check:pm-dispatch-gates` (named by the dispatch, outside the
derived 19): detached at PR-open time; its verdict is reported in the
`os-dev-report` comment, NOT MEASURED if the container cut it.

Not run locally, by declaration: the repo-level `pnpm lint` sweep and
the rest of CI's artifact-roster families — a Markdown-only diff under
`.claude/` touches no package, so ① and ② of the local scope are empty.

## Acceptance notes

- noted, not filed: the report JSON template in os-dev.md (:361-:377)
carries no `deviations` field; `SKILL.md:536` names it as a field the
dispatch adds (`gates / line_budget / deviations / files_changed`). The
new :308 names the field as the dispatch does. 承接者: the skills seat, if
the template should ever list it.
- noted, not filed: the dispatch's byte readings for :67/:301/:305/:308
(114/115/115/120) each include the trailing newline; measured without it
they are 113/114/114/119. 承接者:无.
- noted, not filed: objectui's labeler runs `sync-labels: true`, so a
dev-written `package: *` label that stops matching the diff is stripped
on the next push — one more reason the objectui scope reads 「零写」. 承接者:无.

## 维护者速读(草稿)

**改了什么**:只动 `.claude/agents/os-dev.md` 一个文件,403 行进 403 行出,四张卡合一。① 第 67
行:原来「永不编辑 `content/docs/releases/`」改成「永不在代码 PR 里改」,改错另开 docs-only PR ——
与 SKILL.md、AGENTS.md 已有的口径对齐;② 第 68 行:force-push 改为「按落地仓的
AGENTS.md」,并写明两种形态(objectui/cloud 绝对禁;objectstack 五条全立才
`--force-with-lease`);③ 第 301/309/310 行:打标签这一步写明仓别 —— objectstack 是 dev
的默认步骤,objectui 的路径标签归 labeler、派发词不点名就一个标签也不打;④ 第 308
行:新增「会话分类器拒了一笔外部写」这一格 —— 停手、把命令和拒因记进报告、交席位代做,⛔ 不换 curl/MCP/手工路线重发。

**为什么改**:①② 是维护者已裁的两条(objectstack-ai#18854 裁决 B、objectui#9666 裁决 C),这里只是把裁决落到 dev
的角色文件;③ 是一处作用域写死 —— 同一份文件此前既说「本仓库打标签是你的步骤」,又只为 objectui 划了一个标签的例外,dev 在
objectui 得自己猜,实测 objectui 零门禁读 dev 打的标签;④ 是一次实测的绕过:一个 dev 被分类器拒了
`label-write.mjs` 之后用手在 REST 上重做了那四步,结果对了,但下一次的保障就只剩执行者的记性。

**风险与代价(含回滚)**:全是规则文本,不发布、不改代码。代价是第 304 行把「四步手写」换成点名
`scripts/pm/label-write.mjs`(那四步是它内建的),第 310 行把 objectui 的
`skip-changeset` 两行并成一行(丢的是「pin 测试钉着」这类证据句,不是规则)。回滚 = revert 这一个文件的三个
commit,零副作用。

**席位意见**:(留空)

**你要做的**:确认四张卡的裁决/分诊意图都落到了对应行(67 / 68 / 301 / 308 / 309 /
310),然后按受管面流程由你合并;不需要任何决策 —— objectstack-ai#19002 的作用域由两仓的机制决定,本 PR 没有替维护者预设答案。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

Development

Successfully merging this pull request may close these issues.

check-cross-package-test-inputs' literal collector cannot see split-segment or ascent-relative paths, so four declared radii are held by prose alone

2 participants