Repository navigation
fix(cli)!: objectstack build --strict-body counts each callable once, and --json issues lists callables only - #22849
Conversation
…ble once The header printed `issues.length` while `issues` was the extraction warnings plus one `<aggregate>` placeholder row, so one refused handler read 2 and four read 5, and `--json` `issues` carried a row naming no callable. `lowerCallables` now returns `bodylessCallables`: one row per callable that ships with no metadata body, keyed by ref, so `functions` entries are named, authored bodies are not counted, and an ADR-0130 D4 package callable walked twice is counted once. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
A callable is bodyless when no slot lowered it into a body: the population the old placeholder row counted, itemized and deduped by ref. An inline function beside an authored body stays a row (it still rides the runtime module), so no single-walk `--strict-body` verdict moves; only the count and the rows do. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 18d1e9d162f9a017d1e4b287d241e37fa12b594d && git checkout 18d1e9d162f9a017d1e4b287d241e37fa12b594d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 12b9daf7493f30ebb5faf47e7b40eb99d2bda267 e583a93803453deba054fe3feaae417f2ef0e86d && git checkout -B drift-repro 12b9daf7493f30ebb5faf47e7b40eb99d2bda267 && git merge --no-ff e583a93803453deba054fe3feaae417f2ef0e86d
node scripts/docs-audit/affected-docs.mjs --json 12b9daf7493f30ebb5faf47e7b40eb99d2bda267
|
Contract reviewServed-tier: Inputs read: card #22839 (body and all three comments: triage 6109249693, claim 6109361423, os-dev-report 6109800087), PR #22849 (body, five-file list, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
One function bound as the handler of two or more hooks or actions, beside a `functions` entry or an authored-`body` slot, moves from pass to refuse under `--strict-body` in a single walk too. The changeset's Unchanged sentence now states the true condition (each callable bound at one slot), the shape gets its own BREAKING bullet, and one unit case pins it. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #22839 (body and all five comments: triage 6109249693, claim 6109361423, the three os-dev-reports 6109800087, 6110061229 and 6110082477), PR #22849 (body, five-file list, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Resolves the four lower-callables.ts hunks PR #22849 shares with this branch by carrying both parameters, ref then face: tryExtractBody's signature, the hook call site, lowerActionCallable's tryExtract type, and the action call site. The bodylessCallables / noteUnlowered logic is main's, byte for byte. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22839
Clause-②: no (narrowing)
What was wrong (measured on
origin/main1eff3224d)compile.ts's--strict-bodyblock builtissuesas the body-extraction warnings plus one placeholder row whenevercount - bodyExtracted > 0. The placeholder's origin was the wordaggregatein angle brackets, and its reason was "N callable(s) lowered to handler ref but produced no body". The block then printedissues.lengthas the number of callables. At the door (bin/run-dev.js build --strict-body, the reverse leg below), one refused handler printed--strict-body: 2 callable(s) lack a metadata bodyand four printed5.--jsonissuescarried the placeholder as an extra row beside the real ones.Premise check: are
missingBodyand the warnings the same callables? Not alwaysMeasured with a tsx probe that runs
lowerCallablesand the old arithmetic over nine shapes:hook 'refused_1'functionsmap entry onlyfunction 'score_lead'functionsarray entry + 1 refused hookfunction 'arr_fn'bodybeside an inlinehandlerhook 'clean_1'packages[0].manifestfunctionsentryfunction 'score_lead'functionsentry (one walk)function 'score_lead'So the populations differ in three ways:
functionsentry has nobodyslot. It is never extracted, so it leaves no warning, and only the placeholder row counted it (D, E).bodyis never extracted either, so it also leaves no warning (F).packages[i].manifestbody is walked twice.countdedups by function identity, while extraction runs once per walk. Each refusal is recorded twice and each lowered body is counted twice (G, I). That hid thefunctionsentry in H, so the old gate passed it.The fix
lowerCallables(packages/cli/src/utils/lower-callables.ts) returnsbodylessCallables. It holds one row per registered callable that no slot lowered into a body, each once, keyed by ref, in registration order. This is exactly the population the placeholder row counted, itemized. A refused handler's row is the extractor's refusal, unchanged (origin,reason,kind,freeIdentifiers). Afunctionsentry, or an inline function beside an authoredbody, gets an{ origin, reason }row naming it (function 'name',hook 'name',action 'name').compile.ts:issues = lowering.bodylessCallables. The header's wording is unchanged and its number isissues.length. The--jsonexit keeps the sameerrorstring and the same keys.compile.ts, its tests and the changeset. The producer of the count islower-callables.tsin the same package, so the fix lands there (producer side).extract-hook-body.tsis untouched.Why F stays a row. Two definitions were possible. One is "a slot runs it from the runtime module", under which F is not a row, since the hook binder prefers
body. The other is "no slot lowered it", under which F is a row. The second is the placeholder's own population, and F's function is still bundled (needsBundlereadscount - bodyExtracted, which this PR does not touch). Under it, every stack in which each callable is bound at one slot gets exactly the verdict it got before. TherebodyExtractedcounts each lowered callable once, socount - bodyExtractedis this list's length, and the old gate refused iff that exceeded 0, which is iff this list is non-empty. The first definition would have let--strict-bodypass F while the build still emittedobjectstack-runtime.*.mjsbeside it, so it would have widened the accept set. Verdicts move only from pass to refuse, and only where a callable lowered at two or more slots masked a callable that was never lowered. That is H, the same callable at the top level and in a package body, reachable only through the two-level hand-written shape. It is also J, one function bound as the handler of two or more hooks or actions in a single walk, beside afunctionsentry or an authored-bodyslot. Both are narrowings.Consumers of
--jsonissues(measured, before the shape moved)git grepover the tree at1eff3224d(packages,scripts,.github,examples,packages/qa,content/docs):compile.ts:340(the producer). No reader.strict-body: missing body:build-json-failure-warnings.e2e.test.ts:437, which readserrorandwarnings, notissues.truncation-remainder-notices.test.ts:411is a source-text pin on the payload literal, unchanged here.docs/qa/platform-checklist/areas/cli.json:1344is a manual step that captures the payload and reads no row.payload.issuesreads:build-json-failure-warnings.e2e.test.ts:358and:394, both on other exits (docs validation, capability preflight).--strict-bodyinvocation in.github/workflows,scriptsorexamples.No reader of the placeholder row, so it is removed outright and no summary field is added.
Docs
content/docs/automation/hook-bodies.mdx:173("objectstack build --strict-bodyfails on it") and:345("turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic") were re-read, and both are still true. No othercontent/docspage describes--strict-body's output. No docs edit.Tests (all at
11d4d8c6e, the head of this PR)packages/cli/test/build-strict-body-count.test.ts(new,integrationproject: it spawns the CLI) pins the triage's door pins. CONTROL with 0 refused: exit 0 and no header. 1 refused: the header reads 1 and lists 1 hook;--jsonissueshas 1 entry. 4 refused: the header reads 4 and lists 4 hooks;--jsonissueshas 4 entries, eachkind: 'free-identifiers'. Afunctionsentry beside a refused hook givesissuestwo origins: the hook, thenfunction 'sbc_score'.packages/cli/test/lower-callables.test.ts(unit): 10 new cases overbodylessCallables. They cover the control, 1 and 4 refused, a refused action,functionsentries (map, declared, array), an authoredbodybeside an inline function (hook and action), a single walk whose list length equalscount - bodyExtracted, a callable lowered at any slot, and the two double-walk cases. The divergence cases assert the old answer beside the new one.The
integrationtier is otherwise declared to CI. Locally only the new integration file was run, because the diff adds it.Reverse verification (committed fix, ablation-replace WRAP mode, restore proven)
compile.tsloads fromsrc/in the spawned child (bin/run-dev.jsvia tsx;packages/cli/distwas absent for the first leg A run and for legs B-prime and C; the final leg A re-run ran with dist built and still went red, so the child loads src — corrected by the seat from the dev's report), and the unit file imports../src. So no rebuild leg applies. Each mutation was proven on disk (anchor 1 to 0, blob changed), and each restore was proven (blob equals HEAD,git diff HEADempty).compile.tsput back to warnings + placeholder) at11d4d8c6e: the integration file reds 5 of 6 withExpected "1" Received "2"andExpected "4" Received "5", and the control stays green. Same result at50fd36bbf.bodylessCallablesbecomes the raw warnings): the unit file reds 5 of 19 (thefunctionsentry, authored body, single-walk equality and both double-walk cases).Gates
node scripts/pm/dispatch-gates.mjs --commandsre-derived from this diff gives 65 families, the 54 named at dispatch plus 11 changeset-family gates the diff adds. All 65 were run, each exit code captured before any pipe, and--ranreconciles: 65 derived, 65 run, 0 NOT-MEASURED, all codes recorded.check:dual-build-cjs-loadsandcheck:i18n-coveragefirst exited 3 (PREREQUISITE NOT MET: nodist/for packages outside the cli closure). After a fullturbo run buildthey exit 0.Lint (proven narrowing, not
pnpm lint). First, ESLint's own API (isPathIgnored+calculateConfigForFile) says 4 of the 5 changed paths are linted; the changeset.mdis not. Second,eslint --no-inline-config --format jsonon those 4 files at11d4d8c6elists 4 files, 0 errors and 0 warnings. Third, invariance:eslint.config.mjsnever enables type-aware linting (its own comment says noparserOptions.projectand no typed rules), has no cross-file import rules, and loads only two baseline JSONs that this diff does not touch. So this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis CI's.Acceptance notes
needsBundleincompile.tsstill readscount - bodyExtracted. On a stack carrying the same hook at the top level and inpackages[0].manifestplus afunctionsentry,objectstack build(default) printedSkipping legacy runtime bundle (all 2 callables are body-only), exited 0 and wrote noobjectstack-runtime.*.mjs. Thefunctionsentry therefore ships with no implementation (measured on a scratch app). Fixing it changes which builds emit the bundle, so it is out of this card's scope.composeStacks(…, { manifest: 'preserve' })on main emits onlymanifestandpackages; measured, its top-level keys are exactly those two. So the double walk is reachable only through a hand-written stack carrying both levels, the shapebuild-multi-package-artifact.e2e.test.tsbuilds. That file's comment says the shape is "the waycomposeStacks… writes it", which no longer holds.Clause-②: yes, and the claim wroteClause-②: no (narrowing). This PR keeps the claim's line: it widens nothing (no single-walk verdict moves, no field is added) and narrows the--jsonissuesshape.Generated by Claude Code