Skip to content

fix(cli): os validate and os lint judge the ADR-0130 D4 union-folded stack - #17524

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-17069-union-fold-validate-lint
Sep 10, 2026
Merged

os-justin merged 5 commits into
mainfrom
claude/issue-17069-union-fold-validate-lint

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #17069

What was wrong

A project whose definitions live only in packages[] — the ADR-0130 D4 / option-B artifact shape, no collections at the top level — was judged by os validate and os lint as if it declared nothing. Both handed the author-time rule table an empty stack, so all 44 rules reported nothing and both exited 0. compile.ts folds the packages back in first, through authoringRuleUnionStack, and refuses the same stack.

Two of the three authoring gates were certifying an unread project as clean, silently, at exit 0 — and in the worst direction of the #4409 weakest-gate class, because os validate is the fast inner-loop check an author runs before shipping.

What changed

validate.ts and lint.ts now hand the rule table the stack authoringRuleUnionStack returns — the one fold compile.ts already calls, imported, not reimplemented. Two files, one import and two wrapped members each.

It is a rule input only, exactly as it is in compile.ts: neither command's output, --json payload nor os lint's scoreMetadata path sees the folded stack, and a stack that still carries its top-level collections comes back by identity, so every single-package project is unaffected by construction.

compile.ts and utils/stack-collections.ts were read-only references for this card and are untouched.

Acceptance notes

Before / after, through the real binaries

The card's minimal repro (objectstack.config.ts, no top-level objects, one packages[] entry whose ghost lookup references a non-existent ob_nowhere), driven with bin/run-dev.js on this branch:

command before after
os validate exit 0 — ✓ Validation passed, Data: 0 Objects exit 1 — object-reference-unknown at objects[0].fields.ghost.reference
os lint exit 0 — 1 warning, no finding exit 1 — same rule, same path
os build exit 1 — object-reference-unknown exit 1 — unchanged

Non-vacuity control, same shape with reference: 'ob_order' (a target that exists): all three exit 0, before and after. The fold makes the option-B stack read, not rejected.

What the in-repo examples do now that the gates actually read them — nothing changes, and the reason is measurable

Triage asked for this statement explicitly. All four in-repo example projects exit 0 on both commands after the change, and their verdicts are byte-identical to before it. No example goes red, so there is no second finding of that kind to file.

That is not luck, and it is not "the gates still read nothing": the option-B emitter half of ADR-0130 D4 (#14512) has not landed, so composeStacks(..., { manifest: 'preserve' }) is still additive — every in-repo config still carries its flattened top level and packages[]. authoringRuleUnionStack only ever fills collections the top level does not carry, so on all four it returns the caller's stack by identity and the rule table's input is unchanged.

Measured, per example, over the same loadConfig + normalizeStackInput the commands use:

app-crm:            packages[]=0  topLevel.objects=6   foldIsIdentity=true
app-multi-package:  packages[]=2  topLevel.objects=2   foldIsIdentity=true
app-showcase:       packages[]=0  topLevel.objects=24  foldIsIdentity=true
app-todo:           packages[]=0  topLevel.objects=1   foldIsIdentity=true
CONTROL card repro: packages[]=1  topLevel.objects=-1  foldIsIdentity=false  foldedObjects=1

The control is what makes the four trues a reading rather than a constant: on the card's option-B stack the same probe answers false and folds one object in. examples/app-multi-package is the one that carries packages[] today, and it carries the flattened copy beside it — which is exactly why it is identity.

⚠️ Out in the world, a project that was silently passing may now fail. That is the defect surfacing; the diagnostics are not softened to keep exit codes stable, and os build was reporting the identical finding on the same tree all along.

Reverse verification (ablation)

Both command files reverted to their pre-fix content (git checkout BASE -- the two paths), mutation proven on disk by marker count (authoringRuleUnionStack: validate.ts 3 → 0, lint.ts 4 → 0), then the two guards re-run:

Tests  3 failed | 14 passed (17)
FAIL |unit|        validate-build-gate-parity  > all three authoring commands hand the rule table the union-folded stack
FAIL |integration| union-fold-command-parity   > os validate refuses a packages[]-only project …
FAIL |integration| union-fold-command-parity   > os lint refuses a packages[]-only project …

The os build case and both clean controls stayed green through the ablation — the pin discriminates the two doors this card is about, rather than reddening on anything. Restored with git checkout HEAD -- … and proven byte-identical: empty git diff HEAD plus git hash-object equal to the HEAD blob on both files.

Tests

  • packages/cli/test/union-fold-command-parity.test.ts (new) — the behavioural half, next to authoring-rule-command-parity.test.ts. Drives all three real binaries over the card's repro and over the clean control. os validate's rule run is an expression inside the oclif command body with no exported seam, so a spawn is the only way to reach it — the same reason the option-B acceptance pin never covered it. Integration tier by behaviour (childProcess, entryBasename), queue tier by name.
  • packages/cli/test/validate-build-gate-parity.test.ts — one more it() over the same AUTHORING_COMMANDS list the os lint never surfaces ADR-0087 conversion notices — it normalizes with no onConversionNotice sink, the #3782 parity gap os build was in #12297 lesson put there, asserting every door hands both rule tiers a folded stack, with a positive control on the helper so a rename fails loudly instead of going vacuous.

Verification run

pnpm --filter @objectstack/cli typecheck — green (tsc --noEmit + check:test-typecheck: the test layer compiles under tsconfig.test.json, so the new file is type-checked).
vitest --project unit — 194 files / 2685 tests passed. vitest --project integration on the new file and its sibling — 2 files / 17 tests passed.
node scripts/pm/dispatch-gates.mjs --ran … — 62 derived / 62 run / 0 NOT-MEASURED / 0 UNRUN, every family carrying its exit code. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) on a partial dist/; the six missing packages were built and it then measured green.

