From 49b011e075f9245dee2dc69a71699c89f2128fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:19:12 +0000 Subject: [PATCH 1/4] wip(cli): pin, README and changeset for the ./hook-body subpath ratification Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/cli-hook-body-subpath-export.md | 7 + packages/cli/README.md | 46 ++ .../published-subpath-hook-body.pin.test.ts | 392 ++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 .changeset/cli-hook-body-subpath-export.md create mode 100644 packages/cli/test/published-subpath-hook-body.pin.test.ts diff --git a/.changeset/cli-hook-body-subpath-export.md b/.changeset/cli-hook-body-subpath-export.md new file mode 100644 index 0000000000..af97e82057 --- /dev/null +++ b/.changeset/cli-hook-body-subpath-export.md @@ -0,0 +1,7 @@ +--- +'@objectstack/cli': minor +--- + +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. + +`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. diff --git a/packages/cli/README.md b/packages/cli/README.md index af59ea5aa9..191025cfe4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -314,6 +314,52 @@ os environments bind --artifact dist/objectstack.json # 8. Bind to a Cloud └── package.json # oclif config under "oclif" key ``` +## Public subpath exports + +`@objectstack/cli` is a command-line tool first, and its `exports` map is +deliberately sealed: a deep `dist/` path is not a supported import and an +internal refactor may move it without notice. What an out-of-repo consumer may +resolve is exactly this map — a subpath is added here on purpose, with a +`minor` changeset, never discovered by reaching into `dist/`: + +| Subpath | What it is for | +|:---|:---| +| `@objectstack/cli` | The command classes `bin/run.js` loads — the oclif entry. | +| `@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. | +| `@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). | +| `@objectstack/cli/package.json` | The manifest itself, for the ordinary tooling idiom of reading a dependency's own version. | + +### `@objectstack/cli/hook-body` + +```typescript +import { extractHookBody, HookBodyExtractionError } from '@objectstack/cli/hook-body'; +import type { ExtractedBody, HookBodyRefusalKind } from '@objectstack/cli/hook-body'; + +// The metadata-only source `os build` ships for this handler — hand it to the +// runtime's QuickJS runner in a test and you execute what production executes. +const body: ExtractedBody = extractHookBody(handler, 'hooks.account.beforeInsert'); +body.source; // the lowered function body +body.capabilities; // the capability tokens inferred from it + +// A handler that is no longer shippable body-only is refused with the SAME +// classification `os lint` reports, so a test can assert the kind, not prose. +try { + extractHookBody(leakyHandler, 'hooks.account.afterUpdate'); +} catch (e) { + if (e instanceof HookBodyExtractionError) { + const kind: HookBodyRefusalKind = e.kind; // 'unparseable' | 'forbidden-token' | 'free-identifiers' + e.freeIdentifiers; // the module-scope names the handler reached for + e.nodeOnlyIdentifiers; // the subset only the Node host provides + } +} +``` + +An app that wants to assert "my hooks are still metadata-only" needs the +platform's own extractor: a local reimplementation passes its own tests while +diverging from the rule the build actually applies. `os lint`'s +`hook-body/not-lowerable` rule answers the pass/fail question; this entry hands +a test the lowered `source` to run. The four names above are the whole surface +— the entry re-exports them and nothing else. ## Default capability slate (always-on) diff --git a/packages/cli/test/published-subpath-hook-body.pin.test.ts b/packages/cli/test/published-subpath-hook-body.pin.test.ts new file mode 100644 index 0000000000..34610e420b --- /dev/null +++ b/packages/cli/test/published-subpath-hook-body.pin.test.ts @@ -0,0 +1,392 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/cli/hook-body` and `@objectstack/cli/package.json` are public + * subpaths of the PUBLISHED package — pinned against the packed tarball, from a + * consumer directory outside the workspace, because that is the only place the + * defect this file pins has ever existed. + * + * ## The defect (#15325) + * + * 17.3.0 gave this package an `exports` map (#13123) and ratified exactly one + * subpath beside the root — `./console`, for cloud's `objectos-runtime`. The + * hook-body extractor was reachable as a deep `dist/utils/extract-hook-body.js` + * import until that day, and an app's hook-body fidelity harness (hotcrm's + * `test/helpers/action-sandbox.ts`) reached it that way on purpose: it runs a + * hook through the SAME lowering `os build` ships, so a test executes what + * production will execute rather than a lookalike. After the seal the specifier + * was dead at type level (`TS2307`) and at run time + * (`ERR_PACKAGE_PATH_NOT_EXPORTED`) — while the file still shipped in the + * tarball. Only the door was gone. `@objectstack/cli/package.json` was sealed + * by the same map, breaking the ordinary idiom of reading a dependency's own + * manifest, with no upside. + * + * The remedy is the one #13123's own body prescribes for an out-of-repo + * consumer — ratify the subpath as public surface rather than read `dist/` + * paths — applied to the second consumer. ⛔ NOT a local reimplementation: a + * hand-rolled extractor passes its own tests while diverging from the rule the + * build actually applies, which is the failure mode #13651 was filed about. + * + * ## Why the packed tarball and not the source tree + * + * An `exports` map is a PACKAGING contract. Inside the monorepo nothing is + * sealed — a relative import, a vitest alias or a `paths` entry reaches any + * file — so a test that resolves through the workspace proves nothing about + * what an installed copy can reach. #14874 measured the other half of the same + * lesson: the packaging layer lies to source-tree tests (npm ships a `bin` + * target regardless of `files`). So this file packs the package the way + * `pnpm publish` would, unpacks it into a throwaway `node_modules` under the OS + * temp dir, and asks a child Node process — with that directory as its cwd and + * nothing of this workspace on its resolution path — the three questions the + * card measured. The one thing borrowed from the workspace is the tarball's own + * runtime dependency `ts-morph`, symlinked in so the extractor can be EXECUTED + * from the packed copy and not merely resolved; resolution never consults it. + * + * ## What this file deliberately does NOT do + * + * It does not assert the extractor's behaviour beyond one clean body and two + * classified refusals — `test/extract-hook-body.test.ts` owns that, over the + * source. What this file owns is the DOOR: that the ratified subpath resolves + * under both `require` and `import` conditions, that it exposes exactly the + * four ratified names and nothing the internal module may grow next, that the + * deep `dist/` path STAYS sealed, and that the extractor which answers from the + * packed copy is the platform's own (its refusal is a `HookBodyExtractionError` + * carrying `kind`, not a bare `Error`). ⚠️ That refusal is a build-time class, + * not an ADR-0112 envelope — it carries no `code`/`status` — so the assertions + * below name `kind`, `name` and `originLabel`, never a bare `toThrow()`. + */ + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `packages/cli` — this package's own root, never another package's. */ +const PACKAGE_ROOT = resolve(HERE, '..'); + +type ExportsMap = Record; +interface Manifest { + name: string; + version: string; + exports: ExportsMap; +} + +const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as Manifest; +const PACKAGE_NAME = MANIFEST.name; + +/** The subpath this card ratifies, in the three spellings a consumer meets. */ +const HOOK_BODY_SUBPATH = './hook-body'; +const HOOK_BODY_SPECIFIER = `${PACKAGE_NAME}/hook-body`; +const MANIFEST_SPECIFIER = `${PACKAGE_NAME}/package.json`; +/** The pre-17.3.0 spelling the harness used — the door that is gone and STAYS gone. */ +const DEEP_PATH_SPECIFIER = `${PACKAGE_NAME}/dist/utils/extract-hook-body.js`; + +/** + * The whole ratified surface, by name. Four names, not a barrel: the entry + * re-exports these and nothing else, so an export the internal module gains + * tomorrow is NOT public until someone edits both the entry and this list. + */ +const RATIFIED_SURFACE = ['ExtractedBody', 'HookBodyExtractionError', 'HookBodyRefusalKind', 'extractHookBody']; +/** The two of those that exist at run time (the other two are types). */ +const RATIFIED_RUNTIME_SURFACE = ['HookBodyExtractionError', 'extractHookBody']; + +/** + * Every subpath the published package resolves, in full. A subpath removed + * here is a consumer broken in the exact shape of #15325 and #13662; a subpath + * added here is a `minor` bump (a new accepted key on a published surface). + * Either way this list is edited on purpose, in the same PR, with a changeset. + */ +const RATIFIED_SUBPATHS = ['.', './console', HOOK_BODY_SUBPATH, './package.json']; + +/** + * The probe the consumer directory runs. Plain ESM, no transform, no import of + * anything but Node built-ins and the packed package: `require.resolve` for the + * `require` condition, `import.meta.resolve` for the `import` condition, and a + * real `import()` that CALLS the extractor so the answer is about the code that + * ships, not about a path string. + */ +const PROBE_SOURCE = ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const [hookBody, manifest, deep] = process.argv.slice(2); +const tryRequire = (spec) => { + try { return { ok: true, path: require.resolve(spec) }; } + catch (e) { return { ok: false, code: e?.code ?? String(e) }; } +}; +const tryImport = (spec) => { + try { return { ok: true, path: import.meta.resolve(spec) }; } + catch (e) { return { ok: false, code: e?.code ?? String(e) }; } +}; +const out = { + require: { hookBody: tryRequire(hookBody), manifest: tryRequire(manifest), deep: tryRequire(deep) }, + import: { hookBody: tryImport(hookBody), manifest: tryImport(manifest), deep: tryImport(deep) }, + runtime: null, +}; +try { + const mod = await import(hookBody); + const refuse = (fn, label) => { + try { mod.extractHookBody(fn, label); return null; } + catch (e) { + return { + isInstance: e instanceof mod.HookBodyExtractionError, + name: e?.name, kind: e?.kind, originLabel: e?.originLabel, + freeIdentifiers: e?.freeIdentifiers, nodeOnlyIdentifiers: e?.nodeOnlyIdentifiers, + }; + } + }; + out.runtime = { + keys: Object.keys(mod).sort(), + lowered: mod.extractHookBody((ctx) => { ctx.input.x = 1; return ctx.input; }, 'probe:clean'), + forbidden: refuse((ctx) => { fetch('https://example.invalid'); }, 'probe:forbidden'), + free: refuse((ctx) => { helper(ctx); }, 'probe:free'), + }; +} catch (e) { + out.runtime = { error: e?.code ?? String(e) }; +} +process.stdout.write(JSON.stringify(out)); +`; + +interface Resolution { + ok: boolean; + path?: string; + code?: string; +} +interface Refusal { + isInstance: boolean; + name?: string; + kind?: string; + originLabel?: string; + freeIdentifiers?: string[]; + nodeOnlyIdentifiers?: string[]; +} +interface ProbeResult { + require: { hookBody: Resolution; manifest: Resolution; deep: Resolution }; + import: { hookBody: Resolution; manifest: Resolution; deep: Resolution }; + runtime: + | { + keys: string[]; + lowered: { source: string; capabilities: string[]; isExpression: boolean }; + forbidden: Refusal | null; + free: Refusal | null; + } + | { error: string }; +} + +/** + * Pack the way the release does. `pnpm pack` applies the same manifest + * rewrites as `pnpm publish` (`workspace:*` → concrete versions, `publishConfig` + * overlay), so the tarball is what a downstream `npm install` receives; + * `scripts/publish-smoke-pack.mjs` packs the release candidate with it for the + * same reason. Under `pnpm test` the runner is on `PATH`; `npm_execpath` is + * honoured first when it names pnpm, so a nested invocation packs with the + * pnpm that is running it. + */ +function pnpmPack(destination: string): { filename: string; files: string[] } { + const execpath = process.env.npm_execpath; + const viaExecpath = typeof execpath === 'string' && /pnpm/.test(basename(execpath)); + const [command, prefix]: [string, string[]] = viaExecpath ? [process.execPath, [execpath as string]] : ['pnpm', []]; + const res = spawnSync(command, [...prefix, 'pack', '--pack-destination', destination, '--json'], { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + }); + if (res.error) throw new Error(`pnpm pack could not start (${command}): ${res.error.message}`); + if (res.status !== 0) { + throw new Error(`pnpm pack exited ${res.status}\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`); + } + // pnpm prints the JSON report last; anything a lifecycle script logged + // before it is not JSON, so parse from the first `{` at line start. + const jsonStart = res.stdout.search(/^\{/m); + if (jsonStart < 0) throw new Error(`pnpm pack --json printed no report\n${res.stdout}`); + const report = JSON.parse(res.stdout.slice(jsonStart)) as { filename: string; files: { path: string }[] }; + return { filename: report.filename, files: report.files.map((f) => f.path) }; +} + +/** The names a `.d.ts` exports, read off its AST — no resolution, no program. */ +function declaredExports(dtsPath: string): { names: string[]; starReExports: number } { + const sf = ts.createSourceFile(dtsPath, readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest, true); + const names: string[] = []; + let starReExports = 0; + for (const stmt of sf.statements) { + if (ts.isExportDeclaration(stmt)) { + if (!stmt.exportClause) starReExports += 1; + else if (ts.isNamedExports(stmt.exportClause)) for (const el of stmt.exportClause.elements) names.push(el.name.text); + continue; + } + const exported = ts.canHaveModifiers(stmt) && ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + if (!exported) continue; + if ( + (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && + stmt.name + ) { + names.push(stmt.name.text); + } else if (ts.isVariableStatement(stmt)) { + for (const d of stmt.declarationList.declarations) if (ts.isIdentifier(d.name)) names.push(d.name.text); + } + } + return { names: names.sort(), starReExports }; +} + +let scratch: string; +let packedFiles: string[]; +let installedRoot: string; +let probe: ProbeResult; + +beforeAll(() => { + const rootEntry = MANIFEST.exports['.']; + const rootJs = typeof rootEntry === 'string' ? rootEntry : rootEntry?.default; + if (!rootJs || !existsSync(join(PACKAGE_ROOT, rootJs))) { + // Loud, naming the remedy: a missing build must not read as a sealed door. + throw new Error( + `packages/cli is not built (${rootJs ?? ''} is absent), so the tarball would carry no dist ` + + 'and every resolution below would fail for the wrong reason. Run: pnpm --filter @objectstack/cli build', + ); + } + + scratch = mkdtempSync(join(tmpdir(), 'os-cli-subpath-')); + const packed = pnpmPack(scratch); + packedFiles = packed.files; + + // Unpack into a consumer's node_modules. The tarball root is `package/`. + const extractDir = join(scratch, 'extract'); + mkdirSync(extractDir); + const tar = spawnSync('tar', ['-xzf', packed.filename, '-C', extractDir], { encoding: 'utf8' }); + if (tar.status !== 0) throw new Error(`tar -xzf failed (${tar.status}): ${tar.stderr}`); + const consumer = join(scratch, 'consumer'); + const scope = join(consumer, 'node_modules', ...PACKAGE_NAME.split('/').slice(0, -1)); + mkdirSync(scope, { recursive: true }); + installedRoot = join(consumer, 'node_modules', ...PACKAGE_NAME.split('/')); + renameSync(join(extractDir, 'package'), installedRoot); + + // The extractor's one runtime dependency, so `import()` can EXECUTE it from + // the packed copy. A real-path symlink: pnpm's store keeps ts-morph's own + // dependencies beside the real directory, and Node resolves from there. + symlinkSync(realpathSync(join(PACKAGE_ROOT, 'node_modules', 'ts-morph')), join(consumer, 'node_modules', 'ts-morph'), 'dir'); + + const probePath = join(consumer, 'probe.mjs'); + writeFileSync(probePath, PROBE_SOURCE); + const run = spawnSync(process.execPath, [probePath, HOOK_BODY_SPECIFIER, MANIFEST_SPECIFIER, DEEP_PATH_SPECIFIER], { + cwd: consumer, + encoding: 'utf8', + env: { ...process.env, NODE_PATH: '' }, + }); + if (run.status !== 0) throw new Error(`probe exited ${run.status}\n--- stderr ---\n${run.stderr}\n--- stdout ---\n${run.stdout}`); + probe = JSON.parse(run.stdout) as ProbeResult; +}, 120_000); + +afterAll(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); +}); + +describe('the exports map of the published package (#15325)', () => { + it('declares exactly the ratified subpaths — the root, ./console, ./hook-body and ./package.json', () => { + expect(Object.keys(MANIFEST.exports).sort()).toEqual([...RATIFIED_SUBPATHS].sort()); + }); + + it('points ./hook-body at a dedicated entry with a types condition, in the shape ./console uses', () => { + const entry = MANIFEST.exports[HOOK_BODY_SUBPATH]; + expect(entry, `${HOOK_BODY_SUBPATH} is not declared`).toBeTypeOf('object'); + expect(entry).toEqual({ types: './dist/hook-body.d.ts', default: './dist/hook-body.js' }); + }); + + it('points ./package.json at the manifest itself, as @objectstack/console spells it', () => { + expect(MANIFEST.exports['./package.json']).toBe('./package.json'); + }); +}); + +describe('the tarball ships what the doors open onto (the card\'s premise, re-derived)', () => { + it('carries the extractor, the ratified entry and the manifest', () => { + expect(packedFiles).toEqual( + expect.arrayContaining([ + 'dist/utils/extract-hook-body.js', + 'dist/utils/extract-hook-body.d.ts', + 'dist/hook-body.js', + 'dist/hook-body.d.ts', + 'package.json', + ]), + ); + }); +}); + +describe('resolution from a consumer directory outside the workspace', () => { + it(`resolves ${HOOK_BODY_SPECIFIER} under the require condition to the ratified entry`, () => { + expect(probe.require.hookBody).toEqual({ ok: true, path: join(installedRoot, 'dist', 'hook-body.js') }); + }); + + it(`resolves ${HOOK_BODY_SPECIFIER} under the import condition to the same file`, () => { + expect(probe.import.hookBody.ok, JSON.stringify(probe.import.hookBody)).toBe(true); + expect(fileURLToPath(probe.import.hookBody.path as string)).toBe(join(installedRoot, 'dist', 'hook-body.js')); + }); + + it(`resolves ${MANIFEST_SPECIFIER} under both conditions`, () => { + expect(probe.require.manifest).toEqual({ ok: true, path: join(installedRoot, 'package.json') }); + expect(probe.import.manifest.ok, JSON.stringify(probe.import.manifest)).toBe(true); + expect(fileURLToPath(probe.import.manifest.path as string)).toBe(join(installedRoot, 'package.json')); + }); + + it(`keeps the deep dist/ path sealed under both conditions — the door is the subpath, not the file`, () => { + expect(probe.require.deep).toEqual({ ok: false, code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + expect(probe.import.deep).toEqual({ ok: false, code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + }); +}); + +describe('the ratified surface is exactly four names', () => { + it('at run time: the two values, nothing else', () => { + expect(probe.runtime, 'the entry did not import from the packed copy').not.toHaveProperty('error'); + expect((probe.runtime as { keys: string[] }).keys).toEqual(RATIFIED_RUNTIME_SURFACE); + }); + + it('in the shipped types: the two values and the two types, and no star re-export that would make it a barrel', () => { + const { names, starReExports } = declaredExports(join(installedRoot, 'dist', 'hook-body.d.ts')); + expect(starReExports, 'a star re-export ratifies whatever the internal module grows next').toBe(0); + expect(names).toEqual(RATIFIED_SURFACE); + }); +}); + +describe('the extractor that answers from the packed copy is the platform\'s own', () => { + it('lowers a clean body to the metadata-only source os build ships', () => { + const runtime = probe.runtime as Extract; + expect(runtime.lowered.source).toContain('ctx.input.x = 1'); + expect(runtime.lowered.source).toContain('return ctx.input'); + expect(runtime.lowered.isExpression).toBe(false); + expect(runtime.lowered.capabilities).toEqual([]); + }); + + it('refuses a forbidden token with a HookBodyExtractionError classified forbidden-token', () => { + const runtime = probe.runtime as Extract; + expect(runtime.forbidden).not.toBeNull(); + expect(runtime.forbidden).toMatchObject({ + isInstance: true, + name: 'HookBodyExtractionError', + kind: 'forbidden-token', + originLabel: 'probe:forbidden', + }); + }); + + it('refuses a scope leak with the free identifier named — the ts-morph path runs from the tarball too', () => { + const runtime = probe.runtime as Extract; + expect(runtime.free).not.toBeNull(); + expect(runtime.free).toMatchObject({ + isInstance: true, + name: 'HookBodyExtractionError', + kind: 'free-identifiers', + originLabel: 'probe:free', + freeIdentifiers: ['helper'], + nodeOnlyIdentifiers: [], + }); + }); +}); From 8f1cf059dcfca9fec8e83f33c0e3850132a3a121 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:31:07 +0000 Subject: [PATCH 2/4] feat(cli): ratify ./hook-body and ./package.json as public subpath exports Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/cli/package.json | 7 +++++- packages/cli/src/hook-body.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/hook-body.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 71df44a7c2..5d0ba78b18 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,7 +12,12 @@ "./console": { "types": "./dist/utils/console.d.ts", "default": "./dist/utils/console.js" - } + }, + "./hook-body": { + "types": "./dist/hook-body.d.ts", + "default": "./dist/hook-body.js" + }, + "./package.json": "./package.json" }, "bin": { "objectstack": "./bin/run.js", diff --git a/packages/cli/src/hook-body.ts b/packages/cli/src/hook-body.ts new file mode 100644 index 0000000000..5114c9f5fb --- /dev/null +++ b/packages/cli/src/hook-body.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/cli/hook-body` — the public entry for the hook-body extractor. + * + * ## Why this entry exists (#15325) + * + * `extractHookBody` decides whether a hook or script action is still shippable + * **body-only**: it peels the handler to its statements, refuses the forbidden + * tokens, infers capabilities, and throws a `HookBodyExtractionError` carrying + * `kind` / `freeIdentifiers` / `nodeOnlyIdentifiers`. `os build` applies it to + * lower a handler, `os lint` calls the same function so its verdict cannot + * drift from the build's. An app that wants to assert "my hooks are still + * metadata-only" — and to RUN the lowered `source` through the real QuickJS + * runner in a test — needs this exact function, not a lookalike: a local + * reimplementation passes its own tests while diverging from the rule the + * build actually applies, which is the failure mode #13651 was filed about. + * + * Until 17.3.0 the extractor was reachable as a deep `dist/utils/` import, and + * one out-of-repo consumer (hotcrm's hook-body fidelity harness) reached it + * that way on purpose. #13123 then sealed this package behind an `exports` + * map and named the remedy for an out-of-repo consumer in its own body: + * ratify the subpath as public surface rather than read `dist/` paths. That + * remedy was applied to `./console` for cloud's `objectos-runtime` (#13662); + * this entry applies it to the second consumer. + * + * ## Why a dedicated file and not the internal module itself + * + * `./console` points its subpath straight at `dist/utils/console.js`, so every + * export that module ever gains is public the moment it lands. The card asks + * for four names, and that is what this file re-exports — by name, no star. An + * export `extract-hook-body.ts` grows tomorrow is NOT public until someone + * edits this list, and `test/published-subpath-hook-body.pin.test.ts` holds + * the packed `.d.ts` to exactly these four so the widening is a deliberate, + * reviewed, `minor`-bumped act rather than a side effect of a refactor. + * + * ⛔ Do not add to this list to make something convenient reachable. A new + * name here is a new public contract on a published package. + */ + +export { extractHookBody, HookBodyExtractionError } from './utils/extract-hook-body.js'; +export type { ExtractedBody, HookBodyRefusalKind } from './utils/extract-hook-body.js'; From 90e72f9bc5531246d369095f9d1d011283ee1de5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 23:18:41 +0000 Subject: [PATCH 3/4] test(cli): the subpath pin declares every child's env via childEnv() Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../cli/test/published-subpath-hook-body.pin.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/published-subpath-hook-body.pin.test.ts b/packages/cli/test/published-subpath-hook-body.pin.test.ts index 34610e420b..01e5a6f64a 100644 --- a/packages/cli/test/published-subpath-hook-body.pin.test.ts +++ b/packages/cli/test/published-subpath-hook-body.pin.test.ts @@ -75,6 +75,8 @@ import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { childEnv } from './helpers/serve-process.js'; + const HERE = resolve(fileURLToPath(import.meta.url), '..'); /** `packages/cli` — this package's own root, never another package's. */ const PACKAGE_ROOT = resolve(HERE, '..'); @@ -200,9 +202,13 @@ function pnpmPack(destination: string): { filename: string; files: string[] } { const execpath = process.env.npm_execpath; const viaExecpath = typeof execpath === 'string' && /pnpm/.test(basename(execpath)); const [command, prefix]: [string, string[]] = viaExecpath ? [process.execPath, [execpath as string]] : ['pnpm', []]; + // `childEnv()` — every child spawned from this directory declares its + // environment (check:cli-test-child-env): the vitest worker's `TEST`/`VITEST*` + // family and `NODE_PATH` are stripped, everything pnpm needs (PATH, HOME) stays. const res = spawnSync(command, [...prefix, 'pack', '--pack-destination', destination, '--json'], { cwd: PACKAGE_ROOT, encoding: 'utf8', + env: childEnv(), }); if (res.error) throw new Error(`pnpm pack could not start (${command}): ${res.error.message}`); if (res.status !== 0) { @@ -264,7 +270,7 @@ beforeAll(() => { // Unpack into a consumer's node_modules. The tarball root is `package/`. const extractDir = join(scratch, 'extract'); mkdirSync(extractDir); - const tar = spawnSync('tar', ['-xzf', packed.filename, '-C', extractDir], { encoding: 'utf8' }); + const tar = spawnSync('tar', ['-xzf', packed.filename, '-C', extractDir], { encoding: 'utf8', env: childEnv() }); if (tar.status !== 0) throw new Error(`tar -xzf failed (${tar.status}): ${tar.stderr}`); const consumer = join(scratch, 'consumer'); const scope = join(consumer, 'node_modules', ...PACKAGE_NAME.split('/').slice(0, -1)); @@ -279,10 +285,12 @@ beforeAll(() => { const probePath = join(consumer, 'probe.mjs'); writeFileSync(probePath, PROBE_SOURCE); + // `childEnv()` already strips `NODE_PATH`, so nothing of this workspace's + // resolution base reaches the probe: what resolves, resolves from `consumer`. const run = spawnSync(process.execPath, [probePath, HOOK_BODY_SPECIFIER, MANIFEST_SPECIFIER, DEEP_PATH_SPECIFIER], { cwd: consumer, encoding: 'utf8', - env: { ...process.env, NODE_PATH: '' }, + env: childEnv(), }); if (run.status !== 0) throw new Error(`probe exited ${run.status}\n--- stderr ---\n${run.stderr}\n--- stdout ---\n${run.stdout}`); probe = JSON.parse(run.stdout) as ProbeResult; From f651d8a8b48a3fdd510ff37453ddafc7fc7ce067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 01:15:20 +0000 Subject: [PATCH 4/4] test(cli): the subpath pin asserts the ts-morph borrow before making it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../published-subpath-hook-body.pin.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli/test/published-subpath-hook-body.pin.test.ts b/packages/cli/test/published-subpath-hook-body.pin.test.ts index 01e5a6f64a..ad678e532c 100644 --- a/packages/cli/test/published-subpath-hook-body.pin.test.ts +++ b/packages/cli/test/published-subpath-hook-body.pin.test.ts @@ -41,6 +41,9 @@ * card measured. The one thing borrowed from the workspace is the tarball's own * runtime dependency `ts-morph`, symlinked in so the extractor can be EXECUTED * from the packed copy and not merely resolved; resolution never consults it. + * That borrow is guarded rather than assumed: `beforeAll` asserts the manifest + * still declares `ts-morph` under `dependencies` before symlinking, because the + * copy this file hands over is a copy a real consumer would never receive. * * ## What this file deliberately does NOT do * @@ -86,6 +89,12 @@ interface Manifest { name: string; version: string; exports: ExportsMap; + /** + * Optional on purpose: a manifest that no longer declares `ts-morph` has to be + * REPRESENTABLE here, so the borrow guard in `beforeAll` is what fails — not + * a type assertion quietly promising a key the file on disk may not carry. + */ + dependencies?: Record; } const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as Manifest; @@ -281,6 +290,18 @@ beforeAll(() => { // The extractor's one runtime dependency, so `import()` can EXECUTE it from // the packed copy. A real-path symlink: pnpm's store keeps ts-morph's own // dependencies beside the real directory, and Node resolves from there. + // + // ⛔ Never borrow it unconditionally. A consumer receives `ts-morph` only + // because the PUBLISHED manifest declares it a runtime dependency; this file + // hands itself a copy the consumer would not have, so the premise is asserted + // BEFORE the symlink can paper over its absence. + 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'); symlinkSync(realpathSync(join(PACKAGE_ROOT, 'node_modules', 'ts-morph')), join(consumer, 'node_modules', 'ts-morph'), 'dir'); const probePath = join(consumer, 'probe.mjs');