|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, expect, it } from 'vitest'; |
| 4 | + |
| 5 | +import { AUTHORING_RULES } from './authoring-rules.js'; |
| 6 | +import { |
| 7 | + OBJECT_FIELD_REF_UNKNOWN, |
| 8 | + validateObjectFieldRefs, |
| 9 | +} from './validate-object-field-refs.js'; |
| 10 | +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; |
| 11 | +import { SEMANTIC_ROLE_FIELD_UNKNOWN, validateSemanticRoles } from './validate-semantic-roles.js'; |
| 12 | + |
| 13 | +const obj = (over: Record<string, unknown> = {}) => ({ |
| 14 | + name: 'proj_task', |
| 15 | + label: 'Task', |
| 16 | + sharingModel: 'private', |
| 17 | + fields: { |
| 18 | + name: { type: 'text', label: 'Name' }, |
| 19 | + health_score: { type: 'number', label: 'Health Score' }, |
| 20 | + }, |
| 21 | + nameField: 'name', |
| 22 | + ...over, |
| 23 | +}); |
| 24 | + |
| 25 | +const stackOf = (over: Record<string, unknown> = {}) => ({ objects: [obj(over)] }); |
| 26 | + |
| 27 | +describe('validateObjectFieldRefs — highlightFields', () => { |
| 28 | + it('REFUSES a dangling entry at `error`, naming the rule id and the offending path', () => { |
| 29 | + const findings = validateObjectFieldRefs(stackOf({ highlightFields: ['name', 'field_10'] })); |
| 30 | + |
| 31 | + expect(findings).toHaveLength(1); |
| 32 | + expect(findings[0]).toMatchObject({ |
| 33 | + severity: 'error', |
| 34 | + rule: OBJECT_FIELD_REF_UNKNOWN, |
| 35 | + path: 'objects[0].highlightFields[1]', |
| 36 | + }); |
| 37 | + // The author must read back the string they typed and the object it was |
| 38 | + // resolved against — a finding that names neither is unactionable. |
| 39 | + expect(findings[0]!.message).toContain('field_10'); |
| 40 | + expect(findings[0]!.message).toContain('proj_task'); |
| 41 | + // …and the fields that DO exist, so the fix is one read away. |
| 42 | + expect(findings[0]!.hint).toContain('health_score'); |
| 43 | + }); |
| 44 | + |
| 45 | + it('passes a list whose every entry names a real field', () => { |
| 46 | + expect(validateObjectFieldRefs(stackOf({ highlightFields: ['name', 'health_score'] }))) |
| 47 | + .toEqual([]); |
| 48 | + }); |
| 49 | + |
| 50 | + it('reports EVERY dangling entry, not only the first', () => { |
| 51 | + const findings = validateObjectFieldRefs( |
| 52 | + stackOf({ highlightFields: ['nope_one', 'name', 'nope_two'] }), |
| 53 | + ); |
| 54 | + expect(findings.map((f) => f.path)).toEqual([ |
| 55 | + 'objects[0].highlightFields[0]', |
| 56 | + 'objects[0].highlightFields[2]', |
| 57 | + ]); |
| 58 | + }); |
| 59 | + |
| 60 | + it('judges the retired `compactLayout` spelling at the same position (raw `lint` input)', () => { |
| 61 | + // Not an accepted spelling — `ObjectSchema` refuses it and the ADR-0085 |
| 62 | + // conversion normalizes it away before the parsed tier. Read here only so |
| 63 | + // the raw `lint` path keeps the coverage this clause took over from |
| 64 | + // `validateSemanticRoles`. See the rule's module note. |
| 65 | + const findings = validateObjectFieldRefs(stackOf({ compactLayout: ['name', 'field_10'] })); |
| 66 | + expect(findings).toHaveLength(1); |
| 67 | + expect(findings[0]).toMatchObject({ |
| 68 | + severity: 'error', |
| 69 | + rule: OBJECT_FIELD_REF_UNKNOWN, |
| 70 | + path: 'objects[0].compactLayout[1]', |
| 71 | + }); |
| 72 | + }); |
| 73 | +}); |
| 74 | + |
| 75 | +describe('validateObjectFieldRefs — the Studio click path (#15254)', () => { |
| 76 | + // The reproduction from the card, in the order an author actually clicks: |
| 77 | + // 1. click-create a Number field → it is minted as `field_10` |
| 78 | + // 2. add it to `highlightFields` → the list references `field_10` |
| 79 | + // 3. set its label to "Health Score" → the API name auto-derives to |
| 80 | + // `health_score`, and `highlightFields` still says `field_10` |
| 81 | + // Naming a field after placing it is the natural order, so this is the |
| 82 | + // shape ANY author produces — not a contrived mutation. |
| 83 | + const afterDerivedRename = stackOf({ |
| 84 | + // step 3 has happened: the field is `health_score` … |
| 85 | + fields: { name: { type: 'text' }, health_score: { type: 'number', label: 'Health Score' } }, |
| 86 | + // … and step 2's reference was never rewritten. |
| 87 | + highlightFields: ['field_10'], |
| 88 | + }); |
| 89 | + |
| 90 | + it('REFUSES the derived-rename scenario `field_10` → `health_score`', () => { |
| 91 | + const findings = validateObjectFieldRefs(afterDerivedRename); |
| 92 | + expect(findings).toHaveLength(1); |
| 93 | + expect(findings[0]!.severity).toBe('error'); |
| 94 | + expect(findings[0]!.rule).toBe(OBJECT_FIELD_REF_UNKNOWN); |
| 95 | + expect(findings[0]!.path).toBe('objects[0].highlightFields[0]'); |
| 96 | + }); |
| 97 | + |
| 98 | + it('the SAME body publishes clean once the reference is rewritten', () => { |
| 99 | + expect(validateObjectFieldRefs(stackOf({ |
| 100 | + fields: { name: { type: 'text' }, health_score: { type: 'number' } }, |
| 101 | + highlightFields: ['health_score'], |
| 102 | + }))).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + it('the RUNTIME publish door refuses it — the door a Studio tenant actually has', () => { |
| 106 | + // The whole point of the card: this is the fourth wall (#4463), reached by |
| 107 | + // Studio, REST `/meta` and MCP authors alike, and it is the ONLY one a |
| 108 | + // tenant has. Before #15254 it dispatched no reference-integrity rule at |
| 109 | + // all on an object write. |
| 110 | + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)) |
| 111 | + .toContain('validateReferenceIntegrity'); |
| 112 | + |
| 113 | + const result = runRuntimeAuthoringRules({ |
| 114 | + type: 'object', |
| 115 | + item: afterDerivedRename.objects[0], |
| 116 | + context: { objects: [] }, |
| 117 | + }); |
| 118 | + |
| 119 | + const refusal = result.errors.find((f) => f.rule === OBJECT_FIELD_REF_UNKNOWN); |
| 120 | + expect(refusal, JSON.stringify(result.errors)).toBeDefined(); |
| 121 | + // Name-keyed on the wire (#10064), which is the path the card asks to see |
| 122 | + // on screen: `objects.<name>.highlightFields[i]`, never a snapshot index. |
| 123 | + expect(refusal!.path).toBe('objects.proj_task.highlightFields[0]'); |
| 124 | + expect(refusal!.severity).toBe('error'); |
| 125 | + // `rulesRun` is non-empty, so "clean" and "nothing ran" stay distinguishable. |
| 126 | + expect(result.rulesRun).toContain('validateReferenceIntegrity'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('a clean object still publishes through that door', () => { |
| 130 | + const result = runRuntimeAuthoringRules({ |
| 131 | + type: 'object', |
| 132 | + item: obj({ highlightFields: ['name', 'health_score'] }), |
| 133 | + context: { objects: [] }, |
| 134 | + }); |
| 135 | + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); |
| 136 | + }); |
| 137 | + |
| 138 | + it('does not blame this write for a STORED sibling already dangling (#4463 D4)', () => { |
| 139 | + const stored = obj({ name: 'legacy_thing', highlightFields: ['long_gone'] }); |
| 140 | + const result = runRuntimeAuthoringRules({ |
| 141 | + type: 'object', |
| 142 | + item: obj({ highlightFields: ['name'] }), |
| 143 | + context: { objects: [stored] }, |
| 144 | + }); |
| 145 | + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe('validateObjectFieldRefs — publicSharing.redactFields', () => { |
| 150 | + it('REFUSES a dangling redaction — the one that fails OPEN', () => { |
| 151 | + const findings = validateObjectFieldRefs(stackOf({ |
| 152 | + publicSharing: { enabled: true, redactFields: ['helth_score'] }, |
| 153 | + })); |
| 154 | + expect(findings).toHaveLength(1); |
| 155 | + expect(findings[0]).toMatchObject({ |
| 156 | + severity: 'error', |
| 157 | + rule: OBJECT_FIELD_REF_UNKNOWN, |
| 158 | + path: 'objects[0].publicSharing.redactFields[0]', |
| 159 | + }); |
| 160 | + // The consequence sentence is the point of the position: it is not that |
| 161 | + // the field renders short, it is that it is SERVED. |
| 162 | + expect(findings[0]!.message).toMatch(/fails OPEN/i); |
| 163 | + // A near-miss carries the suggestion. |
| 164 | + expect(findings[0]!.message).toContain('health_score'); |
| 165 | + }); |
| 166 | + |
| 167 | + it('passes a redaction list whose entries all resolve', () => { |
| 168 | + expect(validateObjectFieldRefs(stackOf({ |
| 169 | + publicSharing: { enabled: true, redactFields: ['health_score'] }, |
| 170 | + }))).toEqual([]); |
| 171 | + }); |
| 172 | +}); |
| 173 | + |
| 174 | +describe('validateObjectFieldRefs — the three shared skips', () => { |
| 175 | + it('skip 3: a registry-injected system column is a LIVE pointer, not a miss (#5378)', () => { |
| 176 | + expect(validateObjectFieldRefs({ |
| 177 | + objects: [obj({ ownership: 'user', highlightFields: ['name', 'owner_id'] })], |
| 178 | + })).toEqual([]); |
| 179 | + }); |
| 180 | + |
| 181 | + it('skip 3, the other direction: `ownership: none` injects no owner_id, so it IS a miss', () => { |
| 182 | + const findings = validateObjectFieldRefs({ |
| 183 | + objects: [obj({ ownership: 'none', highlightFields: ['owner_id'] })], |
| 184 | + }); |
| 185 | + expect(findings.map((f) => f.rule)).toEqual([OBJECT_FIELD_REF_UNKNOWN]); |
| 186 | + }); |
| 187 | + |
| 188 | + it('skip 2: an object with no readable field map is never judged (ADR-0015 external)', () => { |
| 189 | + expect(validateObjectFieldRefs({ |
| 190 | + objects: [{ |
| 191 | + name: 'remote_thing', |
| 192 | + external: { remoteName: 'things', writable: false }, |
| 193 | + highlightFields: ['whatever_the_remote_calls_it'], |
| 194 | + }], |
| 195 | + })).toEqual([]); |
| 196 | + }); |
| 197 | + |
| 198 | + it('is inert on junk: no objects, junk entries, non-array lists', () => { |
| 199 | + expect(validateObjectFieldRefs({})).toEqual([]); |
| 200 | + expect(validateObjectFieldRefs({ objects: [null, 7, 'x'] as never })).toEqual([]); |
| 201 | + expect(validateObjectFieldRefs(stackOf({ highlightFields: 'not-an-array' }))).toEqual([]); |
| 202 | + expect(validateObjectFieldRefs(stackOf({ highlightFields: [null, 3, ''] }))).toEqual([]); |
| 203 | + }); |
| 204 | +}); |
| 205 | + |
| 206 | +describe('the clause that moved out of validateSemanticRoles', () => { |
| 207 | + it('semantic roles no longer double-reports highlightFields EXISTENCE', () => { |
| 208 | + const stack = stackOf({ highlightFields: ['field_10'] }); |
| 209 | + const semantic = validateSemanticRoles(stack).filter( |
| 210 | + (f) => f.rule === SEMANTIC_ROLE_FIELD_UNKNOWN && f.path.includes('highlightFields'), |
| 211 | + ); |
| 212 | + expect(semantic, JSON.stringify(semantic)).toEqual([]); |
| 213 | + // One finding on the path, at the gating tier, not two at two tiers. |
| 214 | + expect(validateObjectFieldRefs(stack)).toHaveLength(1); |
| 215 | + }); |
| 216 | + |
| 217 | + it('semantic roles KEEPS stageField — a scalar role pointer, still advisory', () => { |
| 218 | + const findings = validateSemanticRoles(stackOf({ stageField: 'pipeline' })); |
| 219 | + expect(findings.map((f) => f.rule)).toContain(SEMANTIC_ROLE_FIELD_UNKNOWN); |
| 220 | + expect(findings.find((f) => f.rule === SEMANTIC_ROLE_FIELD_UNKNOWN)!.severity).toBe('warning'); |
| 221 | + // …and this rule does not take it: lists only, see the module note. |
| 222 | + expect(validateObjectFieldRefs(stackOf({ stageField: 'pipeline' }))).toEqual([]); |
| 223 | + }); |
| 224 | + |
| 225 | + it('semantic roles KEEPS the PROVENANCE question at the highlightFields position', () => { |
| 226 | + // An ADR-0015 external object: the injected anchor RESOLVES (so this rule |
| 227 | + // is silent, skip 3) but nothing provisions storage behind it (#8116). |
| 228 | + const external = { |
| 229 | + name: 'remote_thing', |
| 230 | + external: { remoteName: 'things', writable: false }, |
| 231 | + fields: { email: { type: 'text' } }, |
| 232 | + ownership: 'user', |
| 233 | + highlightFields: ['email', 'owner_id'], |
| 234 | + }; |
| 235 | + const findings = validateSemanticRoles({ objects: [external] }); |
| 236 | + expect(findings.map((f) => f.rule)).toContain('semantic-role-field-unprovisioned'); |
| 237 | + }); |
| 238 | +}); |
| 239 | + |
| 240 | +describe('registry wiring', () => { |
| 241 | + it('reaches all three commands through the reference-integrity suite entry', () => { |
| 242 | + const entry = AUTHORING_RULES.find((r) => r.name === 'validateReferenceIntegrity')!; |
| 243 | + expect(entry.tier).toBe('gating'); |
| 244 | + expect(entry.commands).toEqual(expect.arrayContaining(['validate', 'build', 'lint'])); |
| 245 | + expect(entry.surfaces).toContain('runtime-publish'); |
| 246 | + expect(entry.runtimeTypes).toContain('object'); |
| 247 | + }); |
| 248 | +}); |
0 commit comments