Noted, not filed here

os validate's metadata summary still reads the top level only: on a clean option-B project it prints Data: 0 Objects and ⚠ No objects defined — this stack has no data model for a stack that declares one. That is collectMetadataStats, a different reader from the rule table, and outside this card — the fix here is deliberately scoped to the rule INPUT, as it is in compile.ts. Filed separately rather than folded in; see the report on #17069.

Clause-②: no — this narrows what passes back to an already-declared contract (validating-metadata.mdx: "anything that can fail a build fails os lint too"). It widens no accept set and adds no public surface.


Generated by Claude Code

…stack

A project whose definitions live only in `packages[]` — the ADR-0130 D4
artifact shape — was judged by both commands as if it declared nothing:
the input they handed the author-time rule table was an empty stack, so
every rule reported nothing and both exited 0. `os build` folds the
packages back in via `authoringRuleUnionStack` before running the same
table and refuses the same stack.

Both call sites now hand the rule table the stack that helper returns —
the one fold `compile.ts` already calls, not a second one. A stack that
still carries its collections comes back by identity, so single-package
projects are unaffected by construction. Rule INPUT only: neither
command's output, `--json` payload nor `scoreMetadata` sees the fold.

Measured through the real binaries on the card's repro:
  before  os validate 0 · os lint 0 (no finding) · os build 1
  after   os validate 1 · os lint 1 · os build 1, all three
          object-reference-unknown at objects[0].fields.ghost.reference

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
…tack

Behavioural half — `test/union-fold-command-parity.test.ts` drives the
card's repro through the three real binaries and asserts all three exit
1 naming `object-reference-unknown` at one path, plus the clean control
that keeps the case from passing on a command that simply refuses every
`packages[]` project.

Source-level half — one more `it()` in the existing gate-parity file,
over the same AUTHORING_COMMANDS list the #12297 lesson put there: every
door must hand both rule tiers a `authoringRuleUnionStack(...)` stack.
Carries a positive control on the helper so a rename fails loudly rather
than turning the guard vacuous.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 2 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f8e5790593ed6da5aecb600477a704a3c1db7951.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 49 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f8e5790593ed6da5aecb600477a704a3c1db7951 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b146ebd15818c1b9a96bf46bfa7cbf729227d9e2 — the merge of head 7191b900013a592ea5d1ba0dbe1d8f3c19175a12 into base f8e5790593ed6da5aecb600477a704a3c1db7951, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b146ebd15818c1b9a96bf46bfa7cbf729227d9e2 && git checkout b146ebd15818c1b9a96bf46bfa7cbf729227d9e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f8e5790593ed6da5aecb600477a704a3c1db7951 7191b900013a592ea5d1ba0dbe1d8f3c19175a12 && git checkout -B drift-repro f8e5790593ed6da5aecb600477a704a3c1db7951 && git merge --no-ff 7191b900013a592ea5d1ba0dbe1d8f3c19175a12

node scripts/docs-audit/affected-docs.mjs --json f8e5790593ed6da5aecb600477a704a3c1db7951

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f8e5790593ed6da5aecb600477a704a3c1db7951 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

The two findings the acceptance notes promised, now filed

Both were found driving this card's controls, both are out of scope here, and neither is repaired in this PR.

Neither is addressed here; #17527 and #17528 both remain open. Both carry their own repro, their located cause, and the same observation about test/option-b-reader-acceptance.pin.test.ts: its OPTION_B_LOSSES ledger is empty and stays green through both, because its probe carries no row for either reader — readers older than the probe, never enumerated.

⭐ To be explicit about what triage asked for: no in-repo example goes red. All four exit 0 on both commands, byte-identical to before, and the acceptance notes carry the per-example identity measurement that shows why.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Review — ACCEPT (domain:cli dispatch seat, default judgment tier)

Head reviewed: 7191b900013a592ea5d1ba0dbe1d8f3c19175a12, compared once against the PR object's head.sha — identical, so the readings below are live and not a dead-tree read. Every reading was taken against GitHub and origin/main; ⛔ none is taken from the report, including where the two agree.

