From 462a273658d20fcc9cea666c3803f2eea52ed067 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:44:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(cli):=20the=20option-B=20acceptance=20?= =?UTF-8?q?pin=20=E2=80=94=20a=20two-package=20fixture=20booted=20through?= =?UTF-8?q?=20every=20entry=20path=20(#15004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader program 1/4 of the ADR-0130 D4 option-B ruling (#14512 comment 5528589044). Option B removes the flattened top-level collections from a multi-package artifact so `packages[]` carries each definition once; the ruled order is readers first, emitter last, and the failure mode that order exists to contain is a reader nobody enumerated — silent, because the collection is simply absent and nothing throws. This commit adds the pin that makes that loud, and nothing else: three new test files, zero production files. - `test/fixtures/option-b-collection-zoo.ts` — two ordinary `defineStack` packages carrying one member of every collection family, composed with `manifest: 'preserve'`, in both shapes. The key set separating the shapes is DERIVED from `ObjectStackDefinitionSchema` ∩ `AssembledPackageBodySchema` rather than transcribed, so a collection family added later joins the probe automatically. - `test/fixtures/option-b-reader-probe.ts` — the instrument. Every row calls a reader this repo ships, or boots a real kernel carrying the real `AppPlugin` and reports what that plugin handed to a subsystem. No row reads `bundle.` and calls it a measurement. - `test/option-b-reader-acceptance.pin.test.ts` — the pin. `OPTION_B_LOSSES` records exactly which subsystems lose their collection today; the assertion is set equality, shrink-only, audited in both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../test/fixtures/option-b-collection-zoo.ts | 260 +++++++++++ .../test/fixtures/option-b-reader-probe.ts | 406 ++++++++++++++++++ .../option-b-reader-acceptance.pin.test.ts | 264 ++++++++++++ 3 files changed, 930 insertions(+) create mode 100644 packages/cli/test/fixtures/option-b-collection-zoo.ts create mode 100644 packages/cli/test/fixtures/option-b-reader-probe.ts create mode 100644 packages/cli/test/option-b-reader-acceptance.pin.test.ts diff --git a/packages/cli/test/fixtures/option-b-collection-zoo.ts b/packages/cli/test/fixtures/option-b-collection-zoo.ts new file mode 100644 index 0000000000..16ccf6f84b --- /dev/null +++ b/packages/cli/test/fixtures/option-b-collection-zoo.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two-package COLLECTION ZOO the option-B acceptance probe boots + * (#15004, reader program 1/4 of the ADR-0130 D4 option-B ruling on #14512). + * + * Two ordinary `defineStack` packages — an App and a module that depends on it + * — carrying one member of every collection family a package can own, split + * across BOTH packages so no reader can pass by looking at one of them. The + * project is composed with `manifest: 'preserve'`, which is the only + * composition that produces `packages[]` (see + * `examples/app-multi-package/objectstack.config.ts`). + * + * ## The two shapes, and why the second one is DERIVED + * + * `additiveProject()` is what the platform emits today: every collection + * flattened to the top level, PLUS `packages[]` carrying the same definitions + * a second time. + * + * `optionBProject()` is what the ruled emitter half will emit: the flattened + * top level GONE, `packages[]` carrying everything exactly once. + * + * The key set that separates them is **derived from the two schemas**, never + * transcribed here — `ObjectStackDefinitionSchema` ∩ `AssembledPackageBodySchema` + * is precisely "the collections a package owns", and the complement is the + * seven artifact-envelope keys `packages/spec/src/assembled-package-body.test.ts` + * classifies. A hand-written list would be a third transcription of a set the + * implementation already refuses to transcribe, and — the half that matters + * here — a collection family added to the stack schema next month would join + * this probe automatically instead of silently sitting outside it. (#14877 is + * to publish that key set as an export; when it lands this module should read + * it instead of deriving it, and nothing else here changes.) + * + * ## What this fixture is NOT + * + * It is not a claim about what `composeStacks` emits. `optionBProject()` builds + * the option-B shape by STRIPPING the composed stack, in the fixture, precisely + * so the probe can measure readers against that shape while the producer stays + * additive — which is the ruled order (readers first, emitter last) and the + * reason this card touches zero production files. + */ + +import { + AssembledPackageBodySchema, + ObjectStackDefinitionSchema, + composeStacks, + defineStack, + type ObjectStackDefinition, +} from '@objectstack/spec'; + +const shapeKeys = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape); + +/** + * Every top-level key that is a PACKAGE-OWNED collection — i.e. every key the + * assembled package body also declares. Derived, per the module header. + * + * The complement (the artifact-envelope keys `manifest`, `packages`, `api`, + * `server`, `i18n`, `runtimeModule`, `onEnable`) is what an option-B artifact + * keeps at the top level. + */ +export const PACKAGE_OWNED_COLLECTION_KEYS: readonly string[] = (() => { + const bodyKeys = new Set(shapeKeys(AssembledPackageBodySchema)); + return shapeKeys(ObjectStackDefinitionSchema).filter((k) => bodyKeys.has(k)).sort(); +})(); + +/** The seven keys an option-B artifact still carries at its top level. */ +export const ARTIFACT_ENVELOPE_KEYS: readonly string[] = (() => { + const owned = new Set(PACKAGE_OWNED_COLLECTION_KEYS); + return shapeKeys(ObjectStackDefinitionSchema).filter((k) => !owned.has(k)).sort(); +})(); + +// ─── The named things every probe row asserts on ──────────────────────────── + +export const CORE_PACKAGE_ID = 'com.example.probe.core'; +export const ORDERS_PACKAGE_ID = 'com.example.probe.orders'; + +/** The datasource `datasourceMapping` routes the project's objects to. */ +export const PROBE_DATASOURCE = 'probe_primary'; +/** Relative to the project root — `resolve-project-database` anchors it there. */ +export const PROBE_DATASOURCE_FILE = '.objectstack/data/probe-primary.db'; +/** The `isDefault` permission set `appSecurityPluginOptions` must resolve. */ +export const PROBE_DEFAULT_PERMISSION_SET = 'probe_default_profile'; +export const PROBE_POSITION = 'probe_position'; +export const PROBE_GLOBAL_ACTION = 'probe_global_action'; +export const PROBE_OBJECT_ACTION = 'probe_object_action'; +export const PROBE_FUNCTION = 'probeSweep'; +/** What the one declared function says about itself — the half option B loses. */ +export const PROBE_FUNCTION_EFFECT = 'writes'; +export const PROBE_JOB = 'probe_nightly'; +export const PROBE_HOOK_OBJECT = 'probe_order'; +/** The object the one seed dataset targets — a dataset is named by its object. */ +export const PROBE_SEED_DATASET = 'probe_account'; +export const PROBE_LOCALE = 'en'; + +// ─── Package 1: the App ───────────────────────────────────────────────────── + +const coreStack = (): ObjectStackDefinition => + defineStack({ + manifest: { + id: CORE_PACKAGE_ID, + name: 'Option-B Probe Core', + namespace: 'probe', + version: '1.0.0', + type: 'app', + description: 'The App half of the option-B acceptance probe fixture', + }, + objects: [ + { + name: 'probe_account', + label: 'Probe Account', + pluralLabel: 'Probe Accounts', + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name', required: true }, + }, + // An OBJECT-EMBEDDED action. `collectBundleActions` walks + // `bundle.objects[i].actions` as well as the global list, so a reader + // that loses the top-level `objects` loses these too — the enumeration + // in #14512 comment 5523603341 measured exactly that widening. + actions: [ + { + name: PROBE_OBJECT_ACTION, + label: 'Probe Object Action', + objectName: 'probe_account', + type: 'script', + body: { language: 'js', source: 'return { ok: true };' }, + }, + ], + }, + ], + datasources: [ + { + name: PROBE_DATASOURCE, + label: 'Probe Primary', + driver: 'sqlite', + config: { filename: PROBE_DATASOURCE_FILE }, + active: true, + }, + ], + datasourceMapping: [ + { datasource: PROBE_DATASOURCE, default: true }, + ], + permissions: [ + { + name: PROBE_DEFAULT_PERMISSION_SET, + label: 'Probe Default Profile', + isDefault: true, + objects: { + probe_account: { allowRead: true }, + }, + }, + ], + positions: [ + { + name: PROBE_POSITION, + label: 'Probe Position', + }, + ], + translations: [ + { + [PROBE_LOCALE]: { + objects: { + probe_account: { label: 'Probe Account (translated)' }, + }, + }, + }, + ], + data: [ + { + object: PROBE_SEED_DATASET, + mode: 'upsert', + externalId: 'name', + records: [{ name: 'Seeded Probe Account' }], + }, + ], + }); + +// ─── Package 2: the module that depends on it ─────────────────────────────── + +const ordersStack = (): ObjectStackDefinition => + defineStack({ + manifest: { + id: ORDERS_PACKAGE_ID, + name: 'Option-B Probe Orders', + namespace: 'probe', + version: '1.0.0', + type: 'module', + description: 'The module half of the option-B acceptance probe fixture', + dependencies: { [CORE_PACKAGE_ID]: '^1.0.0' }, + }, + objects: [ + { + name: PROBE_HOOK_OBJECT, + label: 'Probe Order', + pluralLabel: 'Probe Orders', + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Number', required: true }, + }, + }, + ], + actions: [ + { + name: PROBE_GLOBAL_ACTION, + label: 'Probe Global Action', + type: 'script', + body: { language: 'js', source: 'return { ok: true };' }, + }, + ], + hooks: [ + { + name: 'probe_before_insert', + label: 'Probe Before Insert', + object: PROBE_HOOK_OBJECT, + events: ['beforeInsert'], + body: { language: 'js', source: 'return;' }, + }, + ], + // DECLARED, not bare: the entry carries what the function says about + // itself (`effect`) beside its callable. That declaration is the half an + // option-B artifact loses on the compiled path — `mergeRuntimeModule` + // re-supplies the CALLABLE from the sibling ESM module either way — so a + // bare `() => undefined` here would make the `functions` row report + // 1 -> 1 and read as coverage it does not have. + functions: { + [PROBE_FUNCTION]: { handler: () => undefined, effect: PROBE_FUNCTION_EFFECT }, + }, + jobs: [ + { + name: PROBE_JOB, + label: 'Probe Nightly', + schedule: { type: 'cron', expression: '0 3 * * *', timezone: 'UTC' }, + handler: PROBE_FUNCTION, + }, + ], + }); + +// ─── The two shapes ───────────────────────────────────────────────────────── + +/** Today's emitted shape: flattened top level PLUS `packages[]`. */ +export const additiveProject = (): ObjectStackDefinition => + composeStacks([ordersStack(), coreStack()], { manifest: 'preserve' }); + +/** + * The ruled option-B shape: `packages[]` carries every definition once, and the + * flattened top-level collections are gone. + * + * Built by stripping the composed project — see the module header for why the + * fixture, and not `composeStacks`, is what strips. + */ +export const optionBProject = (): ObjectStackDefinition => { + const composed = additiveProject() as Record; + const out: Record = {}; + const owned = new Set(PACKAGE_OWNED_COLLECTION_KEYS); + for (const [key, value] of Object.entries(composed)) { + if (!owned.has(key)) out[key] = value; + } + return out as ObjectStackDefinition; +}; diff --git a/packages/cli/test/fixtures/option-b-reader-probe.ts b/packages/cli/test/fixtures/option-b-reader-probe.ts new file mode 100644 index 0000000000..7bbbbe8dc6 --- /dev/null +++ b/packages/cli/test/fixtures/option-b-reader-probe.ts @@ -0,0 +1,406 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The measuring instrument behind the option-B acceptance pin (#15004). + * + * One function — {@link measureShape} — takes a project in ONE of the two + * shapes `option-b-collection-zoo.ts` produces and reports, per row, what each + * subsystem actually SAW. The pin beside it compares the two reports. + * + * ## Every row calls a real reader. None re-implements one. + * + * That distinction is the whole value of this file, so it is stated as a rule + * rather than left to inspection: a row either + * + * - invokes a reader this repo SHIPS (`collectBundleActions`, + * `resolveStandaloneDatabase`, `createStandaloneStack`, + * `appSecurityPluginOptions`, …) and reports its return value, or + * - boots a real kernel carrying the real `AppPlugin` and reports what that + * plugin HANDED to a subsystem (a job scheduled, a datasource connected, a + * mapping set, an i18n service registered, a seed dataset merged). + * + * ⛔ No row reads `bundle.` and calls that a measurement. A row + * shaped like that is a second copy of the very read the reader program is + * about to change: it would stay red forever after the reader beside it was + * fixed, which is the failure mode that makes a gate get deleted. + * + * ## Why a booted kernel for the `AppPlugin` half + * + * `AppPlugin` reads `jobs` / `data` / `translations` / `datasources` / + * `datasourceMapping` / `objects` inline inside a 950-line `start()`. There is + * no exported reader to call, and the fold that card 2/4 lands could be at the + * constructor, at each read, or anywhere between — so any probe written against + * `AppPlugin`'s INTERNALS is a bet on an implementation that does not exist yet. + * What cannot move is the far side: the plugin has to hand the collection to a + * subsystem, and the subsystems are ordinary kernel services. The recorder below + * IS those services, so these rows measure the contract rather than the code. + * + * ## The controls, and why they are here rather than in the pin + * + * `registryItems()` reports what the SchemaRegistry holds after the same boot. + * The registry is one of the readers that ALREADY resolves `packages[]` + * (`resolveArtifactPackageOrder`), so it must see the same items in both shapes + * — and measured, it does. That is this file's anti-vacuity control: it proves + * the option-B fixture really carries every definition under `packages[]`, so a + * row that reports zero is a READER losing a collection and never a fixture + * that shipped an empty package. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { + AppPlugin, + collectBundleActions, + collectBundleFunctionEntries, + collectBundleHooks, + createStandaloneStack, + loadArtifactBundle, + readSeedDatasets, + resolveStandaloneDatabase, +} from '@objectstack/runtime'; +import { appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; + +// The lowering itself, not a copy of it — reached as SOURCE, by relative path, +// because this module lives INSIDE `@objectstack/cli`. It is the same function +// `compile.ts` runs, so the artifact written below is the artifact `os build` +// would write (minus `docs`, which is filesystem input rather than stack input). +import { lowerCallables } from '../../src/utils/lower-callables.js'; + +import { + PROBE_DEFAULT_PERMISSION_SET, + PROBE_FUNCTION, + PROBE_FUNCTION_EFFECT, +} from './option-b-collection-zoo.js'; + +type Bag = Record; + +/** One row of the probe: what a named subsystem saw, at a named boundary. */ +export interface ProbeRow { + /** Stable id — ` · · `. Ledger key. */ + id: string; + /** Rendered observation, for the failure text. */ + observed: string; + /** True when the subsystem saw NOTHING of the collection it reads. */ + lost: boolean; +} + +const row = (id: string, observed: unknown, lost: boolean): ProbeRow => ({ + id, + observed: observed === undefined || observed === null ? 'none' : String(observed), + lost, +}); + +const countRow = (id: string, n: number): ProbeRow => row(id, n, n === 0); + +// ─── The compiled artifact, written the way `os build` writes it ──────────── + +/** + * Lower, validate and write `stack` to `/dist/objectstack.json`, plus the + * sibling ESM runtime module the build emits beside it when the stack carries + * callables. + * + * The sibling module is not decoration. `loadArtifactBundle` merges its + * `functions` map onto the bundle, which is the one thing on the compiled path + * that re-supplies a collection the flattened top level no longer carries — and + * measuring that required emitting it. See the `functions` row's note. + */ +export function writeCompiledArtifact(dir: string, stack: unknown): string { + const normalized = normalizeStackInput(stack as never); + const lowered = lowerCallables(normalized as never); + const parsed = ObjectStackDefinitionSchema.safeParse(lowered.lowered); + if (!parsed.success) { + throw new Error( + `option-b probe: the lowered fixture does not satisfy ObjectStackDefinitionSchema, so ` + + `\`os build\` would refuse to write it:\n` + + parsed.error.issues.slice(0, 10).map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'), + ); + } + const artifact = JSON.parse(JSON.stringify(parsed.data)) as Bag; + const distDir = join(dir, 'dist'); + mkdirSync(distDir, { recursive: true }); + + const refs = Object.keys(lowered.functions); + if (refs.length > 0) { + artifact.runtimeModule = './objectstack-runtime.mjs'; + writeFileSync( + join(distDir, 'objectstack-runtime.mjs'), + `export const functions = {${refs.map((r) => `${JSON.stringify(r)}: () => undefined`).join(', ')}};\n`, + ); + } + + const artifactPath = join(distDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact, null, 2)); + return artifactPath; +} + +// ─── The recorder: the subsystems `AppPlugin` hands collections to ────────── + +interface Recording { + scheduledJobs: string[]; + connectedDatasources: string[]; + objectsAtConnect: string[]; + datasourceMappingRules: number; + i18nService: boolean; + seedDatasets: number; + registryObjects: string[]; +} + +/** + * A plugin that IS the subsystems: it registers the `job` and + * `datasource-connection` services `AppPlugin` looks up, wraps the engine's + * `setDatasourceMapping`, and — after the boot — reads back the i18n service + * and the shared seed-dataset registry (`readSeedDatasets`, the runtime's own + * exported accessor for the #3453 register-once-then-mutate list). + * + * Registered BEFORE `AppPlugin`, but that is not what makes the ordering work: + * the kernel completes every `init()` before any `start()`, and every read this + * records happens in `AppPlugin.start()`. + */ +function makeRecorder(rec: Recording) { + let ctxRef: Bag | undefined; + return { + name: 'com.objectstack.probe.option-b-recorder', + type: 'service' as const, + version: '1.0.0', + init: async (ctx: Bag) => { + ctxRef = ctx; + const register = ctx.registerService as (name: string, svc: unknown) => void; + register('job', { + schedule: async (name: string) => { + rec.scheduledJobs.push(name); + return { id: name }; + }, + cancel: async () => undefined, + list: async () => [], + }); + register('datasource-connection', { + connectDeclared: async (input: { + datasources?: Array<{ name?: string }>; + objects?: Array<{ name?: string }>; + }) => { + for (const d of input?.datasources ?? []) rec.connectedDatasources.push(String(d?.name)); + for (const o of input?.objects ?? []) rec.objectsAtConnect.push(String(o?.name)); + return (input?.datasources ?? []).map((d) => ({ name: String(d?.name), status: 'connected' })); + }, + }); + const getService = ctx.getService as (name: string) => Bag; + const ql = getService('objectql'); + const original = ql.setDatasourceMapping as ((rules: unknown[]) => unknown) | undefined; + ql.setDatasourceMapping = (rules: unknown[]) => { + rec.datasourceMappingRules += Array.isArray(rules) ? rules.length : 0; + return original?.call(ql, rules); + }; + }, + /** Read back everything that is observable only AFTER the boot completed. */ + collect: () => { + const ctx = ctxRef; + if (!ctx) return; + const getService = ctx.getService as (name: string) => unknown; + try { + rec.i18nService = !!getService('i18n'); + } catch { + rec.i18nService = false; + } + rec.seedDatasets = (readSeedDatasets(ctx) ?? []).length; + try { + const ql = getService('objectql') as { registry?: { getAllObjects?: () => Array<{ name?: string }> } }; + rec.registryObjects = (ql.registry?.getAllObjects?.() ?? []) + .map((o) => String(o?.name)) + .filter((n) => n.startsWith('probe_')) + .sort(); + } catch { + rec.registryObjects = []; + } + }, + }; +} + +/** + * Boot `bundle` on a lean real kernel through the real `AppPlugin` and report + * what each subsystem received. + * + * `skipSeedData: true` — the seed LOADER needs a driver this kernel has none + * of, and what this probe measures is whether the datasets reached the shared + * registry at all, which happens either way. + */ +async function bootAndRecord(bundle: unknown): Promise { + const rec: Recording = { + scheduledJobs: [], + connectedDatasources: [], + objectsAtConnect: [], + datasourceMappingRules: 0, + i18nService: false, + seedDatasets: 0, + registryObjects: [], + }; + const recorder = makeRecorder(rec); + const kernel = new LiteKernel({ logger: { level: 'error' } }); + kernel.use(new ObjectQLPlugin({}) as never); + kernel.use(recorder as never); + kernel.use(new AppPlugin(bundle, undefined, { skipSeedData: true }) as never); + await kernel.bootstrap(); + recorder.collect(); + await kernel.shutdown(); + return rec; +} + +// ─── The measurement ──────────────────────────────────────────────────────── + +export interface ShapeMeasurement { + rows: ProbeRow[]; + /** Anti-vacuity control — the registry already reads `packages[]`. */ + registryObjectsFromArtifact: string[]; + registryObjectsFromSource: string[]; +} + +/** + * Run every row over one shape. + * + * `projectRoot` must be an empty temp directory: `resolve-project-database` + * anchors a declared sqlite filename on it, and `createStandaloneStack` writes + * its state dir under it. + */ +export async function measureShape(project: unknown, projectRoot: string): Promise { + const artifactPath = writeCompiledArtifact(projectRoot, project); + const bundle = (await loadArtifactBundle(artifactPath, { unwrapEnvelope: true })) as Bag; + if (!bundle) throw new Error('option-b probe: loadArtifactBundle returned null for a file it just wrote'); + + const rows: ProbeRow[] = []; + + // ── B1 · the compiled-artifact load, and the runtime readers it feeds ───── + + rows.push(countRow( + 'B1 · runtime collectBundleActions (action dispatch registration) · actions + objects[].actions', + collectBundleActions(bundle).length, + )); + rows.push(countRow( + 'B1 · runtime collectBundleHooks (declarative hook binding) · hooks', + collectBundleHooks(bundle).length, + )); + + // The `functions` row is about the DECLARATION, not the callable, and it is a + // measured correction to the enumeration in #14512 comment 5523603341 — + // twice over. + // + // First: `mergeRuntimeModule` writes the sibling ESM module's handler map + // onto `bundle.functions` unconditionally, so on the compiled path the + // CALLABLES survive an option-B artifact. A row counting entries here would + // report 1 -> 1 and read as coverage it does not have. + // + // Second, and sharper: what the top level carried was `{ handler, effect }`, + // and the module supplies a BARE callable — which `normalizeFlowFunctionEntry` + // then defaults to `effect: 'pure'`. So the loss is not an absence a + // presence-check could see. A declared WRITER comes back through this reader + // as a pure function, which is #4396's silent un-declaring in a third + // spelling: the function still registers, still runs, and its writes are + // still counted as none. The row therefore asserts the VALUE. + const fnEntries = collectBundleFunctionEntries(bundle) as Record; + const declaredEffect = fnEntries[PROBE_FUNCTION]?.effect; + rows.push(row( + 'B1 · runtime collectBundleFunctionEntries (declared function effect) · functions', + declaredEffect, + declaredEffect !== PROBE_FUNCTION_EFFECT, + )); + + // ── B5 · resolve-project-database, upstream of every candidate fold ─────── + + const db = resolveStandaloneDatabase({ projectRoot, artifactPath } as never) as { + source: string; + datasourceName?: string; + }; + rows.push(row( + 'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources', + `${db.source}${db.datasourceName ? `:${db.datasourceName}` : ''}`, + db.source !== 'config-datasource', + )); + + // ── B1 · createStandaloneStack's surfaced keys ──────────────────────────── + + const standalone = (await createStandaloneStack({ projectRoot, artifactPath } as never)) as { + objects?: unknown[]; + permissions?: unknown[]; + positions?: unknown[]; + }; + rows.push(countRow( + 'B1 · createStandaloneStack surfaced objects (CLI tier resolution + engine/driver auto-registration) · objects', + standalone.objects?.length ?? 0, + )); + rows.push(countRow( + 'B1 · createStandaloneStack surfaced permissions (ADR-0056 D7) · permissions', + standalone.permissions?.length ?? 0, + )); + rows.push(countRow( + 'B1 · createStandaloneStack surfaced positions · positions', + standalone.positions?.length ?? 0, + )); + const artifactSideProfile = appSecurityPluginOptions(standalone)?.fallbackPermissionSet; + rows.push(row( + 'B1 · plugin-security appSecurityPluginOptions over the artifact-serve config (default permission set) · permissions', + artifactSideProfile, + artifactSideProfile !== PROBE_DEFAULT_PERMISSION_SET, + )); + + // ── B2/B3/B4 · the from-source config, as the CLI's config-module load + // boundaries hand it on. All three call sites load an ordinary module and + // pass its export through untouched, so the collection loss is not IN the + // loader — it is in the readers each of them then drives, which is what + // these rows are. + + const fromSourceProfile = appSecurityPluginOptions(project)?.fallbackPermissionSet; + rows.push(row( + 'B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions', + fromSourceProfile, + fromSourceProfile !== PROBE_DEFAULT_PERMISSION_SET, + )); + rows.push(countRow( + 'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions', + collectBundleActions(project as never).length, + )); + rows.push(countRow( + 'B2 · runtime collectBundleHooks over the from-source config · hooks', + collectBundleHooks(project as never).length, + )); + rows.push(countRow( + 'B2 · runtime collectBundleFunctionEntries over the from-source config · functions', + Object.keys(collectBundleFunctionEntries(project as never)).length, + )); + + // ── The booted AppPlugin, on BOTH entry paths ──────────────────────────── + + const bootedFromArtifact = await bootAndRecord(bundle); + const bootedFromSource = await bootAndRecord(project); + + const bootRows = (label: 'B1' | 'B2', what: string, r: Recording): void => { + rows.push(countRow(`${label} · AppPlugin job scheduling (${what}) · jobs`, r.scheduledJobs.length)); + rows.push(countRow( + `${label} · AppPlugin declared-datasource auto-connect (${what}) · datasources`, + r.connectedDatasources.length, + )); + rows.push(countRow( + `${label} · AppPlugin objects handed to datasource connect (${what}) · objects`, + r.objectsAtConnect.length, + )); + rows.push(countRow( + `${label} · AppPlugin ql.setDatasourceMapping (object routing) (${what}) · datasourceMapping`, + r.datasourceMappingRules, + )); + rows.push(row( + `${label} · AppPlugin translation loading into the i18n service (${what}) · translations`, + r.i18nService ? 'i18n service registered' : 'no i18n service', + !r.i18nService, + )); + rows.push(countRow(`${label} · AppPlugin seed datasets merged (${what}) · data`, r.seedDatasets)); + }; + bootRows('B1', 'compiled artifact', bootedFromArtifact); + bootRows('B2', 'from source', bootedFromSource); + + return { + rows, + registryObjectsFromArtifact: bootedFromArtifact.registryObjects, + registryObjectsFromSource: bootedFromSource.registryObjects, + }; +} diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts new file mode 100644 index 0000000000..0d8d06d833 --- /dev/null +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 / option B — **the acceptance pin for the reader program** + * (#15004, card 1/4; the program was ruled on #14512 comment 5528589044, + * maintainer 2026-09-03, decision batch #23). + * + * ## What option B is, and what its failure mode is + * + * A multi-package artifact today serializes every definition TWICE: once + * flattened to the top level, once inside `packages[i].manifest`. Option B + * removes the flattened copy, so `packages[]` carries each definition exactly + * once. The ruled order is **readers first, emitter last** — every reader + * learns to resolve `packages[]` while the artifact stays additive, and only + * then does `composeStacks` stop emitting the flat copy. + * + * The failure mode that order exists to contain is **a reader nobody + * enumerated**, and it is not hypothetical: the enumeration missed sites twice + * (#14512 comments 5523603341 and 5523741937), the second time including one in + * `@objectstack/plugin-security`, a package nobody had scoped. The symptom is + * SILENT. Nothing throws. The collection is simply absent, so a multi-package + * artifact boots clean having lost its declarative actions, its scheduled jobs, + * its seed data, its object routing or its default permission set. + * + * This file is the thing that makes that loud. In the ruling's own words, *"the + * reader half does not land without it."* + * + * ## How it works + * + * `option-b-collection-zoo.ts` composes two ordinary packages carrying one + * member of every collection family, in the two shapes — today's additive one + * and the ruled option-B one (flattened collections stripped, `packages[]` + * intact). `option-b-reader-probe.ts` runs every reader over each shape and + * reports what each subsystem SAW. Both entry paths are driven, because they + * share no seam: the compiled artifact is written to disk and loaded through + * `loadArtifactBundle` / `createStandaloneStack` / `resolve-project-database`, + * and the from-source config is driven exactly as `os serve` / `os dev` / + * `os build` / `os migrate` drive it after their own `loadConfig`. + * + * ## Why the losses are LEDGERED instead of simply asserted away + * + * The card's acceptance is that the option-B leg is RED today — a probe already + * green on option-B before any reader work has landed is a broken probe. A + * permanently red test cannot land, so the red is held the way every other + * measured-state gate in this repo holds one: `OPTION_B_LOSSES` records EXACTLY + * which rows lose today, and the pin asserts set EQUALITY against it. + * + * That gives all four directions at once, and the last two are the ones a bare + * `expect(...).toBe(0)` could not give: + * + * - a reader fixed by card 2/4, 3/4 or 4/4 ⇒ RED, naming the ledger line to + * DELETE. The ledger shrinks one subsystem at a time, exactly as the ruling + * describes, and reaching empty is what "the program is done" means. + * - a reader that REGRESSES ⇒ RED, naming the row. + * - a NEW reader nobody enumerated, arriving in a later change ⇒ RED on + * arrival, which is the entire point of the card. + * - the probe itself quietly measuring less ⇒ RED, because a row that stops + * being measured stops matching its ledger line. + * + * ⛔ `OPTION_B_LOSSES` is SHRINK-ONLY. Adding a line is never how a red build is + * fixed — teaching the reader to resolve `packages[]` is. A row that belongs in + * this ledger is a subsystem that silently loses a collection, and the reader + * program exists to remove it. + * + * ## Reverse-verified, per the card + * + * Run with `OPTION_B_LOSSES` emptied on `origin/main` `33681eaef`, the pin + * reports 21 subsystems losing their collections — the red output is recorded + * in this card's PR body. The additive leg is green in the same run, which is + * what makes the red a discrimination rather than a broken fixture. + * + * ## Boundaries — what this pin does NOT reach, stated rather than implied + * + * Three reads in the enumeration are expressions inline inside oclif command + * bodies, with no exported reader and no service on the far side, so no probe + * short of running the command reaches them. They are named here so the next + * reader does not mistake this file for full coverage of the enumeration: + * + * - `serve.ts` `config.objects` gating ObjectQL engine auto-registration, and + * the sibling gate for storage-driver auto-registration. The artifact half + * of both IS covered — `createStandaloneStack` surfaces `objects` precisely + * so that path can drive them, and that row is in the table — but the + * from-source half is reachable only through a real `os serve`. + * - `dev.ts` `readArtifactObjects()`, a module-private function that opens the + * artifact with its own `JSON.parse(readFileSync(...))` to diff the object + * inventory across recompiles. Non-fatal; it goes permanently empty. + * - `compile.ts`'s union authoring-rule run, which under option B would judge + * an empty stack. + * + * ⛔ Do not "cover" these by asserting `config.objects` in this file. A row + * shaped like the read it is watching is a second copy of the code the reader + * program is about to change: it stays red after the reader beside it is fixed, + * and a gate that cannot go green gets deleted. Cards 3/4 and 4/4 own those + * sites; a probe for them belongs beside whatever seam those cards introduce. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + additiveProject, + optionBProject, + ARTIFACT_ENVELOPE_KEYS, + PACKAGE_OWNED_COLLECTION_KEYS, +} from './fixtures/option-b-collection-zoo.js'; +import { measureShape, type ProbeRow, type ShapeMeasurement } from './fixtures/option-b-reader-probe.js'; + +/** + * The subsystems that silently lose their collection when the flattened top + * level is gone — MEASURED on `origin/main` `33681eaef`, not curated. + * + * ⛔ SHRINK-ONLY, audited in BOTH directions (see the header). Each line names + * a boundary, a subsystem and the collection it reads. + */ +const OPTION_B_LOSSES: readonly string[] = [ + 'B1 · AppPlugin declared-datasource auto-connect (compiled artifact) · datasources', + 'B1 · AppPlugin job scheduling (compiled artifact) · jobs', + 'B1 · AppPlugin objects handed to datasource connect (compiled artifact) · objects', + 'B1 · AppPlugin ql.setDatasourceMapping (object routing) (compiled artifact) · datasourceMapping', + 'B1 · AppPlugin seed datasets merged (compiled artifact) · data', + 'B1 · AppPlugin translation loading into the i18n service (compiled artifact) · translations', + 'B1 · createStandaloneStack surfaced objects (CLI tier resolution + engine/driver auto-registration) · objects', + 'B1 · createStandaloneStack surfaced permissions (ADR-0056 D7) · permissions', + 'B1 · createStandaloneStack surfaced positions · positions', + 'B1 · plugin-security appSecurityPluginOptions over the artifact-serve config (default permission set) · permissions', + 'B1 · runtime collectBundleActions (action dispatch registration) · actions + objects[].actions', + 'B1 · runtime collectBundleFunctionEntries (declared function effect) · functions', + 'B1 · runtime collectBundleHooks (declarative hook binding) · hooks', + 'B2 · AppPlugin declared-datasource auto-connect (from source) · datasources', + 'B2 · AppPlugin job scheduling (from source) · jobs', + 'B2 · AppPlugin objects handed to datasource connect (from source) · objects', + 'B2 · AppPlugin ql.setDatasourceMapping (object routing) (from source) · datasourceMapping', + 'B2 · AppPlugin seed datasets merged (from source) · data', + 'B2 · AppPlugin translation loading into the i18n service (from source) · translations', + 'B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions', + 'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions', + 'B2 · runtime collectBundleFunctionEntries over the from-source config · functions', + 'B2 · runtime collectBundleHooks over the from-source config · hooks', + 'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources', +]; + +const render = (rows: ProbeRow[], only?: (r: ProbeRow) => boolean): string => + rows + .filter((r) => (only ? only(r) : true)) + .map((r) => ` ${r.lost ? 'LOST ' : 'present'} ${r.id} = ${r.observed}`) + .join('\n'); + +describe('#15004 — option-B acceptance pin: every subsystem must see its collections in BOTH shapes', () => { + let roots: string[] = []; + let additive: ShapeMeasurement; + let optionB: ShapeMeasurement; + + beforeAll(async () => { + const mkRoot = (tag: string): string => { + const dir = mkdtempSync(join(tmpdir(), `os-option-b-${tag}-`)); + roots.push(dir); + return dir; + }; + additive = await measureShape(additiveProject(), mkRoot('additive')); + optionB = await measureShape(optionBProject(), mkRoot('optionb')); + }, 120_000); + + afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); + roots = []; + }); + + // ── The two shapes are what they claim to be ───────────────────────────── + + it('the two shapes differ in exactly the package-owned collections, derived from the schemas', () => { + const additiveKeys = Object.keys(additiveProject() as Record).sort(); + const optionBKeys = Object.keys(optionBProject() as Record).sort(); + + // A positive first, so the set difference below is a measurement rather + // than two empties agreeing. + expect(PACKAGE_OWNED_COLLECTION_KEYS.length).toBeGreaterThan(30); + expect(ARTIFACT_ENVELOPE_KEYS).toEqual( + ['api', 'i18n', 'manifest', 'onEnable', 'packages', 'runtimeModule', 'server'], + ); + + // Every key option B drops is a package-owned collection, and every key it + // keeps is an envelope key. Nothing else moved. + const dropped = additiveKeys.filter((k) => !optionBKeys.includes(k)); + expect(dropped.length).toBeGreaterThan(0); + expect(dropped.filter((k) => !PACKAGE_OWNED_COLLECTION_KEYS.includes(k))).toEqual([]); + expect(optionBKeys.filter((k) => !ARTIFACT_ENVELOPE_KEYS.includes(k))).toEqual([]); + }); + + it("CONTROL — the reader that already resolves `packages[]` sees the SAME items in both shapes", () => { + // `MetadataPlugin` / `ObjectQLPlugin` register through + // `resolveArtifactPackageOrder`, so the SchemaRegistry is served by + // `packages[]` and must not notice the difference. This is the anti-vacuity + // control for every LOST row below: it proves the option-B fixture really + // carries every definition under `packages[]`, so a zero is a reader losing + // a collection and never a fixture that shipped an empty package. + expect(additive.registryObjectsFromArtifact).toEqual(['probe_account', 'probe_order']); + expect(optionB.registryObjectsFromArtifact).toEqual(additive.registryObjectsFromArtifact); + expect(optionB.registryObjectsFromSource).toEqual(additive.registryObjectsFromSource); + expect(optionB.registryObjectsFromSource).toEqual(['probe_account', 'probe_order']); + }); + + // ── The baseline: green today, and it must stay green ──────────────────── + + it('BASELINE — on today\'s additive shape every subsystem sees its collections', () => { + const lost = additive.rows.filter((r) => r.lost); + expect( + lost.map((r) => r.id), + `The pin's BASELINE broke: ${lost.length} subsystem(s) see nothing on the shape the ` + + `platform emits TODAY. This is never an option-B finding — it means the fixture stopped ` + + `carrying a collection, or a reader regressed on the additive path.\n${render(additive.rows)}`, + ).toEqual([]); + // Anti-vacuity: a probe that measured no rows would satisfy the line above. + expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length); + }); + + // ── The pin ────────────────────────────────────────────────────────────── + + it('THE PIN — the subsystems that lose their collections under option B are EXACTLY the ledgered ones', () => { + const measured = optionB.rows.filter((r) => r.lost).map((r) => r.id).sort(); + const ledger = [...OPTION_B_LOSSES].sort(); + + const newlyLost = measured.filter((id) => !ledger.includes(id)); + const nowFixed = ledger.filter((id) => !measured.includes(id)); + + expect( + measured, + newlyLost.length > 0 + ? `A subsystem lost a collection that the ledger does not carry — this is the failure ` + + `#15004 exists to make loud. An option-B artifact reaches it with the collection ABSENT ` + + `and NOTHING THROWN.\n\n ${newlyLost.join('\n ')}\n\n` + + `⛔ Do not add these to OPTION_B_LOSSES. Teach the reader to resolve \`packages[]\` ` + + `(cards #15005 runtime / #15006 cli / #15007 plugin-security), or file the site as 5/4 ` + + `if it is a package the program never scoped.\n\nFull option-B report:\n${render(optionB.rows)}` + : `A ledgered subsystem now SEES its collections under option B — the reader program moved ` + + `forward. Delete these lines from OPTION_B_LOSSES:\n\n ${nowFixed.join('\n ')}\n\n` + + `When the ledger is empty the reader half is done and the emitter half (#14512) can land.`, + ).toEqual(ledger); + }); + + it('the ledger names only rows the probe actually measures', () => { + // A phantom ledger line would silently license a subsystem nobody watches. + const ids = new Set(optionB.rows.map((r) => r.id)); + expect(OPTION_B_LOSSES.filter((id) => !ids.has(id))).toEqual([]); + }); + + it('every one of the five boundaries is represented in the probe', () => { + // The enumeration's own finding is that a probe covering one boundary + // proves nothing about the others — B5 in particular runs 112 lines BEFORE + // `loadArtifactBundle` inside `createStandaloneStack` and is reached + // independently from `os dev`, `os start` and `os db clean`. + const ids = optionB.rows.map((r) => r.id); + for (const boundary of ['B1 · ', 'B2 · ', 'B5 · ']) { + expect(ids.filter((id) => id.startsWith(boundary)).length, `${boundary} has no row`) + .toBeGreaterThan(0); + } + // B3 (`os build`) and B4 (`os migrate`) load the config module through the + // same loader B2 does and hand the export on untouched, so their readers + // are the B2 rows. The header's "Boundaries" section states what that does + // and does not cover. + expect(ids.some((id) => id.startsWith('B5 · resolve-project-database'))).toBe(true); + }); +}); From e70392ba79460773d3e59b0a31446c8510b11263 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:14:55 +0000 Subject: [PATCH 2/2] test(cli): resolve the pin's three workspace deps to SOURCE in the test tsc program (#15004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-source-resolution` went red on the pin: `@objectstack/runtime`, `@objectstack/objectql` and `@objectstack/plugin-security` were newly reached by `tsconfig.test.json` and resolved through each package's `exports` map to `dist/index.d.ts` — a build artifact. For a pin whose whole subject is the readers the reader program is about to CHANGE, a type verdict about the last `pnpm build` is the vacuous-green direction that gate exists to refuse. Fixed the way the gate prescribes for a dep exposed through an EXISTING program: `paths` rules, never a widening of its shrink-only registry. Three star-less (exact-match) rules, so `@objectstack/objectql/core` keeps resolving through the package's own `exports` map untouched. Measured, because the registry's own notes record `paths` billing other packages' diagnostics into the consumer's ledger (PR #12570): with the workspace closure built, `pnpm --filter @objectstack/cli check:test-typecheck` reports 3 files / 28 errors / 6 pinned signatures — byte for byte the pre-existing `test-typecheck-debt.json`. Zero new diagnostics. Also corrects the pin header's reverse-verification count to the measured 24 and records the `effect: 'writes'` -> `effect: 'pure'` row, a loss no presence-check would have found. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../option-b-reader-acceptance.pin.test.ts | 15 ++++++++-- packages/cli/tsconfig.test.json | 30 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index 0d8d06d833..62ae5777d1 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -65,9 +65,18 @@ * ## Reverse-verified, per the card * * Run with `OPTION_B_LOSSES` emptied on `origin/main` `33681eaef`, the pin - * reports 21 subsystems losing their collections — the red output is recorded - * in this card's PR body. The additive leg is green in the same run, which is - * what makes the red a discrimination rather than a broken fixture. + * reports **24 subsystems** losing their collections, across all three packages + * the program scopes — the full red output is recorded in this card's PR body. + * In the SAME run the additive baseline and the `packages[]` control both pass, + * which is what makes the red a discrimination rather than a broken fixture. + * + * One row in that output is worth naming here, because it is a loss no + * presence-check would have found: on the compiled path a function declared + * `effect: 'writes'` comes back through `collectBundleFunctionEntries` as + * `effect: 'pure'`. `mergeRuntimeModule` re-supplies the CALLABLE from the + * sibling ESM module regardless of shape, and `normalizeFlowFunctionEntry` + * defaults a bare callable's effect — so the collection is not absent, it is + * WRONG, and the writer's writes are counted as none. * * ## Boundaries — what this pin does NOT reach, stated rather than implied * diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index cf31dbccbf..e87ce6c578 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -125,7 +125,35 @@ "module": "esnext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["node"] + "types": ["node"], + // [#15004] THREE bare-name rules, no star, for the three workspace deps the + // option-B acceptance pin reaches (`test/option-b-reader-acceptance.pin.test.ts` + // and its two fixtures). Without them tsc resolves those specifiers through + // each package's `exports` map to `dist/index.d.ts` — A BUILD ARTIFACT — so + // this suite's type verdict about the readers the reader program is about to + // CHANGE would be a verdict about the last `pnpm build` instead, which is + // exactly what `check:type-source-resolution` refuses. That gate's registry + // is shrink-only and widening it is not the fix; `paths` is, for a dep + // exposed through an EXISTING program, which `tsconfig.test.json` is. + // + // ⛔ No star and no directory target. A starred rule matches by PREFIX and + // would swallow subpath imports, resolving them THROUGH a file + // (`…/src/index.ts/core`, ENOTDIR at run time). A star-less rule matches + // the specifier EXACTLY, which is load-bearing here rather than incidental: + // `@objectstack/objectql` publishes a second subpath (`./core`), and that + // specifier deliberately keeps resolving through the package's own + // `exports` map, untouched by this table. `plugin-security` and `runtime` + // each publish only `"."`. And a target matching nothing on disk is worse + // than absent, because tsc then falls back to node resolution — i.e. to + // `dist` — silently. + // + // `rootDir` above already spans the repo root, so pulling these source trees + // into this program needs no widening. + "paths": { + "@objectstack/objectql": ["../objectql/src/index.ts"], + "@objectstack/plugin-security": ["../plugins/plugin-security/src/index.ts"], + "@objectstack/runtime": ["../runtime/src/index.ts"] + } }, "include": ["test/**/*", "vitest.config.ts", "vitest-tiers.ts", "vitest-tiers.fixtures.ts"], "exclude": ["node_modules", "dist"]