From 2909cf86170dbb75651106d51b336995efe2b12d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:20:02 +0000 Subject: [PATCH 1/5] feat(cli): give the ./console subpath a public barrel and a surface pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — changeset and gate runs still to come. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/cli/README.md | 2 +- packages/cli/package.json | 4 +- packages/cli/src/console.ts | 81 +++ packages/cli/src/hook-body.ts | 8 +- packages/cli/src/utils/console.ts | 11 +- .../published-subpath-console.pin.test.ts | 633 ++++++++++++++++++ 6 files changed, 732 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/console.ts create mode 100644 packages/cli/test/published-subpath-console.pin.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 191025cfe4..5b9e089014 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -325,7 +325,7 @@ resolve is exactly this map — a subpath is added here on purpose, with a | 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/console` | Exactly three Console SPA mounting helpers — `resolveConsolePath`, `hasConsoleDist`, `createConsoleStaticPlugin` — consumed by cloud's `objectos-runtime` node server to mount the Console. The drift guards and the rest of `utils/console.ts` are **not** on this subpath (#16046). | | `@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. | diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d0ba78b18..88d6e00c43 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,8 +10,8 @@ "default": "./dist/index.js" }, "./console": { - "types": "./dist/utils/console.d.ts", - "default": "./dist/utils/console.js" + "types": "./dist/console.d.ts", + "default": "./dist/console.js" }, "./hook-body": { "types": "./dist/hook-body.d.ts", diff --git a/packages/cli/src/console.ts b/packages/cli/src/console.ts new file mode 100644 index 0000000000..364557b998 --- /dev/null +++ b/packages/cli/src/console.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/cli/console` — the public entry for mounting the Console SPA. + * + * ## Why this entry exists (#16046) + * + * Until this file landed, `./console` pointed its subpath straight at + * `dist/utils/console.js` — an INTERNAL module with 13 top-level exports and no + * surface pin of any kind, neither names nor shapes. The only assertion that + * existed anywhere in the tree was that `./console` *is a declared subpath*. So + * every export that module gained was published the moment it landed: an + * accidental `export *` widening, or a symbol added for an internal reason, + * became public API silently, with nothing that would notice. + * + * That is a strictly weaker position than the one #15630 repaired on + * `./hook-body`, where a pin at least held the ratified names. This file is the + * same remedy applied one door over: the subpath stays — cloud's + * `objectos-runtime` node server depends on it, and sealing it would be #13662 + * and #15325 a third time — but it now points at a barrel that re-exports the + * intended public face BY NAME, no star. + * + * `test/published-subpath-console.pin.test.ts` holds the packed `.d.ts` to + * exactly these names AND their shapes, so a 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. + * + * ## What the public face is, and why it is these three + * + * The one ledgered out-of-repo consumer + * (`packages/qa/downstream-contract/consumer-specifiers.ledger.json`) mounts the + * Console SPA through exactly `resolveConsolePath` / `hasConsoleDist` / + * `createConsoleStaticPlugin`. Those three are the face. + * + * ### The two the ruling left to a measurement, and how it came out + * + * The #16046 ruling admitted `decideConsoleMount` and `createRuntimeAssetsPlugin` + * "only if the implementer finds an intended external caller", stating that + * `commands/serve.ts`'s own use does not count because it is in-package. Both + * are EXCLUDED, on four readings taken at `0ea5f9d9f79`: + * + * 1. Every reference to either name in this repo is inside `packages/cli/` + * — `commands/serve.ts`, `utils/console.ts` itself, and two of this + * package's own tests — plus one historical `CHANGELOG.md` line. No + * caller outside the package exists to be intended. + * 2. The consumer-specifier ledger — this repo's owned, shrink-only record of + * what out-of-repo consumers import, and the only place such a claim is + * written down at all — names three functions for this specifier and + * neither of these two. + * 3. `decideConsoleMount`'s own docblock scopes it to `isDev` only and says + * "Published installs carry no pin, so no production or cloud deployment + * can reach the refusal." The sole ledgered consumer IS a cloud + * deployment, so the source says the external caller cannot reach the + * behaviour this function exists to produce. + * 4. `objectui`, the one sibling checkout reachable from the implementing + * container, imports `@objectstack/cli/console` zero times. + * + * ⚠️ The `cloud` checkout was NOT reachable from that container, so the reading + * that matters most is second-hand — the ledger's and the ruling's, not one + * this author took. If a consumer of either name ever surfaces, the remedy is + * the one #13123's body prescribes and #13662 applied: re-open the name here + * deliberately, with a changeset, and ledger the consumer. ⛔ Not a deep + * `dist/` import, and not a local reimplementation. + * + * ## What retiring the other names does and does not do + * + * Nothing is deleted: `utils/console.ts` keeps all 13 exports and every + * in-package caller keeps importing it directly. What changes is only which of + * them a PUBLISHED specifier can name. + * + * One consequence is worth stating because it is invisible from the export list. + * `ResolveConsoleOptions` is retired, but it is still `resolveConsolePath`'s + * parameter type, so a consumer keeps passing the same options object — the + * shape stays reachable STRUCTURALLY through the signature — and loses only the + * ability to NAME the type through this subpath. The pin asserts both halves, so + * neither can drift silently. + */ + +export { resolveConsolePath, hasConsoleDist, createConsoleStaticPlugin } from './utils/console.js'; diff --git a/packages/cli/src/hook-body.ts b/packages/cli/src/hook-body.ts index 5114c9f5fb..e1dcdad3f2 100644 --- a/packages/cli/src/hook-body.ts +++ b/packages/cli/src/hook-body.ts @@ -26,9 +26,11 @@ * * ## 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 + * `./console` used to point its subpath straight at `dist/utils/console.js`, so + * every export that module gained was public the moment it landed — the defect + * #16046 then repaired by giving that subpath a barrel of its own, in the shape + * of this one. 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, diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index eee8fe557b..22d9c5763e 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -1,7 +1,16 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Console UI Integration Utilities + * Console UI Integration Utilities — an INTERNAL module (#16046). + * + * ⚠️ This module is not a published entry point. Until #16046 the `./console` + * subpath pointed straight at this file's build output, so all 13 of its + * top-level exports were public API and any export it gained became public on + * landing. The published face is now `src/console.ts`, which re-exports three + * of them by name; the other ten are reachable only from inside this package. + * Adding an export here no longer publishes anything, and ⛔ moving one into + * the public face means editing that barrel and its pin + * (`test/published-subpath-console.pin.test.ts`) on purpose, with a changeset. * * Mirrors `studio.ts` / `account.ts` but for the opinionated, fork-ready * runtime console. The Console SPA is mounted at `/_console/` by every diff --git a/packages/cli/test/published-subpath-console.pin.test.ts b/packages/cli/test/published-subpath-console.pin.test.ts new file mode 100644 index 0000000000..359b80fce2 --- /dev/null +++ b/packages/cli/test/published-subpath-console.pin.test.ts @@ -0,0 +1,633 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/cli/console` is a public subpath 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 can exist. + * + * ## The defect (#16046) + * + * 17.3.0 gave this package an `exports` map (#13123) and ratified `./console` + * for cloud's `objectos-runtime` (#13662). The subpath pointed straight at + * `dist/utils/console.js` — an INTERNAL module — and it had **no surface pin at + * all**, neither names nor shapes. The only assertion anywhere in the tree was + * that `./console` *is a declared subpath*. + * + * So all 13 of that module's top-level exports were public API, and every export + * it gained afterwards was published the moment it landed: an accidental + * `export *` widening, or a symbol added for an internal reason, became a + * permanent public contract silently, with nothing that would notice. That is + * strictly weaker than the position #15630 repaired on `./hook-body`, where a + * pin at least held the ratified names. + * + * The remedy is the one #15630 applied one door over: the subpath stays — cloud + * depends on it, and sealing it would be #13662 and #15325 a third time — but it + * now points at `src/console.ts`, a barrel that re-exports the intended public + * face BY NAME, and this file holds the packed `.d.ts` to exactly those names + * AND their shapes. + * + * ## 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 a + * dependent can reach. Every assertion below is taken from a `pnpm pack`ed + * tarball unpacked into a consumer's `node_modules`, which is what a downstream + * `npm install` actually receives. + * + * ## The two halves, and why BOTH are here + * + * A name pin alone would have passed #15630's defect green, so the shapes are + * compiled by a real consumer against the packed `.d.ts`. And a shape pin alone + * would say nothing about the ten names this card RETIRED, so their absence is + * asserted too — at run time, in the shipped types, and in the compiler. A + * retirement nothing holds down is a retirement that reverts on the next + * refactor that finds the barrel convenient. + */ + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +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'; + +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, '..'); + +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; + +const CONSOLE_SUBPATH = './console'; +const CONSOLE_SPECIFIER = `${PACKAGE_NAME}/console`; +/** The pre-17.3.0 spelling cloud used — the door that is gone and STAYS gone. */ +const DEEP_PATH_SPECIFIER = `${PACKAGE_NAME}/dist/utils/console.js`; + +/** + * The whole public face, by name. Three names, not a barrel over an internal + * module: `src/console.ts` re-exports these and nothing else, so an export + * `utils/console.ts` gains tomorrow is NOT public until someone edits both that + * file and this list. + * + * These three and no others because the one ledgered out-of-repo consumer + * (`packages/qa/downstream-contract/consumer-specifiers.ledger.json` — cloud's + * `objectos-runtime` node server) mounts the Console through exactly them. + */ +const PUBLIC_SURFACE = ['createConsoleStaticPlugin', 'hasConsoleDist', 'resolveConsolePath']; + +/** + * The ten names #16046 retired from this subpath — every remaining top-level + * export of `utils/console.ts`, which had 13. + * + * ⛔ Nothing here was DELETED. `utils/console.ts` still exports all thirteen and + * every in-package caller still imports it directly; what these ten lost is only + * the ability to be named through a PUBLISHED specifier. Re-admitting one is a + * deliberate act — edit `src/console.ts`, edit `PUBLIC_SURFACE` above, and write + * the changeset — never a side effect. + * + * `decideConsoleMount` and `createRuntimeAssetsPlugin` are here because the + * #16046 ruling admitted them "only if the implementer finds an intended + * external caller", and the measurement found none: every reference to either + * name in this repo is inside `packages/cli/`, the consumer-specifier ledger + * names neither, and `decideConsoleMount`'s own docblock says no cloud + * deployment can reach the refusal it exists to produce. + */ +const RETIRED_FROM_THIS_SUBPATH = [ + 'CONSOLE_PATH', + 'ConsoleShaDrift', + 'DRIFT_OVERRIDE_ENV', + 'ResolveConsoleOptions', + 'createRuntimeAssetsPlugin', + 'decideConsoleMount', + 'detectConsoleShaDrift', + 'formatConsoleShaDriftRefusal', + 'formatConsoleShaDriftWarning', + 'isConsoleVersionCompatible', +]; + +/** The eight of those that exist at run time as VALUES (the other two are types). */ +const RETIRED_RUNTIME_NAMES = [ + 'CONSOLE_PATH', + 'DRIFT_OVERRIDE_ENV', + 'createRuntimeAssetsPlugin', + 'decideConsoleMount', + 'detectConsoleShaDrift', + 'formatConsoleShaDriftRefusal', + 'formatConsoleShaDriftWarning', + 'isConsoleVersionCompatible', +]; + +/** + * 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. Either way this list is edited on purpose, in the same + * PR, with a changeset. + */ +const RATIFIED_SUBPATHS = ['.', CONSOLE_SUBPATH, './hook-body', './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 two of the three helpers so the answer is about the + * code that ships, not about a path string. + * + * The calls are chosen to need no console dist and to touch no network or + * filesystem state: `hasConsoleDist` on a path that cannot exist must answer + * `false`, and `createConsoleStaticPlugin` must hand back a plugin object whose + * `start` is never invoked here. + */ +const PROBE_SOURCE = ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const [subpath, 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: { subpath: tryRequire(subpath), deep: tryRequire(deep) }, + import: { subpath: tryImport(subpath), deep: tryImport(deep) }, + runtime: null, +}; +try { + const mod = await import(subpath); + const plugin = mod.createConsoleStaticPlugin('/nonexistent-console-dist'); + out.runtime = { + keys: Object.keys(mod).sort(), + hasConsoleDistOnAMissingPath: mod.hasConsoleDist('/nonexistent-console-dist'), + pluginName: plugin?.name, + pluginMembers: Object.keys(plugin ?? {}).sort(), + }; +} catch (e) { + out.runtime = { error: e?.code ?? String(e) }; +} +process.stdout.write(JSON.stringify(out)); +`; + +/** + * The consumer's `tsconfig.json`. Three options carry the whole question: + * + * - `moduleResolution: nodenext` is what makes this a test of the PUBLISHED + * door — it reads the `exports` map's `types` condition, so a condition that + * stops resolving is `TS2307` here, exactly as it would be for a real + * dependent. `bundler` would answer a laxer question under the same name. + * - `strict` — a shape assertion under a non-strict program is a weaker + * assertion, and `strictNullChecks` in particular is load-bearing for + * `resolveConsolePath`'s `string | null` return. + * - `skipLibCheck` — the consumer directory installs this ONE tarball, so the + * `.d.ts` files of the workspace dependencies it references are absent by + * construction. Checking them would report their absence, which is a fact + * about the fixture's cupboard and not about the public surface. It does not + * weaken anything asserted below: `conformance.ts` is not a declaration + * file, so every diagnostic in IT is still reported. + * + * `types: []` keeps `@types/node` out of the program — the fixture reaches for + * no Node global, and a missing ambient package would otherwise red for a reason + * that has nothing to do with this surface. + */ +const CONSUMER_TSCONFIG = JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + target: 'es2022', + lib: ['ES2022'], + module: 'nodenext', + moduleResolution: 'nodenext', + types: [], + }, + include: ['conformance.ts'], + }, + null, + 2, +); + +/** + * The consumer the `.d.ts` is compiled for (#15630's shape). Written into the + * consumer directory rather than checked in under `test/` — this file's header + * says why. + * + * `Equals` is the invariant identity check, not an assignability check: two + * types satisfy it only when tsc considers them THE SAME, so a member added to + * an options bag reds as loudly as one removed. An assignability pin would let + * every widening through, and a widening on a published surface is the half this + * card exists to stop being silent. + * + * ⛔ Every `@ts-expect-error` below is a CONTROL and must stay unsatisfiable-on + * -purpose. If a real change makes one of them legal, the directive goes unused + * and tsc reports TS2578 — which is the pin telling you the contract moved, not + * a lint to silence. The ten retirement controls are the load-bearing ones here: + * they are the only thing standing between this subpath and the barrel it used + * to be. + */ +const CONFORMANCE_FIXTURE = ` +import { createConsoleStaticPlugin, hasConsoleDist, resolveConsolePath } from '@objectstack/cli/console'; + +type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +// --- The options bag, reached the only way a consumer still can ------------ +// \`ResolveConsoleOptions\` is retired from this subpath, so it has no importable +// NAME here. It is still \`resolveConsolePath\`'s parameter type, which means the +// shape stays reachable STRUCTURALLY — and that is exactly what a consumer who +// passes an object literal depends on. Pinning it through \`Parameters\` asserts +// the half that survived the retirement without re-publishing the name. +type ResolveOptions = NonNullable[0]>; +type DriftArgument = Parameters>[0]; + +type ResolveOptionsHasExactlyTheseFourFields = Expect>; +type ResolveOptionsMembersKeepTheirRatifiedTypes = Expect< + Equals< + ResolveOptions, + { + cwd?: string; + cliVersion?: string; + warn?: (message: string) => void; + onDrift?: (drift: DriftArgument) => void; + } + > +>; +type DriftShapeIsStillTheThreeStrings = Expect>; + +// --- The three ratified signatures, exactly -------------------------------- +type ResolveConsolePathKeepsItsRatifiedSignature = Expect string | null>>; +type HasConsoleDistKeepsItsRatifiedSignature = Expect boolean>>; +type CreateConsoleStaticPluginKeepsItsRatifiedSignature = Expect< + Equals< + typeof createConsoleStaticPlugin, + ( + distPath: string, + options?: { isDev?: boolean; rootRedirect?: boolean }, + ) => { name: string; init: () => Promise; start: (ctx: any) => Promise } + > +>; + +// --- The consumer limb: code a real dependent writes, compiled for real ---- +// The assertions above answer "did the shape move". This answers the question +// the shape exists for — can cloud's node server still WRITE what it writes. +export async function mountConsole(app: { use: (p: unknown) => Promise }): Promise { + const consolePath: string | null = resolveConsolePath({ cwd: '/srv/app', warn: (m: string) => void m }); + if (consolePath === null) return 'unresolved'; + if (!hasConsoleDist(consolePath)) return 'no-dist'; + const plugin = createConsoleStaticPlugin(consolePath + '/dist', { isDev: false, rootRedirect: true }); + await app.use(plugin); + return plugin.name; +} + +// --- Controls: the ten names this card RETIRED from this subpath ----------- +// Each directive MUST fire. An unused one is TS2578, which is this pin saying a +// retired name came back onto the published surface. +// @ts-expect-error CONTROL — CONSOLE_PATH is retired from this subpath +import { CONSOLE_PATH } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — isConsoleVersionCompatible is retired from this subpath +import { isConsoleVersionCompatible } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — ResolveConsoleOptions is retired from this subpath +import type { ResolveConsoleOptions } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — ConsoleShaDrift is retired from this subpath +import type { ConsoleShaDrift } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — detectConsoleShaDrift is retired from this subpath +import { detectConsoleShaDrift } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — formatConsoleShaDriftWarning is retired from this subpath +import { formatConsoleShaDriftWarning } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — formatConsoleShaDriftRefusal is retired from this subpath +import { formatConsoleShaDriftRefusal } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — DRIFT_OVERRIDE_ENV is retired from this subpath +import { DRIFT_OVERRIDE_ENV } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — decideConsoleMount is retired from this subpath (no intended external caller found) +import { decideConsoleMount } from '@objectstack/cli/console'; +// @ts-expect-error CONTROL — createRuntimeAssetsPlugin is retired from this subpath (no intended external caller found) +import { createRuntimeAssetsPlugin } from '@objectstack/cli/console'; + +// --- Controls: the shapes that stayed -------------------------------------- +// @ts-expect-error CONTROL — the deep dist/ path is sealed and stays sealed +import { resolveConsolePath as viaDeepPath } from '@objectstack/cli/dist/utils/console.js'; +// @ts-expect-error CONTROL — a field the options bag does not declare +const unknownOption = resolveConsolePath({ nope: 1 }); +// @ts-expect-error CONTROL — hasConsoleDist takes a required string +const distWithoutAPath = hasConsoleDist(); +// @ts-expect-error CONTROL — resolveConsolePath returns string | null, never a bare string +const nullabilityDropped: string = resolveConsolePath(); +`; + +/** The fixture, line-numbered, so a tsc diagnostic's line points at something. */ +function numbered(source: string): string { + const lines = source.split('\n'); + const width = String(lines.length).length; + return lines.map((line, i) => `${String(i + 1).padStart(width, ' ')} | ${line}`).join('\n'); +} + +interface Resolution { + ok: boolean; + path?: string; + code?: string; +} +interface ProbeResult { + require: { subpath: Resolution; deep: Resolution }; + import: { subpath: Resolution; deep: Resolution }; + runtime: + | { + keys: string[]; + hasConsoleDistOnAMissingPath: boolean; + pluginName: string; + pluginMembers: string[]; + } + | { 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. + */ +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) { + throw new Error(`pnpm pack exited ${res.status}\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`); + } + 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; +let typecheckDir: string; +let conformance: { status: number; diagnostics: string; programFiles: string[] }; + +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-console-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', 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)); + mkdirSync(scope, { recursive: true }); + installedRoot = join(consumer, 'node_modules', ...PACKAGE_NAME.split('/')); + renameSync(join(extractDir, 'package'), installedRoot); + + // ⛔ No dependency is borrowed from this workspace. The barrel and the module + // behind it import Node built-ins only, so what executes below executes with + // exactly the cupboard a real consumer of the tarball has. (`./hook-body`'s + // pin has to symlink `ts-morph`; this surface needs nothing, and helping it + // to something would be the same mistake that pin's own comment warns about.) + 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, CONSOLE_SPECIFIER, DEEP_PATH_SPECIFIER], { + cwd: consumer, + encoding: 'utf8', + 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; + + // The SHAPE half (#15630). A sibling directory, not `consumer` itself: its own + // `package.json` declares `type: module` so `nodenext` classifies the fixture + // as ESM (this package IS ESM-only, and a CJS-classified fixture would red + // with TS1479 — a fact about the fixture's own manifest, not about the public + // surface). Nothing of the probe's environment changes. + typecheckDir = join(consumer, 'typecheck'); + mkdirSync(typecheckDir); + writeFileSync( + join(typecheckDir, 'package.json'), + JSON.stringify({ name: 'objectstack-cli-console-consumer', private: true, type: 'module' }, null, 2), + ); + writeFileSync(join(typecheckDir, 'tsconfig.json'), CONSUMER_TSCONFIG); + writeFileSync(join(typecheckDir, 'conformance.ts'), CONFORMANCE_FIXTURE); + // The compiler is resolved from THIS package (a consumer brings its own tsc; + // the version question is not what this file pins), but it is spawned with the + // consumer directory as cwd, so what it RESOLVES it resolves from there. + const tscEntry = createRequire(import.meta.url).resolve('typescript/lib/tsc.js'); + // `--listFiles` is not decoration: a clean tsc run and a tsc run that compiled + // NOTHING both print nothing and both exit 0, so "no diagnostics" is only + // evidence once the program is known to contain the fixture AND the packed + // `.d.ts` it is supposed to be judging. The file list is what separates those + // two, and it is asserted below rather than assumed here. + const tsc = spawnSync(process.execPath, [tscEntry, '--pretty', 'false', '--listFiles', '-p', 'tsconfig.json'], { + cwd: typecheckDir, + encoding: 'utf8', + env: childEnv(), + }); + if (tsc.error) throw new Error(`tsc could not start: ${tsc.error.message}`); + // tsc interleaves the file list with the diagnostics on stdout. A listed file + // is a path that EXISTS; a diagnostic is `path(l,c): error TSxxxx: …`, which + // never does — so the split is by disk, not by a regex over prose. + const lines = `${tsc.stdout ?? ''}${tsc.stderr ?? ''}`.split('\n').map((l) => l.trim()).filter((l) => l !== ''); + const listed = new Set(lines.filter((l) => existsSync(l))); + conformance = { + status: tsc.status ?? -1, + diagnostics: lines.filter((l) => !listed.has(l)).join('\n'), + programFiles: [...listed].map((p) => realpathSync(p)), + }; +}, 120_000); + +afterAll(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); +}); + +describe('the exports map still opens ./console, and opens it onto the barrel (#16046)', () => { + 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 ./console at the dedicated barrel, NOT at the internal module it used to name', () => { + const entry = MANIFEST.exports[CONSOLE_SUBPATH]; + expect(entry, `${CONSOLE_SUBPATH} is not declared`).toBeTypeOf('object'); + expect( + entry, + 'pointing this subpath back at dist/utils/console.js republishes all 13 exports of an internal module, ' + + 'which is #16046 verbatim.', + ).toEqual({ types: './dist/console.d.ts', default: './dist/console.js' }); + }); +}); + +describe('the tarball ships what the door opens onto', () => { + it('carries the barrel, the internal module behind it and the manifest', () => { + expect(packedFiles).toEqual( + expect.arrayContaining(['dist/console.js', 'dist/console.d.ts', 'dist/utils/console.js', 'dist/utils/console.d.ts', 'package.json']), + ); + }); +}); + +describe('resolution from a consumer directory outside the workspace', () => { + it(`resolves ${CONSOLE_SPECIFIER} under the require condition to the barrel`, () => { + expect(probe.require.subpath).toEqual({ ok: true, path: join(installedRoot, 'dist', 'console.js') }); + }); + + it(`resolves ${CONSOLE_SPECIFIER} under the import condition to the same file`, () => { + expect(probe.import.subpath.ok, JSON.stringify(probe.import.subpath)).toBe(true); + expect(fileURLToPath(probe.import.subpath.path as string)).toBe(join(installedRoot, 'dist', 'console.js')); + }); + + 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 public surface is exactly three names', () => { + it('at run time: the three helpers, nothing else', () => { + expect(probe.runtime, 'the barrel did not import from the packed copy').not.toHaveProperty('error'); + expect((probe.runtime as { keys: string[] }).keys).toEqual(PUBLIC_SURFACE); + }); + + it('in the shipped types: the same three, and no star re-export that would make it a barrel over the module', () => { + const { names, starReExports } = declaredExports(join(installedRoot, 'dist', 'console.d.ts')); + expect( + starReExports, + 'a star re-export ratifies whatever utils/console.ts grows next — which is the whole of #16046', + ).toBe(0); + expect(names).toEqual(PUBLIC_SURFACE); + }); + + it('publishes NONE of the ten names this card retired — neither as a value nor in the types', () => { + const runtimeKeys = new Set((probe.runtime as { keys: string[] }).keys); + const stillRuntime = RETIRED_RUNTIME_NAMES.filter((n) => runtimeKeys.has(n)); + expect(stillRuntime, 'retired names reachable at run time through the published subpath').toEqual([]); + + const declared = new Set(declaredExports(join(installedRoot, 'dist', 'console.d.ts')).names); + const stillTyped = RETIRED_FROM_THIS_SUBPATH.filter((n) => declared.has(n)); + expect(stillTyped, 'retired names reachable at type level through the published subpath').toEqual([]); + }); + + it('retires exactly the names that are NOT public — the two lists partition the module, with nothing dropped', () => { + // The census is taken from the packed INTERNAL module rather than from a + // constant here, so a 14th export added to `utils/console.ts` lands in + // neither list and fails this — instead of silently being neither published + // nor recorded as retired. + const internal = declaredExports(join(installedRoot, 'dist', 'utils', 'console.d.ts')); + expect(internal.starReExports).toBe(0); + expect(internal.names).toEqual([...PUBLIC_SURFACE, ...RETIRED_FROM_THIS_SUBPATH].sort()); + }); +}); + +describe('the helpers that answer from the packed copy are the platform\'s own', () => { + it('answers hasConsoleDist(false) for a path that cannot exist, from the tarball', () => { + const runtime = probe.runtime as Extract; + expect(runtime.hasConsoleDistOnAMissingPath).toBe(false); + }); + + it('builds the console static plugin with the id the host kernel registers', () => { + const runtime = probe.runtime as Extract; + expect(runtime.pluginName).toBe('com.objectstack.console-static'); + expect(runtime.pluginMembers).toEqual(['init', 'name', 'start']); + }); +}); + +describe('the public surface still has the SHAPES a consumer compiles against (#15630)', () => { + // ⛔ This assertion comes FIRST on purpose. Zero diagnostics is the verdict the + // next test reads, and zero diagnostics is also what a program that compiled + // nothing prints — so the population has to be established before the silence + // over it means anything. + it('put the fixture AND the packed .d.ts in the program — not the workspace source, not nothing', () => { + const real = (p: string): string => realpathSync(p); + expect(conformance.programFiles, 'the fixture itself was never compiled').toContain(real(join(typecheckDir, 'conformance.ts'))); + expect( + conformance.programFiles, + 'the barrel was not reached — a `types` condition that stops resolving lands here', + ).toContain(real(join(installedRoot, 'dist', 'console.d.ts'))); + expect( + conformance.programFiles, + 'the shapes were read from somewhere other than the PACKED tarball', + ).toContain(real(join(installedRoot, 'dist', 'utils', 'console.d.ts'))); + // Nothing of this workspace may be in that program: a source-tree file would + // make every shape below a verdict about the checkout instead of about what + // ships. + expect(conformance.programFiles.filter((p) => p.startsWith(`${realpathSync(PACKAGE_ROOT)}/`))).toEqual([]); + }); + + it('compiles a real consumer against the PACKED .d.ts, reached through the exports map', () => { + expect( + conformance.diagnostics, + 'tsc reported diagnostics compiling the conformance fixture against the packed .d.ts. Either the public ' + + 'shape moved — in which case this is a BREAKING change to a published surface and the fixture is updated ' + + 'deliberately, with a changeset — or a CONTROL stopped firing (TS2578), which says the same thing from the ' + + `other side. A retirement control that stops firing means a retired name is public again. The fixture, ` + + `numbered:\n${numbered(CONFORMANCE_FIXTURE)}`, + ).toBe(''); + expect(conformance.status, 'tsc exited non-zero').toBe(0); + }); +}); From 6a7f10516554d7c76adac21b4eca1df789dae440 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:29:03 +0000 Subject: [PATCH 2/5] feat(cli): point ./console at a public barrel with a name-and-shape pin Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/olive-donkeys-repeat.md | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .changeset/olive-donkeys-repeat.md diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md new file mode 100644 index 0000000000..cc25a3d96e --- /dev/null +++ b/.changeset/olive-donkeys-repeat.md @@ -0,0 +1,38 @@ +--- +'@objectstack/cli': minor +--- + +feat(cli): point `@objectstack/cli/console` at a public barrel with a name-and-shape pin + +**BREAKING**: `@objectstack/cli/console` publishes three names instead of thirteen. Ten names it used to resolve no longer resolve through that subpath. + +The subpath pointed straight at `dist/utils/console.js` — an internal module — and carried no surface pin of any kind, neither names nor shapes. The only assertion anywhere in the tree was that `./console` *is a declared subpath*. So all thirteen of that module's top-level exports were public API, and every export it gained afterwards became a permanent public contract the moment it landed, silently. + +The subpath stays open and now points at a dedicated barrel, `dist/console.js`, which re-exports by name (no star) exactly the three helpers the one ledgered out-of-repo consumer uses to mount the Console SPA: + +- `resolveConsolePath` +- `hasConsoleDist` +- `createConsoleStaticPlugin` + +Those three keep their existing shapes exactly, so a consumer importing only them compiles unchanged. + +These ten are no longer reachable through `@objectstack/cli/console`: + +- `CONSOLE_PATH` +- `ConsoleShaDrift` +- `DRIFT_OVERRIDE_ENV` +- `ResolveConsoleOptions` +- `createRuntimeAssetsPlugin` +- `decideConsoleMount` +- `detectConsoleShaDrift` +- `formatConsoleShaDriftRefusal` +- `formatConsoleShaDriftWarning` +- `isConsoleVersionCompatible` + +Nothing was deleted. `utils/console.ts` still exports all thirteen and every in-package caller still imports it directly; what these ten lost is only the ability to be named through a published specifier. `ResolveConsoleOptions` in particular is still `resolveConsolePath`'s parameter type, so the options object a caller passes keeps working structurally — only the type's name is no longer importable from this subpath. + +`decideConsoleMount` and `createRuntimeAssetsPlugin` were retired on a measurement rather than by default: every reference to either name in this repo is inside `packages/cli`, the consumer-specifier ledger names neither, and `decideConsoleMount`'s own docblock scopes it to `isDev` and states that no published install can reach the refusal it exists to produce. + +`packages/cli/test/published-subpath-console.pin.test.ts` now holds the packed `.d.ts` to exactly the three names and their shapes, compiled by a real consumer outside the workspace, with a control per retired name. Re-admitting any of the ten is a deliberate, reviewed, `minor`-bumped edit to that barrel and that pin. + + From 76b1888046d96d47172dff436c597b350633ee39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 23:08:47 +0000 Subject: [PATCH 3/5] fix(cli): correct a false claim in the console-barrel docs and close the census blind spot Three corrections to the two commits before this one on this branch. Both of those commits stay; neither is amended. 1. A FALSE SENTENCE, in three places. The changeset, `src/console.ts` and the pin test header each said, of the state before this branch: "The only assertion anywhere in the tree was that `./console` *is a declared subpath*." That is wrong. Re-measured at 0ea5f9d9f79, TWO assertion families covered this subpath: `published-subpath-hook-body.pin.test.ts` held `./console` among the declared `exports` KEYS, and `packages/qa/downstream-contract/test/consumer-specifier-ledger.test.ts` held `@objectstack/cli/console` to RESOLVING from the packed tarball under both the require and import conditions, with the file behind it shipped. Both answer "is the door open". Neither can answer "what is behind it", so the defect this branch repairs is unchanged -- but the sentence describing it was not true, and a wrong citation is the class that cost this board a separate PR this week. 2. THE CENSUS COULD UNDERCOUNT. `declaredExports()` walked the packed `.d.ts` and silently skipped any export form it did not recognise. Measured on a probe declaring a function, an `export declare enum`, an `export declare namespace` and an `export default`, it named 2 of 4: the enum and the namespace were invisible. So the census test's own promise -- "a 14th export added to `utils/console.ts` lands in neither list and fails this" -- did not hold for either form: the equality would still have held, green, over a surface read short. That is this branch's own defect reproduced inside the instrument built to catch it. Unrecognised forms are now REPORTED rather than skipped, and all three call sites assert the list is empty. Reporting the kind closes the class instead of enumerating two more members of it. Verified after the change: the same probe now names 4 of 4, and the new limb fires on `export * as ns from` (NamespaceExport), `export =` (ExportAssignment) and a destructured declaration (ArrayBindingPattern), so it is a control that can fail. On the real packed files it reports 3 names for the barrel and 13 for the internal module, with nothing unrecognised. 3. PROVENANCE THAT CANNOT BE RE-DERIVED. `src/console.ts` justified excluding `decideConsoleMount` / `createRuntimeAssetsPlugin` partly on "objectui, the one sibling checkout reachable from the implementing container" -- a claim about a container, unverifiable from anywhere else. Replaced with a reading anyone can re-run, and with its control: GitHub code search over `org:objectstack-ai` returns hits for these names in this repository only, and `repo:objectstack-ai/objectui console` returns 1,640 hits from the same index, so objectui's zero is a measurement. `cloud` is now stated as NOT MEASURED rather than implied: the same control against it returns 0 hits with `incomplete_results: true`, i.e. the index does not cover it from this seat and no checkout is reachable. An unreachable repository is never "zero consumers"; for cloud the evidence is second-hand by construction. Also re-flows the paragraph the previous commit rewrapped in `hook-body.ts`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/olive-donkeys-repeat.md | 2 +- packages/cli/src/console.ts | 25 ++++-- packages/cli/src/hook-body.ts | 10 +-- .../published-subpath-console.pin.test.ts | 79 ++++++++++++++++--- 4 files changed, 93 insertions(+), 23 deletions(-) diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md index cc25a3d96e..93976dfd94 100644 --- a/.changeset/olive-donkeys-repeat.md +++ b/.changeset/olive-donkeys-repeat.md @@ -6,7 +6,7 @@ feat(cli): point `@objectstack/cli/console` at a public barrel with a name-and-s **BREAKING**: `@objectstack/cli/console` publishes three names instead of thirteen. Ten names it used to resolve no longer resolve through that subpath. -The subpath pointed straight at `dist/utils/console.js` — an internal module — and carried no surface pin of any kind, neither names nor shapes. The only assertion anywhere in the tree was that `./console` *is a declared subpath*. So all thirteen of that module's top-level exports were public API, and every export it gained afterwards became a permanent public contract the moment it landed, silently. +The subpath pointed straight at `dist/utils/console.js` — an internal module — and carried no surface pin of any kind, neither names nor shapes. Two assertions did exist and neither is one: `./console` was held among the declared `exports` keys, and the specifier was held to resolving from the packed tarball. Both answer *is the door open*; neither can answer *what is behind it*. So all thirteen of that module's top-level exports were public API, and every export it gained afterwards became a permanent public contract the moment it landed, silently. The subpath stays open and now points at a dedicated barrel, `dist/console.js`, which re-exports by name (no star) exactly the three helpers the one ledgered out-of-repo consumer uses to mount the Console SPA: diff --git a/packages/cli/src/console.ts b/packages/cli/src/console.ts index 364557b998..3a7c001293 100644 --- a/packages/cli/src/console.ts +++ b/packages/cli/src/console.ts @@ -7,8 +7,10 @@ * * Until this file landed, `./console` pointed its subpath straight at * `dist/utils/console.js` — an INTERNAL module with 13 top-level exports and no - * surface pin of any kind, neither names nor shapes. The only assertion that - * existed anywhere in the tree was that `./console` *is a declared subpath*. So + * surface pin of any kind, neither names nor shapes. Two assertions did exist + * (re-measured at `0ea5f9d9f79`): `./console` is among the declared `exports` + * KEYS, and the specifier RESOLVES from the packed tarball. Both answer *is the + * door open*; neither can answer *what is behind it*. So * every export that module gained was published the moment it landed: an * accidental `export *` widening, or a symbol added for an internal reason, * became public API silently, with nothing that would notice. @@ -54,12 +56,21 @@ * can reach the refusal." The sole ledgered consumer IS a cloud * deployment, so the source says the external caller cannot reach the * behaviour this function exists to produce. - * 4. `objectui`, the one sibling checkout reachable from the implementing - * container, imports `@objectstack/cli/console` zero times. + * 4. GitHub code search over `org:objectstack-ai` (2026-09-06) returns, for + * `@objectstack/cli/console` and for the two names themselves, hits in + * THIS repository only — zero in `objectui`. That zero counts only + * because it carries a control: `repo:objectstack-ai/objectui console` + * answers 1,640 hits from the same index in the same session, so objectui + * is genuinely indexed and its zero is a measurement rather than a silence. * - * ⚠️ The `cloud` checkout was NOT reachable from that container, so the reading - * that matters most is second-hand — the ledger's and the ruling's, not one - * this author took. If a consumer of either name ever surfaces, the remedy is + * ⚠️ `cloud` is NOT MEASURED, which is a different thing from zero. The same + * control run against it — `repo:objectstack-ai/cloud objectstack` — answers 0 + * hits with `incomplete_results: true`: the index does not cover that + * repository from this seat, and no checkout of it is reachable either. An + * unreachable repository never reads as "no consumers". So for `cloud` the + * evidence is second-hand BY CONSTRUCTION — the consumer-specifier ledger, + * which names exactly the three, and the #16046 ruling that reads it the same + * way. If a consumer of either name ever surfaces, the remedy is * the one #13123's body prescribes and #13662 applied: re-open the name here * deliberately, with a changeset, and ledger the consumer. ⛔ Not a deep * `dist/` import, and not a local reimplementation. diff --git a/packages/cli/src/hook-body.ts b/packages/cli/src/hook-body.ts index e1dcdad3f2..6e15a0190e 100644 --- a/packages/cli/src/hook-body.ts +++ b/packages/cli/src/hook-body.ts @@ -30,11 +30,11 @@ * every export that module gained was public the moment it landed — the defect * #16046 then repaired by giving that subpath a barrel of its own, in the shape * of this one. 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. + * 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. diff --git a/packages/cli/test/published-subpath-console.pin.test.ts b/packages/cli/test/published-subpath-console.pin.test.ts index 359b80fce2..7d33b39298 100644 --- a/packages/cli/test/published-subpath-console.pin.test.ts +++ b/packages/cli/test/published-subpath-console.pin.test.ts @@ -10,8 +10,17 @@ * 17.3.0 gave this package an `exports` map (#13123) and ratified `./console` * for cloud's `objectos-runtime` (#13662). The subpath pointed straight at * `dist/utils/console.js` — an INTERNAL module — and it had **no surface pin at - * all**, neither names nor shapes. The only assertion anywhere in the tree was - * that `./console` *is a declared subpath*. + * all**, neither names nor shapes. + * + * Two assertion families did exist — re-measured at `0ea5f9d9f79`, because an + * earlier draft of this header claimed there was exactly one and that was + * wrong. `published-subpath-hook-body.pin.test.ts` held `./console` among the + * declared `exports` KEYS, and + * `packages/qa/downstream-contract/test/consumer-specifier-ledger.test.ts` held + * `@objectstack/cli/console` to RESOLVING from the packed tarball under both + * conditions with the file behind it shipped. Both answer *is the door open*. + * Neither can answer *what is behind it* — which is the question this file + * adds, and the reason the correction changes nothing about the defect. * * So all 13 of that module's top-level exports were public API, and every export * it gained afterwards was published the moment it landed: an accidental @@ -389,29 +398,71 @@ function pnpmPack(destination: string): { filename: string; files: 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 } { +/** + * The names a `.d.ts` exports, read off its AST — no resolution, no program. + * + * ⛔ An export form this walk does not recognise is REPORTED in `unrecognized`, + * never skipped, and every caller asserts that list is empty. The difference is + * not theoretical: the silent-skip version of this function saw **2 of 4** + * exports on a probe `.d.ts` carrying a function, an `export declare enum`, an + * `export declare namespace` and an `export default` — the enum and the + * namespace were invisible to it. A 14th export of either form could then have + * landed in neither `PUBLIC_SURFACE` nor `RETIRED_FROM_THIS_SUBPATH` with the + * census below still green: this file's own defect, reproduced inside the + * instrument meant to catch it. Reporting the kind rather than enumerating more + * of them closes the CLASS — a form nobody has thought of yet reds too. + */ +function declaredExports(dtsPath: string): { names: string[]; starReExports: number; unrecognized: string[] } { const sf = ts.createSourceFile(dtsPath, readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest, true); const names: string[] = []; + const unrecognized: 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); + else unrecognized.push(ts.SyntaxKind[stmt.exportClause.kind]); + continue; + } + // `export default …` and `export = …` are neither a named declaration nor + // an export declaration; both put something on the public surface. + if (ts.isExportAssignment(stmt)) { + unrecognized.push(ts.SyntaxKind[stmt.kind]); 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 + (ts.isFunctionDeclaration(stmt) || + ts.isClassDeclaration(stmt) || + ts.isInterfaceDeclaration(stmt) || + ts.isTypeAliasDeclaration(stmt) || + ts.isEnumDeclaration(stmt) || + ts.isModuleDeclaration(stmt)) && + stmt.name && + ts.isIdentifier(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); + for (const d of stmt.declarationList.declarations) { + if (ts.isIdentifier(d.name)) names.push(d.name.text); + else unrecognized.push(ts.SyntaxKind[d.name.kind]); + } + } else { + unrecognized.push(ts.SyntaxKind[stmt.kind]); } } - return { names: names.sort(), starReExports }; + return { names: names.sort(), starReExports, unrecognized }; +} + +/** Every export of `path` was named — the census over it means what it says. */ +function expectEveryExportNamed(what: string, read: { unrecognized: string[] }): void { + expect( + read.unrecognized, + `${what} carries an export form this file cannot name, so any census over it UNDERCOUNTS and the ` + + 'surface it reports is smaller than the one that ships. Teach `declaredExports` the form — do not ' + + 'delete this assertion.', + ).toEqual([]); } let scratch: string; @@ -555,7 +606,9 @@ describe('the public surface is exactly three names', () => { }); it('in the shipped types: the same three, and no star re-export that would make it a barrel over the module', () => { - const { names, starReExports } = declaredExports(join(installedRoot, 'dist', 'console.d.ts')); + const read = declaredExports(join(installedRoot, 'dist', 'console.d.ts')); + expectEveryExportNamed('the packed barrel `.d.ts`', read); + const { names, starReExports } = read; expect( starReExports, 'a star re-export ratifies whatever utils/console.ts grows next — which is the whole of #16046', @@ -568,7 +621,9 @@ describe('the public surface is exactly three names', () => { const stillRuntime = RETIRED_RUNTIME_NAMES.filter((n) => runtimeKeys.has(n)); expect(stillRuntime, 'retired names reachable at run time through the published subpath').toEqual([]); - const declared = new Set(declaredExports(join(installedRoot, 'dist', 'console.d.ts')).names); + const typed = declaredExports(join(installedRoot, 'dist', 'console.d.ts')); + expectEveryExportNamed('the packed barrel `.d.ts`', typed); + const declared = new Set(typed.names); const stillTyped = RETIRED_FROM_THIS_SUBPATH.filter((n) => declared.has(n)); expect(stillTyped, 'retired names reachable at type level through the published subpath').toEqual([]); }); @@ -579,6 +634,10 @@ describe('the public surface is exactly three names', () => { // neither list and fails this — instead of silently being neither published // nor recorded as retired. const internal = declaredExports(join(installedRoot, 'dist', 'utils', 'console.d.ts')); + // ⛔ First: the census is only a partition if every export was NAMEABLE. An + // export form this walk cannot name is missing from both lists, and the + // equality below would still hold — green, over a surface read short. + expectEveryExportNamed('the packed internal module `.d.ts`', internal); expect(internal.starReExports).toBe(0); expect(internal.names).toEqual([...PUBLIC_SURFACE, ...RETIRED_FROM_THIS_SUBPATH].sort()); }); From 465262d7783c2c32e3dec06dce36eaed43569234 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 23:22:05 +0000 Subject: [PATCH 4/5] docs(cli): record the console pin's ablation result, and retire an earlier WIP note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records in the pin's own header what the two ablations measured, so the evidence that this pin discriminates lives beside the pin instead of only in a pull request body: the name half reds 4 of 14 (including TS2578 from a retirement control), and the shape half reds 1 of 14 with 2 x TS2344 while every name and count stays green — the half a names-only pin cannot see. This commit also exists to correct the FIRST commit on this branch. Its body says, in full: "WIP — changeset and gate runs still to come." That was true of the tree it described and is now false of this branch. Both halves have since landed: the changeset is `.changeset/olive-donkeys-repeat.md` (minor, listing every retired name), and the gates derived for this change set have been run. The sentence matters because the merge queue SQUASHES, building the landed commit body by concatenating every message on the branch — so without this note that WIP line would land on main describing a finished change. Nothing on this branch is work in progress. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../published-subpath-console.pin.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli/test/published-subpath-console.pin.test.ts b/packages/cli/test/published-subpath-console.pin.test.ts index 7d33b39298..75add739a8 100644 --- a/packages/cli/test/published-subpath-console.pin.test.ts +++ b/packages/cli/test/published-subpath-console.pin.test.ts @@ -52,6 +52,26 @@ * asserted too — at run time, in the shipped types, and in the compiler. A * retirement nothing holds down is a retirement that reverts on the next * refactor that finds the barrel convenient. + * + * ## Measured to discriminate, on both halves + * + * A pin is worth what it reds on, so both halves were ablated — mutation proven + * on disk, `packages/cli` REBUILT, and the mutation proven live in `dist/` via + * `scripts/ablation-dist-preflight.mjs`, before any verdict was read. + * + * NAME half — re-export `decideConsoleMount` from the barrel. 4 of 14 red: + * the run-time keys, the shipped types, the ten-retired assertion, and the + * conformance compile, the last as `TS2578: Unused '@ts-expect-error'` — + * the retirement control reporting that a retired name came back. + * SHAPE half — add one optional member to `ResolveConsoleOptions`, the + * options bag of a KEPT public name. Every name and count is unmoved, so + * 13 of 14 stay green and ONLY the conformance compile reds, with 2 × + * `TS2344` on the two options-bag `Equals` assertions. This is the half a + * names-only pin cannot see: the surface's leaves are identical and the + * component moved. + * + * Both restored under a trap, then rebuilt and re-checked `--absent` so no + * later run in this worktree measures a mutated `dist/`. */ import { spawnSync } from 'node:child_process'; From 679ed5aedf50c89b9241853840e27f51e183ed1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 00:12:51 +0000 Subject: [PATCH 5/5] docs(cli): state the console consumer reading at its measured strength in the changeset The changeset is the half of this branch that becomes CHANGELOG.md, and it had gone stale against `src/console.ts` in its own PR. Its ADR-0087 marker still said, in full (reflowed here to fit a commit body; the blob carries it on one line, and joining these lines on single spaces reproduces it byte for byte): There is also nothing to prescribe: the re-derived consumer reading found no importer of any of the ten, in this repo or in the one sibling checkout reachable, and the channel that would reach one is the compiler (TS2305 on the retired name), which is more precise than a ledger line. Commit 76b1888 had already retired that framing from `src/console.ts` -- it removed "the one sibling checkout reachable from the implementing container" as "a claim about a container, unverifiable from anywhere else" and replaced it with a reading anyone can re-run, plus an explicit NOT MEASURED for `cloud`. The changeset was not carried along, so the artifact that ships read as though the retirement had been consumer-verified across the reachable world, while the source file beside it said the opposite about the one repository the ruling's consumer reading actually rested on. Nothing mechanical reads the sentence; the reason it matters is that the decision to proceed was recorded publicly as acceptable BECAUSE the gap is declared NOT MEASURED rather than reported as a zero. A changelog that states it more strongly than that no longer matches the reasoning the decision rested on. What the marker says now, at exactly the strength each half was measured: objectui -- a real ZERO, re-derived here at the pinned `.objectui-sha` (a472b07167a3, which is the sha this repository pins at every commit on this branch and at origin/main). The specifier `cli/console` does not occur in that tree; none of the ten occurs as an identifier, except CONSOLE_PATH twice, both inside comment prose in one browser test and neither an import. Its control: 545 lines of the same tree DO import from the `@objectstack/` scope, so the corpus is live and the zero is a reading, not a silence. cloud -- NOT MEASURED, which is not the same thing as zero. The code-search index does not cover it from this seat (0 hits, `incomplete_results: true`) and no checkout of it is reachable. Every channel tried refused. An unreachable repository never reads as "no consumers"; a refusal is not an absence. The channel that would actually reach a surprised consumer is the compiler, TS2305 naming the retired symbol at the import site -- which is why no ledger prescription is owed. The retired-name list is untouched: the same ten, byte-identical. ALSO RECORDED, not repaired. An independent contract review drove this pin's `declaredExports` walk over 38 export forms and found nothing silent that is a module export name, with three residuals. They are now written in that function's own docblock, where whoever changes it will read them, rather than left in a review comment that scrolls away: 1. A named default is attributed to its LOCAL name -- `export default function df` reports `df` where the module publishes `default`. Contrived on a barrel that re-exports by name only, and every in-package importer would fail the build first, but it is the silent class this pin exists to prevent, so the repair is written down with it. 2. Overloads produce a duplicate name, so an added overload reds the partition test with the export set unchanged -- a false red, but a loud one, which is the only direction this file may fail in. 3. `export as namespace UMD;` is skipped silently, the one exception to the docblock's otherwise absolute promise. The census stays right in effect because a UMD global is not a module export name; the exception is now named instead of waiting to be discovered. Fixing any of the three would re-open an instrument that review verified form by form, and would owe that 38-form probe again. This round changes no executable line: 44 added lines under `packages/`, every one a comment, none removed. One review finding is DECLINED as stated, because it is not true of this tree. It reported that objectui could not be checked at the pinned sha, `.objectui-sha` being 67dadd602a3 and absent from the local clone. `.objectui-sha` is a472b07167a39e55491109e864bb5a54027dcfbd at all five commits on this branch, at the merge base and at origin/main; 67dadd602a3 was the pin two bumps ago, superseded on 2026-09-04 and again on 2026-09-05, and it is not a valid object in the local objectui clone at all. That clone is checked out at exactly the pinned sha, so the removal-safety step AGENTS.md prescribes DID run at the pin. Its result is now recorded in `src/console.ts` as a fifth reading, marked for what makes it different from the container claim that was retired: a sha this repository itself pins is re-runnable by anyone who checks objectui out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/olive-donkeys-repeat.md | 2 +- packages/cli/src/console.ts | 12 +++++++ .../published-subpath-console.pin.test.ts | 32 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md index 93976dfd94..fa5f075988 100644 --- a/.changeset/olive-donkeys-repeat.md +++ b/.changeset/olive-donkeys-repeat.md @@ -35,4 +35,4 @@ Nothing was deleted. `utils/console.ts` still exports all thirteen and every in- `packages/cli/test/published-subpath-console.pin.test.ts` now holds the packed `.d.ts` to exactly the three names and their shapes, compiled by a real consumer outside the workspace, with a control per retired name. Re-admitting any of the ten is a deliberate, reviewed, `minor`-bumped edit to that barrel and that pin. - + diff --git a/packages/cli/src/console.ts b/packages/cli/src/console.ts index 3a7c001293..fd83ba39cd 100644 --- a/packages/cli/src/console.ts +++ b/packages/cli/src/console.ts @@ -62,6 +62,18 @@ * because it carries a control: `repo:objectstack-ai/objectui console` * answers 1,640 hits from the same index in the same session, so objectui * is genuinely indexed and its zero is a measurement rather than a silence. + * 5. The PINNED sibling checkout — the reading AGENTS.md prescribes before a + * removal ships, taken at `.objectui-sha` (`a472b07167a3`) rather than at + * whatever that checkout's HEAD happens to be. The specifier + * `cli/console` does not occur in objectui at that sha, and none of the + * ten occurs as an identifier, except `CONSOLE_PATH` twice — both inside + * comment prose in one browser test, neither an import. Its control: 545 + * lines of that same tree DO import from the `@objectstack/` scope, so the + * corpus is live and the zero is a reading. Note what this is NOT: an + * earlier draft of this docblock justified the exclusion on "the one + * sibling checkout reachable from the implementing container", and that + * was retired for being unverifiable from anywhere else. A sha this + * repository itself pins is re-runnable by anyone who checks objectui out. * * ⚠️ `cloud` is NOT MEASURED, which is a different thing from zero. The same * control run against it — `repo:objectstack-ai/cloud objectstack` — answers 0 diff --git a/packages/cli/test/published-subpath-console.pin.test.ts b/packages/cli/test/published-subpath-console.pin.test.ts index 75add739a8..5f4a028202 100644 --- a/packages/cli/test/published-subpath-console.pin.test.ts +++ b/packages/cli/test/published-subpath-console.pin.test.ts @@ -431,6 +431,38 @@ function pnpmPack(destination: string): { filename: string; files: string[] } { * census below still green: this file's own defect, reproduced inside the * instrument meant to catch it. Reporting the kind rather than enumerating more * of them closes the CLASS — a form nobody has thought of yet reds too. + * + * ## Where that promise stops — measured, then recorded rather than repaired + * + * An independent contract review extracted this walk byte-identically and drove + * it over 38 export forms, predictions written before each run. Nothing that is + * a module export NAME is silent. Three residuals stand. They are written down + * here instead of fixed because fixing one re-opens an instrument whose + * behaviour that review verified form by form — whoever changes this function + * owes that 38-form probe again, and should read these three first: + * + * 1. A NAMED DEFAULT is attributed to its LOCAL name. `export default + * function df` reports `df`; the name the module actually publishes is + * `default`. Rewriting an `export function X` into an + * `export default function X` would therefore keep the census green while + * the published surface changed — the silent class this file exists to + * prevent. Contrived HERE, which is why it is recorded and not repaired: + * the barrel re-exports by name only, `utils/console.ts` has no default + * export, and every in-package importer of `X` would fail the build first. + * The repair, when someone wants it: push `default` when the + * `DefaultKeyword` modifier is present. `export default X;` — an + * identifier with no declaration — is NOT affected; that is an + * `ExportAssignment` and is already reported. + * 2. OVERLOADS produce a duplicate name (`['ov', 'ov']`), so adding an + * overload to any of the 13 reds the partition test with the export set + * unchanged. A FALSE red — but a loud one, and loud is the only direction + * this file is allowed to fail in. + * 3. `export as namespace UMD;` IS skipped silently — the one exception to + * the paragraph above. It parses as a `NamespaceExportDeclaration`, which + * carries no `ExportKeyword` modifier, so the `exported` test below drops + * it before either limb sees it. The census stays right in EFFECT, because + * a UMD global is not a module export name; it is named here so the + * exception is read rather than discovered. */ function declaredExports(dtsPath: string): { names: string[]; starReExports: number; unrecognized: string[] } { const sf = ts.createSourceFile(dtsPath, readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest, true);