Skip to content

feat(cli): ratify ./hook-body and ./package.json as public subpath exports of @objectstack/cli - #15611

Merged
os-litant merged 7 commits into
mainfrom
claude/issue-15325-ratify-hook-body-subpath-export
Sep 5, 2026
Merged

feat(cli): ratify ./hook-body and ./package.json as public subpath exports of @objectstack/cli#15611
os-litant merged 7 commits into
mainfrom
claude/issue-15325-ratify-hook-body-subpath-export

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15325

What this ratifies, and what it deliberately leaves sealed

@objectstack/cli's exports map gains two keys. Nothing else about the 17.3.0 seal changes.

Subpath Target What a consumer can now reach
./hook-body ./dist/hook-body.js (types: ./dist/hook-body.d.ts) exactly four names — extractHookBody, HookBodyExtractionError, HookBodyRefusalKind, ExtractedBody
./package.json ./package.json the manifest, for the ordinary tooling idiom of reading a dependency's own version
  • A dedicated entry, not the internal module. packages/cli/src/hook-body.ts re-exports the four names by name — no star, no barrel. ./console points straight at dist/utils/console.js, so every export that module gains is public the moment it lands; this entry does not do that, on purpose: an export extract-hook-body.ts grows 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).
  • Same shape as ./console: a types + default object; types points at the entry's own .d.ts.
  • Deliberately left sealed: every deep dist/ path — including dist/utils/extract-hook-body.js, the harness's pre-17.3.0 spelling — is pinned to stay ERR_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.
  • The pin also holds the whole map to exactly ., ./console, ./hook-body, ./package.json, so the next subpath removal from this package goes red in-repo.

Why not a local reimplementation (triage, verbatim)

extractHookBody 决定一个 hook 是否还能 body-only 发布:剥函数体、拒禁用 token、推断能力、抛带 kind / freeIdentifiers / nodeOnlyIdentifiersHookBodyExtractionError。⇒ 手写一份拷贝正是那份谐调器头部注释存在的理由所要防的东西:它会通过测试的同时与构建实际应用的规则分叉,而那就是 #13651 当初被立卡的失效形态。⇒ ⛔ 不得以「应用自己实现一个」结案。

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 ./console for cloud's objectos-runtime (#13662), and this applies it for hotcrm's hook-body fidelity harness. os lint's hook-body/not-lowerable does not answer this — it gives pass/fail and never hands a test the lowered source to run.

Red first, from outside the repo — re-derived, not quoted

At BASE 0c5e97368 (origin/main at claim): pnpm pack of @objectstack/cli@17.3.0 (485 files), unpacked into a throwaway node_modules under the OS temp dir, require.resolve from a probe with that directory as cwd, Node v22.22.2:

Specifier At BASE At 90e72f9bc55
@objectstack/cli dist/index.js dist/index.js
@objectstack/cli/console dist/utils/console.js dist/utils/console.js
@objectstack/cli/dist/utils/extract-hook-body.js ERR_PACKAGE_PATH_NOT_EXPORTED ERR_PACKAGE_PATH_NOT_EXPORTED (stays sealed)
@objectstack/cli/package.json ERR_PACKAGE_PATH_NOT_EXPORTED package.json
@objectstack/cli/hook-body ERR_PACKAGE_PATH_NOT_EXPORTED dist/hook-body.js

The tarball carries dist/utils/extract-hook-body.js and its .d.ts at 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.ts and two maps).

The pin — packages/cli/test/published-subpath-hook-body.pin.test.ts

