Skip to content

Commit 95d5cbb

Browse files
os-litantclaude
andauthored
feat(cli): ratify ./hook-body and ./package.json as public subpath exports of @objectstack/cli (#15611)
* wip(cli): pin, README and changeset for the ./hook-body subpath ratification Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * feat(cli): ratify ./hook-body and ./package.json as public subpath exports Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): the subpath pin declares every child's env via childEnv() Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): the subpath pin asserts the ts-morph borrow before making it 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f99dbcc commit 95d5cbb

5 files changed

Lines changed: 522 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again.
6+
7+
`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.

packages/cli/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,52 @@ os environments bind <id> --artifact dist/objectstack.json # 8. Bind to a Cloud
314314
└── package.json # oclif config under "oclif" key
315315
```
316316

317+
## Public subpath exports
318+
319+
`@objectstack/cli` is a command-line tool first, and its `exports` map is
320+
deliberately sealed: a deep `dist/` path is not a supported import and an
321+
internal refactor may move it without notice. What an out-of-repo consumer may
322+
resolve is exactly this map — a subpath is added here on purpose, with a
323+
`minor` changeset, never discovered by reaching into `dist/`:
324+
325+
| Subpath | What it is for |
326+
|:---|:---|
327+
| `@objectstack/cli` | The command classes `bin/run.js` loads — the oclif entry. |
328+
| `@objectstack/cli/console` | Console SPA resolution helpers (`resolveConsolePath`, `hasConsoleDist`, `createConsoleStaticPlugin` and the drift guards), consumed by cloud's `objectos-runtime` node server to mount the Console. |
329+
| `@objectstack/cli/hook-body` | The hook-body extractor `os build` and `os lint` apply, for an app harness that must run the **same** body-only lowering the build ships (below). |
330+
| `@objectstack/cli/package.json` | The manifest itself, for the ordinary tooling idiom of reading a dependency's own version. |
331+
332+
### `@objectstack/cli/hook-body`
333+
334+
```typescript
335+
import { extractHookBody, HookBodyExtractionError } from '@objectstack/cli/hook-body';
336+
import type { ExtractedBody, HookBodyRefusalKind } from '@objectstack/cli/hook-body';
337+
338+
// The metadata-only source `os build` ships for this handler — hand it to the
339+
// runtime's QuickJS runner in a test and you execute what production executes.
340+
const body: ExtractedBody = extractHookBody(handler, 'hooks.account.beforeInsert');
341+
body.source; // the lowered function body
342+
body.capabilities; // the capability tokens inferred from it
343+
344+
// A handler that is no longer shippable body-only is refused with the SAME
345+
// classification `os lint` reports, so a test can assert the kind, not prose.
346+
try {
347+
extractHookBody(leakyHandler, 'hooks.account.afterUpdate');
348+
} catch (e) {
349+
if (e instanceof HookBodyExtractionError) {
350+
const kind: HookBodyRefusalKind = e.kind; // 'unparseable' | 'forbidden-token' | 'free-identifiers'
351+
e.freeIdentifiers; // the module-scope names the handler reached for
352+
e.nodeOnlyIdentifiers; // the subset only the Node host provides
353+
}
354+
}
355+
```
356+
357+
An app that wants to assert "my hooks are still metadata-only" needs the
358+
platform's own extractor: a local reimplementation passes its own tests while
359+
diverging from the rule the build actually applies. `os lint`'s
360+
`hook-body/not-lowerable` rule answers the pass/fail question; this entry hands
361+
a test the lowered `source` to run. The four names above are the whole surface
362+
— the entry re-exports them and nothing else.
317363

318364
## Default capability slate (always-on)
319365

packages/cli/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
"./console": {
1313
"types": "./dist/utils/console.d.ts",
1414
"default": "./dist/utils/console.js"
15-
}
15+
},
16+
"./hook-body": {
17+
"types": "./dist/hook-body.d.ts",
18+
"default": "./dist/hook-body.js"
19+
},
20+
"./package.json": "./package.json"
1621
},
1722
"bin": {
1823
"objectstack": "./bin/run.js",

packages/cli/src/hook-body.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `@objectstack/cli/hook-body` — the public entry for the hook-body extractor.
5+
*
6+
* ## Why this entry exists (#15325)
7+
*
8+
* `extractHookBody` decides whether a hook or script action is still shippable
9+
* **body-only**: it peels the handler to its statements, refuses the forbidden
10+
* tokens, infers capabilities, and throws a `HookBodyExtractionError` carrying
11+
* `kind` / `freeIdentifiers` / `nodeOnlyIdentifiers`. `os build` applies it to
12+
* lower a handler, `os lint` calls the same function so its verdict cannot
13+
* drift from the build's. An app that wants to assert "my hooks are still
14+
* metadata-only" — and to RUN the lowered `source` through the real QuickJS
15+
* runner in a test — needs this exact function, not a lookalike: a local
16+
* reimplementation passes its own tests while diverging from the rule the
17+
* build actually applies, which is the failure mode #13651 was filed about.
18+
*
19+
* Until 17.3.0 the extractor was reachable as a deep `dist/utils/` import, and
20+
* one out-of-repo consumer (hotcrm's hook-body fidelity harness) reached it
21+
* that way on purpose. #13123 then sealed this package behind an `exports`
22+
* map and named the remedy for an out-of-repo consumer in its own body:
23+
* ratify the subpath as public surface rather than read `dist/` paths. That
24+
* remedy was applied to `./console` for cloud's `objectos-runtime` (#13662);
25+
* this entry applies it to the second consumer.
26+
*
27+
* ## Why a dedicated file and not the internal module itself
28+
*
29+
* `./console` points its subpath straight at `dist/utils/console.js`, so every
30+
* export that module ever gains is public the moment it lands. The card asks
31+
* for four names, and that is what this file re-exports — by name, no star. An
32+
* export `extract-hook-body.ts` grows tomorrow is NOT public until someone
33+
* edits this list, and `test/published-subpath-hook-body.pin.test.ts` holds
34+
* the packed `.d.ts` to exactly these four so the widening is a deliberate,
35+
* reviewed, `minor`-bumped act rather than a side effect of a refactor.
36+
*
37+
* ⛔ Do not add to this list to make something convenient reachable. A new
38+
* name here is a new public contract on a published package.
39+
*/
40+
41+
export { extractHookBody, HookBodyExtractionError } from './utils/extract-hook-body.js';
42+
export type { ExtractedBody, HookBodyRefusalKind } from './utils/extract-hook-body.js';

0 commit comments

Comments
 (0)