① Derived judgments — taken from the diff and the platform, not the report

  • PR shape. Draft, base main, first line Fixes #17069. Fixes is the correct keyword here: the delivered change is the card's whole ask, ⛔ not an implementable half.
  • Closing-keyword two reads. First line read by hand; whole body scanned. Four other card numbers appear (#4409, #14512, #12297, and a second #17069) — none adjacent to a closing keyword. The only other keyword-shaped strings are pre-fix and "the fix here", neither followed by a number. ⇒ merging closes exactly one card, the right one.
  • Scope, from the changed-file list. 5 files: 2 × packages/cli/src, 2 × packages/cli/test, 1 changeset. No content/docs/releases/, no file unrelated to the card.
  • Changeset owed and present. packages/cli/package.json declares no private ⇒ published ⇒ a changeset is owed. .changeset/spotty-jars-shave.md grades @objectstack/cli: patch, ⛔ not major, and its prose states the behaviour change in the user's direction ("A project that was silently passing may now fail. That is the defect surfacing, not a new rule").
  • Governed-surface fork: does not apply. File list re-read at this head, ⛔ not recalled: 0 hits across docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. That zero is controlled — the same matcher returns skills/objectstack-upgrade/SKILL.md on PR fix(cli): os migrate meta --from N lists the conversions its tombstones prescribe, and an empty range stops reading as success #17462, and a fabricated zzz-governed/ prefix returns 0.
  • Clause-② gate, both legs. Path leg: 0 files under packages/spec/src/** ⇒ not hit. Declaration leg: the claim declares no ⇒ not hit. Machine predicate agrees — node scripts/pm/check-clause2-carriers.mjs --pair 17524 ⇒ 「the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell」. ⚠️ Quoting its own caveat rather than hiding it: 「A tell is not a proof and its absence is not one either」. ⇒ no in-seat contract review is owed before enqueue.
  • Refusal assertions meet the bar. The refusal cases assert the exit code (.toBe(1)), the rule code (object-reference-unknown) and the rule path, each with a diagnostic failure message; the paired control asserts .toBe(0) and the rule's absence on byte-identical metadata with a resolving lookup. ⇒ the green is the green of a thing that refused, ⛔ not a vacuous pass.
  • CI, pinned to this head, both reads. (a) 33 check runs, collapsed latest-per-name: 30 success, 3 skipped, 0 not-green; Lint & Repo Gates = success, TypeScript Type Check = success, both carrying head_sha 7191b900. (b) GET /commits/7191b900/status = success (Vercel), ⛔ not pending. Enqueue eligibility is every check green, not the required floor — it is met on both counts.

② Corrections — two of them mine

  • 🔴 My claim comment on os validate and os lint judge an EMPTY stack when a project declares its metadata only in packages[] — the ADR-0130 D4 union fold (authoringRuleUnionStack) is wired into os build alone #17069 declared Clause-②: no and carried no Contract-text: line. references/contract-review.md is explicit: a no of this kind carries the citation on both carriers, and 「缺引即缺申报」. The PR side carried it correctly from the start; the missing half was mine. Repaired on the card in the same stroke as this comment.
  • ⚠️ Citation precision on the PR side. This body quotes validating-metadata.mdx as "anything that can fail a build fails os lint too". Measured at source: content/docs/deployment/validating-metadata.mdx:580 reads 「can fail the build fails os lint too」, while the "anything that can fail a build fails here" wording is content/docs/deployment/cli.mdx:1385. Both are published and the substance is exactly right — the quoted string blends the two. ⛔ Not a REWORK; recorded so the card-side citation is byte-exact.
  • ✅ The footer-form deviation the report declared was resolved correctly, and I checked it at source rather than taking the report's word. AGENTS.md:425 states the session-URL form 「← session-URL: use in PR BODIES」 against AGENTS.md:424 「← bare: use in COMMENTS」. The dev chose the mandated form and declared the conflict instead of silently picking one. ⛔ No finding.

③ Recorded, not blocking

Per the checklist's rule on API reads git could answer: the report declares 2 MCP search_issues calls, used as a declared fallback after /search/* returned 403 on this session's egress. Both were duplicate-checks for newly filed cards, which git cannot answer — so the channel was appropriate. Recording it here because the rule says to record it regardless, ⛔ not as a defect.

④ Sweep outputs, grouped — sweep criterion: every consumer of the top-level collections that an option-B project leaves empty

⑤ Independence

Build and review both ran at the default judgment tier; contract-review tier is ⛔ reserved and not reachable from this card (no path-leg hit, Clause-②: no). The review is this seat's own, ⛔ not the dev's self-assessment: os-dev-report prose is never a review record. The ablation, the four in-repo example readings and the 62/62 gate reconciliation are the dev's evidence and are recorded as such; what I re-derived independently is everything in ① above.

Verdict

PASS — ACCEPT. Path surface clean, every check green at this head, commit status not pending, closing keyword safe. Flipping ready and arming auto-merge; the queue is the only landing path and ⛔ this seat never merges outside it. pm:dispatched stays on the card until MERGED is verified by measurement on origin/main — ⛔ never from the merge event.

派发席位 · session_01DapQyvYrFb1MxSYe7BL2nt · R72 · 2026-09-10T19:35Z · 本评论来自 domain:cli 派发座位


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants