feat(cli): ratify ./hook-body and ./package.json as public subpath exports of @objectstack/cli - #15611
Conversation
…ication Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…ports Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…tify-hook-body-subpath-export
…tify-hook-body-subpath-export
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift Check
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
ACCEPT WITH FINDINGS — contract review of PR #15611 (card #15325) at head CI on the head — read 2026-09-05T00:46:50Z53 check runs on
Blocking finding 1 — a red check run on the headThe failed The bump — pass
ADR-0087 — not applicable, measured on the file
The contract — substanceWhat is ratified, read from "./hook-body": { "types": "./dist/hook-body.d.ts", "default": "./dist/hook-body.js" },
"./package.json": "./package.json"
What external consumers now hold, load-bearing (read from
Three things this freezes that the PR body does not name. They are the maintainer's to confirm; I am naming them, not deciding them:
Is the new surface actually shipped? Did the pin run in CI, on a built tree? Does the pin pin what ratification means? Largely yes: it packs with Non-blocking finding 2 —
|
Contract review: ACCEPT WITH FINDINGS — one blocking finding, and it is infrastructureVerdict at The blocking finding, and the one re-run I am entitled toSix sibling This is the one case the rules let me re-run: it died before any test body ran — a network failure inside dependency install, naming a service the diff does not touch. ⛔ "Flake" is not a root cause, and I am not treating it as one — I am recording precisely why this specific failure is not the diff's. ⭐⭐ The two findings that matter — the pin has blind spotsI asked the reviewer to try to construct a break the pin would miss. It found two. This PR's whole deliverable is a pin that ratifies a public surface, so a pin that can stay green while the contract breaks is not a side issue here — it is the product.
⇒ Finding 1 is a one-line hardening inside this PR's own purpose and will be folded in. Finding 2 is a real addition, not a nit, so it becomes its own card rather than a scope-widening rider here. What passed, measured at source
⭐ The shipped-surface question I was most worried about is answered.
|
The pin symlinks `ts-morph` in from the workspace so the packed extractor can be EXECUTED and not merely resolved. That borrow was unconditional: had the entry left `dependencies` (it is `^28.0.0` there today), an installed copy of the tarball would fail the free-identifiers path with ERR_MODULE_NOT_FOUND while this pin — handing itself a copy no consumer receives — stayed green. A pin asserting a public surface works when it does not. `beforeAll` now asserts `MANIFEST.dependencies['ts-morph']` before symlinking, with a failure message naming what the borrow hides and what a real consumer would hit instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…tify-hook-body-subpath-export
Review follow-up — finding 1 folded in at
|
| Leg | Tree | Predicted | Observed |
|---|---|---|---|
| A | mutated manifest + this PR's pin | red inside beforeAll at the new assertion, before symlinkSync ever runs; all 13 tests reported failed carrying my message, not a resolution error |
1 failed suite / 13 skipped — AssertionError: @objectstack/cli must declare ts-morph in "dependencies" ...: expected undefined to be type of 'string', reported at the assertion with the symlinkSync line printed underneath as the next statement. VERDICT command-exit 1 |
| B | the same mutated manifest + the PRE-hardening pin, restored from BASE 90e72f9bc55 and proved identical by blob hash |
green — the blind spot the finding names | Tests 13 passed (13), VERDICT command-exit 0 |
Leg B re-derives the finding instead of taking it on trust: one identical manifest, green before this commit and red after it, so the assertion is what bites and nothing else is.
Verification at ee9a25bc242 (shared-box seconds, lock-serialised)
- The pin unmodified, after the restore —
Tests 13 passed (13), VERDICTcommand-exit 0. pnpm --filter @objectstack/cli typecheck— VERDICTcommand-exit 0;check:test-typecheck: OK ... 3 file(s) / 28 error(s) / 6 pinned signature(s) held. Measured rather than assumed:tsc -p tsconfig.json --listFiles(the source program) does not contain the edited test file (0 hits), so that program alone would have been NOT MEASURED for this edit;tsc -p tsconfig.test.json --listFilesdoes contain it (1 hit) and 0 of its 28 pre-existing errors name it.origin/mainmerged in throughscripts/pm/os-regen-merge.sh(no regen deferral recorded), thenpnpm install --frozen-lockfileand a rebuild of the@objectstack/cliclosure — VERDICTcommand-exit 0.- Gate union re-derived on this head by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— no STALE TREE banner; the pre-merge derivation did fire one (15 commits behind, 10 gate-deriving scripts changed onorigin/main), which is why the merge came first. 66 commands, each exit code captured after redirection and never through a pipe: 64 exit 0. The 2 non-zero are exit 3, self-declared NOT MEASURED rather than findings —check:dual-build-cjs-loads("PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured") andcheck:i18n-coverage("Nothing was compared: 12 config(s) did lint, but a partial round cannot judge the ratchet"); both want a full-workspace build, which CI performs. eslint --no-inline-config --format jsonnarrowed to the changed file — 1 file (count read from the JSON report), 0 errors, 0 warnings. Narrowing evidence:pnpm lint's population iseslint .under the singleeslint.config.mjs, which "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not", so an edit inside one file cannot move an untouched file's verdict.- Changeset confirmed rather than assumed: unchanged at
@objectstack/cli: minor. A test-only hardening neither raises nor lowers the level the review verified. Check Changeseton this head: success — job 101226973505, run 33937075469, 01:44:54Z to 01:45:44Z. The previous head's red died insidepnpm install --frozen-lockfileand never reached a changeset step; this push started CI fresh, and the job read the changeset and passed.
Clause ② re-declared from the delivered diff: this follow-up is test-only — one file, +21 lines, no new export, no new accepted key, no change to accept/reject behaviour. The public surface under review is exactly the one the review accepted.
Generated by Claude Code
…h doors The set-equality assertion did exactly what its header said it would. #15611 ratified `./hook-body` and `./package.json` and merged while this branch was in flight; merging main turned the suite red naming the two lines to delete. They are deleted, so the ledger of losses has reached its floor: every specifier a named out-of-repo consumer imports now resolves from a packed tarball. The empty table is KEPT rather than removed. "Every ledgered specifier resolves" is the contract; "the sealed set is exactly empty" is the ratchet — the line an author has to edit, past a ⛔, to make a red green by recording a break instead of repairing it. Also separates two diagnoses the exports map cannot tell apart on its own: a specifier that fails with ERR_PACKAGE_PATH_NOT_EXPORTED is SEALED (the subject of this file), while one failing MODULE_NOT_FOUND is a declared door leading nowhere — the package is unbuilt, or `files` does not ship the path `exports` names. Now that all three doors are open, an unbuilt tree would otherwise have read as "hotcrm is broken". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Fixes #15325
What this ratifies, and what it deliberately leaves sealed
@objectstack/cli'sexportsmap gains two keys. Nothing else about the 17.3.0 seal changes../hook-body./dist/hook-body.js(types:./dist/hook-body.d.ts)extractHookBody,HookBodyExtractionError,HookBodyRefusalKind,ExtractedBody./package.json./package.jsonpackages/cli/src/hook-body.tsre-exports the four names by name — no star, no barrel../consolepoints straight atdist/utils/console.js, so every export that module gains is public the moment it lands; this entry does not do that, on purpose: an exportextract-hook-body.tsgrows later is not public until the entry and the pin are both edited. The subpath name follows the card's suggestion and the vocabulary already in use (HookBody,hook-bodies.mdx,hook-body/not-lowerable)../console: atypes+defaultobject;typespoints at the entry's own.d.ts.dist/path — includingdist/utils/extract-hook-body.js, the harness's pre-17.3.0 spelling — is pinned to stayERR_PACKAGE_PATH_NOT_EXPORTED. Not exposed either:detectFreeIdentifiers,SANDBOX_GLOBALS,NODE_ONLY_GLOBALS,FreeIdentifierResult(the extractor's internal dependency), or anything beyond the four names the card asks for..,./console,./hook-body,./package.json, so the next subpath removal from this package goes red in-repo.Why not a local reimplementation (triage, verbatim)
This is the existing policy applied to a second consumer: #13123's own body names the remedy (ratify the subpath rather than read
dist/paths), 17.3.0 applied it to./consolefor cloud'sobjectos-runtime(#13662), and this applies it for hotcrm's hook-body fidelity harness.os lint'shook-body/not-lowerabledoes not answer this — it gives pass/fail and never hands a test the loweredsourceto run.Red first, from outside the repo — re-derived, not quoted
At BASE
0c5e97368(origin/main at claim):pnpm packof@objectstack/cli@17.3.0(485 files), unpacked into a throwawaynode_modulesunder the OS temp dir,require.resolvefrom a probe with that directory as cwd, Node v22.22.2:90e72f9bc55@objectstack/clidist/index.jsdist/index.js@objectstack/cli/consoledist/utils/console.jsdist/utils/console.js@objectstack/cli/dist/utils/extract-hook-body.jsERR_PACKAGE_PATH_NOT_EXPORTEDERR_PACKAGE_PATH_NOT_EXPORTED(stays sealed)@objectstack/cli/package.jsonERR_PACKAGE_PATH_NOT_EXPORTEDpackage.json@objectstack/cli/hook-bodyERR_PACKAGE_PATH_NOT_EXPORTEDdist/hook-body.jsThe tarball carries
dist/utils/extract-hook-body.jsand its.d.tsat BASE — the card's premise ("the file still ships, only the door is gone") holds on the current tree. The fixed tarball has 489 files (+ the entry's.js,.d.tsand two maps).The pin —
packages/cli/test/published-subpath-hook-body.pin.test.tsPacks the package the way the release does (
pnpm pack), unpacks it into a consumer dir outside the workspace, and asks a child Node process (env viachildEnv(), soNODE_PATHand the vitest family never reach it) the three questions above under both therequireandimportconditions; thenimport()s the ratified entry from the packed copy and CALLS it: a clean body lowers toctx.input.x = 1, afetch(body is refused with aHookBodyExtractionErrorofkind: 'forbidden-token', a scope leak is refused withkind: 'free-identifiers'andfreeIdentifiers: ['helper'](so the ts-morph path runs from the tarball too). The shippeddist/hook-body.d.tsis parsed and must name exactly the four exports with zero star re-exports. Inside the monorepo nothing is sealed, so a workspace-resolving test would prove nothing about this contract — #14874 measured the packaging layer lying to source-tree tests.code/status), so the assertions namekind,name,originLabelandfreeIdentifiers— never a baretoThrow().Bump:
minor, notpatchA new subpath on a published package's
exportsmap is a purely additive widening of its public surface — "a new accepted key" — which takes at leastminorunder the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it../consolewas ratified atminortoo (#13662).@objectstack/cliis in the changesetsfixedgroup, so the group moves together — that is the rule's cost, not a choice made here.Ablation — three legs, direction predicted before each
Fix committed first; every leg restores with
git checkout HEAD -- ABSOLUTE_PATHunder a trap and proves the restore by blob-hash equality with the HEAD blob AND an emptygit diff HEAD(porcelain clean). Mutations are proved on disk by counting the removed text (0) and an injected marker (1)../hook-bodyremoved fromexports(marker at manifest top level)./package.jsonpin, tarball contents, package.json resolution, deep-seal and shipped-.d.tspins survive a manifest-only cut, as they should./package.jsonremoved fromexportsHookBodyExtractionErrordropped from the entry's re-export + an exported marker const;pnpm --filter @objectstack/cli build;scripts/ablation-dist-preflight.mjs @objectstack/cli MARKERexit 0 before reading--absentexit 0 (484 built files, tree clean)A first attempt at A and B is kept on disk as VOID and reported here rather than hidden: the marker was injected inside
exports, and a non-dot key there makes Node reject the whole map (ERR_INVALID_PACKAGE_CONFIG), so both legs read 10 red / 3 green — red for the wrong reason. Redone with the marker outside the map.Verification at
90e72f9bc55(shared-box seconds, lock-serialised)pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/published-subpath-hook-body.pin.test.ts—Tests 13 passed (13), VERDICTcommand-exit 0pnpm --filter @objectstack/cli typecheck—check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held;tsc --listFiles -p tsconfig.test.jsonshows the new test IN the program with 0 errors naming it (the 28 are the pre-existing ledger)pnpm build(turbo, full) after each merge of origin/main — 72/72 tasks, VERDICTcommand-exit 0; a scopedtypes→clirebuild after the second (docs-only-adjacent) mergenode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon this head (66 commands, not called stale) pluspnpm check:published-readme-exports— all run to a verdict;check:published-filesandcheck:published-readme-exportsverdict lines are in the report comment on the card.check:cli-test-child-envwent red ONCE on this branch, on my own spawn sites (env undeclared / bareprocess.envspread), and is green after switching every child tochildEnv().eslint --no-inline-confignarrowed to the two changed sources: 2 files, 0 errors, 0 warnings. Narrowing evidence: type-aware linting is off ineslint.config.mjs(noparserOptions.project), so this diff cannot move an untouched file's verdict; README,package.jsonand the changeset are outside eslint's population ("no matching configuration").pnpm lintsweep and the fullpnpm testfarm — CI owns those.Not this PR
os migrate metaprinting(runtime 17.0.0)beside real versions — a different defect per triage, filed asos migrate metaprints(runtime 17.0.0)beside real package versions —PROTOCOL_VERSIONis the protocol major padded to semver, and reads as the runtime's version on a 17.3.0 install #15585;os migrate metaprints(runtime 17.0.0)beside real package versions —PROTOCOL_VERSIONis the protocol major padded to semver, and reads as the runtime's version on a 17.3.0 install #15585 is not addressed here../consoleand./hook-bodyevents visible before publish — proposed as Make the nextexports-map sealing regression visible before publish — an in-repo ledger of out-of-repo consumers' specifiers, held against each package's exports map #15589 (afinding), not landed here.For the contract-review tier (this PR parks under
needs:contract-review)Read the
exportsdiff inpackages/cli/package.json, the four re-exports inpackages/cli/src/hook-body.ts, the surface pin in the test, the README's "Public subpath exports" section, and theminorchangeset. The question to answer is only: are these four names, and the manifest, the right public surface — everything else in this diff is measurement.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code