|
33 | 33 | */ |
34 | 34 | import { describe, expect, it } from 'vitest'; |
35 | 35 | import { AUTHORING_RULES } from './authoring-rules.js'; |
| 36 | +import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; |
36 | 37 | import { |
37 | 38 | runRuntimeAuthoringRules, |
38 | 39 | runtimeAuthoringRulesFor, |
@@ -113,12 +114,19 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () |
113 | 114 | 'validateFunctionalCompleteness', |
114 | 115 | 'validateManagedApiMethods', |
115 | 116 | 'validatePresetComparands', // #8793 — at this door before #4716 |
116 | | - // [#15254] The reference-integrity suite, dispatched here so its ONE |
117 | | - // object-judging member runs (`validateObjectFieldRefs`). The entry |
118 | | - // arrives; the suite's per-member `runtimeTypes` decides who judges the |
119 | | - // snapshot, and every other member keeps `['flow','view']` or the frozen |
120 | | - // `['flow']` default. Before this, the only door a Studio tenant has ran |
121 | | - // no reference-integrity rule at all on an object write. |
| 117 | + // [#15254] The reference-integrity suite, dispatched here so its |
| 118 | + // object-judging members run. The entry arrives; the suite's per-member |
| 119 | + // `runtimeTypes` decides who judges the snapshot, and every member that |
| 120 | + // does not name `object` keeps `['flow','view']` or the frozen `['flow']` |
| 121 | + // default. Before this, the only door a Studio tenant has ran no |
| 122 | + // reference-integrity rule at all on an object write. |
| 123 | + // |
| 124 | + // [#15495] The roster line is UNCHANGED by the two field-existence |
| 125 | + // crossings that card added — the entry was already here, and which |
| 126 | + // MEMBERS judge an object snapshot is the suite's own finer axis. That |
| 127 | + // axis is pinned by name in its own case below, so a member joining or |
| 128 | + // leaving the object door is caught by something even though this exact |
| 129 | + // list cannot move. |
122 | 130 | 'validateReferenceIntegrity', |
123 | 131 | 'lintAutonumberFormats', |
124 | 132 | 'validateSecurityPosture', // #8310 — at this door before #4716 |
@@ -151,6 +159,104 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () |
151 | 159 | } |
152 | 160 | }); |
153 | 161 |
|
| 162 | + // ── [#15495] The MEMBER surface of the reference-integrity suite ── |
| 163 | + // |
| 164 | + // The roster case above pins which AUTHORING_RULES entries reach this door. |
| 165 | + // The suite is ONE of those entries, so that list cannot say which of its |
| 166 | + // members judge an object snapshot — and that is the axis this card moved. |
| 167 | + // Written out rather than derived, exactly as the `view` twin in |
| 168 | + // `runtime-gate.view-writes.test.ts` is, so a fourth crossing has to be |
| 169 | + // argued here instead of arriving by drift. |
| 170 | + |
| 171 | + it('pins the member surface: exactly the crossed members declare `object`', () => { |
| 172 | + const crossed = REFERENCE_INTEGRITY_RULES |
| 173 | + .filter((r) => (r.runtimeTypes ?? ['flow']).includes('object')) |
| 174 | + .map((r) => r.name); |
| 175 | + // In registry order. `validateObjectFieldRefs` is #15254's crossing (the |
| 176 | + // object's OWN field-name lists); the two above it are this card's, and |
| 177 | + // they are the same KIND — a field name written in metadata, resolved |
| 178 | + // against `stack.objects`, the one collection every per-write snapshot |
| 179 | + // carries, so neither opens a missing-collection false-positive channel. |
| 180 | + // Each was measured over the shipped object corpus before crossing (116 |
| 181 | + // objects, 0 findings, precision 1.0); the comments on the members carry |
| 182 | + // the populations, and the four controls below are the non-vacuity half. |
| 183 | + expect(crossed).toEqual([ |
| 184 | + 'validateSearchableFields', |
| 185 | + 'validateListViewFieldRefs', |
| 186 | + 'validateObjectFieldRefs', |
| 187 | + ]); |
| 188 | + // `validateSortableFields` measured equally clean but was NOT crossed: |
| 189 | + // that is its own adjudication, and this pin is where it has to be made. |
| 190 | + expect(crossed).not.toContain('validateSortableFields'); |
| 191 | + // Every crossed member still judges flow snapshots — the #4463 P1 surface |
| 192 | + // is not narrowed by the member axis existing. |
| 193 | + const offFlow = REFERENCE_INTEGRITY_RULES |
| 194 | + .filter((r) => !(r.runtimeTypes ?? ['flow']).includes('flow')) |
| 195 | + .map((r) => r.name); |
| 196 | + expect(offFlow).toEqual([]); |
| 197 | + }); |
| 198 | + |
| 199 | + it('REFUSES an object whose searchableFields names a field it does not have', () => { |
| 200 | + // The ADR-0061 canonical set, on the object itself. Before this card the |
| 201 | + // door read it with nothing: `searchable-field-unknown` existed and was |
| 202 | + // `error`, but only a CLI ran it, and a Studio tenant has no CLI. |
| 203 | + const result = expectSingleRefusal( |
| 204 | + cleanObject({ searchableFields: ['owner', 'gone_field'] }), |
| 205 | + 'searchable-field-unknown', |
| 206 | + ); |
| 207 | + const f = result.errors.find((e) => e.rule === 'searchable-field-unknown')!; |
| 208 | + // Name-keyed on the wire (#10064), and the author reads back what they typed. |
| 209 | + expect(f.path).toBe('objects.leave_request.searchableFields[1]'); |
| 210 | + expect(f.message).toContain('gone_field'); |
| 211 | + }); |
| 212 | + |
| 213 | + it('REFUSES an object whose built-in list view names a column it does not have', () => { |
| 214 | + const result = expectSingleRefusal( |
| 215 | + cleanObject({ listViews: { all: { label: 'All', columns: ['owner', 'gone_column'] } } }), |
| 216 | + 'list-view-field-unknown', |
| 217 | + ); |
| 218 | + const f = result.errors.find((e) => e.rule === 'list-view-field-unknown')!; |
| 219 | + expect(f.path).toBe('objects.leave_request.listViews.all.columns[1]'); |
| 220 | + expect(f.message).toContain('gone_column'); |
| 221 | + }); |
| 222 | + |
| 223 | + it('a clean object carrying BOTH declarations publishes — the refusal is about the reference', () => { |
| 224 | + // The other half of each control above: the same two keys, every name |
| 225 | + // resolving, and the door adds nothing. Without this, "refuses a dangling |
| 226 | + // name" and "refuses the key" are indistinguishable. |
| 227 | + const result = gateObject( |
| 228 | + cleanObject({ |
| 229 | + fields: { owner: { type: 'text' }, subject: { type: 'text' } }, |
| 230 | + searchableFields: ['owner', 'subject'], |
| 231 | + listViews: { all: { label: 'All', columns: ['owner', 'subject'] } }, |
| 232 | + }), |
| 233 | + ); |
| 234 | + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); |
| 235 | + // The suite RAN — the zero is a clean verdict, not a dead gate. |
| 236 | + expect(result.rulesRun).toContain('validateReferenceIntegrity'); |
| 237 | + }); |
| 238 | + |
| 239 | + it('a `view` write still dispatches both members exactly as before — the crossing is additive', () => { |
| 240 | + // The control the #9313 surface owes this card: adding `object` to a |
| 241 | + // member's `runtimeTypes` must not disturb the type it already judged. A |
| 242 | + // flattened list overlay is the shape `PUT /api/v1/meta/view` carries. |
| 243 | + const overlay = { |
| 244 | + name: 'case_backlog', |
| 245 | + viewKind: 'list', |
| 246 | + data: { provider: 'object', object: 'leave_request' }, |
| 247 | + columns: ['owner', 'gone_column'], |
| 248 | + searchableFields: ['owner', 'gone_field'], |
| 249 | + }; |
| 250 | + const result = runRuntimeAuthoringRules({ |
| 251 | + type: 'view', |
| 252 | + item: overlay, |
| 253 | + context: { objects: STORED }, |
| 254 | + }); |
| 255 | + const rules = result.errors.map((e) => e.rule); |
| 256 | + expect(rules, JSON.stringify(result.errors)).toContain('list-view-field-unknown'); |
| 257 | + expect(rules, JSON.stringify(result.errors)).toContain('searchable-field-unknown'); |
| 258 | + }); |
| 259 | + |
154 | 260 | // ── The six refusal controls — the exemption's non-vacuity evidence ── |
155 | 261 | // |
156 | 262 | // Each body is one a tenant could save through Studio/REST/MCP today: |
|
0 commit comments