Skip to content

Commit aa94566

Browse files
fix(cli)!: objectstack build writes the runtime module whenever a callable still needs it (#22853) (#22884)
Fixes #22853 Clause-②: no ## What was wrong (measured on `origin/main` `69d4218058`) `objectstack build` decided whether to write `objectstack-runtime.*.mjs` from `lowering.count - lowering.bodyExtracted` plus the extraction warnings (`compile.ts`, the `needsBundle` line). `count` is per callable, `bodyExtracted` per lowered slot, so a callable lowered at two slots cancelled a `functions` entry beside it. A tsx probe over `lowerCallables` at the base: | shape | count | bodyExtracted | old `needsBundle` | `bodylessCallables` (PR #22849) | |---|---|---|---|---| | card: hook + `functions` entry, top level AND `packages[0].manifest` | 2 | 2 | false | `function 'pb_score'` | | addendum: one handler shared by two hooks + a `functions` entry | 2 | 2 | false | `function 'pb_score'` | | one function as a lowered hook handler AND a `functions` entry | 1 | 1 | false | **empty** | | one function as a hook handler (hook face refuses it) AND an action target (action face lowers it) | 1 | 1 | true (warning) | **empty** | | CONTROL: all body-only, one walk / two walks | 2 / 1 | 2 / 2 | false | empty | At the door (`bin/run-dev.js build`, the ablation leg below), the first three printed `Skipping legacy runtime bundle (all 2 callables are body-only)` (twice) and `(all 1 callables are body-only)`, exited 0 and wrote no module. **PM mechanism assumption 2 holds.** `compile.ts` alone covers the card's shape and the addendum's, but not the third row: `bodylessCallables` dropped every ref some slot lowered (`[...unlowered].filter(([ref]) => !loweredRefs.has(ref))`), so a function lowered for its hook read as body-only although its `functions` entry has no body slot. The fourth row is the same hole in the other direction: a refused hook was dropped because the action lowered the same function, so `--strict-body` passed a hook that ships with no body. ## The fix - **`packages/cli/src/utils/lower-callables.ts` (the producer of the population).** Each slot that leaves a callable unlowered now says which kind it is: `refused`, `functions-entry` or `authored-body`. A `refused` or `functions-entry` slot has no body at all, so another slot lowering the same function does not take the callable off `bodylessCallables`. Only an `authored-body` slot is taken off by a lowering elsewhere (every slot then has a body), which keeps PR #22849's verdict for that case. A callable bound at several slots reports its strongest row (refusal, then `functions` entry, then authored body), so the row is the extractor's refusal whenever one exists. New export `isExtractionRefusal(row)`, a type guard over the same rows. - **`packages/cli/src/commands/compile.ts`.** `needsBundle = lowering.bodylessCallables.length > 0`. `stillNeeded` and the `|| bodyExtractionWarnings.length > 0` term are gone: every refusal is a row of the list, so the term could add nothing. The default-path warning, its summary line and the `--no-runtime-bundle` message count `refusedCallables` (the list's refusal rows), each refused handler once. - **Landing point.** The claim's surface named `lower-callables.ts` only if a single-walk member needed the population to change. Measured: one does (row 3), so it lands there. `extract-hook-body.ts`, `detect-free-identifiers.ts` and `hook-body.ts` are untouched. No `packages/spec` change. **The decision only moves from skip to write.** If `count - bodyExtracted > 0`, some callable has no lowered slot, and every registered callable is either lowered somewhere or noted unlowered, so it is a row. Every refusal is a row too. A unit pin asserts the implication over eight shapes. ## What moves for consumers (changeset `minor`, `Clause-②: no (narrowing)` in its body) - `objectstack build` writes the module for the shapes above. Nothing it wrote before is skipped. - `--no-runtime-bundle` now refuses those stacks (exit 1); it passed them while the entry had no implementation. - `--strict-body` now refuses a function bound at a lowered slot AND as a `functions` entry or at a refused slot. PR #22849 pinned the third row as passing (`a callable lowered at ANY slot is not a row`); triage's grade on this card rules it the other way, so that pin is replaced. - The default-path warning counts a refused handler once, not once per slot. `--json` `bodyExtractionWarnings` is unchanged (still one record per slot). ## Tests (all at `b464a20646`) - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 281 files / 4245 tests passed, 2 failed at `5cbd9c5ceb`. Both were source-text pins on `compile.ts`: `truncation-remainder-notices` (the precedent loop's list is now `refusedCallables`) and `validate-build-gate-parity` (`isExtractionRefusal` classified under local helpers). After that update, the 4 affected unit files passed: 112 tests. - New unit pins in `test/lower-callables.test.ts`: the `functions` entry beside a lowered hook is a row; a refused slot beside a lowered action is a row; an authored-body slot IS taken off by a lowering elsewhere; strongest-row precedence; a two-walk all-clean CONTROL; old-decision-implies-new over eight shapes. - New integration pin `test/build-runtime-bundle-bodyless.test.ts` (spawns `os build`; queue tier, not nightly): `--project integration` 6/6 passed. The two-level stack and the shared-handler stack write the module, the artifact points at it, and `pb_score` runs through `@objectstack/runtime`'s `loadArtifactBundle` (answers 1). The hook-and-`functions` stack writes the module, and its `functions` entry resolves to a callable. CONTROLS: an all-body-only stack (one walk and two walks) still prints the skip line and leaves no module in `dist/`. A refused hook walked twice reads `1 handler could not be lowered` and one bullet. - Also run: `build-strict-body-count.test.ts` (integration, green); nightly `hook-body-build-reach.e2e`, `build-json-failure-warnings.e2e`, `build-multi-package-artifact.e2e` under `OS_TEST_TIERS=nightly`: 25/25. - `pnpm --filter @objectstack/cli typecheck`: exit 0. `tsc -p tsconfig.test.json --listFiles` includes both new or edited test files, with no errors in them. **Ablations** (each through `scripts/ablation-replace.mjs`: anchor hit 1 to 0, blob changed, restored to the HEAD blob with `git diff HEAD` empty, plus a trap leg that re-proves both files. All are source edits run through tsx, so there is no `dist/` leg): - A, the population filter reverted to `!loweredRefs.has(ref)`: 3 red (the two new unit pins and the door's hook-and-`functions` pin), 28 green. - B, `needsBundle` reverted to the old arithmetic: 3 red at the door (two-level, shared-handler, hook-and-`functions`), each printing the skip line; CONTROLS and the warning pin green. - C, the warning count reverted to `bodyExtractionWarnings.length`: the double-walked refusal reads `2 handlers could not be lowered`, red. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final tree derives the same 65 commands as the dispatch. All 65 exit 0 at `b464a20646`. Reconciled: `✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3).` Three gates first exited 3 (PREREQUISITE NOT MET: unbuilt packages, then a contended build OOM-killed at 137). After a targeted rebuild they read: `check:dual-build-cjs-loads` 107 entry points load; `check:i18n-coverage` `OK (13 config(s), 621 baselined untranslated string(s), none new)`; `check:type-check-debt` `surplus: none`. Also run: the four roster gates whose roster sits under a touched directory (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), all exit 0. **Lint, proven narrowing.** The 6 touched code files (2 source, 4 test), `eslint --no-inline-config --format json`: 6 files, 0 errors, 0 warnings. Population: `--print-config` resolves a config (5 to 6 rules) for every file. Invariance: `parserOptions.project` and `projectService` are null for each file (this repo never enables type-aware linting), so this diff cannot move the verdict on an untouched file. ## Acceptance notes - **Found beside this card, not fixed here (class (a), measured at the door on this branch):** `lowerCallables` re-keys a `functions` map entry by the ref it registered. - `functions: { pr_score: tidy }` beside hook `pr_tidy` bound to the same `tidy` ships `functions: { pr_tidy: 'pr_tidy' }`, so the authored name `pr_score` is gone. - A hook `pc_score` (refused) beside a distinct `functions: { pc_score: scoreFn }` ships `functions` keys `pc_score__2` (a bare string, no callable after `loadArtifactBundle`) and `pc_score`, which is `scoreFn`. The hook's handler ref `pc_score` therefore resolves to the `functions` entry's code. - The runtime module's entry collector keys hooks by name and functions by authored key, so the two collide. Reported for the seat to file. - `--json` `bodyExtractionWarnings` and `handlersBundled` keep their per-slot / per-callable meaning. They are machine-read payload keys and were left alone. - The PR body's `Clause-②: no` is the claim's line verbatim. The changeset declares `(narrowing)` because `--no-runtime-bundle` and `--strict-body` refuse stacks they passed. The report names this for the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bc7836 commit aa94566

7 files changed

Lines changed: 449 additions & 46 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli)!: `objectstack build` writes the runtime module whenever a callable still needs it, and its warning counts each refused handler once (#22853)
6+
7+
Clause-②: no (narrowing)
8+
9+
`objectstack build` decided whether to write `objectstack-runtime.*.mjs` from the number of callables minus the number of slots lowered into a metadata body. The first counts each callable once and the second counts each slot, so a callable lowered at two slots cancelled a `functions` entry beside it. The build then printed `Skipping legacy runtime bundle (all 2 callables are body-only)`, exited 0 and wrote `dist/objectstack.json` alone, and the `functions` entry shipped with no implementation. Three shapes reached it:
10+
11+
- the same hook at the top level and inside a `packages[i].manifest` body, beside a `functions` entry;
12+
- one handler shared by two hooks, beside a `functions` entry;
13+
- one function bound both as a hook handler and as a `functions` entry.
14+
15+
The module is now decided from the list `--strict-body` counts: one row per callable the runtime module has to carry, each callable once. A slot that has no body, which is a `functions` entry or a hook or action whose extraction was refused, keeps its callable on that list even when another slot lowered the same function. The default build's warning (`N handlers could not be lowered to a metadata body`) and its summary line now count the refused handlers on that list, each once.
16+
17+
**BREAKING: what moves for consumers.**
18+
19+
- **`objectstack build`.** It now writes the runtime module for the three shapes above, and for a function bound at a refused slot and at a slot that lowered it. Every stack it wrote the module for before still gets one.
20+
- **`objectstack build --no-runtime-bundle`.** Those stacks are now refused (exit 1). They passed at exit 0 while the `functions` entry, or the refused hook, had no implementation. The message's first number counts the callables that lack a body, and its second counts the refused handlers, each once.
21+
- **`objectstack build --strict-body`.** A stack that binds one function at a slot that lowered it AND as a `functions` entry or at a refused slot is now refused, and the row names the `functions` entry or the refused slot. It passed before. For a function bound at several slots, the row in `--json` `issues` is the extractor's refusal when any slot refused it, then the `functions` entry's row, then the authored-`body` row. Before, it was the first such slot's row.
22+
- **The default build's warning count.** A refused handler shared by two hooks, or carried at the top level and in a package body, counts as one handler. It counted once per slot.
23+
24+
**The one-line fix**, for a CI step that runs `--no-runtime-bundle` on a stack carrying a `functions` entry: drop the flag. A `functions` entry has no metadata body, so it runs only from the runtime module.
25+
26+
**Unchanged.** For a stack in which each callable is bound at one slot and walked once, the module decision, `--no-runtime-bundle` and `--strict-body` give the same verdicts as before. Exit codes and `error` strings are the same. `--json` `bodyExtractionWarnings` still carries one record per refused slot. No spec key, export, type or stored shape moves.
27+
28+
<!-- adr-0087: not-required (no-migration-prescription) a CLI build verdict: the runtime module is now written for stacks whose `functions` entry needed it, and `--no-runtime-bundle` / `--strict-body` refuse those stacks where a miscount let them pass. No authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and it is a build verdict, not a runtime TS interface or a type surface (not runtime-interface-only or type-surface-only). -->

‎packages/cli/src/commands/compile.ts‎

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from '@objectstack/spec';
1717
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1818
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
19-
import { lowerCallables } from '../utils/lower-callables.js';
19+
import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js';
2020
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
2121
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
2222
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
@@ -328,6 +328,12 @@ export default class Compile extends Command {
328328
// all hooks disabled.
329329
if (!flags.json) printStep('Lowering inline handlers...');
330330
const lowering = lowerCallables(normalized);
331+
// The callables the extractor refused, each once: the refusal rows of the
332+
// one population every verdict below reads. ⛔ Not
333+
// `bodyExtractionWarnings`, which records a refusal once per SLOT — a
334+
// handler shared by two hooks, or the same callable at the top level and
335+
// in a `packages[i].manifest` body, is in it twice.
336+
const refusedCallables = lowering.bodylessCallables.filter(isExtractionRefusal);
331337

332338
// Strict-body gate: refuse to ship if any callable was not lowered into a
333339
// metadata body, i.e. if the runtime module would still have to carry it.
@@ -379,14 +385,18 @@ export default class Compile extends Command {
379385
// dump for `--strict-body`'s per-callable diagnostic; on the default path
380386
// we print the first line and point at the flag for the rest, so the
381387
// default build stays readable while staying honest.
382-
if (lowering.bodyExtractionWarnings.length > 0 && !flags.json) {
383-
const n = lowering.bodyExtractionWarnings.length;
388+
//
389+
// It counts and lists `refusedCallables`, each refused handler once, so
390+
// its number is the number of handlers the runtime module carries for
391+
// a refusal, not the number of slots that refused one.
392+
if (refusedCallables.length > 0 && !flags.json) {
393+
const n = refusedCallables.length;
384394
console.log('');
385395
printWarning(
386396
`${n} handler${n === 1 ? '' : 's'} could not be lowered to a metadata body — ` +
387397
`bundled via the legacy runtime module instead (build still succeeds)`,
388398
);
389-
for (const w of lowering.bodyExtractionWarnings.slice(0, 20)) {
399+
for (const w of refusedCallables.slice(0, 20)) {
390400
console.log(` • ${w.origin}: ${String(w.reason).split('\n')[0]}`);
391401
}
392402
if (n > 20) console.log(chalk.dim(` … and ${n - 20} more`));
@@ -1019,18 +1029,28 @@ export default class Compile extends Command {
10191029
if (lowering.count > 0) {
10201030
// New default: auto-skip the legacy bundle when every callable is
10211031
// body-only (the metadata is fully self-describing). The bundle is
1022-
// emitted only when (a) some callable could not be lowered, or
1023-
// (b) the user explicitly opted in via --runtime-bundle.
1024-
const stillNeeded = lowering.count - lowering.bodyExtracted;
1025-
const needsBundle = stillNeeded > 0 || lowering.bodyExtractionWarnings.length > 0;
1032+
// emitted only when (a) some callable still needs it, or (b) the user
1033+
// explicitly opted in via --runtime-bundle.
1034+
//
1035+
// (a) is `bodylessCallables` being non-empty: the list `--strict-body`
1036+
// counts, one row per callable the module has to carry. ⛔ Never
1037+
// decide it from `count - bodyExtracted` again. `count` is per
1038+
// callable and `bodyExtracted` per lowered slot, so a callable lowered
1039+
// at two slots (a handler shared by two hooks, or the same hook at the
1040+
// top level and in a `packages[i].manifest` body) cancelled a
1041+
// `functions` entry beside it: the module was skipped and the entry
1042+
// shipped with no implementation, at exit 0. Every extraction refusal
1043+
// is a row of this list, so the warnings need no second term here.
1044+
const bodyless = lowering.bodylessCallables;
1045+
const needsBundle = bodyless.length > 0;
10261046
const forceBundle = flags['runtime-bundle'];
10271047
const strictNoBundle = flags['no-runtime-bundle'];
10281048

10291049
if (strictNoBundle && needsBundle) {
10301050
// Legacy strict mode: explicit --no-runtime-bundle fails loudly
10311051
// when any callable still requires the bundle. Preserved so CI
10321052
// pipelines can guard against accidental regressions.
1033-
const msg = `--no-runtime-bundle requires every callable to have a metadata body (${stillNeeded} missing, ${lowering.bodyExtractionWarnings.length} extraction warning(s)). Re-run with --strict-body to see details, or omit --no-runtime-bundle.`;
1053+
const msg = `--no-runtime-bundle requires every callable to have a metadata body (${bodyless.length} missing, ${refusedCallables.length} extraction warning(s)). Re-run with --strict-body to see details, or omit --no-runtime-bundle.`;
10341054
if (flags.json) {
10351055
await emitJson({ success: false, error: msg, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
10361056
this.exit(1);
@@ -1176,11 +1196,11 @@ export default class Compile extends Command {
11761196
if (ruleAdvisories.length > 0) {
11771197
printWarning(`${ruleAdvisories.length} author-time warning(s) — see above`);
11781198
}
1179-
if (lowering.bodyExtractionWarnings.length > 0) {
1199+
if (refusedCallables.length > 0) {
11801200
// [#10678] Repeat the tally in the summary: the detail printed before the
1181-
// parse, and a long build scrolls it away.
1201+
// parse, and a long build scrolls it away. Same number as that detail.
11821202
printWarning(
1183-
`${lowering.bodyExtractionWarnings.length} handler(s) bundled instead of lowered to a metadata body — see above`,
1203+
`${refusedCallables.length} handler(s) bundled instead of lowered to a metadata body — see above`,
11841204
);
11851205
}
11861206
console.log('');

0 commit comments

Comments
 (0)