Skip to content

Commit 6c17a50

Browse files
fix(lint): the runtime gate's object-write baseline keeps the written item's stored self (#22133)
Fixes #22118 Clause-②: no (the fix restores the gate's published contract: `runtime-gate.ts` "the gate blocks new writes, never stored rows" and `reference-integrity-suite.ts` "a stored object already in violation is never charged to someone else's write"; the refusal it removes is one that text already denies) ## What changed The runtime publish gate judged a stored sibling's finding against a baseline that had dropped the written object's stored self. A label-only save of a master was refused (422) for a stored detail the author never touched. The gate now also judges the stored universe on an update into a context collection. `packages/lint/src/runtime-gate.ts`: - `buildRuntimeWriteSnapshotSet` (module-level, on neither package entry) builds the baseline and candidate as before. On an UPDATE into a context collection it also builds `stored`: the baseline with the written item's stored self put back, at the slot the item takes in the candidate. Every sibling sits at the same index in all three snapshots. - `runRuntimeAuthoringRules` subtracts `baseline` findings, as before. It also subtracts `stored` findings, but only those whose path positively names another entry (`isLocatedOnAnotherEntry`). Findings located on the written item are never read from that pass, so they are judged as before. - `isLocatedOnAnotherEntry` reads a location off the finding's path spelling, never off its rule. It reads the three spellings the door's rules use: positional `objects[3]…`, name-keyed `objects.acme_invoice…`, and an object named in prose (`object 'fx_detail' · …`, the path the expression and autonumber rules emit). A path it cannot locate keeps the previous verdict. That direction can leave a sibling's finding charged to a write; it can never wave the written item's own finding through. - `buildRuntimeWriteSnapshots` is on both package entries, so its signature is byte-identical to `main`. It returns the baseline/candidate pair read off the new builder. A pin asserts it still returns exactly those two keys. - A create, and a write of a type that is not a context collection, run exactly the two passes they ran before. `.changeset/22118-gate-baseline-stored-self.md`: patch, `@objectstack/lint`, with `Clause-②: no`. ## Readings (Zone 2) All readings are taken at the door's own snapshot shape on this branch, unless they say otherwise. - **H1: holds.** On `main` `51290bca`, the gate charged a label-only `fx_master` save with `object-field-ref-unknown @ objects.fx_detail2.fields.m.lookupColumns[0]` and with `expression-invalid @ object 'fx_detail' · field 'qty' readonlyWhen`. The baseline printed as `[fx_account, fx_detail2]`, with the master absent. The fingerprint is `rule · where · path · message`. Sibling slots are identical across the passes. What differs is presence: `lookupColumns` against an absent target is unknowable (`validate-object-field-refs`, #20432), and the `parent` traversal cannot resolve. So the finding is new against a baseline without the master. - **H2: holds.** On a create there is no stored self and no `stored` snapshot, so the verdict is unchanged. It is pinned: creating the master beside the stored detail is still charged with the detail's finding, because the stored universe never held it. - **H3: holds.** Two writes that newly break a sibling are still refused, one per spelling: - Positional: the write removes `code`, which the detail's `lookupColumns` names. - Prose: the write turns `status` into a lookup, so the detail's `readonlyWhen: "parent.status.name == 'x'"` now reads through a reference. - At the door, the positional control answers `{ code: 'INVALID_METADATA', status: 422 }`. - **H4, by type:** - **permission: the same defect, at advisory tier.** `security-master-detail-ungranted` is silent while no permission set is authored. A label-only re-save of a tenant's only set was therefore charged a stored detail's warning: `objects.fx_line.fields.hdr`, measured red under the reversal below. With this change it carries none; creating the same set still reports it. Covered, because the construction is the one shared line. - **book and dataset: no instance.** The book door runs `validateSecurityPosture` and `validateSecurityRoleWord`. The dataset door runs `validateDatasetMeasureAggregates` and `validateReferenceIntegrity`. Each judges the written entry against `permissions` or `objects` and resolves nothing into a sibling of its own collection, so the stored pass cancels nothing there today. See the Acceptance notes. ## The contract sentence (narrowed to what holds) The module header now states what "added" means. The bare sentence "the gate blocks new writes, never stored rows" used to sit in the builder docblock. It now heads a precise list, every item of which the code does: - a finding located on another entry is the write's only when neither the universe without the item nor the stored universe holds it; - a finding located on the written item itself is the write's whenever the universe without the item does not hold it, whatever the stored row held ("re-saving a row is writing it"); - a finding whose path names no locatable entry is judged as one on the written item. Other changed lines: - "runs the rules TWICE" became "on the context alone and again with the item grafted in". - The cost line now says "two passes … three on an update into a context collection". - The `restoredCredentialPaths` comment states that the stored pass can match the item's slot, and why that is inert. The `reference-integrity-suite.ts` sentence ("a stored object already in violation is never charged to someone else's write") sits in a paragraph about the FLOW snapshot, where it was and remains true. It is untouched. ## Tests - `packages/lint/src/runtime-gate.stored-self-baseline.test.ts` (new; it keeps off `runtime-gate.object-writes.test.ts`, which PR #22103 edits): 25 cases. - The two measured cases resolve. Each has a non-vacuity check: the finding is absent from the baseline and present in the candidate and in `stored`, at the same raw path. - Both H3 controls. - The written-object control in all three spellings. Each check confirms the stored self carries the identical finding. - Create, and permission relabel/create. - Snapshot shape, and that the published builder returns only baseline and candidate. - Twelve location-reader cases, including five unlocatable spellings, each answering `false`. - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, new block `#22118`: 4 cases through the real `saveMetaItem`, with the registry holding the stored universe. - Both measured cases save, and the row lands. - The sibling-breaking write and the written object's own finding are each refused with `{ code: 'INVALID_METADATA', status: 422 }` and the issue's path, and nothing lands. ## Reverse verification (one-off, at `8d2a3c3d`, no permanent ablation file) Both legs went through `scripts/ablation-replace.mjs`, with a literal anchor that must hit (x1 to x0, blob `625a165b` to the mutated blob). Each leg ran `pnpm --filter @objectstack/lint build` and `scripts/ablation-dist-preflight.mjs` before measuring: the marker was hit in `dist/index.{js,cjs}` and `dist/runtime.{js,cjs}`. The door suite reads `@objectstack/lint` through `dist/`. - **Leg 1, the reversal** (stored pass disabled, which is `main`'s behaviour): - Lint: 3 failed / 22 passed. The failures are the two measured cases and the permission relabel. - Door: 2 failed / 2 passed. Both cases answered the card's own refusals: `object/fx_master failed author-time validation: 1 issue — objects.fx_detail2.fields.m.lookupColumns[0] [object-field-ref-unknown]` and `… object 'fx_detail' · field 'qty' readonlyWhen [expression-invalid]`. - Both controls stayed green. - **Leg 2, the location reader ablated** (every stored-pass finding cancels): - Lint: 3 failed / 22 passed. The failures are the written-object control in all three spellings. - Door: 1 failed / 3 passed. The failure is the written-object control. - **Restore, after each leg:** blob equal to HEAD, `git diff HEAD` empty, `dist/` rebuilt with the marker absent from all 14 built files, and the tree clean. ## Local verification at `8d2a3c3d` - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 124 files, 5705 tests passed. - `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` plus the test layer): OK. No new test-typecheck signature. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 221 files passed, 3 skipped; 28243 tests passed, 19 skipped. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `--listFiles` includes the edited test file (1 hit; 224 test files in the program). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands from the merge base. Reconciled with `--ran`: 63 derived, 62 run green, 1 NOT MEASURED. - `check-plugin-teardown-shape --self-test` and `check:lean-entry-closure` first answered PREREQUISITE NOT MET: a fixture commit was outside the shallow clone, and `objectql` had no `dist/`. Both were re-measured green after fetching the commit and building `objectql`. - **NOT MEASURED:** `check:dual-build-cjs-loads`, reason: it reads the built output of every workspace package (about 68 had no `dist/` here). That is CI's run. A narrowed direct reading: `packages/lint/dist/runtime.cjs` and `index.cjs` load, `runtime.cjs` exports the same six names, and neither entry exposes `buildRuntimeWriteSnapshotSet` or `isLocatedOnAnotherEntry`. - Lint, narrowed to the 3 touched TS files with `eslint --no-inline-config --format json`: 3 files, 0 errors, 0 warnings. `eslint.config.mjs` never enables type-aware linting (0 hits for `parserOptions.project` or `projectService`, and the config says so in prose). So this diff cannot move the verdict for any untouched file. The full `pnpm lint` run is CI's. ## Acceptance notes - **book / dataset (H4):** they share the construction because it is one line. A per-type exception would be a second policy beside the one differential, and the next rule that judged a sibling book or dataset against the written one would re-create this defect silently. On those updates the stored pass costs one more rule pass, with no measured effect today. - **Unlocatable spellings keep the previous verdict.** No door rule emits a double-quoted `object "x"` path today (the double-quoted form appears only in `where` beside a positional path). A rule that did would not get this relief until the reader learns that spelling. - **Observation, not filed:** in this branch's probe, a detail's `readonlyWhen: "parent.status == 'x'"` drew no door finding when the master lacked `status`. Whether any surface judges field existence through `parent` was not measured. Carrier: none. - **Overlap:** PR #22103 edits `runtime-gate.object-writes.test.ts`. This PR does not touch that file. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec8f37c commit 6c17a50

4 files changed

Lines changed: 646 additions & 31 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
The runtime publish gate no longer charges a write with a stored sibling's finding that only shows when the written item is present. Re-saving a master object with only its label changed answered `422 INVALID_METADATA` for a stored detail the author never touched: a detail's `lookupColumns` entry naming no field of the master, or a detail's `readonlyWhen` read through `parent`.
6+
7+
Clause-②: no
8+
9+
- On an update into a context collection (`object`, `permission`, `book`, `dataset`) the gate also judges the stored universe: the live collections with the written item's stored self in place. A finding located on another entry that the stored universe already holds is not this write's.
10+
- A finding located on the written item itself is judged as before, even when the stored row carries the same finding. Re-saving a row is writing it.
11+
- A write that newly breaks a sibling is still refused, for example removing the master field a detail's `lookupColumns` names.
12+
- A create is judged as before.
13+
- On a permission write the same change drops a stored detail's `security-master-detail-ungranted` advisory from a label-only re-save of the tenant's only permission set.
14+
- A finding whose path names no entry the gate can locate keeps the previous verdict.
15+
- ⛔ Nothing you author changes, and no export or signature changes.
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22118 — the object-write differential judges a sibling's finding against
5+
* the STORED universe, which includes the written object's stored self.
6+
*
7+
* ## The state this closes
8+
*
9+
* `buildRuntimeWriteSnapshots` drops the written item's stored self from the
10+
* baseline, so the baseline is the universe WITHOUT the written object. A
11+
* stored sibling whose finding only shows when that object is present — a
12+
* detail's `lookupColumns` entry, judged against its master only when the
13+
* master is in the snapshot; a detail's `readonlyWhen` read through `parent`
14+
* — therefore had its finding absent from the baseline and present in the
15+
* candidate, and a label-only save of the master answered 422 for a detail
16+
* the author never touched. That contradicts the gate's own contract: a
17+
* stored object already in violation is never charged to someone else's write.
18+
*
19+
* ## The ruling this pins
20+
*
21+
* - The baseline keeps the written object's stored self: a finding located on
22+
* another object that already exists against the stored universe is not new.
23+
* - Findings located on the written object itself are judged as before.
24+
* - No per-rule exemptions: every door rule reads the one differential, so
25+
* the location is read off the finding's path spelling, never its rule.
26+
*
27+
* The protocol-level half — the same verdicts through the real `saveMetaItem`,
28+
* with the 422 envelope's `code` and `status` — is the #22118 block of
29+
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
30+
*/
31+
import { describe, expect, it } from 'vitest';
32+
import type { AuthoringFinding } from './authoring-rules.js';
33+
import {
34+
buildRuntimeWriteSnapshotSet,
35+
buildRuntimeWriteSnapshots,
36+
isLocatedOnAnotherEntry,
37+
runRuntimeAuthoringRules,
38+
runtimeAuthoringRulesFor,
39+
} from './runtime-gate.js';
40+
41+
type Fields = Record<string, Record<string, unknown>>;
42+
43+
/** The master. `sharingModel` keeps `security-owd-unset` quiet on every fixture here. */
44+
const master = (fields: Fields = {}, over: Record<string, unknown> = {}) => ({
45+
name: 'fx_master',
46+
label: 'Master',
47+
sharingModel: 'private',
48+
fields: {
49+
name: { type: 'text', label: 'Name' },
50+
code: { type: 'text', label: 'Code' },
51+
status: { type: 'text', label: 'Status' },
52+
acct: { type: 'lookup', label: 'Account', reference: 'fx_account' },
53+
...fields,
54+
},
55+
...over,
56+
});
57+
58+
/** The master as the author re-saves it: only its label changed. */
59+
const relabelled = (stored: ReturnType<typeof master>) => ({ ...stored, label: 'Master (renamed)' });
60+
61+
/** The master without one of its fields. */
62+
const without = (stored: ReturnType<typeof master>, field: string) => {
63+
const fields = { ...stored.fields } as Fields;
64+
delete fields[field];
65+
return { ...stored, label: 'Master (renamed)', fields };
66+
};
67+
68+
const account = {
69+
name: 'fx_account',
70+
label: 'Account',
71+
sharingModel: 'private',
72+
fields: { name: { type: 'text', label: 'Name' } },
73+
};
74+
75+
/** Measured case 1: a lookup into the master whose picker names `columns`. */
76+
const pickerDetail = (columns: string[]) => ({
77+
name: 'fx_detail2',
78+
label: 'Picker Detail',
79+
sharingModel: 'private',
80+
fields: { m: { type: 'lookup', label: 'Master', reference: 'fx_master', lookupColumns: columns } },
81+
});
82+
83+
/** Measured case 2: a detail (one `master_detail`) whose `readonlyWhen` reads through `parent`. */
84+
const parentDetail = (predicate: string) => ({
85+
name: 'fx_detail',
86+
label: 'Parent Detail',
87+
sharingModel: 'private',
88+
fields: {
89+
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_master' },
90+
qty: { type: 'number', label: 'Quantity', readonlyWhen: predicate },
91+
},
92+
});
93+
94+
const gateObject = (item: unknown, objects: unknown[]) =>
95+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects } });
96+
97+
/** The door's own rules run over ONE snapshot — what that snapshot holds, not a verdict. */
98+
const findingsIn = (stack: Record<string, unknown>, type = 'object'): AuthoringFinding[] =>
99+
runtimeAuthoringRulesFor(type).flatMap((r) => r.run(stack, { runtimeWriteType: type }));
100+
101+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
102+
103+
/** The two measured cases: the stored detail, and the finding it carries. */
104+
const MEASURED = [
105+
{
106+
label: 'an unknown `lookupColumns` entry on a detail',
107+
detail: pickerDetail(['nope_col']),
108+
rule: 'object-field-ref-unknown',
109+
rawPath: 'objects[1].fields.m.lookupColumns[0]',
110+
},
111+
{
112+
label: 'a `readonlyWhen` read through `parent`',
113+
detail: parentDetail("parent.acct.name == 'x'"),
114+
rule: 'expression-invalid',
115+
rawPath: "object 'fx_detail' · field 'qty' readonlyWhen",
116+
},
117+
] as const;
118+
119+
describe('#22118 — a label-only master save is not charged with a stored detail\'s finding', () => {
120+
for (const { label, detail, rule, rawPath } of MEASURED) {
121+
it(`⭐ RESOLVES — ${label}`, () => {
122+
const stored = [master(), account, detail];
123+
const snapshots = buildRuntimeWriteSnapshotSet({
124+
type: 'object',
125+
item: relabelled(master()),
126+
context: { objects: stored },
127+
})!;
128+
129+
// Non-vacuous: the detail's finding is NEW against the universe without
130+
// the master — the state that answered 422 — and the stored universe
131+
// already holds it, at the same raw path.
132+
const at = (stack: Record<string, unknown>) =>
133+
findingsIn(stack).filter((f) => f.rule === rule && f.path === rawPath);
134+
expect(at(snapshots.baseline), dump(findingsIn(snapshots.baseline))).toEqual([]);
135+
expect(at(snapshots.candidate), dump(findingsIn(snapshots.candidate))).toHaveLength(1);
136+
expect(at(snapshots.stored!), dump(findingsIn(snapshots.stored!))).toHaveLength(1);
137+
138+
const result = gateObject(relabelled(master()), stored);
139+
140+
expect(result.rulesRun.length).toBeGreaterThan(0);
141+
expect(result.errors, dump(result)).toEqual([]);
142+
expect(result.advisories, dump(result)).toEqual([]);
143+
});
144+
}
145+
});
146+
147+
describe('#22118 — the controls: what the write changes is still the write\'s', () => {
148+
it('⭐ CONTROL — a write that newly breaks a sibling is still REFUSED (positional path)', () => {
149+
// The stored master has `code`, so the stored universe holds no finding;
150+
// the write removes it, so the picker column now names nothing.
151+
const result = gateObject(without(master(), 'code'), [master(), account, pickerDetail(['code'])]);
152+
153+
expect(result.errors, dump(result)).toEqual([
154+
expect.objectContaining({
155+
severity: 'error',
156+
rule: 'object-field-ref-unknown',
157+
path: 'objects.fx_detail2.fields.m.lookupColumns[0]',
158+
}),
159+
]);
160+
expect(result.errors[0]!.message).toContain('"code" is not a field on object "fx_master"');
161+
});
162+
163+
it('⭐ CONTROL — a write that newly breaks a sibling is still REFUSED (an object named in prose)', () => {
164+
// The stored master's `status` is text, so `parent.status.name` traverses
165+
// nothing; the write turns it into a lookup, and the detail's predicate
166+
// now reads through a reference field.
167+
const before = master();
168+
const after = master({ status: { type: 'lookup', label: 'Status', reference: 'fx_account' } }, { label: 'Master (renamed)' });
169+
const result = gateObject(after, [before, account, parentDetail("parent.status.name == 'x'")]);
170+
171+
const where = "object 'fx_detail' · field 'qty' readonlyWhen";
172+
expect(result.errors, dump(result)).toEqual([
173+
expect.objectContaining({ severity: 'error', rule: 'expression-invalid', where, path: where }),
174+
]);
175+
expect(result.errors[0]!.message).toContain('through `parent.status`');
176+
});
177+
178+
// The written object's own finding, carried identically by its stored self:
179+
// one body per path spelling the location reader reads, so each arm's
180+
// written-item answer is exercised through the gate.
181+
const OWN = [
182+
{
183+
spelling: 'positional',
184+
fields: { acct: { type: 'lookup', label: 'Account', reference: 'fx_account', lookupColumns: ['nope'] } },
185+
over: {},
186+
rule: 'object-field-ref-unknown',
187+
path: 'objects.fx_master.fields.acct.lookupColumns[0]',
188+
},
189+
{
190+
spelling: 'name-keyed',
191+
fields: {},
192+
over: { validations: [{ name: 'code_shape', type: 'format', field: 'code', regex: '(', message: 'Bad code' }] },
193+
rule: 'validation-rule-regex-uncompilable',
194+
path: 'objects.fx_master.validations.code_shape.regex',
195+
},
196+
{
197+
spelling: 'an object named in prose',
198+
fields: { code: { type: 'text', label: 'Code', readonlyWhen: "record.acct.name == 'x'" } },
199+
over: {},
200+
rule: 'expression-invalid',
201+
path: "object 'fx_master' · field 'code' readonlyWhen",
202+
},
203+
] as const;
204+
205+
for (const { spelling, fields, over, rule, path } of OWN) {
206+
it(`⭐ CONTROL — a finding on the written object itself is still REFUSED, though its stored self carries it (${spelling})`, () => {
207+
const stored = master(fields as Fields, over);
208+
// Non-vacuous: the stored self carries the identical finding, so a
209+
// baseline that let it cancel would wave this write through.
210+
const snapshots = buildRuntimeWriteSnapshotSet({
211+
type: 'object',
212+
item: relabelled(stored),
213+
context: { objects: [stored, account] },
214+
})!;
215+
expect(findingsIn(snapshots.stored!).filter((f) => f.rule === rule).length).toBeGreaterThan(0);
216+
217+
const result = gateObject(relabelled(stored), [stored, account]);
218+
219+
expect(result.errors, dump(result)).toEqual([expect.objectContaining({ severity: 'error', rule, path })]);
220+
});
221+
}
222+
223+
it('a CREATE is judged as before: no stored self, so the stored universe is the baseline', () => {
224+
// Creating the master makes the stored detail's picker column judgeable;
225+
// the stored universe never held that finding, so it is this write's.
226+
const snapshots = buildRuntimeWriteSnapshotSet({
227+
type: 'object',
228+
item: master(),
229+
context: { objects: [account, pickerDetail(['nope_col'])] },
230+
})!;
231+
expect(snapshots.stored).toBeUndefined();
232+
233+
const result = gateObject(master(), [account, pickerDetail(['nope_col'])]);
234+
235+
expect(result.errors, dump(result)).toEqual([
236+
expect.objectContaining({ rule: 'object-field-ref-unknown', path: 'objects.fx_detail2.fields.m.lookupColumns[0]' }),
237+
]);
238+
});
239+
});
240+
241+
describe('#22118 — the same differential on a PERMISSION write', () => {
242+
// `security-master-detail-ungranted` is silent while the stack authors no
243+
// permission set at all, so with the tenant's only set dropped from the
244+
// baseline every ungranted detail read as this write's advisory.
245+
const objects = [
246+
master(),
247+
{
248+
name: 'fx_line',
249+
label: 'Line',
250+
sharingModel: 'controlled_by_parent',
251+
fields: { hdr: { type: 'master_detail', label: 'Header', reference: 'fx_master', required: true } },
252+
},
253+
];
254+
const set = { name: 'fx_ops', label: 'Ops', objects: { fx_master: { allowRead: true, readScope: 'org' } } };
255+
const gatePermission = (permissions: unknown[]) =>
256+
runRuntimeAuthoringRules({ type: 'permission', item: { ...set, label: 'Ops (renamed)' }, context: { objects, permissions } });
257+
258+
it('a label-only re-save of the only set carries no stored detail\'s advisory', () => {
259+
const result = gatePermission([set]);
260+
261+
expect(result.rulesRun).toContain('validateSecurityPosture');
262+
expect(result.errors, dump(result)).toEqual([]);
263+
expect(result.advisories, dump(result)).toEqual([]);
264+
});
265+
266+
it('CONTROL — creating that set still reports it: the stored universe never held the finding', () => {
267+
const result = gatePermission([]);
268+
269+
expect(result.advisories, dump(result)).toEqual([
270+
expect.objectContaining({ rule: 'security-master-detail-ungranted', path: 'objects.fx_line.fields.hdr' }),
271+
]);
272+
});
273+
});
274+
275+
describe('#22118 — the snapshot shape', () => {
276+
it('`stored` puts the stored self at the slot the item takes in the candidate; siblings keep theirs', () => {
277+
const stored = master();
278+
const s = buildRuntimeWriteSnapshotSet({
279+
type: 'object',
280+
item: relabelled(stored),
281+
context: { objects: [stored, account, pickerDetail([])] },
282+
})!;
283+
const names = (stack: Record<string, unknown>) => (stack.objects as { name: string }[]).map((o) => o.name);
284+
285+
expect(names(s.baseline)).toEqual(['fx_account', 'fx_detail2']);
286+
expect(names(s.candidate)).toEqual(['fx_account', 'fx_detail2', 'fx_master']);
287+
expect(names(s.stored!)).toEqual(['fx_account', 'fx_detail2', 'fx_master']);
288+
expect((s.stored!.objects as unknown[])[2]).toBe(stored);
289+
expect((s.candidate.objects as { label: string }[])[2]!.label).toBe('Master (renamed)');
290+
});
291+
292+
it('the published builder still returns exactly its baseline/candidate pair — `stored` is the gate\'s own', () => {
293+
const args = { type: 'object', item: relabelled(master()), context: { objects: [master(), account] } };
294+
const published = buildRuntimeWriteSnapshots(args)!;
295+
const set = buildRuntimeWriteSnapshotSet(args)!;
296+
297+
expect(Object.keys(published)).toEqual(['baseline', 'candidate']);
298+
expect(set.stored).toBeDefined();
299+
expect(published.baseline).toEqual(set.baseline);
300+
expect(published.candidate).toEqual(set.candidate);
301+
});
302+
303+
it('`stored` is absent for a write whose type is not a context collection', () => {
304+
const s = buildRuntimeWriteSnapshotSet({
305+
type: 'flow',
306+
item: { name: 'f1' },
307+
context: { objects: [master()] },
308+
})!;
309+
expect(s.stored).toBeUndefined();
310+
});
311+
});
312+
313+
describe('#22118 — `isLocatedOnAnotherEntry` reads the location off the path spelling', () => {
314+
const snapshot = { objects: [{}, {}, {}], permissions: [{}], books: [], datasets: [] };
315+
const located = { snapshot, stackKey: 'objects', itemName: 'fx_master', writtenSlot: 2 };
316+
317+
const CASES: ReadonlyArray<readonly [string, boolean]> = [
318+
// positional
319+
['objects[2].fields.acct.lookupColumns[0]', false],
320+
['objects[1].fields.m.lookupColumns[0]', true],
321+
['permissions[0].objects.fx_master.readScope', true],
322+
// name-keyed
323+
['objects.fx_master.validations.v.regex', false],
324+
['objects.fx_detail.validations.v.regex', true],
325+
// an object named in prose
326+
["object 'fx_master' · field 'code' readonlyWhen", false],
327+
["object 'fx_detail' · field 'qty' readonlyWhen", true],
328+
// NOT positively located: today's verdict, never a cancellation
329+
['flows[0].nodes[1].config', false],
330+
['packages/lint/src/validate-expressions.ts', false],
331+
['object "fx_detail" › fields.m', false],
332+
["flow 'f1' · node 'n1'", false],
333+
['', false],
334+
];
335+
336+
for (const [path, another] of CASES) {
337+
it(`${JSON.stringify(path)} → ${another}`, () => {
338+
expect(isLocatedOnAnotherEntry(path, located)).toBe(another);
339+
});
340+
}
341+
});

0 commit comments

Comments
 (0)