|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Tests for the unknown-authoring-key lint (#3786). |
| 4 | + * Tests for the unknown-authoring-key CORE (#3786): the comparator and the |
| 5 | + * curated guidance tables. The stack walker that applies them across every |
| 6 | + * metadata collection is tested in `kernel/metadata-authoring-lint.test.ts`. |
5 | 7 | * |
6 | | - * Two jobs: prove the rule actually fires on the drifts that motivated it, and |
7 | | - * hold the guidance tables to the same non-rotting discipline the #4045 / #4040 |
8 | | - * ledgers use — a `to` that names a key the schema no longer declares is advice |
9 | | - * pointing into a void, which is worse than no advice. |
| 8 | + * The guidance tables are held to the #4045 / #4040 non-rotting discipline — a |
| 9 | + * `to` that names a key the schema no longer declares is advice pointing into a |
| 10 | + * void, which is worse than no advice. |
10 | 11 | */ |
11 | 12 |
|
12 | 13 | import { describe, it, expect } from 'vitest'; |
13 | 14 |
|
14 | 15 | import { |
15 | | - lintUnknownAuthoringKeys, |
| 16 | + lintAuthoredRecordKeys, |
16 | 17 | formatUnknownAuthoringKey, |
17 | 18 | FIELD_KEY_GUIDANCE, |
18 | 19 | OBJECT_KEY_GUIDANCE, |
| 20 | + type UnknownAuthoringKeyFinding, |
19 | 21 | } from './authoring-key-lint'; |
20 | 22 | import { ObjectSchema } from './object.zod'; |
21 | 23 | import { FieldSchema } from './field.zod'; |
22 | 24 |
|
23 | 25 | const shapeKeys = (s: unknown) => Object.keys((s as { shape: Record<string, unknown> }).shape); |
24 | 26 |
|
25 | | -/** A minimal well-formed stack with one object and one field. */ |
26 | | -const stackWith = (obj: Record<string, unknown>, field: Record<string, unknown>) => ({ |
27 | | - objects: [ |
28 | | - { name: 'crm_case', label: 'Case', fields: { owner: { label: 'Owner', type: 'text', ...field } }, ...obj }, |
29 | | - ], |
30 | | -}); |
31 | | - |
32 | | -describe('lintUnknownAuthoringKeys (#3786)', () => { |
33 | | - it('is silent on a clean stack', () => { |
34 | | - expect(lintUnknownAuthoringKeys(stackWith({}, {}))).toEqual([]); |
35 | | - }); |
36 | | - |
37 | | - it('reports a field key the schema does not declare', () => { |
38 | | - const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { pii: true })); |
39 | | - expect(rest).toEqual([]); |
40 | | - expect(finding).toMatchObject({ |
41 | | - path: 'objects.crm_case.fields.owner.pii', |
42 | | - surface: 'field', |
43 | | - key: 'pii', |
44 | | - }); |
45 | | - // A retired key gets a prescription, not a "did you mean" into a void. |
46 | | - expect(finding.guidance).toBeTruthy(); |
47 | | - expect(finding.suggestion).toBeUndefined(); |
| 27 | +function runComparator( |
| 28 | + record: Record<string, unknown>, |
| 29 | + declared: readonly string[], |
| 30 | + guidance: Readonly<Record<string, { to?: string; why?: string }>> = {}, |
| 31 | +): UnknownAuthoringKeyFinding[] { |
| 32 | + const out: UnknownAuthoringKeyFinding[] = []; |
| 33 | + lintAuthoredRecordKeys(record, new Set(declared), guidance, 'field', 'p', out); |
| 34 | + return out; |
| 35 | +} |
| 36 | + |
| 37 | +describe('lintAuthoredRecordKeys (#3786)', () => { |
| 38 | + it('is silent when every key is declared', () => { |
| 39 | + expect(runComparator({ a: 1, b: 2 }, ['a', 'b', 'c'])).toEqual([]); |
48 | 40 | }); |
49 | 41 |
|
50 | | - it('reports an object key the schema does not declare, with the rename', () => { |
51 | | - const [finding] = lintUnknownAuthoringKeys(stackWith({ capabilities: { trackHistory: true } }, {})); |
52 | | - expect(finding).toMatchObject({ |
53 | | - path: 'objects.crm_case.capabilities', |
54 | | - surface: 'object', |
55 | | - key: 'capabilities', |
56 | | - suggestion: 'enable', |
57 | | - }); |
| 42 | + it('reports an undeclared key with an edit-distance suggestion for a typo', () => { |
| 43 | + const [f] = runComparator({ requred: true }, ['required', 'label']); |
| 44 | + expect(f).toMatchObject({ path: 'p.requred', key: 'requred', suggestion: 'required' }); |
58 | 45 | }); |
59 | 46 |
|
60 | | - /** |
61 | | - * The exact set #4120 found rendering in `object.form.ts` while saving |
62 | | - * nothing. If the lint had existed, each of these would have been one warning |
63 | | - * rather than releases of a dead toggle. |
64 | | - */ |
65 | | - const FORM_DRIFT_KEYS = [ |
66 | | - 'indexed', 'immutable', 'filterable', 'placeholder', 'validation', 'errorMessage', |
67 | | - 'audit', 'pii', 'encrypted', 'startingNumber', |
68 | | - 'referenceFilter', 'cascadeDelete', 'formula', 'displayFormat', 'summaryType', 'summaryField', |
69 | | - ] as const; |
70 | | - |
71 | | - it.each(FORM_DRIFT_KEYS)('catches the #4120 drift key %s and says something actionable', (key) => { |
72 | | - const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { [key]: 'x' })); |
73 | | - expect(finding, `${key} should be reported`).toBeDefined(); |
74 | | - expect(rest).toEqual([]); |
75 | | - expect(finding.key).toBe(key); |
76 | | - // Either a rename target or a retirement reason — never a bare "unknown". |
77 | | - expect( |
78 | | - finding.suggestion ?? finding.guidance, |
79 | | - `${key} needs a rename target or a retirement reason`, |
80 | | - ).toBeTruthy(); |
81 | | - expect(formatUnknownAuthoringKey(finding)).toContain(key); |
82 | | - }); |
83 | | - |
84 | | - it('falls back to edit distance for a plain typo', () => { |
85 | | - const [finding] = lintUnknownAuthoringKeys(stackWith({}, { requred: true })); |
86 | | - expect(finding.suggestion).toBe('required'); |
| 47 | + it('a retirement suppresses the edit-distance fallback', () => { |
| 48 | + // `pii` is 3 edits from `min` — "did you mean min?" reads as advice while |
| 49 | + // being nonsense. A `why` with no `to` must yield guidance and NO suggestion. |
| 50 | + const [f] = runComparator({ pii: true }, ['min', 'max'], FIELD_KEY_GUIDANCE); |
| 51 | + expect(f.guidance).toBeTruthy(); |
| 52 | + expect(f.suggestion).toBeUndefined(); |
87 | 53 | }); |
88 | 54 |
|
89 | | - it('ignores the underscore-prefixed packaging channel', () => { |
90 | | - const findings = lintUnknownAuthoringKeys( |
91 | | - stackWith({ _packageId: 'p', _lock: true }, { _provenance: 'x' }), |
92 | | - ); |
93 | | - expect(findings).toEqual([]); |
| 55 | + it('a rename wins over edit distance', () => { |
| 56 | + const [f] = runComparator({ capabilities: {} }, ['enable', 'label'], OBJECT_KEY_GUIDANCE); |
| 57 | + expect(f.suggestion).toBe('enable'); |
94 | 58 | }); |
95 | 59 |
|
96 | | - it('reports every offending key, across objects and fields', () => { |
97 | | - const findings = lintUnknownAuthoringKeys({ |
98 | | - objects: [ |
99 | | - { name: 'a', label: 'A', capabilities: {}, fields: { x: { type: 'text', pii: true } } }, |
100 | | - { name: 'b', label: 'B', fields: { y: { type: 'text', indexed: true } } }, |
101 | | - ], |
102 | | - }); |
103 | | - expect(findings.map((f) => f.path).sort()).toEqual([ |
104 | | - 'objects.a.capabilities', |
105 | | - 'objects.a.fields.x.pii', |
106 | | - 'objects.b.fields.y.indexed', |
107 | | - ]); |
| 60 | + it('skips the underscore-prefixed packaging channel', () => { |
| 61 | + expect(runComparator({ _packageId: 'p', _lock: true, _provenance: 'x' }, ['label'])).toEqual([]); |
108 | 62 | }); |
109 | 63 |
|
110 | | - it('survives malformed input rather than throwing', () => { |
111 | | - // The lint runs before the parse, so it is handed whatever the author wrote. |
112 | | - for (const junk of [undefined, null, 42, 'x', {}, { objects: 'nope' }, { objects: [null, 7] }]) { |
113 | | - expect(() => lintUnknownAuthoringKeys(junk)).not.toThrow(); |
114 | | - } |
115 | | - expect(lintUnknownAuthoringKeys({ objects: [{ name: 'a', fields: 'nope' }] })).toEqual([]); |
| 64 | + it('formats guidance over suggestion over bare', () => { |
| 65 | + const base = { path: 'p.k', surface: 'field', key: 'k' } as UnknownAuthoringKeyFinding; |
| 66 | + expect(formatUnknownAuthoringKey({ ...base, guidance: 'gone.' })).toContain('gone.'); |
| 67 | + expect(formatUnknownAuthoringKey({ ...base, suggestion: 's' })).toContain("did you mean 's'"); |
| 68 | + expect(formatUnknownAuthoringKey(base)).toMatch(/dropped at load\.$/); |
116 | 69 | }); |
117 | 70 | }); |
118 | 71 |
|
|
0 commit comments