|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0130 D4 — #18204] The per-package leg's resolution context, asserted |
| 5 | + * against the FUNCTION the commands call. |
| 6 | + * |
| 7 | + * `compile.ts` (step 3b-ii) and `lint.ts` both build one package's stack with |
| 8 | + * {@link packageBodyAsStack}, handing it the artifact's own `packages[]` so a |
| 9 | + * reference into a SIBLING package's object resolves (#16611). Two neighbours |
| 10 | + * already measure parts of that, and neither covers what this file does: |
| 11 | + * |
| 12 | + * - `packages/lint/src/validate-object-references.test.ts` runs the rule |
| 13 | + * against a local three-key REPLICA of this function's output. It stays |
| 14 | + * green if the CLI stops producing that shape — which is the only way this |
| 15 | + * defect can ever return. |
| 16 | + * - `test/build-multi-package-artifact.e2e.test.ts` runs the real command end |
| 17 | + * to end, and is the stronger reading — but it carries the `*.e2e` name, so |
| 18 | + * `OS_TEST_TIERS` puts it in the NIGHTLY population (`../../vitest-tiers.ts`) |
| 19 | + * and the merge queue never runs it. A regression lands green and is found |
| 20 | + * the next morning. |
| 21 | + * |
| 22 | + * So this file is the queue-tier half: the real `artifactPackages` + |
| 23 | + * `packageBodyAsStack`, the real rule, and no replica anywhere in the chain. |
| 24 | + * |
| 25 | + * ## The two LEVELS, which is the card |
| 26 | + * |
| 27 | + * #18204 reported one reference accepted at the field level and the same name |
| 28 | + * refused at the action-param level in the same build. That is what |
| 29 | + * `@objectstack/cli@17.4.0` does — its `packageBodyAsStack(body)` takes no |
| 30 | + * context argument at all, so the action-param site is judged against one |
| 31 | + * package's objects while the field site is not judged at all by that release's |
| 32 | + * rule. Both halves moved since; this pins them MOVED TOGETHER, because a |
| 33 | + * platform that accepts `Field.lookup('x')` and refuses `reference: 'x'` on an |
| 34 | + * action param is contradicting itself inside one command. |
| 35 | + * |
| 36 | + * ⛔ Not a "skip the site per package" pin. The dangling name is asserted |
| 37 | + * present at BOTH levels in the same call, so a context that silences the |
| 38 | + * ladder fails here exactly as loudly as a context that never arrives. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, expect, it } from 'vitest'; |
| 42 | +import { validateObjectReferences } from '@objectstack/lint'; |
| 43 | + |
| 44 | +import { artifactPackages, packageBodyAsStack } from './artifact-packages.js'; |
| 45 | + |
| 46 | +/** |
| 47 | + * A two-package artifact in the shape `composeStacks(…, { manifest: 'preserve' })` |
| 48 | + * writes and `ObjectStackDefinitionSchema` parses: each entry's assembled body |
| 49 | + * under `manifest`. Modelled on `examples/app-multi-package`, whose `orders` |
| 50 | + * package reads `crm_account` out of its `core` sibling. |
| 51 | + */ |
| 52 | +const CORE_BODY = { |
| 53 | + id: 'com.example.multi.core', |
| 54 | + objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], |
| 55 | +}; |
| 56 | + |
| 57 | +const ORDERS_BODY = { |
| 58 | + id: 'com.example.multi.orders', |
| 59 | + dependencies: { 'com.example.multi.core': '^1.0.0' }, |
| 60 | + objects: [ |
| 61 | + { |
| 62 | + name: 'crm_order', |
| 63 | + fields: { |
| 64 | + name: { type: 'text' }, |
| 65 | + // Level 1 — `Field.lookup()`'s target, accepted across packages by |
| 66 | + // ADR-0130 §1.5. |
| 67 | + account: { type: 'lookup', reference: 'crm_account' }, |
| 68 | + ghost: { type: 'lookup', reference: 'crm_nowhere' }, |
| 69 | + }, |
| 70 | + actions: [ |
| 71 | + { |
| 72 | + name: 'link_account', |
| 73 | + type: 'script', |
| 74 | + locations: [], |
| 75 | + params: [ |
| 76 | + // Level 2 — the record picker's search target, the card's level. |
| 77 | + { name: 'account', type: 'lookup', reference: 'crm_account' }, |
| 78 | + { name: 'phantom', type: 'lookup', reference: 'crm_nowhere_param' }, |
| 79 | + ], |
| 80 | + body: { language: 'js', source: 'return { ok: true };', capabilities: [] }, |
| 81 | + }, |
| 82 | + ], |
| 83 | + }, |
| 84 | + ], |
| 85 | +}; |
| 86 | + |
| 87 | +const ARTIFACT = { |
| 88 | + manifest: { id: 'com.example.multi.core' }, |
| 89 | + objects: CORE_BODY.objects, |
| 90 | + packages: [{ manifest: ORDERS_BODY }, { manifest: CORE_BODY }], |
| 91 | +} as Record<string, unknown>; |
| 92 | + |
| 93 | +/** |
| 94 | + * Exactly what `compile.ts` step 3b-ii does for one entry. |
| 95 | + * |
| 96 | + * ⛔ `context` takes no DEFAULT: a default would be applied to the CONTROL |
| 97 | + * leg's explicit `undefined` — the exact value the pre-#16611 call site passes |
| 98 | + * — and that leg would silently measure the fixed shape and pass. |
| 99 | + */ |
| 100 | +const perPackageStack = (index: number, context: unknown) => |
| 101 | + packageBodyAsStack(artifactPackages(ARTIFACT)[index].body, context); |
| 102 | + |
| 103 | +describe('packageBodyAsStack — the per-package leg resolves a sibling package at BOTH reference levels (#18204)', () => { |
| 104 | + it('CONTROL — with no context the SAME body errors at both levels, so the context is what does the work', () => { |
| 105 | + // Without this leg a green assertion below is indistinguishable from a rule |
| 106 | + // that stopped judging these sites at all. |
| 107 | + const paths = validateObjectReferences(perPackageStack(0, undefined)).map((f) => f.path); |
| 108 | + expect(paths).toEqual([ |
| 109 | + 'objects[0].fields.account.reference', |
| 110 | + 'objects[0].fields.ghost.reference', |
| 111 | + 'objects[0].actions[0].params[0].reference', |
| 112 | + 'objects[0].actions[0].params[1].reference', |
| 113 | + ]); |
| 114 | + }); |
| 115 | + |
| 116 | + it('resolves the sibling\'s object at both levels, and still refuses a name no package provides', () => { |
| 117 | + const findings = validateObjectReferences(perPackageStack(0, ARTIFACT.packages)); |
| 118 | + // One equality carries all four verdicts: `crm_account` is absent at BOTH |
| 119 | + // levels (resolved through `packages[]`) and the two dangling names are |
| 120 | + // present at BOTH levels (the refusal MOVED — it did not disappear). |
| 121 | + expect(findings.map((f) => f.path)).toEqual([ |
| 122 | + 'objects[0].fields.ghost.reference', |
| 123 | + 'objects[0].actions[0].params[1].reference', |
| 124 | + ]); |
| 125 | + expect(findings.map((f) => f.rule)).toEqual([ |
| 126 | + 'object-reference-unknown', |
| 127 | + 'object-reference-unknown', |
| 128 | + ]); |
| 129 | + expect(findings.map((f) => f.severity)).toEqual(['error', 'error']); |
| 130 | + }); |
| 131 | + |
| 132 | + it('judges the sibling from its own side unchanged', () => { |
| 133 | + // The context widens what a package can RESOLVE, never what it JUDGES: the |
| 134 | + // app package owns no reference, so it has nothing to report either way. |
| 135 | + expect(validateObjectReferences(perPackageStack(1, ARTIFACT.packages))).toEqual([]); |
| 136 | + }); |
| 137 | + |
| 138 | + it('hands the rule the artifact\'s own entries — the body of each package is under `manifest`', () => { |
| 139 | + // The shape is the contract between this function and the rule: the rule |
| 140 | + // reads `packages[].manifest.<collection>`, so a context reduced to some |
| 141 | + // other entry shape resolves nothing while every type still checks. |
| 142 | + const stack = perPackageStack(0, ARTIFACT.packages) as { packages?: unknown; manifest?: unknown }; |
| 143 | + expect(stack.packages).toBe(ARTIFACT.packages); |
| 144 | + expect(stack.manifest).toBe(artifactPackages(ARTIFACT)[0].body); |
| 145 | + }); |
| 146 | +}); |
0 commit comments