Skip to content

Commit 7bc5d37

Browse files
hotlongclaude
andauthored
test(cli): the option-B acceptance pin — a two-package fixture booted through every entry path with the flattened collections absent (#15039)
* test(cli): the option-B acceptance pin — a two-package fixture booted through every entry path (#15004) 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.<collection>` 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(cli): resolve the pin's three workspace deps to SOURCE in the test tsc program (#15004) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efb3513 commit 7bc5d37

4 files changed

Lines changed: 968 additions & 1 deletion

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The two-package COLLECTION ZOO the option-B acceptance probe boots
5+
* (#15004, reader program 1/4 of the ADR-0130 D4 option-B ruling on #14512).
6+
*
7+
* Two ordinary `defineStack` packages — an App and a module that depends on it
8+
* — carrying one member of every collection family a package can own, split
9+
* across BOTH packages so no reader can pass by looking at one of them. The
10+
* project is composed with `manifest: 'preserve'`, which is the only
11+
* composition that produces `packages[]` (see
12+
* `examples/app-multi-package/objectstack.config.ts`).
13+
*
14+
* ## The two shapes, and why the second one is DERIVED
15+
*
16+
* `additiveProject()` is what the platform emits today: every collection
17+
* flattened to the top level, PLUS `packages[]` carrying the same definitions
18+
* a second time.
19+
*
20+
* `optionBProject()` is what the ruled emitter half will emit: the flattened
21+
* top level GONE, `packages[]` carrying everything exactly once.
22+
*
23+
* The key set that separates them is **derived from the two schemas**, never
24+
* transcribed here — `ObjectStackDefinitionSchema` ∩ `AssembledPackageBodySchema`
25+
* is precisely "the collections a package owns", and the complement is the
26+
* seven artifact-envelope keys `packages/spec/src/assembled-package-body.test.ts`
27+
* classifies. A hand-written list would be a third transcription of a set the
28+
* implementation already refuses to transcribe, and — the half that matters
29+
* here — a collection family added to the stack schema next month would join
30+
* this probe automatically instead of silently sitting outside it. (#14877 is
31+
* to publish that key set as an export; when it lands this module should read
32+
* it instead of deriving it, and nothing else here changes.)
33+
*
34+
* ## What this fixture is NOT
35+
*
36+
* It is not a claim about what `composeStacks` emits. `optionBProject()` builds
37+
* the option-B shape by STRIPPING the composed stack, in the fixture, precisely
38+
* so the probe can measure readers against that shape while the producer stays
39+
* additive — which is the ruled order (readers first, emitter last) and the
40+
* reason this card touches zero production files.
41+
*/
42+
43+
import {
44+
AssembledPackageBodySchema,
45+
ObjectStackDefinitionSchema,
46+
composeStacks,
47+
defineStack,
48+
type ObjectStackDefinition,
49+
} from '@objectstack/spec';
50+
51+
const shapeKeys = (schema: unknown): string[] =>
52+
Object.keys((schema as { shape: Record<string, unknown> }).shape);
53+
54+
/**
55+
* Every top-level key that is a PACKAGE-OWNED collection — i.e. every key the
56+
* assembled package body also declares. Derived, per the module header.
57+
*
58+
* The complement (the artifact-envelope keys `manifest`, `packages`, `api`,
59+
* `server`, `i18n`, `runtimeModule`, `onEnable`) is what an option-B artifact
60+
* keeps at the top level.
61+
*/
62+
export const PACKAGE_OWNED_COLLECTION_KEYS: readonly string[] = (() => {
63+
const bodyKeys = new Set(shapeKeys(AssembledPackageBodySchema));
64+
return shapeKeys(ObjectStackDefinitionSchema).filter((k) => bodyKeys.has(k)).sort();
65+
})();
66+
67+
/** The seven keys an option-B artifact still carries at its top level. */
68+
export const ARTIFACT_ENVELOPE_KEYS: readonly string[] = (() => {
69+
const owned = new Set(PACKAGE_OWNED_COLLECTION_KEYS);
70+
return shapeKeys(ObjectStackDefinitionSchema).filter((k) => !owned.has(k)).sort();
71+
})();
72+
73+
// ─── The named things every probe row asserts on ────────────────────────────
74+
75+
export const CORE_PACKAGE_ID = 'com.example.probe.core';
76+
export const ORDERS_PACKAGE_ID = 'com.example.probe.orders';
77+
78+
/** The datasource `datasourceMapping` routes the project's objects to. */
79+
export const PROBE_DATASOURCE = 'probe_primary';
80+
/** Relative to the project root — `resolve-project-database` anchors it there. */
81+
export const PROBE_DATASOURCE_FILE = '.objectstack/data/probe-primary.db';
82+
/** The `isDefault` permission set `appSecurityPluginOptions` must resolve. */
83+
export const PROBE_DEFAULT_PERMISSION_SET = 'probe_default_profile';
84+
export const PROBE_POSITION = 'probe_position';
85+
export const PROBE_GLOBAL_ACTION = 'probe_global_action';
86+
export const PROBE_OBJECT_ACTION = 'probe_object_action';
87+
export const PROBE_FUNCTION = 'probeSweep';
88+
/** What the one declared function says about itself — the half option B loses. */
89+
export const PROBE_FUNCTION_EFFECT = 'writes';
90+
export const PROBE_JOB = 'probe_nightly';
91+
export const PROBE_HOOK_OBJECT = 'probe_order';
92+
/** The object the one seed dataset targets — a dataset is named by its object. */
93+
export const PROBE_SEED_DATASET = 'probe_account';
94+
export const PROBE_LOCALE = 'en';
95+
96+
// ─── Package 1: the App ─────────────────────────────────────────────────────
97+
98+
const coreStack = (): ObjectStackDefinition =>
99+
defineStack({
100+
manifest: {
101+
id: CORE_PACKAGE_ID,
102+
name: 'Option-B Probe Core',
103+
namespace: 'probe',
104+
version: '1.0.0',
105+
type: 'app',
106+
description: 'The App half of the option-B acceptance probe fixture',
107+
},
108+
objects: [
109+
{
110+
name: 'probe_account',
111+
label: 'Probe Account',
112+
pluralLabel: 'Probe Accounts',
113+
sharingModel: 'private',
114+
fields: {
115+
name: { name: 'name', type: 'text', label: 'Name', required: true },
116+
},
117+
// An OBJECT-EMBEDDED action. `collectBundleActions` walks
118+
// `bundle.objects[i].actions` as well as the global list, so a reader
119+
// that loses the top-level `objects` loses these too — the enumeration
120+
// in #14512 comment 5523603341 measured exactly that widening.
121+
actions: [
122+
{
123+
name: PROBE_OBJECT_ACTION,
124+
label: 'Probe Object Action',
125+
objectName: 'probe_account',
126+
type: 'script',
127+
body: { language: 'js', source: 'return { ok: true };' },
128+
},
129+
],
130+
},
131+
],
132+
datasources: [
133+
{
134+
name: PROBE_DATASOURCE,
135+
label: 'Probe Primary',
136+
driver: 'sqlite',
137+
config: { filename: PROBE_DATASOURCE_FILE },
138+
active: true,
139+
},
140+
],
141+
datasourceMapping: [
142+
{ datasource: PROBE_DATASOURCE, default: true },
143+
],
144+
permissions: [
145+
{
146+
name: PROBE_DEFAULT_PERMISSION_SET,
147+
label: 'Probe Default Profile',
148+
isDefault: true,
149+
objects: {
150+
probe_account: { allowRead: true },
151+
},
152+
},
153+
],
154+
positions: [
155+
{
156+
name: PROBE_POSITION,
157+
label: 'Probe Position',
158+
},
159+
],
160+
translations: [
161+
{
162+
[PROBE_LOCALE]: {
163+
objects: {
164+
probe_account: { label: 'Probe Account (translated)' },
165+
},
166+
},
167+
},
168+
],
169+
data: [
170+
{
171+
object: PROBE_SEED_DATASET,
172+
mode: 'upsert',
173+
externalId: 'name',
174+
records: [{ name: 'Seeded Probe Account' }],
175+
},
176+
],
177+
});
178+
179+
// ─── Package 2: the module that depends on it ───────────────────────────────
180+
181+
const ordersStack = (): ObjectStackDefinition =>
182+
defineStack({
183+
manifest: {
184+
id: ORDERS_PACKAGE_ID,
185+
name: 'Option-B Probe Orders',
186+
namespace: 'probe',
187+
version: '1.0.0',
188+
type: 'module',
189+
description: 'The module half of the option-B acceptance probe fixture',
190+
dependencies: { [CORE_PACKAGE_ID]: '^1.0.0' },
191+
},
192+
objects: [
193+
{
194+
name: PROBE_HOOK_OBJECT,
195+
label: 'Probe Order',
196+
pluralLabel: 'Probe Orders',
197+
sharingModel: 'private',
198+
fields: {
199+
name: { name: 'name', type: 'text', label: 'Number', required: true },
200+
},
201+
},
202+
],
203+
actions: [
204+
{
205+
name: PROBE_GLOBAL_ACTION,
206+
label: 'Probe Global Action',
207+
type: 'script',
208+
body: { language: 'js', source: 'return { ok: true };' },
209+
},
210+
],
211+
hooks: [
212+
{
213+
name: 'probe_before_insert',
214+
label: 'Probe Before Insert',
215+
object: PROBE_HOOK_OBJECT,
216+
events: ['beforeInsert'],
217+
body: { language: 'js', source: 'return;' },
218+
},
219+
],
220+
// DECLARED, not bare: the entry carries what the function says about
221+
// itself (`effect`) beside its callable. That declaration is the half an
222+
// option-B artifact loses on the compiled path — `mergeRuntimeModule`
223+
// re-supplies the CALLABLE from the sibling ESM module either way — so a
224+
// bare `() => undefined` here would make the `functions` row report
225+
// 1 -> 1 and read as coverage it does not have.
226+
functions: {
227+
[PROBE_FUNCTION]: { handler: () => undefined, effect: PROBE_FUNCTION_EFFECT },
228+
},
229+
jobs: [
230+
{
231+
name: PROBE_JOB,
232+
label: 'Probe Nightly',
233+
schedule: { type: 'cron', expression: '0 3 * * *', timezone: 'UTC' },
234+
handler: PROBE_FUNCTION,
235+
},
236+
],
237+
});
238+
239+
// ─── The two shapes ─────────────────────────────────────────────────────────
240+
241+
/** Today's emitted shape: flattened top level PLUS `packages[]`. */
242+
export const additiveProject = (): ObjectStackDefinition =>
243+
composeStacks([ordersStack(), coreStack()], { manifest: 'preserve' });
244+
245+
/**
246+
* The ruled option-B shape: `packages[]` carries every definition once, and the
247+
* flattened top-level collections are gone.
248+
*
249+
* Built by stripping the composed project — see the module header for why the
250+
* fixture, and not `composeStacks`, is what strips.
251+
*/
252+
export const optionBProject = (): ObjectStackDefinition => {
253+
const composed = additiveProject() as Record<string, unknown>;
254+
const out: Record<string, unknown> = {};
255+
const owned = new Set(PACKAGE_OWNED_COLLECTION_KEYS);
256+
for (const [key, value] of Object.entries(composed)) {
257+
if (!owned.has(key)) out[key] = value;
258+
}
259+
return out as ObjectStackDefinition;
260+
};

0 commit comments

Comments
 (0)