From e10a412e6f960612ced718abef1e6c64966e97ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 18:36:00 +0000 Subject: [PATCH 1/2] test(cli): pin the per-package leg's resolution context, both directions `compile.ts`'s `packageBodyAsStack` hands each package's body the artifact's own `packages[]` as resolution context, so a reference into an object a SIBLING package of the same artifact ships resolves while one no entry provides still errors. Neither half had an in-package pin: the firing direction was held only by `examples/app-multi-package`, and the non-degeneracy direction by a `packages/lint` control whose input is a local three-key replica of `packageBodyAsStack` -- green even if this command stops building that shape. One fixture measures both, because the discriminating fact is WHICH of two lookups on the package's object is reported. The top level carries the sibling object alone, so `objects` is present, `authoringRuleUnionStack` folds nothing (it fills absent keys only), and the union run never sees the referencing object's fields -- whatever the build reports about them comes from the per-package leg and nowhere else. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../build-multi-package-artifact.e2e.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/build-multi-package-artifact.e2e.test.ts b/packages/cli/test/build-multi-package-artifact.e2e.test.ts index 98e8755b97..b7b13b5a9e 100644 --- a/packages/cli/test/build-multi-package-artifact.e2e.test.ts +++ b/packages/cli/test/build-multi-package-artifact.e2e.test.ts @@ -132,6 +132,61 @@ export default { }; `; +/** + * [#17169 — F1 of #17066's verdict] The per-package leg's RESOLUTION CONTEXT, + * both directions in ONE build. + * + * `compile.ts`'s `packageBodyAsStack` hands each package's body the artifact's + * own `packages[]` as resolution context (#16611), so a reference into an + * object a SIBLING package of the same artifact ships RESOLVES, while one no + * entry provides still ERRORS — the second half being the whole difference + * between the ruled fix and "skip the site per package". + * + * One fixture measures both, because the discriminating fact is WHICH of the + * two lookups on `probe_order` is reported: `account` (a sibling provides it) + * must not be, `ghost` (nothing provides it) must be. Dropping the `packages[]` + * pass-through reports both; dropping the judging reports neither. + * + * ⚠️ The top level carries `probe_account`, so `objects` is PRESENT and + * `authoringRuleUnionStack` folds nothing into it — it only ever fills ABSENT + * keys. The union run therefore never sees `probe_order`'s fields at all, and + * whatever this build reports about them was produced by the per-package leg + * and by nothing else. That is what makes this a pin on `compile.ts`'s half + * rather than a second copy of `packages/lint`'s rule test, whose input is a + * local three-key REPLICA of `packageBodyAsStack` (`perPackageStack`) and stays + * green if this command stops building that shape. + */ +const CONFIG_PKG_REFS = ` +const coreManifest = { id: 'com.example.probe.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'probe' }; +const probeAccount = { + name: 'probe_account', label: 'Account', sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, +}; +const probeOrder = { + name: 'probe_order', label: 'Order', sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Number' }, + account: { type: 'lookup', label: 'Account', reference: 'probe_account' }, + ghost: { type: 'lookup', label: 'Ghost', reference: 'probe_nothing' }, + }, +}; + +export default { + manifest: coreManifest, + objects: [probeAccount], + packages: [ + { manifest: { ...coreManifest, objects: [probeAccount] } }, + { + manifest: { + id: 'com.example.probe.orders', name: 'orders', version: '1.0.0', type: 'module', namespace: 'probe', + dependencies: { 'com.example.probe.core': '^1.0.0' }, + objects: [probeOrder], + }, + }, + ], +}; +`; + interface Artifact { manifest?: { id?: string }; objects?: Array<{ name: string }>; @@ -147,7 +202,7 @@ const artifactOf = (dir: string): Artifact => beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'os-multi-package-')); - for (const [name, config] of Object.entries({ multi: CONFIG_MULTI, single: CONFIG_SINGLE, globs: CONFIG_GLOBS })) { + for (const [name, config] of Object.entries({ multi: CONFIG_MULTI, single: CONFIG_SINGLE, globs: CONFIG_GLOBS, refs: CONFIG_PKG_REFS })) { const dir = join(root, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); @@ -217,6 +272,25 @@ describe('ADR-0130 D4 — `os build` emits one artifact carrying `packages[]`', const paths = (payload.errors ?? []).map((e) => (e.path ?? []).join('.')); expect(paths).toContain('packages.0.manifest.objects.0'); }, 180_000); + + it('resolves a SIBLING package\'s object on the per-package leg, and still errors on an artifact-wide dangling one', async () => { + const run = await runCli(['build', '--json'], dirs.refs); + expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(1); + const payload = JSON.parse(run.stdout) as { + error?: string; + issues?: Array<{ rule: string; path: string; package?: string }>; + }; + // WHICH leg spoke, asserted rather than assumed: the union run's own + // failure exit carries `error: 'author-time rules failed'`, so this string + // is the only thing that distinguishes the two exits from outside. + expect(payload.error).toBe('author-time rules failed for one or more packages'); + const refs = (payload.issues ?? []).filter((i) => i.rule === 'object-reference-unknown'); + // Both directions in one equality: `ghost` is present (the leg still + // JUDGES) and `account` is absent (the leg RESOLVED it through the + // artifact's `packages[]`). A pass-through that went missing reports both. + expect(refs.map((i) => i.path)).toEqual(['objects[0].fields.ghost.reference']); + expect(refs[0].package).toBe('com.example.probe.orders'); + }, 180_000); }); describe('ADR-0130 D7 — the single-package path is untouched', () => { From f96e1099ce07c2c5c2bfbbbf0d2d9352faef3fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 19:35:29 +0000 Subject: [PATCH 2/2] test(cli): record the union-leg reading the pin rests on, measured The case's docblock argued that the union author-time rule run cannot be the reporter for `probe_order`'s two lookups. That argument is now three readings, each reddening only this case and leaving the other six in the file green: 1. drop `packages[]` from `packageBodyAsStack` -> the equality receives BOTH paths, `objects[0].fields.account.reference` first; 2. hand the per-package leg no `objects` -- the "skip the site per package" option the ruling rejected -- and `os build` exits 0 with `success: true`, so the union run is not a second reporter for these paths and nothing else in the command catches the dangling reference either; 3. add `probeOrder` to the TOP-LEVEL `objects` and the union run does report it, answering `error: 'author-time rules failed'` -- which is why that string is asserted before the paths are read. The fold-skip premise the fixture's placement relies on is not re-derived here either: `src/utils/stack-collections.test.ts` already pins it BY IDENTITY on this exact shape, so the docblock cites that pin instead of re-arguing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../build-multi-package-artifact.e2e.test.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/build-multi-package-artifact.e2e.test.ts b/packages/cli/test/build-multi-package-artifact.e2e.test.ts index b7b13b5a9e..ccc11753be 100644 --- a/packages/cli/test/build-multi-package-artifact.e2e.test.ts +++ b/packages/cli/test/build-multi-package-artifact.e2e.test.ts @@ -149,12 +149,28 @@ export default { * * ⚠️ The top level carries `probe_account`, so `objects` is PRESENT and * `authoringRuleUnionStack` folds nothing into it — it only ever fills ABSENT - * keys. The union run therefore never sees `probe_order`'s fields at all, and - * whatever this build reports about them was produced by the per-package leg - * and by nothing else. That is what makes this a pin on `compile.ts`'s half - * rather than a second copy of `packages/lint`'s rule test, whose input is a - * local three-key REPLICA of `packageBodyAsStack` (`perPackageStack`) and stays - * green if this command stops building that shape. + * keys, which `src/utils/stack-collections.test.ts` pins BY IDENTITY on exactly + * this shape (a stack carrying both its collections and `packages[]`). So the + * union run never sees `probe_order`'s fields, and this is a pin on + * `compile.ts`'s half rather than a second copy of `packages/lint`'s rule test, + * whose input is a local three-key REPLICA of `packageBodyAsStack` + * (`perPackageStack`) and stays green if this command stops building that + * shape. + * + * ⭐ That last claim is MEASURED rather than argued. Three ablations, each + * reddening THIS case alone and leaving the other six in this file green: + * + * 1. drop `packages[]` from `packageBodyAsStack` (the pre-#16611 shape) — the + * equality below receives BOTH paths, `account` first; + * 2. hand the per-package leg no `objects` at all, i.e. the "skip the site per + * package" option the ruling rejected — `os build` exits **0** with + * `success: true`, so the union run is NOT a second reporter for these + * paths and nothing else in the command catches the dangling reference + * either; + * 3. add `probeOrder` to the TOP-LEVEL `objects` — the union run does report + * it, and `error` reads `author-time rules failed`. That is why the error + * string is asserted BEFORE the paths are read: it is the only thing that + * tells the two exits apart from outside. */ const CONFIG_PKG_REFS = ` const coreManifest = { id: 'com.example.probe.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'probe' };