Packs 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 via childEnv(), so NODE_PATH and the vitest family never reach it) the three questions above under both the require and import conditions; then import()s the ratified entry from the packed copy and CALLS it: a clean body lowers to ctx.input.x = 1, a fetch( body is refused with a HookBodyExtractionError of kind: 'forbidden-token', a scope leak is refused with kind: 'free-identifiers' and freeIdentifiers: ['helper'] (so the ts-morph path runs from the tarball too). The shipped dist/hook-body.d.ts is 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.

⚠️ The refusal is a build-time class, not an ADR-0112 envelope (no code/status), so the assertions name kind, name, originLabel and freeIdentifiers — never a bare toThrow().

Bump: minor, not patch

A new subpath on a published package's exports map is a purely additive widening of its public surface — "a new accepted key" — which takes at least minor under 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. ./console was ratified at minor too (#13662). @objectstack/cli is in the changesets fixed group, 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_PATH under a trap and proves the restore by blob-hash equality with the HEAD blob AND an empty git diff HEAD (porcelain clean). Mutations are proved on disk by counting the removed text (0) and an injected marker (1).

Leg Mutation Predicted Observed
A ./hook-body removed from exports (marker at manifest top level) 8 red / 5 green 8 red / 5 green — the ./package.json pin, tarball contents, package.json resolution, deep-seal and shipped-.d.ts pins survive a manifest-only cut, as they should
B ./package.json removed from exports 3 red / 10 green 3 red / 10 green
C HookBodyExtractionError dropped from the entry's re-export + an exported marker const; pnpm --filter @objectstack/cli build; scripts/ablation-dist-preflight.mjs @objectstack/cli MARKER exit 0 before reading 5 red / 8 green 5 red / 8 green; restore → rebuild → preflight --absent exit 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.tsTests 13 passed (13), VERDICT command-exit 0
  • pnpm --filter @objectstack/cli typecheckcheck:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held; tsc --listFiles -p tsconfig.test.json shows 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, VERDICT command-exit 0; a scoped typescli rebuild after the second (docs-only-adjacent) merge
  • Gate union re-derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on this head (66 commands, not called stale) plus pnpm check:published-readme-exports — all run to a verdict; check:published-files and check:published-readme-exports verdict lines are in the report comment on the card. check:cli-test-child-env went red ONCE on this branch, on my own spawn sites (env undeclared / bare process.env spread), and is green after switching every child to childEnv().
  • eslint --no-inline-config narrowed to the two changed sources: 2 files, 0 errors, 0 warnings. Narrowing evidence: type-aware linting is off in eslint.config.mjs (no parserOptions.project), so this diff cannot move an untouched file's verdict; README, package.json and the changeset are outside eslint's population ("no matching configuration").
  • Not run locally: the full pnpm lint sweep and the full pnpm test farm — CI owns those.

Not this PR

For the contract-review tier (this PR parks under needs:contract-review)

Read the exports diff in packages/cli/package.json, the four re-exports in packages/cli/src/hook-body.ts, the surface pin in the test, the README's "Public subpath exports" section, and the minor changeset. 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

@github-actions github-actions Bot added the size/l label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 3 changed file(s) yielded no anchor (packages/cli/README.md, packages/cli/package.json, packages/cli/src/hook-body.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/cli/README.md, packages/cli/package.json, packages/cli/src/hook-body.ts) — pages documenting those are invisible to this run
  • 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 — 22 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 1be26b0de27b2c45d208a20edffefbac1e4892c2packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

ACCEPT WITH FINDINGS — contract review of PR #15611 (card #15325) at head 90e72f9bc5531246d369095f9d1d011283ee1de5. One blocking finding (a red check on the head — infrastructure, not the diff) and four non-blocking findings. The contract itself — the four names, the dedicated entry, the manifest subpath, the minor bump — is the right public surface for what the card asks. This comment is not an approval; this seat does not approve.

CI on the head — read 2026-09-05T00:46:50Z

53 check runs on 90e72f9bc55, listing read to exhaustion (total_count 53, 53 returned).

  • 52 are success or skipped.
  • 1 is failure: Check Changeset, job 101207454443, run 33930336391 (pull_request event, attempt 1, 23:39:46Z–23:40:18Z). Its log: the job died in pnpm install --frozen-lockfilebetter-sqlite3 node-gyp rebuildFetchError: request to https://nodejs.org/download/release/v22.23.2/node-v22.23.2-headers.tar.gz failed, reason: read ECONNRESETELIFECYCLE Command failed with exit code 1. It never reached a changeset step.
  • The six other Check Changeset runs on the same head (jobs 101206896945, 101207451187, 101207451652, 101207452753, 101207452775, 101207452795) are green. The first one's log reads the diff: This PR adds 1 changeset(s), No empty-frontmatter changeset introduced, check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen), This diff introduces no major bump (23:37:12Z–23:37:18Z).
  • Test Core bare context, all six shards, Build Core, Lint & Repo Gates (23:49:45Z), all four Type Check jobs, Governed Surface Queue Guard, Dogfood Verify CLI, the three Dogfood Regression Gate shards, Temporal Conformance: green. Packed-tarball smoke (opt-in): skipped (opt-in).

Blocking finding 1 — a red check run on the head

The failed Check Changeset run is the most recently created check run of that name on this commit (highest id). Cause is a transient ECONNRESET on nodejs.org during native-module install, not anything in this diff — the six sibling runs that got past install all read the changeset and passed. It is still a red check on the head. What clears it: re-run job 101207454443 / run 33930336391, or any new push. I do not re-run CI from this seat.

The bump — pass

.changeset/cli-hook-body-subpath-export.md: '@objectstack/cli': minor. Rule read at its source, .github/workflows/pr-automation.yml, Check Changeset step, "WHICH LEVEL": "A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least minor" (and scripts/check-changeset-no-major.mjs's header pointing back to it). Two new keys on a published exports map is exactly that case; minor is the floor and the PR takes it. @objectstack/cli is in the changesets fixed group (.changeset/config.json), so the group moves together — the rule's cost, as the PR says.

ADR-0087 — not applicable, measured on the file

grep -c BREAKING on the changeset → 0 (exit 1); grep -c adr-0087 → 0 (exit 1); the summary line carries no !. No disposition marker is owed. CI's check-adr-0087-registration on the green run agrees (above).

The contract — substance

What is ratified, read from packages/cli/package.json at head:

"./hook-body": { "types": "./dist/hook-body.d.ts", "default": "./dist/hook-body.js" },
"./package.json": "./package.json"

src/hook-body.ts re-exports exactly extractHookBody, HookBodyExtractionError (values) and ExtractedBody, HookBodyRefusalKind (types) from ./utils/extract-hook-body.js. Measured against the source module: extract-hook-body.ts exports exactly those four and nothing else (its one import is ./detect-free-identifiers.js, which imports ts-morph). src/index.ts exports none of them (grep: no match), so the root entry is untouched.

What external consumers now hold, load-bearing (read from extract-hook-body.ts):

  • extractHookBody(fn: (...a: unknown[]) => unknown, originLabel: string): ExtractedBody
  • ExtractedBody = { source: string; capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'log'>; isExpression: boolean }
  • HookBodyRefusalKind = 'unparseable' | 'forbidden-token' | 'free-identifiers'
  • HookBodyExtractionError extends Errorkind, originLabel, freeIdentifiers, nodeOnlyIdentifiers, name === 'HookBodyExtractionError'; constructor (kind, originLabel, message, freeIdentifiers?, nodeOnlyIdentifiers?)

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:

  1. ExtractedBody.capabilities' literal union — the capability vocabulary — becomes a public type. Adding a token is then a public widening (minor under the rule), and a consumer that switches exhaustively on it breaks at compile time.
  2. HookBodyRefusalKind's three members likewise. The README documents all three, so the README row is contract now too.
  3. HookBodyExtractionError carries kind, not a code/status. ADR-0112's own scope sentence — "the catalog governs the code a failing request answers with" — is a wire vocabulary; a build-time class thrown in-process is outside it, so no envelope is owed. The class documents its message as byte-identical prose that hook-bodies.mdx quotes; the README correctly tells consumers to branch on kind, not prose.

./package.json: the same spelling @objectstack/console and @objectstack/spec already use (git grep at head: three packages). It ratifies resolvability of the manifest, not stability of any field beyond the version the README row names. oclif reads its own manifest by path, not through exports; package.json is in npm's always-packed set, so files does not gate it. No further consequence found.

Is the new surface actually shipped? files: ["dist", "README.md", "CHANGELOG.md"]. tsconfig.build.json: include: ["src"], rootDir: src, outDir: dist, declaration: true — so src/hook-body.ts emits dist/hook-body.js and dist/hook-body.d.ts, both under dist. scripts/check-published-files.mjs' SUFFICIENT verdict ("every path the manifest points at (types, module, exports subpaths) is covered by [files]") is the gate for exactly this shape. The pin asserts the packed listing carries dist/hook-body.js, dist/hook-body.d.ts, dist/utils/extract-hook-body.js, package.json. I did not pack locally (NOT MEASURED here); CI did — next.

Did the pin run in CI, on a built tree? turbo.json declares "@objectstack/cli#test": { "dependsOn": ["build"] }, so the package's own build precedes its tests. Shard Test Core (1/6) (job 101206947139) ran @objectstack/cli:test: Test Files 253 passed (253), Tests 2947 passed | 6 expected fail (2953), summary at 23:55:28Z; check-test-completeness: OK … 2953 test(s) declared and all accounted for. The file is in the population — vitest's default *.test.ts glob under both the unit and integration projects, and nothing in packages/cli/vitest.config.ts excludes pin tests. I did not read the per-file line for published-subpath-hook-body.pin.test.ts in that log (NOT MEASURED at file granularity); a collected file whose beforeAll threw would have turned the shard red, and it is not.

Does the pin pin what ratification means? Largely yes: it packs with pnpm pack, resolves under both require and import conditions from a consumer directory outside the workspace, executes the extractor from the packed copy, holds the map to exactly four subpaths and the entry to exactly four names with zero star re-exports, and keeps the deep dist/ path sealed. Two breaks it would miss, stated concretely:

Non-blocking finding 2 — ts-morph is borrowed, not proven

beforeAll does symlinkSync(realpathSync(join(PACKAGE_ROOT, 'node_modules', 'ts-morph')), …) unconditionally. Today ts-morph is in dependencies (^28.0.0, measured), so a real install gets it. If it is ever moved to devDependencies, a consumer's extractHookBody on the free-identifiers path fails with ERR_MODULE_NOT_FOUND while this pin stays green — the symlink supplies what the manifest no longer does. One line closes it: assert MANIFEST.dependencies?.['ts-morph'] before symlinking (or symlink only names present in dependencies). Fold into this PR or file a follow-up; I have not filed a card.

Non-blocking finding 3 — types are pinned by name, not by shape

declaredExports() collects export names and counts star re-exports. A changed extractHookBody signature, a renamed ExtractedBody field, or 'unparseable' dropped from HookBodyRefusalKind all pass (the runtime probe asserts only source / capabilities / isExpression, and only the forbidden-token and free-identifiers kinds). A consumer-side tsc fixture compiled against the packed .d.ts, or a snapshot of dist/hook-body.d.ts plus the four declarations it re-exports, would make the README's contract the pin's.

Non-blocking finding 4 — require is resolution, not loading

type: module, engines.node: >=22.0.0. The pin proves require.resolve under the require condition; require() of an ESM default target loads only on Node >=22.12 (require(esm)). Same shape as ./console, and hotcrm's harness is a TS/vitest consumer, so nothing breaks today. Likewise there is no typesVersions, so a moduleResolution: node consumer does not see subpath types — again the ./console shape. Noted; whether CJS consumers on 22.0–22.11 are a supported audience is a maintainer question, not this PR's.

ADR-0112 in the diff — pass

The only toThrow( string in the new test is in its header docblock; every refusal assertion is toMatchObject on isInstance, name, kind, originLabel, freeIdentifiers, nodeOnlyIdentifiers. No wire envelope is asserted because none is thrown.

Governed surfaces — none

Changed files, listing read to exhaustion (5 of 5): .changeset/cli-hook-body-subpath-export.md, packages/cli/README.md, packages/cli/package.json, packages/cli/src/hook-body.ts, packages/cli/test/published-subpath-hook-body.pin.test.ts. Nothing under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, and content/docs/releases/ is untouched. Governed Surface Queue Guard green.

README against source

HookBodyRefusalKind comment 'unparseable' | 'forbidden-token' | 'free-identifiers' matches the source union; the four names match; the "Public subpath exports" table matches the map. content/docs/automation/hook-bodies.mdx is not touched (the docs-drift bot flags this diff as unanchored) — a one-line cross-reference there is the author's own listed follow-up, not a finding here.

Not measured here

  • No local pnpm pack / build; the tarball contents rest on CI shard 1 plus the files / tsconfig.build.json / check-published-files chain above.
  • hotcrm's harness end to end (no access); the pin exercises the same import shape and executes the extractor.
  • Whether check:published-files ran inside Lint & Repo Gates — I read the rule from the script, not that job's step list.

Summary

  • Blocking: 1 — the red Check Changeset run on the head (infrastructure; a re-run clears it).
  • Non-blocking: 2 (borrowed ts-morph), 3 (types pinned by name only), 4 (require = resolution only), plus the three freeze points above for the maintainer to confirm.
  • Bump minor: correct. ADR-0087: not applicable. ADR-0112: pass. Governed surfaces: none. The dedicated entry over a direct module pointer is the better shape, and the pin's exact-map equality makes the next widening or removal a reviewed act rather than a side effect.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review: ACCEPT WITH FINDINGS — one blocking finding, and it is infrastructure

Verdict at 5548187992, taken at tier against head 90e72f9bc55. ⛔ Not flipped ready, not enqueued.

The blocking finding, and the one re-run I am entitled to

Check Changeset — failure
run 33930336391 · job 101207454443 · 23:39:46–23:40:18Z
died in `pnpm install --frozen-lockfile`, better-sqlite3 node-gyp ECONNRESET
fetching Node headers — never reached a changeset step

Six sibling Check Changeset runs on this same head are green and did read the changeset (1 non-breaking changeset, no major bump, 23:37:12–18Z). 53 check runs total, listing exhausted; 52 success or skipped.

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. rerun_failed_jobs fired on run 33930336391 at the time of this comment. ⚠️ That is my one re-run on this head. A second failure is real and is this PR's to root-cause, not a flake.

⛔ "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 spots

I 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.

  1. Borrowed ts-morph. The pin symlinks ts-morph from the workspace unconditionally. It is in dependencies today (^28.0.0), but if it ever left, a real consumer's free-identifiers path would fail with ERR_MODULE_NOT_FOUND while the pin stayed green. The fix is one line: assert MANIFEST.dependencies['ts-morph'] before symlinking.
  2. Types are pinned by NAME, not SHAPE. declaredExports() checks only names and star re-exports. A signature change, a renamed ExtractedBody field, or 'unparseable' dropped from HookBodyRefusalKind would all pass. Closing it needs a consumer-side tsc fixture against the packed .d.ts, or a .d.ts snapshot.

⇒ 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

minor is correct (@objectstack/cli is in the fixed group; rule read from pr-automation.yml "WHICH LEVEL" and check-changeset-no-major.mjs, ⛔ not from AGENTS.md). ADR-0087 not applicable (0 BREAKING, 0 markers, no bang). ADR-0112 not applicable — the only toThrow( in the diff is a docblock line; assertions are toMatchObject on kind / name / originLabel. Governed surfaces: 5 of 5 changed files read, none governed.

The shipped-surface question I was most worried about is answered. files covers dist; tsconfig.build.json emits dist/hook-body.{js,d.ts}; package.json is always packed; check-published-files' SUFFICIENT rule covers exports targets; and turbo.json's @objectstack/cli#test depends on build, so the pin ran on a built tree (shard 1, 253/253 files, 2947 passed). The export map does not name a path the tarball omits.

⚠️ Three freeze points, named for the maintainer — ⛔ not decided here

Ratifying these subpaths makes the following public, and this seat does not rule on what the project commits to keeping stable:

  • ExtractedBody.capabilities' literal union becomes a public type.
  • HookBodyRefusalKind's members become a public type.
  • HookBodyExtractionError carries kind, not code. The reviewer's reading is that ADR-0112's scope is a wire vocabulary ("the code a failing request answers with"), so no envelope is owed here — that reading looks right to me, and it is still the maintainer's to confirm, because it decides whether a thrown error's shape is part of the ratified surface.

NOT MEASURED

The per-file log line for the pin, and any local pack/build. ⛔ Neither is a pass.


Generated by Claude Code

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
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up — finding 1 folded in at ee9a25bc242

The at-tier contract review returned ACCEPT WITH FINDINGS (verdict 5548187992). Finding 1 is folded in by f651d8a8b48; finding 2 (shape-pinning) is filed as its own card, #15630, and is deliberately not in this PR. The body's "Verification" section is pinned to 90e72f9bc55 and stays accurate for that head — this comment carries the readings for the new one.

The finding

test/published-subpath-hook-body.pin.test.ts symlinked ts-morph in from the workspace unconditionally, so the packed extractor could be EXECUTED and not merely resolved. A real consumer never gets that symlink: it gets ts-morph only because the published manifest declares it a runtime dependency. Located by content, it is in packages/cli's dependencies today (^28.0.0) and absent from devDependencies — but had the entry ever left, 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.

The assertion — in beforeAll, immediately above the symlinkSync it guards

expect(
  MANIFEST.dependencies?.['ts-morph'],
  `${PACKAGE_NAME} must declare ts-morph in "dependencies" — the symlink below borrows it from this workspace, ` +
    'but an installed copy of the tarball receives it only from that manifest entry. Moved to devDependencies or ' +
    'dropped, the free-identifiers path would fail with ERR_MODULE_NOT_FOUND for every real consumer while this ' +
    'pin, supplying its own copy, stayed green.',
).toBeTypeOf('string');

Manifest.dependencies is declared optional on purpose: a manifest that no longer carries the key has to be representable, so the guard is what fails rather than a type quietly promising a key the file on disk may not have.

Ablation — the assertion bites, and the unhardened pin would not have

Direction predicted in writing before each leg. Mutation: "ts-morph": renamed to "ts-morph-ABLATED": in packages/cli/package.json, proved on disk by the removed-text count (1 to 0) and the injected-marker count (0 to 1) — never by the editor's exit code. Restore: git checkout HEAD -- ABSOLUTE_PATH under trap ... EXIT INT TERM with the repo root resolved absolutely, proved by blob-hash equality against the HEAD blob (an empty hash read as FAILURE, not as "nothing to compare"), an empty git diff HEAD, a clean git status --porcelain, and a marker-residue scan.

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 skippedAssertionError: @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), VERDICT command-exit 0.
  • pnpm --filter @objectstack/cli typecheck — VERDICT command-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 --listFiles does contain it (1 hit) and 0 of its 28 pre-existing errors name it.
  • origin/main merged in through scripts/pm/os-regen-merge.sh (no regen deferral recorded), then pnpm install --frozen-lockfile and a rebuild of the @objectstack/cli closure — VERDICT command-exit 0.
  • Gate union re-derived on this head by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackno STALE TREE banner; the pre-merge derivation did fire one (15 commits behind, 10 gate-deriving scripts changed on origin/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") and check: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 json narrowed to the changed file — 1 file (count read from the JSON report), 0 errors, 0 warnings. Narrowing evidence: pnpm lint's population is eslint . under the single eslint.config.mjs, which "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) 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 Changeset on this head: success — job 101226973505, run 33937075469, 01:44:54Z to 01:45:44Z. The previous head's red died inside pnpm install --frozen-lockfile and 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

@os-litant
os-litant marked this pull request as ready for review September 5, 2026 02:18
@os-litant
os-litant enabled auto-merge September 5, 2026 02:18
@os-litant
os-litant added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 95d5cbb Sep 5, 2026
39 checks passed
@os-litant
os-litant deleted the claude/issue-15325-ratify-hook-body-subpath-export branch September 5, 2026 03:10
baozhoutao pushed a commit that referenced this pull request Sep 5, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants