@mmnto/totem 1.118.0 → 1.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/admission.d.ts +12 -12
- package/dist/artifacts/verdict.d.ts +16 -16
- package/dist/compile-manifest.d.ts +89 -0
- package/dist/compile-manifest.d.ts.map +1 -1
- package/dist/compile-manifest.js +180 -31
- package/dist/compile-manifest.js.map +1 -1
- package/dist/compile-manifest.test.js +169 -1
- package/dist/compile-manifest.test.js.map +1 -1
- package/dist/compiler-schema.d.ts +1067 -0
- package/dist/compiler-schema.d.ts.map +1 -1
- package/dist/compiler-schema.js +204 -1
- package/dist/compiler-schema.js.map +1 -1
- package/dist/compiler-schema.test.js +92 -0
- package/dist/compiler-schema.test.js.map +1 -1
- package/dist/compiler.d.ts +2 -6
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +6 -17
- package/dist/compiler.js.map +1 -1
- package/dist/freeze.d.ts +3 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -12
- package/dist/lesson-format.d.ts +5 -2
- package/dist/lesson-format.d.ts.map +1 -1
- package/dist/lesson-format.js +16 -5
- package/dist/lesson-format.js.map +1 -1
- package/dist/lesson-format.test.js +11 -0
- package/dist/lesson-format.test.js.map +1 -1
- package/dist/regex-safety/apply-rules-bounded.d.ts +11 -0
- package/dist/regex-safety/apply-rules-bounded.d.ts.map +1 -1
- package/dist/regex-safety/apply-rules-bounded.js +59 -5
- package/dist/regex-safety/apply-rules-bounded.js.map +1 -1
- package/dist/regex-validation.d.ts +7 -0
- package/dist/regex-validation.d.ts.map +1 -0
- package/dist/regex-validation.js +32 -0
- package/dist/regex-validation.js.map +1 -0
- package/dist/rule-engine.d.ts +11 -1
- package/dist/rule-engine.d.ts.map +1 -1
- package/dist/rule-engine.js +125 -18
- package/dist/rule-engine.js.map +1 -1
- package/dist/spine/authored-rule.d.ts +669 -245
- package/dist/spine/authored-rule.d.ts.map +1 -1
- package/dist/spine/authored-rule.js +113 -27
- package/dist/spine/authored-rule.js.map +1 -1
- package/dist/spine/authored-rule.test.js +170 -4
- package/dist/spine/authored-rule.test.js.map +1 -1
- package/dist/spine/authoring-ledger.d.ts +69 -10
- package/dist/spine/authoring-ledger.d.ts.map +1 -1
- package/dist/spine/authoring-ledger.js +46 -25
- package/dist/spine/authoring-ledger.js.map +1 -1
- package/dist/spine/authoring-ledger.test.js +20 -7
- package/dist/spine/authoring-ledger.test.js.map +1 -1
- package/dist/spine/candidate-rule.d.ts +18 -1
- package/dist/spine/candidate-rule.d.ts.map +1 -1
- package/dist/spine/candidate-rule.js.map +1 -1
- package/dist/spine/cert-corpus-seed.d.ts +8 -8
- package/dist/spine/compile.d.ts +4 -1
- package/dist/spine/compile.d.ts.map +1 -1
- package/dist/spine/compile.js +50 -3
- package/dist/spine/compile.js.map +1 -1
- package/dist/spine/compile.test.js +189 -23
- package/dist/spine/compile.test.js.map +1 -1
- package/dist/spine/frozen-split.d.ts +18 -18
- package/dist/spine/ledgers.d.ts +24 -24
- package/dist/spine/lf-normalize.d.ts +9 -0
- package/dist/spine/lf-normalize.d.ts.map +1 -0
- package/dist/spine/lf-normalize.js +38 -0
- package/dist/spine/lf-normalize.js.map +1 -0
- package/dist/spine/preimage-differential.d.ts +3 -1
- package/dist/spine/preimage-differential.d.ts.map +1 -1
- package/dist/spine/preimage-differential.js +9 -6
- package/dist/spine/preimage-differential.js.map +1 -1
- package/dist/spine/preimage-differential.test.js +59 -0
- package/dist/spine/preimage-differential.test.js.map +1 -1
- package/dist/spine/record-exemplars.fixture.d.ts +5 -0
- package/dist/spine/record-exemplars.fixture.d.ts.map +1 -0
- package/dist/spine/record-exemplars.fixture.js +61 -0
- package/dist/spine/record-exemplars.fixture.js.map +1 -0
- package/dist/spine/record-lower.d.ts +90 -0
- package/dist/spine/record-lower.d.ts.map +1 -0
- package/dist/spine/record-lower.js +418 -0
- package/dist/spine/record-lower.js.map +1 -0
- package/dist/spine/record-lower.test.d.ts +2 -0
- package/dist/spine/record-lower.test.d.ts.map +1 -0
- package/dist/spine/record-lower.test.js +562 -0
- package/dist/spine/record-lower.test.js.map +1 -0
- package/dist/spine/record-runtime.d.ts +215 -0
- package/dist/spine/record-runtime.d.ts.map +1 -0
- package/dist/spine/record-runtime.js +330 -0
- package/dist/spine/record-runtime.js.map +1 -0
- package/dist/spine/record-runtime.test.d.ts +2 -0
- package/dist/spine/record-runtime.test.d.ts.map +1 -0
- package/dist/spine/record-runtime.test.js +814 -0
- package/dist/spine/record-runtime.test.js.map +1 -0
- package/dist/spine/rule-record.d.ts +864 -0
- package/dist/spine/rule-record.d.ts.map +1 -0
- package/dist/spine/rule-record.js +781 -0
- package/dist/spine/rule-record.js.map +1 -0
- package/dist/spine/rule-record.test.d.ts +2 -0
- package/dist/spine/rule-record.test.d.ts.map +1 -0
- package/dist/spine/rule-record.test.js +1262 -0
- package/dist/spine/rule-record.test.js.map +1 -0
- package/dist/spine/windtunnel-firing.d.ts.map +1 -1
- package/dist/spine/windtunnel-firing.js +28 -1
- package/dist/spine/windtunnel-firing.js.map +1 -1
- package/dist/spine/windtunnel-lock.d.ts +10 -10
- package/dist/stage4-verifier.d.ts.map +1 -1
- package/dist/stage4-verifier.js +61 -17
- package/dist/stage4-verifier.js.map +1 -1
- package/dist/sys/glob.d.ts +30 -0
- package/dist/sys/glob.d.ts.map +1 -1
- package/dist/sys/glob.js +62 -1
- package/dist/sys/glob.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
// ─── Prop 310 § Design 14 — the V1 record-grammar CONFORMANCE SUITE ──────────
|
|
2
|
+
//
|
|
3
|
+
// § Design 14 flips normativity: the spec is normative and the parser is its
|
|
4
|
+
// implementation. This suite is the conformance instrument that flip names — one
|
|
5
|
+
// negative fixture per banned-silent behaviour (silent default / silent skip /
|
|
6
|
+
// silent expansion / silent promotion), one per § Design 7 dialect rule, plus the
|
|
7
|
+
// closed-key and inexpressible-key closure at multiple depths. § Falsifying
|
|
8
|
+
// Metric: "any shipped parser behavior that silently defaults, skips, expands, or
|
|
9
|
+
// promotes where this spec mandates an error" falsifies the grammar — these
|
|
10
|
+
// fixtures are how that is mechanically checked.
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
|
13
|
+
import { DeclaredEngineSchema } from './authored-rule.js';
|
|
14
|
+
import { checkGlobDialect, CURATION_PROCESS_FIELDS, GLOB_DIALECT_RULES, LEGACY_ENGINE_DETAIL, parseRuleRecord, RULE_RECORD_FORBIDDEN_PROTOTYPE_KEYS, RULE_RECORD_INEXPRESSIBLE_KEYS, RULE_RECORD_SCHEMA_VERSION, ruleExamplePairHash, RuleRecordNoSilentSkipError, RuleRecordParseError, RuleRecordProducerKeyError, RuleRecordPrototypeKeyError, RuleRecordSchema, RuleTargetTypeSchema, } from './rule-record.js';
|
|
15
|
+
const FILE = '.totem/rules/fail-open-catch.rule.yaml';
|
|
16
|
+
// Built via char codes, never source escapes, so no authoring layer can mangle
|
|
17
|
+
// the control bytes these CR-blindness fixtures depend on.
|
|
18
|
+
const CR = String.fromCharCode(13);
|
|
19
|
+
const LF = String.fromCharCode(10);
|
|
20
|
+
/**
|
|
21
|
+
* Prop 310 § Design 4's exemplar record, transcribed (comments stripped). This
|
|
22
|
+
* document MUST parse — it is the spec's own coherent exemplar.
|
|
23
|
+
*/
|
|
24
|
+
const DESIGN4_EXEMPLAR = [
|
|
25
|
+
'schemaVersion: 1',
|
|
26
|
+
'',
|
|
27
|
+
'severity: error',
|
|
28
|
+
'message: "Catch blocks must re-throw or route the error — fail-open catch is banned."',
|
|
29
|
+
'recoveryHint: "Re-throw after logging, or route to the error channel."',
|
|
30
|
+
'',
|
|
31
|
+
'target:',
|
|
32
|
+
' type: ast-grep',
|
|
33
|
+
' language: typescript',
|
|
34
|
+
' rule:',
|
|
35
|
+
' kind: catch_clause',
|
|
36
|
+
' not: { has: { kind: throw_statement, stopBy: end } }',
|
|
37
|
+
' scope:',
|
|
38
|
+
' fileGlobs: ["packages/**/*.ts"]',
|
|
39
|
+
' excludeGlobs: ["**/*.test.ts"]',
|
|
40
|
+
'',
|
|
41
|
+
'examples:',
|
|
42
|
+
' - bad: "try { risky() } catch (e) { log(e) }"',
|
|
43
|
+
' good: "try { risky() } catch (e) { log(e); throw e }"',
|
|
44
|
+
'',
|
|
45
|
+
'curation:',
|
|
46
|
+
' sourceLesson: "lesson-77c5e668389fb1a2"',
|
|
47
|
+
' curatedBy: "strategy-gemini"',
|
|
48
|
+
' curatedAt: "2026-06-15T14:00:00Z"',
|
|
49
|
+
' baseline5Phase: 3',
|
|
50
|
+
'',
|
|
51
|
+
'verification_shadow:',
|
|
52
|
+
' type: rego',
|
|
53
|
+
' source: |',
|
|
54
|
+
' package totem.rules.example',
|
|
55
|
+
'',
|
|
56
|
+
].join(LF);
|
|
57
|
+
/**
|
|
58
|
+
* Prop 310 § Design 8's `requires:` exemplar, transcribed (comments stripped).
|
|
59
|
+
* This document MUST parse — it is the spec's coherent absence/must-contain shape.
|
|
60
|
+
*/
|
|
61
|
+
const DESIGN8_EXEMPLAR = [
|
|
62
|
+
'schemaVersion: 1',
|
|
63
|
+
'severity: warning',
|
|
64
|
+
'message: "git output-consuming commands must pin LC_ALL=C on the same line."',
|
|
65
|
+
'target:',
|
|
66
|
+
' type: regex',
|
|
67
|
+
" pattern: '\\bgit\\s+(log|diff|status)\\b'",
|
|
68
|
+
' scope:',
|
|
69
|
+
' fileGlobs: ["**/*.sh", "**/*.cjs"]',
|
|
70
|
+
'requires:',
|
|
71
|
+
" pattern: 'LC_ALL=C'",
|
|
72
|
+
' scope: line',
|
|
73
|
+
'examples:',
|
|
74
|
+
' - bad: "git log --oneline"',
|
|
75
|
+
' good: "LC_ALL=C git log --oneline"',
|
|
76
|
+
'',
|
|
77
|
+
].join(LF);
|
|
78
|
+
/** A minimal dialect-clean ast-grep record, mutated per negative fixture. */
|
|
79
|
+
function astGrepRecord() {
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
severity: 'error',
|
|
83
|
+
message: 'Catch blocks must re-throw or route the error.',
|
|
84
|
+
target: {
|
|
85
|
+
type: 'ast-grep',
|
|
86
|
+
language: 'typescript',
|
|
87
|
+
rule: { kind: 'catch_clause' },
|
|
88
|
+
scope: { fileGlobs: ['packages/**/*.ts'] },
|
|
89
|
+
},
|
|
90
|
+
examples: [
|
|
91
|
+
{
|
|
92
|
+
bad: 'try { risky() } catch (e) { log(e) }',
|
|
93
|
+
good: 'try { risky() } catch (e) { throw e }',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A minimal dialect-clean regex record, mutated per negative fixture. */
|
|
99
|
+
function regexRecord() {
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
severity: 'warning',
|
|
103
|
+
message: 'git output-consuming commands must pin LC_ALL=C on the same line.',
|
|
104
|
+
target: {
|
|
105
|
+
type: 'regex',
|
|
106
|
+
pattern: 'git log',
|
|
107
|
+
scope: { fileGlobs: ['**/*.sh'] },
|
|
108
|
+
},
|
|
109
|
+
examples: [{ bad: 'git log --oneline', good: 'LC_ALL=C git log --oneline' }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function target(record) {
|
|
113
|
+
return record.target;
|
|
114
|
+
}
|
|
115
|
+
function scope(record) {
|
|
116
|
+
return target(record).scope;
|
|
117
|
+
}
|
|
118
|
+
function parseObject(record, file = FILE) {
|
|
119
|
+
return parseRuleRecord(yamlStringify(record), file);
|
|
120
|
+
}
|
|
121
|
+
/** Assert the record is REJECTED and return the error, so a fixture can pin its class + path. */
|
|
122
|
+
function reject(record, file = FILE) {
|
|
123
|
+
let thrown;
|
|
124
|
+
try {
|
|
125
|
+
parseObject(record, file);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
thrown = err;
|
|
129
|
+
}
|
|
130
|
+
expect(thrown).toBeInstanceOf(RuleRecordParseError);
|
|
131
|
+
const failure = thrown;
|
|
132
|
+
// § Failure modes — every diagnostic names the FILE.
|
|
133
|
+
expect(failure.message).toContain(file);
|
|
134
|
+
expect(failure.filePath).toBe(file);
|
|
135
|
+
return failure;
|
|
136
|
+
}
|
|
137
|
+
/** Assert a raw YAML document is REJECTED (for carrier-level fixtures). */
|
|
138
|
+
function rejectRaw(content, file = FILE) {
|
|
139
|
+
let thrown;
|
|
140
|
+
try {
|
|
141
|
+
parseRuleRecord(content, file);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
thrown = err;
|
|
145
|
+
}
|
|
146
|
+
expect(thrown).toBeInstanceOf(RuleRecordParseError);
|
|
147
|
+
const failure = thrown;
|
|
148
|
+
expect(failure.message).toContain(file);
|
|
149
|
+
return failure;
|
|
150
|
+
}
|
|
151
|
+
// ── Positives ────────────────────────────────────────────────────────────────
|
|
152
|
+
describe('Prop 310 exemplars parse (§ Design 4, § Design 8)', () => {
|
|
153
|
+
it('parses § Design 4’s exemplar record verbatim', () => {
|
|
154
|
+
const parsed = parseRuleRecord(DESIGN4_EXEMPLAR, FILE);
|
|
155
|
+
expect(parsed.record.schemaVersion).toBe(RULE_RECORD_SCHEMA_VERSION);
|
|
156
|
+
expect(parsed.record.severity).toBe('error');
|
|
157
|
+
expect(parsed.record.message).toContain('fail-open catch is banned');
|
|
158
|
+
expect(parsed.record.recoveryHint).toBe('Re-throw after logging, or route to the error channel.');
|
|
159
|
+
expect(parsed.record.target.type).toBe('ast-grep');
|
|
160
|
+
expect(parsed.record.target.language).toBe('typescript');
|
|
161
|
+
expect(parsed.record.target.pattern).toBeUndefined();
|
|
162
|
+
expect(parsed.record.target.scope.fileGlobs).toEqual(['packages/**/*.ts']);
|
|
163
|
+
expect(parsed.record.target.scope.excludeGlobs).toEqual(['**/*.test.ts']);
|
|
164
|
+
expect(parsed.record.examples).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
it('carries the § Design 4 compound payload interior verbatim (IR-2 — opaque at parse)', () => {
|
|
167
|
+
const parsed = parseRuleRecord(DESIGN4_EXEMPLAR, FILE);
|
|
168
|
+
// NapiConfig structural validation is the COMPILE-stage engine gate (slice 2);
|
|
169
|
+
// the record grammar closes its own key space, not the engine's.
|
|
170
|
+
expect(parsed.record.target.rule).toEqual({
|
|
171
|
+
kind: 'catch_clause',
|
|
172
|
+
not: { has: { kind: 'throw_statement', stopBy: 'end' } },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
it('carries the § Design 4 curation block under its camelCase keys', () => {
|
|
176
|
+
const parsed = parseRuleRecord(DESIGN4_EXEMPLAR, FILE);
|
|
177
|
+
expect(parsed.record.curation).toEqual({
|
|
178
|
+
sourceLesson: 'lesson-77c5e668389fb1a2',
|
|
179
|
+
curatedBy: 'strategy-gemini',
|
|
180
|
+
curatedAt: '2026-06-15T14:00:00Z',
|
|
181
|
+
baseline5Phase: 3,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
it('retains `verification_shadow` verbatim under its deliberate snake_case name (§ Design 4 named exception)', () => {
|
|
185
|
+
const parsed = parseRuleRecord(DESIGN4_EXEMPLAR, FILE);
|
|
186
|
+
expect(parsed.record.verification_shadow).toEqual({
|
|
187
|
+
type: 'rego',
|
|
188
|
+
source: `package totem.rules.example${LF}`,
|
|
189
|
+
});
|
|
190
|
+
// The key is NEVER renamed — it is already reserved in shipped forward-compat readers.
|
|
191
|
+
expect(Object.keys(parsed.record)).toContain('verification_shadow');
|
|
192
|
+
expect(Object.keys(parsed.record)).not.toContain('verificationShadow');
|
|
193
|
+
});
|
|
194
|
+
it('parses § Design 8’s `requires:` exemplar verbatim', () => {
|
|
195
|
+
const parsed = parseRuleRecord(DESIGN8_EXEMPLAR, FILE);
|
|
196
|
+
expect(parsed.record.target.type).toBe('regex');
|
|
197
|
+
expect(parsed.record.target.language).toBeUndefined();
|
|
198
|
+
expect(parsed.record.requires).toEqual({ pattern: 'LC_ALL=C', scope: 'line' });
|
|
199
|
+
expect(parsed.record.target.scope.fileGlobs).toEqual(['**/*.sh', '**/*.cjs']);
|
|
200
|
+
});
|
|
201
|
+
it('accepts `requires.scope: file` — the other implemented V1 unit (§ Design 8)', () => {
|
|
202
|
+
const record = regexRecord();
|
|
203
|
+
record.requires = { pattern: 'LC_ALL=C', scope: 'file' };
|
|
204
|
+
expect(parseObject(record).record.requires).toEqual({ pattern: 'LC_ALL=C', scope: 'file' });
|
|
205
|
+
});
|
|
206
|
+
it('accepts an optional record with neither requires, curation, nor verification_shadow', () => {
|
|
207
|
+
const parsed = parseObject(astGrepRecord());
|
|
208
|
+
expect(parsed.record.requires).toBeUndefined();
|
|
209
|
+
expect(parsed.record.curation).toBeUndefined();
|
|
210
|
+
expect(parsed.record.verification_shadow).toBeUndefined();
|
|
211
|
+
expect(parsed.record.recoveryHint).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
it('accepts a flat `pattern:` ast-grep payload (the other half of the XOR)', () => {
|
|
214
|
+
const record = astGrepRecord();
|
|
215
|
+
delete target(record).rule;
|
|
216
|
+
target(record).pattern = 'try { $$$ } catch ($E) { $$$ }';
|
|
217
|
+
expect(parseObject(record).record.target.pattern).toBe('try { $$$ } catch ($E) { $$$ }');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('§ Design 3 — `declaredEngine` is DERIVED from `target.type`, never authored', () => {
|
|
221
|
+
it('maps `type: ast-grep` to derivedEngine `ast-grep`', () => {
|
|
222
|
+
expect(parseRuleRecord(DESIGN4_EXEMPLAR, FILE).derivedEngine).toBe('ast-grep');
|
|
223
|
+
});
|
|
224
|
+
it('maps `type: regex` to derivedEngine `regex`', () => {
|
|
225
|
+
expect(parseRuleRecord(DESIGN8_EXEMPLAR, FILE).derivedEngine).toBe('regex');
|
|
226
|
+
});
|
|
227
|
+
it('keeps the V1 target enum a SUBSET of the reader-side DeclaredEngineSchema (R16)', () => {
|
|
228
|
+
// The compile-time half is the `_ruleTargetTypeSubsetCheck` assignment in
|
|
229
|
+
// rule-record.ts; this is the runtime half. A target-enum widening that
|
|
230
|
+
// outruns the enum the compiled artifact is read under fails HERE.
|
|
231
|
+
for (const option of RuleTargetTypeSchema.options) {
|
|
232
|
+
expect(DeclaredEngineSchema.options).toContain(option);
|
|
233
|
+
}
|
|
234
|
+
// `ast` is the reader-side inert legacy member the AUTHORING enum drops (R16).
|
|
235
|
+
expect(DeclaredEngineSchema.options).toContain('ast');
|
|
236
|
+
expect(RuleTargetTypeSchema.options).not.toContain('ast');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('§ Design 10 / Amendment 1 item 3 — CR-blind per-pair content hashes', () => {
|
|
240
|
+
it('emits one hash per examples[i], in ordinal order', () => {
|
|
241
|
+
const record = astGrepRecord();
|
|
242
|
+
record.examples = [
|
|
243
|
+
{ bad: 'bad-0', good: 'good-0' },
|
|
244
|
+
{ bad: 'bad-1', good: 'good-1' },
|
|
245
|
+
{ bad: 'bad-2', good: 'good-2' },
|
|
246
|
+
];
|
|
247
|
+
const parsed = parseObject(record);
|
|
248
|
+
expect(parsed.examplePairHashes.map((h) => h.ordinal)).toEqual([0, 1, 2]);
|
|
249
|
+
expect(new Set(parsed.examplePairHashes.map((h) => h.hash)).size).toBe(3);
|
|
250
|
+
for (const entry of parsed.examplePairHashes) {
|
|
251
|
+
expect(entry.hash).toMatch(/^[0-9a-f]{64}$/);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
it('a CRLF-authored FILE parses to the same hashes — pins YAML line-break normalization, NOT the CR-blind hash', () => {
|
|
255
|
+
// Deliberately weak, and labelled so: `split(LF).join(CRLF)` puts CR only into
|
|
256
|
+
// LINE BREAKS, which the YAML reader normalizes before this module sees a
|
|
257
|
+
// value. It pins the file-level authoring case; the load-bearing CR-blindness
|
|
258
|
+
// fixture is the escape-smuggled one below, where a real CR reaches a parsed
|
|
259
|
+
// value (the trial's P3 `serialization-admit` class).
|
|
260
|
+
const lfDoc = DESIGN4_EXEMPLAR;
|
|
261
|
+
const crlfDoc = lfDoc.split(LF).join(CR + LF);
|
|
262
|
+
expect(crlfDoc).not.toBe(lfDoc);
|
|
263
|
+
expect(parseRuleRecord(crlfDoc, FILE).examplePairHashes).toEqual(parseRuleRecord(lfDoc, FILE).examplePairHashes);
|
|
264
|
+
});
|
|
265
|
+
it('hashes an escape-smuggled CRLF exemplar identically to its LF twin (the trial’s P3 `serialization-admit` class)', () => {
|
|
266
|
+
const lfRecord = astGrepRecord();
|
|
267
|
+
lfRecord.examples = [{ bad: `a${LF}b`, good: `c${LF}d` }];
|
|
268
|
+
const crlfRecord = astGrepRecord();
|
|
269
|
+
crlfRecord.examples = [{ bad: `a${CR}${LF}b`, good: `c${CR}${LF}d` }];
|
|
270
|
+
const crlfParsed = parseObject(crlfRecord);
|
|
271
|
+
// The smuggle must actually survive the YAML round-trip, else the fixture is vacuous.
|
|
272
|
+
expect(crlfParsed.record.examples[0].bad).toContain(CR);
|
|
273
|
+
expect(crlfParsed.examplePairHashes).toEqual(parseObject(lfRecord).examplePairHashes);
|
|
274
|
+
});
|
|
275
|
+
it('is a real drift SENSOR — a changed exemplar changes its pair hash', () => {
|
|
276
|
+
expect(ruleExamplePairHash({ bad: 'x', good: 'y' })).not.toBe(ruleExamplePairHash({ bad: 'x', good: 'z' }));
|
|
277
|
+
});
|
|
278
|
+
it('does not confuse the bad and good legs of a pair', () => {
|
|
279
|
+
expect(ruleExamplePairHash({ bad: 'x', good: 'y' })).not.toBe(ruleExamplePairHash({ bad: 'y', good: 'x' }));
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
describe('§ Design 3 — the parser is pure and deterministic', () => {
|
|
283
|
+
it('returns a deep-equal result for the same bytes, twice', () => {
|
|
284
|
+
expect(parseRuleRecord(DESIGN4_EXEMPLAR, FILE)).toEqual(parseRuleRecord(DESIGN4_EXEMPLAR, FILE));
|
|
285
|
+
expect(parseRuleRecord(DESIGN8_EXEMPLAR, FILE)).toEqual(parseRuleRecord(DESIGN8_EXEMPLAR, FILE));
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
// ── § Design 14 — one negative fixture per banned-silent behaviour ────────────
|
|
289
|
+
describe('§ Design 14 — banned silent behaviour: SILENT DEFAULT', () => {
|
|
290
|
+
it('rejects an omitted `severity` — the census’s silent warning-default is killed (§ Design 4)', () => {
|
|
291
|
+
const record = astGrepRecord();
|
|
292
|
+
delete record.severity;
|
|
293
|
+
expect(reject(record).message).toContain('severity');
|
|
294
|
+
});
|
|
295
|
+
it('rejects an out-of-vocab `severity` (`info` is not in the closed V1 enum)', () => {
|
|
296
|
+
const record = astGrepRecord();
|
|
297
|
+
record.severity = 'info';
|
|
298
|
+
expect(reject(record).keyPath).toBe('severity');
|
|
299
|
+
});
|
|
300
|
+
it('rejects an omitted `requires.scope` — file-as-default is a silent default reborn (§ Design 8)', () => {
|
|
301
|
+
const record = regexRecord();
|
|
302
|
+
record.requires = { pattern: 'LC_ALL=C' };
|
|
303
|
+
expect(reject(record).message).toContain('requires.scope');
|
|
304
|
+
});
|
|
305
|
+
it('rejects an out-of-vocab `requires.scope` (§ Design 8 closed name space)', () => {
|
|
306
|
+
const record = regexRecord();
|
|
307
|
+
record.requires = { pattern: 'LC_ALL=C', scope: 'paragraph' };
|
|
308
|
+
expect(reject(record).keyPath).toBe('requires.scope');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
describe('§ Design 14 — banned silent behaviour: SILENT SKIP (§ Design 2 no-silent-skip gate)', () => {
|
|
312
|
+
it('rejects an unknown `schemaVersion` with the distinct no-silent-skip diagnostic', () => {
|
|
313
|
+
const record = astGrepRecord();
|
|
314
|
+
record.schemaVersion = 2;
|
|
315
|
+
const failure = reject(record);
|
|
316
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
317
|
+
expect(failure.construct).toBe('schemaVersion');
|
|
318
|
+
});
|
|
319
|
+
it('rejects a string `schemaVersion` — the version axis is an INTEGER (§ Design 2)', () => {
|
|
320
|
+
const record = astGrepRecord();
|
|
321
|
+
record.schemaVersion = '1';
|
|
322
|
+
expect(reject(record)).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
323
|
+
});
|
|
324
|
+
it('rejects a missing `schemaVersion` — every record opens with it (§ Design 2/§ Design 5)', () => {
|
|
325
|
+
const record = astGrepRecord();
|
|
326
|
+
delete record.schemaVersion;
|
|
327
|
+
expect(reject(record).keyPath).toBe('schemaVersion');
|
|
328
|
+
});
|
|
329
|
+
it('rejects an unknown `target.type` (`rego` joins by version bump, § Design 6)', () => {
|
|
330
|
+
const record = astGrepRecord();
|
|
331
|
+
target(record).type = 'rego';
|
|
332
|
+
const failure = reject(record);
|
|
333
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
334
|
+
expect(failure.construct).toBe('target.type');
|
|
335
|
+
});
|
|
336
|
+
it('rejects `type: ast` with the DROPPED-not-deferred message, never the version-bump text (R16, § Design 6)', () => {
|
|
337
|
+
const record = astGrepRecord();
|
|
338
|
+
target(record).type = 'ast';
|
|
339
|
+
const failure = reject(record);
|
|
340
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
341
|
+
// Pinned exactly: `ast` does not come back by version bump the way `rego` and
|
|
342
|
+
// ADR-109's action-rule type do, and telling an author to wait for one would
|
|
343
|
+
// be a wrong answer, not a terse one.
|
|
344
|
+
expect(failure.message).toBe(`[Totem Error] ${FILE}: target.type — ${LEGACY_ENGINE_DETAIL}`);
|
|
345
|
+
expect(failure.message).toContain('does NOT return by version bump');
|
|
346
|
+
// The generic unknown-type text — which DOES promise a bump — must not be reused here.
|
|
347
|
+
expect(failure.message).not.toContain('join by grammar version bump');
|
|
348
|
+
});
|
|
349
|
+
it.each(['AST', 'Ast', 'ast ', ' ast'])('gives the near-miss %j the DROPPED-tier answer, and still rejects it', (nearMiss) => {
|
|
350
|
+
// Case-folded + trimmed for DIAGNOSTIC SELECTION only: these are the same
|
|
351
|
+
// authoring mistake and deserve the same answer, not a promise of a version
|
|
352
|
+
// bump that will never carry `ast`.
|
|
353
|
+
const record = astGrepRecord();
|
|
354
|
+
target(record).type = nearMiss;
|
|
355
|
+
const failure = reject(record);
|
|
356
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
357
|
+
expect(failure.message).toBe(`[Totem Error] ${FILE}: target.type — ${LEGACY_ENGINE_DETAIL}`);
|
|
358
|
+
});
|
|
359
|
+
it.each([' ast-grep ', 'AST-GREP', 'Regex'])('still REJECTS the near-miss %j — admission stays the closed case-sensitive enum', (nearMiss) => {
|
|
360
|
+
// The diagnostic softening must not soften ADMISSION: only the exact
|
|
361
|
+
// lowercase tokens parse, so a case/whitespace variant of a LIVE type is
|
|
362
|
+
// still an unknown type, not a silent accept.
|
|
363
|
+
const record = astGrepRecord();
|
|
364
|
+
target(record).type = nearMiss;
|
|
365
|
+
const failure = reject(record);
|
|
366
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
367
|
+
expect(failure.construct).toBe('target.type');
|
|
368
|
+
});
|
|
369
|
+
it('lets the VERSION gate win over the V1 key set — an unknown version is diagnosed first', () => {
|
|
370
|
+
// `version:` is inexpressible at V1 (§ Design 3) but is the field § Design 3
|
|
371
|
+
// itself names as "can return by version bump". A future-version record
|
|
372
|
+
// carrying it must be told its VERSION is unknown — the V1 reserved set has no
|
|
373
|
+
// standing over a document that does not declare V1.
|
|
374
|
+
const record = { ...astGrepRecord(), schemaVersion: 2, version: '3' };
|
|
375
|
+
const failure = reject(record);
|
|
376
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
377
|
+
expect(failure).not.toBeInstanceOf(RuleRecordProducerKeyError);
|
|
378
|
+
expect(failure.construct).toBe('schemaVersion');
|
|
379
|
+
});
|
|
380
|
+
it('rejects the reserved-unimplemented `requires.scope: block` (§ Design 8)', () => {
|
|
381
|
+
const record = regexRecord();
|
|
382
|
+
record.requires = { pattern: 'LC_ALL=C', scope: 'block' };
|
|
383
|
+
const failure = reject(record);
|
|
384
|
+
expect(failure).toBeInstanceOf(RuleRecordNoSilentSkipError);
|
|
385
|
+
expect(failure.construct).toBe('requires.scope');
|
|
386
|
+
expect(failure.message).toContain('RESERVED-UNIMPLEMENTED');
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
describe('§ Design 14 — banned silent behaviour: SILENT EXPANSION', () => {
|
|
390
|
+
it('rejects a brace-expanded glob on the record path (§ Design 7 brace ruling)', () => {
|
|
391
|
+
const record = astGrepRecord();
|
|
392
|
+
scope(record).fileGlobs = ['packages/**/*.{ts,tsx}'];
|
|
393
|
+
const failure = reject(record);
|
|
394
|
+
expect(failure.message).toContain('brace expansion');
|
|
395
|
+
// The tolerant `sanitizeFileGlobs` behaviour serving the FROZEN legacy lesson
|
|
396
|
+
// corpus is untouched — this dialect binds the record path only (§ Design 7
|
|
397
|
+
// conformance scope). That freeze-isolation guard is slice 2's, beside the
|
|
398
|
+
// legacy path it must not perturb.
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
describe('§ Design 14 — banned silent behaviour: SILENT PROMOTION', () => {
|
|
402
|
+
it('retains a shallow `*.ts` glob BYTE-VERBATIM — no promotion to tree-wide (§ Design 7)', () => {
|
|
403
|
+
const record = astGrepRecord();
|
|
404
|
+
scope(record).fileGlobs = ['*.ts'];
|
|
405
|
+
const parsed = parseObject(record);
|
|
406
|
+
// A glob means what it says: `*.ts` is root-level, tree-wide is written `**/*.ts`.
|
|
407
|
+
// The parser performs NO transformation of any glob, ever.
|
|
408
|
+
expect(parsed.record.target.scope.fileGlobs).toEqual(['*.ts']);
|
|
409
|
+
// NOTE: asserting that the shallow glob MATCHES root-level only (effective
|
|
410
|
+
// scope == declared scope) needs the matcher, which lands with the compiled
|
|
411
|
+
// homes in slice 2; this fixture pins the parse-side half — verbatim retention.
|
|
412
|
+
});
|
|
413
|
+
it('retains every declared glob byte-verbatim, exclusions included', () => {
|
|
414
|
+
const record = astGrepRecord();
|
|
415
|
+
scope(record).fileGlobs = ['packages/**/*.ts', 'tools/*.mjs'];
|
|
416
|
+
scope(record).excludeGlobs = ['**/*.test.ts', 'packages/core/dist/*.js'];
|
|
417
|
+
const parsed = parseObject(record);
|
|
418
|
+
expect(parsed.record.target.scope.fileGlobs).toEqual(['packages/**/*.ts', 'tools/*.mjs']);
|
|
419
|
+
expect(parsed.record.target.scope.excludeGlobs).toEqual([
|
|
420
|
+
'**/*.test.ts',
|
|
421
|
+
'packages/core/dist/*.js',
|
|
422
|
+
]);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
// ── § Design 7 — one negative fixture per dialect rule ───────────────────────
|
|
426
|
+
describe('§ Design 7 — the normative glob dialect', () => {
|
|
427
|
+
const violations = [
|
|
428
|
+
['brace-expansion', 'packages/**/*.{ts,tsx}'],
|
|
429
|
+
['negation', '!**/*.test.ts'],
|
|
430
|
+
['adjacent-globstar', '**/**/*.ts'],
|
|
431
|
+
['embedded-globstar', 'src/**.ts'],
|
|
432
|
+
['regex-syntax', 'src/[abc]/*.ts'],
|
|
433
|
+
['regex-syntax', 'src/(a|b)/*.ts'],
|
|
434
|
+
['regex-syntax', 'src/?.ts'],
|
|
435
|
+
['regex-syntax', 'src/a+/*.ts'],
|
|
436
|
+
// Each regex construct varied INDEPENDENTLY of the others: bare alternation
|
|
437
|
+
// with no parens, and each anchor with no brackets — otherwise an earlier
|
|
438
|
+
// blacklist entry would mask a missing one.
|
|
439
|
+
['regex-syntax', 'src/a|b/*.ts'],
|
|
440
|
+
['regex-syntax', 'src/^foo/*.ts'],
|
|
441
|
+
['regex-syntax', 'src/foo$/*.ts'],
|
|
442
|
+
// Same masking cure applied to the CLOSING halves. `src/[abc]/*.ts` and
|
|
443
|
+
// `src/(a|b)/*.ts` above contain BOTH members of their pair, so `]` and `)`
|
|
444
|
+
// were only ever rejected by their opening partner's blacklist entry — drop
|
|
445
|
+
// `]` or `)` from the set and every fixture above still passed. These pin
|
|
446
|
+
// each one alone (Prop 310 Amendment 2's strict set is ruled spec text, so
|
|
447
|
+
// every member needs its own fixture).
|
|
448
|
+
['regex-syntax', 'src/abc]/*.ts'],
|
|
449
|
+
['regex-syntax', 'src/ab)/*.ts'],
|
|
450
|
+
['separator', 'packages\\core\\*.ts'],
|
|
451
|
+
['absolute-path', '/packages/core/*.ts'],
|
|
452
|
+
['drive-letter', 'C:/packages/core/*.ts'],
|
|
453
|
+
['empty', ''],
|
|
454
|
+
['empty-segment', 'packages//core/*.ts'],
|
|
455
|
+
['current-segment', './packages/*.ts'],
|
|
456
|
+
['current-segment', 'a/./b/*.ts'],
|
|
457
|
+
['parent-segment', '../sibling-repo/src/*.ts'],
|
|
458
|
+
['parent-segment', 'packages/../tools/*.mjs'],
|
|
459
|
+
['surrounding-whitespace', ' packages/**/*.ts'],
|
|
460
|
+
['surrounding-whitespace', 'packages/**/*.ts '],
|
|
461
|
+
];
|
|
462
|
+
it('every § Design 7 rule has a negative fixture (no rule ships unexercised)', () => {
|
|
463
|
+
// Mirrors the inexpressible-key guard: a rule added to the dialect without a
|
|
464
|
+
// fixture is a rule nothing pins (§ Design 14).
|
|
465
|
+
expect(GLOB_DIALECT_RULES.length).toBe(13);
|
|
466
|
+
const exercised = new Set(violations.map(([rule]) => rule));
|
|
467
|
+
for (const rule of GLOB_DIALECT_RULES) {
|
|
468
|
+
expect(exercised.has(rule)).toBe(true);
|
|
469
|
+
}
|
|
470
|
+
// …and no fixture cites a rule outside the closed set.
|
|
471
|
+
for (const rule of exercised) {
|
|
472
|
+
expect(GLOB_DIALECT_RULES).toContain(rule);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
it.each(violations)('cites the `%s` rule for %j', (rule, glob) => {
|
|
476
|
+
const violation = checkGlobDialect(glob);
|
|
477
|
+
expect(violation).not.toBeNull();
|
|
478
|
+
expect(violation.rule).toBe(rule);
|
|
479
|
+
expect(violation.message).toContain('§ Design 7');
|
|
480
|
+
});
|
|
481
|
+
it.each(violations)('rejects the `%s` violation %j in fileGlobs', (_rule, glob) => {
|
|
482
|
+
const record = astGrepRecord();
|
|
483
|
+
scope(record).fileGlobs = [glob];
|
|
484
|
+
expect(reject(record).keyPath).toBe('target.scope.fileGlobs.0');
|
|
485
|
+
});
|
|
486
|
+
it.each(violations)('rejects the `%s` violation %j in excludeGlobs', (_rule, glob) => {
|
|
487
|
+
const record = astGrepRecord();
|
|
488
|
+
scope(record).excludeGlobs = [glob];
|
|
489
|
+
expect(reject(record).keyPath).toBe('target.scope.excludeGlobs.0');
|
|
490
|
+
});
|
|
491
|
+
it.each([
|
|
492
|
+
'packages/**/*.ts',
|
|
493
|
+
'**/*.test.ts',
|
|
494
|
+
'*.ts',
|
|
495
|
+
'src/*',
|
|
496
|
+
'**',
|
|
497
|
+
'tools/install-hooks.js',
|
|
498
|
+
'packages/*/src/**/*.ts',
|
|
499
|
+
// Dots are rejected as WHOLE segments only — inside a segment a dot is an
|
|
500
|
+
// ordinary literal, which is what every extension form is built on.
|
|
501
|
+
'.github/workflows/*.yml',
|
|
502
|
+
'src/a..b/*.ts',
|
|
503
|
+
'docs/release.notes.md',
|
|
504
|
+
])('admits the dialect-clean glob %j', (glob) => {
|
|
505
|
+
expect(checkGlobDialect(glob)).toBeNull();
|
|
506
|
+
});
|
|
507
|
+
// Per-character LEGAL NEAR-MISS: the same glob shape with the banned character
|
|
508
|
+
// removed and nothing else changed. Without these, the negative fixtures above
|
|
509
|
+
// are satisfiable by a validator that rejects far too much — every one of them
|
|
510
|
+
// would still pass if `checkGlobDialect` simply refused any glob containing a
|
|
511
|
+
// letter from `abc`. Pairing each rejection with the minimally-different
|
|
512
|
+
// acceptance is what makes the boundary a boundary rather than a wall.
|
|
513
|
+
it.each([
|
|
514
|
+
['[', 'src/[abc]/*.ts', 'src/abc/*.ts'],
|
|
515
|
+
[']', 'src/abc]/*.ts', 'src/abc/*.ts'],
|
|
516
|
+
['(', 'src/(ab/*.ts', 'src/ab/*.ts'],
|
|
517
|
+
[')', 'src/ab)/*.ts', 'src/ab/*.ts'],
|
|
518
|
+
['?', 'src/?.ts', 'src/a.ts'],
|
|
519
|
+
['+', 'src/a+/*.ts', 'src/a/*.ts'],
|
|
520
|
+
['|', 'src/a|b/*.ts', 'src/ab/*.ts'],
|
|
521
|
+
['^', 'src/^foo/*.ts', 'src/foo/*.ts'],
|
|
522
|
+
['$', 'src/foo$/*.ts', 'src/foo/*.ts'],
|
|
523
|
+
])('bans %j but admits the construct-free literal beside it', (char, banned, clean) => {
|
|
524
|
+
const violation = checkGlobDialect(banned);
|
|
525
|
+
expect(violation?.rule).toBe('regex-syntax');
|
|
526
|
+
expect(violation?.message).toContain(`'${char}'`);
|
|
527
|
+
expect(checkGlobDialect(clean)).toBeNull();
|
|
528
|
+
});
|
|
529
|
+
it('never rewrites a glob — the validator is a pure predicate', () => {
|
|
530
|
+
const record = astGrepRecord();
|
|
531
|
+
scope(record).fileGlobs = ['packages/*/src/**/*.ts'];
|
|
532
|
+
expect(parseObject(record).record.target.scope.fileGlobs[0]).toBe('packages/*/src/**/*.ts');
|
|
533
|
+
});
|
|
534
|
+
it('rejects a `!`-negated entry inside excludeGlobs — exclusion is STRUCTURAL (§ Design 7)', () => {
|
|
535
|
+
const record = astGrepRecord();
|
|
536
|
+
scope(record).excludeGlobs = ['!**/*.test.ts'];
|
|
537
|
+
expect(reject(record).message).toContain('`!`-negation');
|
|
538
|
+
});
|
|
539
|
+
it('rejects an empty `fileGlobs` array (§ Design 5 mandatory set)', () => {
|
|
540
|
+
const record = astGrepRecord();
|
|
541
|
+
scope(record).fileGlobs = [];
|
|
542
|
+
expect(reject(record).keyPath).toBe('target.scope.fileGlobs');
|
|
543
|
+
});
|
|
544
|
+
it('rejects an empty `excludeGlobs` array — "no exclusions" is expressed by OMISSION', () => {
|
|
545
|
+
// An empty list is a silent no-op, and no key in this grammar carries a
|
|
546
|
+
// do-nothing value (§ Design 4).
|
|
547
|
+
const record = astGrepRecord();
|
|
548
|
+
scope(record).excludeGlobs = [];
|
|
549
|
+
expect(reject(record).keyPath).toBe('target.scope.excludeGlobs');
|
|
550
|
+
});
|
|
551
|
+
it('accepts an OMITTED `excludeGlobs` — the optional key stays optional', () => {
|
|
552
|
+
const record = astGrepRecord();
|
|
553
|
+
expect(parseObject(record).record.target.scope.excludeGlobs).toBeUndefined();
|
|
554
|
+
});
|
|
555
|
+
it('enforces the dialect through `RuleRecordSchema` itself, not only the parser', () => {
|
|
556
|
+
const record = astGrepRecord();
|
|
557
|
+
scope(record).fileGlobs = ['packages/**/*.{ts,tsx}'];
|
|
558
|
+
expect(RuleRecordSchema.safeParse(record).success).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
// ── § Design 4 — closed keys at every depth of the record's key space ────────
|
|
562
|
+
describe('§ Design 4 — closed keys (unknown keys are parse errors at any depth)', () => {
|
|
563
|
+
it('rejects an unknown key at the TOP level', () => {
|
|
564
|
+
const record = { ...astGrepRecord(), notes: 'hello' };
|
|
565
|
+
expect(reject(record).message).toContain('notes');
|
|
566
|
+
});
|
|
567
|
+
it('rejects an unknown key inside `target`', () => {
|
|
568
|
+
const record = astGrepRecord();
|
|
569
|
+
target(record).engine = 'ast-grep';
|
|
570
|
+
expect(reject(record).message).toContain('engine');
|
|
571
|
+
});
|
|
572
|
+
it('rejects an unknown key inside `target.scope`', () => {
|
|
573
|
+
const record = astGrepRecord();
|
|
574
|
+
scope(record).ignoreGlobs = ['**/*.test.ts'];
|
|
575
|
+
expect(reject(record).message).toContain('ignoreGlobs');
|
|
576
|
+
});
|
|
577
|
+
it('rejects an unknown key inside `examples[i]`', () => {
|
|
578
|
+
const record = astGrepRecord();
|
|
579
|
+
record.examples = [{ bad: 'x', good: 'y', note: 'z' }];
|
|
580
|
+
expect(reject(record).message).toContain('note');
|
|
581
|
+
});
|
|
582
|
+
it('rejects an unknown key inside `requires`', () => {
|
|
583
|
+
const record = regexRecord();
|
|
584
|
+
record.requires = { pattern: 'LC_ALL=C', scope: 'line', within: 'line' };
|
|
585
|
+
expect(reject(record).message).toContain('within');
|
|
586
|
+
});
|
|
587
|
+
it('rejects an unknown key inside `curation` (Prop 270 §8’s snake_case is NORMALIZED, not admitted)', () => {
|
|
588
|
+
const record = astGrepRecord();
|
|
589
|
+
record.curation = {
|
|
590
|
+
sourceLesson: 'lesson-77c5e668389fb1a2',
|
|
591
|
+
curatedBy: 'strategy-gemini',
|
|
592
|
+
curatedAt: '2026-06-15T14:00:00Z',
|
|
593
|
+
baseline5Phase: 3,
|
|
594
|
+
source_lesson: 'lesson-77c5e668389fb1a2',
|
|
595
|
+
};
|
|
596
|
+
expect(reject(record).message).toContain('source_lesson');
|
|
597
|
+
});
|
|
598
|
+
it('rejects an unknown key inside `verification_shadow`', () => {
|
|
599
|
+
const record = astGrepRecord();
|
|
600
|
+
record.verification_shadow = { type: 'rego', source: 'package x', verified: true };
|
|
601
|
+
expect(reject(record).message).toContain('verified');
|
|
602
|
+
});
|
|
603
|
+
it('rejects a `verification_shadow` missing its `source` (§ Design 12 closed keys)', () => {
|
|
604
|
+
const record = astGrepRecord();
|
|
605
|
+
record.verification_shadow = { type: 'rego' };
|
|
606
|
+
expect(reject(record).message).toContain('source');
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
// ── § Design 4 — the curation block, per operator ruling 2026-08-21 ──────────
|
|
610
|
+
describe('§ Design 4 — `curation`: the link stands alone, the process trio is all-or-none', () => {
|
|
611
|
+
const SOURCE_LESSON = 'lesson-77c5e668389fb1a2';
|
|
612
|
+
const TRIO = {
|
|
613
|
+
curatedBy: 'strategy-gemini',
|
|
614
|
+
curatedAt: '2026-06-15T14:00:00Z',
|
|
615
|
+
baseline5Phase: 3,
|
|
616
|
+
};
|
|
617
|
+
function withCuration(curation) {
|
|
618
|
+
const record = astGrepRecord();
|
|
619
|
+
record.curation = curation;
|
|
620
|
+
return record;
|
|
621
|
+
}
|
|
622
|
+
it('ADMITS `sourceLesson` alone — the direct-authored record→lesson link (§ Design 1)', () => {
|
|
623
|
+
// Superseded IR-4: the build-time complete-or-absent reading rejected this
|
|
624
|
+
// record; the operator ruling makes the link the block's only mandatory half.
|
|
625
|
+
const parsed = parseObject(withCuration({ sourceLesson: SOURCE_LESSON }));
|
|
626
|
+
expect(parsed.record.curation).toEqual({ sourceLesson: SOURCE_LESSON });
|
|
627
|
+
});
|
|
628
|
+
it('ADMITS the full block — link plus the whole Baseline-5 process record', () => {
|
|
629
|
+
const parsed = parseObject(withCuration({ sourceLesson: SOURCE_LESSON, ...TRIO }));
|
|
630
|
+
expect(parsed.record.curation).toEqual({ sourceLesson: SOURCE_LESSON, ...TRIO });
|
|
631
|
+
});
|
|
632
|
+
// Every PROPER non-empty subset of the trio — the three 1-of-3s and the three
|
|
633
|
+
// 2-of-3s. All-or-none has exactly these six ways to be broken.
|
|
634
|
+
const partialTrios = [
|
|
635
|
+
['curatedBy only', ['curatedBy']],
|
|
636
|
+
['curatedAt only', ['curatedAt']],
|
|
637
|
+
['baseline5Phase only', ['baseline5Phase']],
|
|
638
|
+
['curatedBy + curatedAt', ['curatedBy', 'curatedAt']],
|
|
639
|
+
['curatedBy + baseline5Phase', ['curatedBy', 'baseline5Phase']],
|
|
640
|
+
['curatedAt + baseline5Phase', ['curatedAt', 'baseline5Phase']],
|
|
641
|
+
];
|
|
642
|
+
it.each(partialTrios)('REJECTS the partial trio (%s), naming every missing member', (_label, present) => {
|
|
643
|
+
const curation = { sourceLesson: SOURCE_LESSON };
|
|
644
|
+
for (const field of present)
|
|
645
|
+
curation[field] = TRIO[field];
|
|
646
|
+
const failure = reject(withCuration(curation));
|
|
647
|
+
const missing = CURATION_PROCESS_FIELDS.filter((field) => !present.includes(field));
|
|
648
|
+
for (const field of missing) {
|
|
649
|
+
expect(failure.message).toContain(`curation.${field} is REQUIRED`);
|
|
650
|
+
}
|
|
651
|
+
// …and never reports a member that IS present as missing.
|
|
652
|
+
for (const field of present) {
|
|
653
|
+
expect(failure.message).not.toContain(`curation.${field} is REQUIRED`);
|
|
654
|
+
}
|
|
655
|
+
expect(failure.keyPath).toBe(`curation.${missing[0]}`);
|
|
656
|
+
});
|
|
657
|
+
it('REJECTS the trio WITHOUT `sourceLesson` — the link is not the optional half', () => {
|
|
658
|
+
const failure = reject(withCuration({ ...TRIO }));
|
|
659
|
+
expect(failure.keyPath).toBe('curation.sourceLesson');
|
|
660
|
+
});
|
|
661
|
+
it('REJECTS an empty `curation: {}` block', () => {
|
|
662
|
+
expect(reject(withCuration({})).keyPath).toBe('curation.sourceLesson');
|
|
663
|
+
});
|
|
664
|
+
it('REJECTS an empty-string `sourceLesson`', () => {
|
|
665
|
+
expect(reject(withCuration({ sourceLesson: ' ' })).keyPath).toBe('curation.sourceLesson');
|
|
666
|
+
});
|
|
667
|
+
it('keeps the block CLOSED under the relaxation — an unknown key still fails', () => {
|
|
668
|
+
const failure = reject(withCuration({ sourceLesson: SOURCE_LESSON, curatedFor: 'baseline-5' }));
|
|
669
|
+
expect(failure.message).toContain('curatedFor');
|
|
670
|
+
});
|
|
671
|
+
it('introduces NO default — an absent trio stays ABSENT, never filled in', () => {
|
|
672
|
+
const parsed = parseObject(withCuration({ sourceLesson: SOURCE_LESSON }));
|
|
673
|
+
for (const field of CURATION_PROCESS_FIELDS) {
|
|
674
|
+
expect(parsed.record.curation).not.toHaveProperty(field);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
it('still admits an OMITTED curation block — optional for direct-authored rules', () => {
|
|
678
|
+
expect(parseObject(astGrepRecord()).record.curation).toBeUndefined();
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
// ── § Design 4 — the inexpressible producer-owned / intake-seam key set ──────
|
|
682
|
+
describe('§ Design 4 — producer-owned and intake-seam keys are INEXPRESSIBLE', () => {
|
|
683
|
+
it('pins the closed inexpressible set (a silent shrink would reopen the smuggle)', () => {
|
|
684
|
+
expect(RULE_RECORD_INEXPRESSIBLE_KEYS.size).toBe(21);
|
|
685
|
+
for (const key of [
|
|
686
|
+
'id',
|
|
687
|
+
'version',
|
|
688
|
+
'ruleId',
|
|
689
|
+
'declaredEngine',
|
|
690
|
+
'provenance',
|
|
691
|
+
'author',
|
|
692
|
+
'authoredAt',
|
|
693
|
+
'targetDefect',
|
|
694
|
+
'structuralClass',
|
|
695
|
+
'positiveFixtures',
|
|
696
|
+
'negativeFixtures',
|
|
697
|
+
'origin',
|
|
698
|
+
'unverified',
|
|
699
|
+
'authoringLedgerRef',
|
|
700
|
+
'structuralEligibility',
|
|
701
|
+
'decidable',
|
|
702
|
+
'judgedBy',
|
|
703
|
+
'basis',
|
|
704
|
+
'classifierDisposition',
|
|
705
|
+
'disposition',
|
|
706
|
+
'routing',
|
|
707
|
+
]) {
|
|
708
|
+
expect(RULE_RECORD_INEXPRESSIBLE_KEYS.has(key)).toBe(true);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
it.each([...RULE_RECORD_INEXPRESSIBLE_KEYS])('rejects the producer-owned key `%s` with a SPECIFIC diagnostic, not a generic unknown-key message', (key) => {
|
|
712
|
+
const record = { ...astGrepRecord(), [key]: 'smuggled' };
|
|
713
|
+
const failure = reject(record);
|
|
714
|
+
expect(failure).toBeInstanceOf(RuleRecordProducerKeyError);
|
|
715
|
+
expect(failure.producerKey).toBe(key);
|
|
716
|
+
expect(failure.message).toContain('producer-owned');
|
|
717
|
+
expect(failure.message).toContain('INEXPRESSIBLE');
|
|
718
|
+
expect(failure.keyPath).toBe(key);
|
|
719
|
+
});
|
|
720
|
+
it('rejects a producer-owned key NESTED inside `curation` (`.strict()` alone is not recursive)', () => {
|
|
721
|
+
const record = astGrepRecord();
|
|
722
|
+
record.curation = {
|
|
723
|
+
sourceLesson: 'lesson-77c5e668389fb1a2',
|
|
724
|
+
curatedBy: 'strategy-gemini',
|
|
725
|
+
curatedAt: '2026-06-15T14:00:00Z',
|
|
726
|
+
baseline5Phase: 3,
|
|
727
|
+
ruleId: '0123456789abcdef',
|
|
728
|
+
};
|
|
729
|
+
const failure = reject(record);
|
|
730
|
+
expect(failure).toBeInstanceOf(RuleRecordProducerKeyError);
|
|
731
|
+
expect(failure.keyPath).toBe('curation.ruleId');
|
|
732
|
+
});
|
|
733
|
+
it('rejects a producer-owned key nested inside `target.scope`', () => {
|
|
734
|
+
const record = astGrepRecord();
|
|
735
|
+
scope(record).provenance = { mergedPr: 1 };
|
|
736
|
+
expect(reject(record).keyPath).toBe('target.scope.provenance');
|
|
737
|
+
});
|
|
738
|
+
it('rejects a producer-owned key inside an ARRAY element, in DOT-form (one path grammar)', () => {
|
|
739
|
+
const record = astGrepRecord();
|
|
740
|
+
record.examples = [{ bad: 'x', good: 'y', origin: 'mined' }];
|
|
741
|
+
const failure = reject(record);
|
|
742
|
+
// Dot-form at every depth, ordinals included — the same grammar Zod renders
|
|
743
|
+
// (`examples.0.bad`). Two renderings of one address make a diagnostic ungreppable.
|
|
744
|
+
expect(failure.keyPath).toBe('examples.0.origin');
|
|
745
|
+
});
|
|
746
|
+
it('renders scan paths and Zod paths in the SAME grammar for the same address', () => {
|
|
747
|
+
const scanRecord = astGrepRecord();
|
|
748
|
+
scanRecord.examples = [{ bad: 'x', good: 'y', origin: 'mined' }];
|
|
749
|
+
const zodRecord = astGrepRecord();
|
|
750
|
+
zodRecord.examples = [{ bad: '', good: 'y' }];
|
|
751
|
+
const [scanHead] = reject(scanRecord).keyPath.split('.');
|
|
752
|
+
const [zodHead, zodOrdinal] = reject(zodRecord).keyPath.split('.');
|
|
753
|
+
expect(scanHead).toBe(zodHead);
|
|
754
|
+
expect(zodOrdinal).toBe('0');
|
|
755
|
+
expect(reject(scanRecord).keyPath.split('.')[1]).toBe('0');
|
|
756
|
+
});
|
|
757
|
+
it('uses dot-form in the FULL rendered message, not just keyPath — no bracket-index residue', () => {
|
|
758
|
+
// The one-path-grammar rule governs what the author READS, so it is swept over
|
|
759
|
+
// whole messages: a bracket INDEX (`[0]`, `[]`) from either the scan or a Zod
|
|
760
|
+
// label is the residue class. A bare `'['` — as in the regex-syntax diagnostic
|
|
761
|
+
// that quotes the offending character — is content, not a path, and stays legal.
|
|
762
|
+
const BRACKET_INDEX = /\[\d*\]/;
|
|
763
|
+
const fixtures = [
|
|
764
|
+
() => {
|
|
765
|
+
const r = astGrepRecord();
|
|
766
|
+
r.examples = [{ bad: '', good: 'y' }];
|
|
767
|
+
return r;
|
|
768
|
+
},
|
|
769
|
+
() => {
|
|
770
|
+
const r = astGrepRecord();
|
|
771
|
+
r.examples = [{ bad: 'x', good: ' ' }];
|
|
772
|
+
return r;
|
|
773
|
+
},
|
|
774
|
+
() => {
|
|
775
|
+
const r = astGrepRecord();
|
|
776
|
+
r.examples = [{ bad: 'x' }];
|
|
777
|
+
return r;
|
|
778
|
+
},
|
|
779
|
+
() => {
|
|
780
|
+
const r = astGrepRecord();
|
|
781
|
+
r.examples = [{ bad: 'x', good: 'y', note: 'z' }];
|
|
782
|
+
return r;
|
|
783
|
+
},
|
|
784
|
+
() => {
|
|
785
|
+
const r = astGrepRecord();
|
|
786
|
+
r.examples = [{ bad: 'x', good: 'y', origin: 'mined' }];
|
|
787
|
+
return r;
|
|
788
|
+
},
|
|
789
|
+
() => {
|
|
790
|
+
const r = astGrepRecord();
|
|
791
|
+
scope(r).fileGlobs = ['packages/**/*.{ts,tsx}'];
|
|
792
|
+
return r;
|
|
793
|
+
},
|
|
794
|
+
() => {
|
|
795
|
+
const r = astGrepRecord();
|
|
796
|
+
scope(r).excludeGlobs = ['!**/*.test.ts'];
|
|
797
|
+
return r;
|
|
798
|
+
},
|
|
799
|
+
];
|
|
800
|
+
for (const build of fixtures) {
|
|
801
|
+
const failure = reject(build());
|
|
802
|
+
expect(failure.message).not.toMatch(BRACKET_INDEX);
|
|
803
|
+
expect(failure.keyPath).not.toMatch(BRACKET_INDEX);
|
|
804
|
+
expect(failure.recoveryHint).not.toMatch(BRACKET_INDEX);
|
|
805
|
+
}
|
|
806
|
+
// Guard the guard: the regex-syntax diagnostic DOES quote a bracket, and that
|
|
807
|
+
// is content the sweep must not be tuned to forbid.
|
|
808
|
+
const bracketGlob = astGrepRecord();
|
|
809
|
+
scope(bracketGlob).fileGlobs = ['src/[abc]/*.ts'];
|
|
810
|
+
const quoted = reject(bracketGlob);
|
|
811
|
+
expect(quoted.message).toContain('[');
|
|
812
|
+
expect(quoted.message).not.toMatch(BRACKET_INDEX);
|
|
813
|
+
});
|
|
814
|
+
it('rejects a producer-owned key inside the OPAQUE ast-grep payload interior (IR-3)', () => {
|
|
815
|
+
// IR-2 keeps the payload interior opaque to NapiConfig validation; § Design 4’s
|
|
816
|
+
// "inexpressible at ANY depth" still binds, so the closure scan descends.
|
|
817
|
+
const record = astGrepRecord();
|
|
818
|
+
target(record).rule = { kind: 'catch_clause', declaredEngine: 'regex' };
|
|
819
|
+
const failure = reject(record);
|
|
820
|
+
expect(failure).toBeInstanceOf(RuleRecordProducerKeyError);
|
|
821
|
+
expect(failure.keyPath).toBe('target.rule.declaredEngine');
|
|
822
|
+
});
|
|
823
|
+
it('adds the PASTED-CONFIG hint on `target.rule.id` when a whole ast-grep config is pasted in', () => {
|
|
824
|
+
// A complete ast-grep config carries `id` AND `language` at ITS top level; the
|
|
825
|
+
// record's `target.rule` carries only the payload. `id` is the only half that
|
|
826
|
+
// TRIPS here — it is inexpressible, while `language` is a legal record key at
|
|
827
|
+
// `target.language`, so no producer-key error can ever fire for it. The single
|
|
828
|
+
// reachable diagnostic therefore has to tell the author where BOTH halves go.
|
|
829
|
+
const record = astGrepRecord();
|
|
830
|
+
target(record).rule = {
|
|
831
|
+
id: 'no-fail-open-catch',
|
|
832
|
+
language: 'typescript',
|
|
833
|
+
rule: { kind: 'catch_clause' },
|
|
834
|
+
message: 'pasted from a standalone ast-grep config',
|
|
835
|
+
};
|
|
836
|
+
const failure = reject(record);
|
|
837
|
+
expect(failure).toBeInstanceOf(RuleRecordProducerKeyError);
|
|
838
|
+
expect(failure.keyPath).toBe('target.rule.id');
|
|
839
|
+
expect(failure.producerKey).toBe('id');
|
|
840
|
+
expect(failure.recoveryHint).toContain('PASTED complete ast-grep config');
|
|
841
|
+
expect(failure.recoveryHint).toContain('target.language');
|
|
842
|
+
});
|
|
843
|
+
it('never fires a producer-key error for `language` — it is a LEGAL record key, not an inexpressible one', () => {
|
|
844
|
+
// Pins the premise the hint rests on: `language` is absent from the closed
|
|
845
|
+
// inexpressible set, so a `language` inside the opaque payload is carried, not
|
|
846
|
+
// rejected, and the hint has exactly one reachable trigger (`id`).
|
|
847
|
+
expect(RULE_RECORD_INEXPRESSIBLE_KEYS.has('language')).toBe(false);
|
|
848
|
+
const record = astGrepRecord();
|
|
849
|
+
target(record).rule = { kind: 'catch_clause', language: 'typescript' };
|
|
850
|
+
expect(parseObject(record).record.target.rule).toEqual({
|
|
851
|
+
kind: 'catch_clause',
|
|
852
|
+
language: 'typescript',
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
it('does NOT add the pasted-config hint for the same key at the record top level', () => {
|
|
856
|
+
const record = { ...astGrepRecord(), id: 'hand-written' };
|
|
857
|
+
const failure = reject(record);
|
|
858
|
+
expect(failure.keyPath).toBe('id');
|
|
859
|
+
expect(failure.recoveryHint).not.toContain('PASTED');
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
// ── Security floor — JS prototype machinery is never a mapping key ───────────
|
|
863
|
+
describe('security floor — prototype-machinery keys are rejected at any depth', () => {
|
|
864
|
+
const PROTO_KEYS = [...RULE_RECORD_FORBIDDEN_PROTOTYPE_KEYS];
|
|
865
|
+
function payloadCarrying(key) {
|
|
866
|
+
return [
|
|
867
|
+
'schemaVersion: 1',
|
|
868
|
+
'severity: error',
|
|
869
|
+
'message: "m"',
|
|
870
|
+
'target:',
|
|
871
|
+
' type: ast-grep',
|
|
872
|
+
' language: typescript',
|
|
873
|
+
' rule:',
|
|
874
|
+
` ${key}:`,
|
|
875
|
+
' polluted: true',
|
|
876
|
+
' scope:',
|
|
877
|
+
' fileGlobs: ["src/*.ts"]',
|
|
878
|
+
'examples:',
|
|
879
|
+
' - bad: "b"',
|
|
880
|
+
' good: "g"',
|
|
881
|
+
'',
|
|
882
|
+
].join(LF);
|
|
883
|
+
}
|
|
884
|
+
function topLevelCarrying(key) {
|
|
885
|
+
return [
|
|
886
|
+
'schemaVersion: 1',
|
|
887
|
+
'severity: error',
|
|
888
|
+
'message: "m"',
|
|
889
|
+
`${key}:`,
|
|
890
|
+
' polluted: true',
|
|
891
|
+
'target:',
|
|
892
|
+
' type: regex',
|
|
893
|
+
' pattern: "x"',
|
|
894
|
+
' scope:',
|
|
895
|
+
' fileGlobs: ["src/*.ts"]',
|
|
896
|
+
'examples:',
|
|
897
|
+
' - bad: "b"',
|
|
898
|
+
' good: "g"',
|
|
899
|
+
'',
|
|
900
|
+
].join(LF);
|
|
901
|
+
}
|
|
902
|
+
it('PREMISE: `yaml` materializes `__proto__` as an OWN ENUMERABLE key the scan can see', () => {
|
|
903
|
+
// Pinned, not assumed: the whole floor rests on the scan's `Object.entries`
|
|
904
|
+
// actually visiting these keys. If a `yaml` upgrade ever stopped materializing
|
|
905
|
+
// them as own properties, this fails and tells us the premise moved — rather
|
|
906
|
+
// than the rejection fixtures passing vacuously for a new reason.
|
|
907
|
+
const parsed = yamlParse('rule:\n __proto__:\n polluted: true\n');
|
|
908
|
+
expect(Object.keys(parsed.rule)).toContain('__proto__');
|
|
909
|
+
expect(Object.entries(parsed.rule).map(([k]) => k)).toContain('__proto__');
|
|
910
|
+
expect(Object.getOwnPropertyDescriptor(parsed.rule, '__proto__')).toBeDefined();
|
|
911
|
+
});
|
|
912
|
+
it.each(PROTO_KEYS)('rejects `%s` directly inside the opaque `target.rule` payload', (key) => {
|
|
913
|
+
const failure = rejectRaw(payloadCarrying(key));
|
|
914
|
+
expect(failure).toBeInstanceOf(RuleRecordPrototypeKeyError);
|
|
915
|
+
expect(failure.prototypeKey).toBe(key);
|
|
916
|
+
expect(failure.keyPath).toBe(`target.rule.${key}`);
|
|
917
|
+
expect(failure.message).toContain('prototype machinery');
|
|
918
|
+
// Rejected on SECURITY grounds, not as a § Design 4 producer-owned key.
|
|
919
|
+
expect(failure.message).not.toContain('producer-owned');
|
|
920
|
+
expect(failure).not.toBeInstanceOf(RuleRecordProducerKeyError);
|
|
921
|
+
});
|
|
922
|
+
it.each(PROTO_KEYS)('rejects `%s` at the document TOP LEVEL', (key) => {
|
|
923
|
+
const failure = rejectRaw(topLevelCarrying(key));
|
|
924
|
+
expect(failure).toBeInstanceOf(RuleRecordPrototypeKeyError);
|
|
925
|
+
expect(failure.keyPath).toBe(key);
|
|
926
|
+
expect(failure.message).not.toContain('producer-owned');
|
|
927
|
+
});
|
|
928
|
+
it('rejects `__proto__` DEEP-nested inside a compound payload (under `has:`)', () => {
|
|
929
|
+
const failure = rejectRaw([
|
|
930
|
+
'schemaVersion: 1',
|
|
931
|
+
'severity: error',
|
|
932
|
+
'message: "m"',
|
|
933
|
+
'target:',
|
|
934
|
+
' type: ast-grep',
|
|
935
|
+
' language: typescript',
|
|
936
|
+
' rule:',
|
|
937
|
+
' kind: catch_clause',
|
|
938
|
+
' not:',
|
|
939
|
+
' has:',
|
|
940
|
+
' __proto__:',
|
|
941
|
+
' polluted: true',
|
|
942
|
+
' scope:',
|
|
943
|
+
' fileGlobs: ["src/*.ts"]',
|
|
944
|
+
'examples:',
|
|
945
|
+
' - bad: "b"',
|
|
946
|
+
' good: "g"',
|
|
947
|
+
'',
|
|
948
|
+
].join(LF));
|
|
949
|
+
expect(failure).toBeInstanceOf(RuleRecordPrototypeKeyError);
|
|
950
|
+
expect(failure.keyPath).toBe('target.rule.not.has.__proto__');
|
|
951
|
+
});
|
|
952
|
+
it('is a SEPARATE set from the § Design 4 inexpressible keys — the 21-key guard is untouched', () => {
|
|
953
|
+
// The spec-defined set stays exactly 21 producer-owned keys; the floor is
|
|
954
|
+
// three keys rejected on different grounds, with a different diagnostic.
|
|
955
|
+
expect(RULE_RECORD_INEXPRESSIBLE_KEYS.size).toBe(21);
|
|
956
|
+
expect(RULE_RECORD_FORBIDDEN_PROTOTYPE_KEYS.size).toBe(3);
|
|
957
|
+
for (const key of PROTO_KEYS) {
|
|
958
|
+
expect(RULE_RECORD_INEXPRESSIBLE_KEYS.has(key)).toBe(false);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
it('still admits the censused ast-grep payload vocabulary — the floor is narrow', () => {
|
|
962
|
+
const record = astGrepRecord();
|
|
963
|
+
target(record).rule = {
|
|
964
|
+
kind: 'catch_clause',
|
|
965
|
+
not: { has: { kind: 'throw_statement', stopBy: 'end' } },
|
|
966
|
+
inside: { kind: 'function_declaration' },
|
|
967
|
+
};
|
|
968
|
+
expect(parseObject(record).record.target.rule).toBeDefined();
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
// ── § Design 1 / § Design 12 — a record is a finite tree ─────────────────────
|
|
972
|
+
describe('§ Design 1 — a cyclic anchor is a parse error, never a crash', () => {
|
|
973
|
+
it('rejects a recursive `&anchor`/`*alias` with a RuleRecordParseError, not a RangeError', () => {
|
|
974
|
+
// `yaml` resolves a recursive alias into a genuinely cyclic object; unguarded
|
|
975
|
+
// recursion over it blows the stack, which would break this module's totality
|
|
976
|
+
// contract (a parse either returns a value or throws RuleRecordParseError).
|
|
977
|
+
const cyclic = [
|
|
978
|
+
'schemaVersion: 1',
|
|
979
|
+
'severity: error',
|
|
980
|
+
'message: "m"',
|
|
981
|
+
'target: &t',
|
|
982
|
+
' type: ast-grep',
|
|
983
|
+
' language: typescript',
|
|
984
|
+
' rule:',
|
|
985
|
+
' loop: *t',
|
|
986
|
+
' scope:',
|
|
987
|
+
' fileGlobs: ["src/*.ts"]',
|
|
988
|
+
'examples:',
|
|
989
|
+
' - bad: "b"',
|
|
990
|
+
' good: "g"',
|
|
991
|
+
'',
|
|
992
|
+
].join(LF);
|
|
993
|
+
let thrown;
|
|
994
|
+
try {
|
|
995
|
+
parseRuleRecord(cyclic, FILE);
|
|
996
|
+
}
|
|
997
|
+
catch (err) {
|
|
998
|
+
thrown = err;
|
|
999
|
+
}
|
|
1000
|
+
expect(thrown).toBeInstanceOf(RuleRecordParseError);
|
|
1001
|
+
expect(thrown).not.toBeInstanceOf(RangeError);
|
|
1002
|
+
const failure = thrown;
|
|
1003
|
+
expect(failure.filePath).toBe(FILE);
|
|
1004
|
+
expect(failure.keyPath).toBe('target.rule.loop');
|
|
1005
|
+
expect(failure.message).toContain(FILE);
|
|
1006
|
+
expect(failure.message).toContain('cyclic YAML anchor');
|
|
1007
|
+
});
|
|
1008
|
+
it('rejects a cycle reached through an ARRAY element too', () => {
|
|
1009
|
+
const cyclic = [
|
|
1010
|
+
'schemaVersion: 1',
|
|
1011
|
+
'severity: error',
|
|
1012
|
+
'message: "m"',
|
|
1013
|
+
'target: &t',
|
|
1014
|
+
' type: ast-grep',
|
|
1015
|
+
' language: typescript',
|
|
1016
|
+
' rule:',
|
|
1017
|
+
' any:',
|
|
1018
|
+
' - *t',
|
|
1019
|
+
' scope:',
|
|
1020
|
+
' fileGlobs: ["src/*.ts"]',
|
|
1021
|
+
'examples:',
|
|
1022
|
+
' - bad: "b"',
|
|
1023
|
+
' good: "g"',
|
|
1024
|
+
'',
|
|
1025
|
+
].join(LF);
|
|
1026
|
+
const failure = rejectRaw(cyclic);
|
|
1027
|
+
expect(failure.keyPath).toBe('target.rule.any.0');
|
|
1028
|
+
expect(failure.message).toContain('cyclic YAML anchor');
|
|
1029
|
+
});
|
|
1030
|
+
it('still admits a legal SHARED anchor — a DAG is not a cycle', () => {
|
|
1031
|
+
// The guard tracks ANCESTORS, not visits: aliasing one node twice as siblings
|
|
1032
|
+
// is legal YAML and must not be misdiagnosed as recursion.
|
|
1033
|
+
const shared = [
|
|
1034
|
+
'schemaVersion: 1',
|
|
1035
|
+
'severity: error',
|
|
1036
|
+
'message: "m"',
|
|
1037
|
+
'target:',
|
|
1038
|
+
' type: ast-grep',
|
|
1039
|
+
' language: typescript',
|
|
1040
|
+
' rule:',
|
|
1041
|
+
' all:',
|
|
1042
|
+
' - &leaf { kind: catch_clause }',
|
|
1043
|
+
' - *leaf',
|
|
1044
|
+
' scope:',
|
|
1045
|
+
' fileGlobs: ["src/*.ts"]',
|
|
1046
|
+
'examples:',
|
|
1047
|
+
' - bad: "b"',
|
|
1048
|
+
' good: "g"',
|
|
1049
|
+
'',
|
|
1050
|
+
].join(LF);
|
|
1051
|
+
const parsed = parseRuleRecord(shared, FILE);
|
|
1052
|
+
expect(parsed.record.target.rule).toEqual({
|
|
1053
|
+
all: [{ kind: 'catch_clause' }, { kind: 'catch_clause' }],
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
// ── § Design 4 / § Design 6 — the language binding ───────────────────────────
|
|
1058
|
+
describe('§ Design 4/§ Design 6 — `language` is required iff ast-grep', () => {
|
|
1059
|
+
it('rejects a missing `language` for `type: ast-grep` (undefined-for-type is an error)', () => {
|
|
1060
|
+
const record = astGrepRecord();
|
|
1061
|
+
delete target(record).language;
|
|
1062
|
+
const failure = reject(record);
|
|
1063
|
+
expect(failure.keyPath).toBe('target.language');
|
|
1064
|
+
expect(failure.message).toContain('REQUIRED');
|
|
1065
|
+
});
|
|
1066
|
+
it('rejects a present `language` for `type: regex`', () => {
|
|
1067
|
+
const record = regexRecord();
|
|
1068
|
+
target(record).language = 'bash';
|
|
1069
|
+
const failure = reject(record);
|
|
1070
|
+
expect(failure.keyPath).toBe('target.language');
|
|
1071
|
+
expect(failure.message).toContain('FORBIDDEN');
|
|
1072
|
+
});
|
|
1073
|
+
it('rejects an empty `language` token', () => {
|
|
1074
|
+
const record = astGrepRecord();
|
|
1075
|
+
target(record).language = ' ';
|
|
1076
|
+
expect(reject(record).keyPath).toBe('target.language');
|
|
1077
|
+
});
|
|
1078
|
+
it('carries `language` as an identifier TOKEN — resolution against the registry is compile-stage (§ Design 6)', () => {
|
|
1079
|
+
// An unregistered language must NOT fail at parse: parse results stay
|
|
1080
|
+
// installation-independent while resolution stays explicit-or-error at compile.
|
|
1081
|
+
const record = astGrepRecord();
|
|
1082
|
+
target(record).language = 'brainfuck';
|
|
1083
|
+
expect(parseObject(record).record.target.language).toBe('brainfuck');
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
// ── § Design 4 — the payload XOR ─────────────────────────────────────────────
|
|
1087
|
+
describe('§ Design 4 — the target payload is exactly one of `pattern` / `rule`', () => {
|
|
1088
|
+
it('rejects an ast-grep target carrying BOTH `pattern` and `rule`', () => {
|
|
1089
|
+
const record = astGrepRecord();
|
|
1090
|
+
target(record).pattern = 'try { $$$ }';
|
|
1091
|
+
expect(reject(record).keyPath).toBe('target.rule');
|
|
1092
|
+
});
|
|
1093
|
+
it('rejects an ast-grep target carrying NEITHER `pattern` nor `rule`', () => {
|
|
1094
|
+
const record = astGrepRecord();
|
|
1095
|
+
delete target(record).rule;
|
|
1096
|
+
expect(reject(record).keyPath).toBe('target.pattern');
|
|
1097
|
+
});
|
|
1098
|
+
it('rejects a regex target carrying a compound `rule` payload', () => {
|
|
1099
|
+
const record = regexRecord();
|
|
1100
|
+
target(record).rule = { kind: 'catch_clause' };
|
|
1101
|
+
expect(reject(record).keyPath).toBe('target.rule');
|
|
1102
|
+
});
|
|
1103
|
+
it('rejects a regex target carrying NO `pattern`', () => {
|
|
1104
|
+
const record = regexRecord();
|
|
1105
|
+
delete target(record).pattern;
|
|
1106
|
+
expect(reject(record).keyPath).toBe('target.pattern');
|
|
1107
|
+
});
|
|
1108
|
+
it('rejects an empty compound payload — a degenerate `rule: {}` satisfies no XOR', () => {
|
|
1109
|
+
const record = astGrepRecord();
|
|
1110
|
+
target(record).rule = {};
|
|
1111
|
+
expect(reject(record).message).toContain('target.rule');
|
|
1112
|
+
});
|
|
1113
|
+
it('rejects an empty `pattern`', () => {
|
|
1114
|
+
const record = regexRecord();
|
|
1115
|
+
target(record).pattern = ' ';
|
|
1116
|
+
expect(reject(record).keyPath).toBe('target.pattern');
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
// ── § Design 5 — the mandatory set + IR-1 exemplar non-emptiness ─────────────
|
|
1120
|
+
describe('§ Design 5 — the mandatory set is non-omittable', () => {
|
|
1121
|
+
it('rejects a missing `message` (R8 — 4 of 140 hand-authored lessons carried one)', () => {
|
|
1122
|
+
const record = astGrepRecord();
|
|
1123
|
+
delete record.message;
|
|
1124
|
+
expect(reject(record).keyPath).toBe('message');
|
|
1125
|
+
});
|
|
1126
|
+
it('rejects an empty `message`', () => {
|
|
1127
|
+
const record = astGrepRecord();
|
|
1128
|
+
record.message = ' ';
|
|
1129
|
+
expect(reject(record).keyPath).toBe('message');
|
|
1130
|
+
});
|
|
1131
|
+
it('rejects an empty `recoveryHint` when the optional key is present', () => {
|
|
1132
|
+
const record = astGrepRecord();
|
|
1133
|
+
record.recoveryHint = '';
|
|
1134
|
+
expect(reject(record).keyPath).toBe('recoveryHint');
|
|
1135
|
+
});
|
|
1136
|
+
it('rejects a missing `target`', () => {
|
|
1137
|
+
const record = astGrepRecord();
|
|
1138
|
+
delete record.target;
|
|
1139
|
+
expect(reject(record).keyPath).toBe('target');
|
|
1140
|
+
});
|
|
1141
|
+
it('rejects a missing `target.scope`', () => {
|
|
1142
|
+
const record = astGrepRecord();
|
|
1143
|
+
delete target(record).scope;
|
|
1144
|
+
expect(reject(record).keyPath).toBe('target.scope');
|
|
1145
|
+
});
|
|
1146
|
+
it('rejects a missing `target.scope.fileGlobs`', () => {
|
|
1147
|
+
const record = astGrepRecord();
|
|
1148
|
+
delete scope(record).fileGlobs;
|
|
1149
|
+
expect(reject(record).keyPath).toBe('target.scope.fileGlobs');
|
|
1150
|
+
});
|
|
1151
|
+
it('rejects a missing `examples` — certification’s primary preimage source', () => {
|
|
1152
|
+
const record = astGrepRecord();
|
|
1153
|
+
delete record.examples;
|
|
1154
|
+
expect(reject(record).keyPath).toBe('examples');
|
|
1155
|
+
});
|
|
1156
|
+
it('rejects an EMPTY `examples` list (§ Design 5 minimum one pair)', () => {
|
|
1157
|
+
const record = astGrepRecord();
|
|
1158
|
+
record.examples = [];
|
|
1159
|
+
expect(reject(record).keyPath).toBe('examples');
|
|
1160
|
+
});
|
|
1161
|
+
it('accepts multiple pairs — a single-pair cap would narrow the shipped ADR-112 arity', () => {
|
|
1162
|
+
const record = astGrepRecord();
|
|
1163
|
+
record.examples = [
|
|
1164
|
+
{ bad: 'b0', good: 'g0' },
|
|
1165
|
+
{ bad: 'b1', good: 'g1' },
|
|
1166
|
+
];
|
|
1167
|
+
expect(parseObject(record).record.examples).toHaveLength(2);
|
|
1168
|
+
});
|
|
1169
|
+
it('rejects a zero-byte `bad` exemplar (IR-1 — it cannot serve as a preimage differential)', () => {
|
|
1170
|
+
const record = astGrepRecord();
|
|
1171
|
+
record.examples = [{ bad: '', good: 'ok' }];
|
|
1172
|
+
expect(reject(record).keyPath).toBe('examples.0.bad');
|
|
1173
|
+
});
|
|
1174
|
+
it('rejects a zero-byte `good` exemplar (IR-1)', () => {
|
|
1175
|
+
const record = astGrepRecord();
|
|
1176
|
+
record.examples = [{ bad: 'ok', good: '' }];
|
|
1177
|
+
expect(reject(record).keyPath).toBe('examples.0.good');
|
|
1178
|
+
});
|
|
1179
|
+
it('rejects an exemplar pair missing its `good` leg', () => {
|
|
1180
|
+
const record = astGrepRecord();
|
|
1181
|
+
record.examples = [{ bad: 'ok' }];
|
|
1182
|
+
expect(reject(record).keyPath).toBe('examples.0.good');
|
|
1183
|
+
});
|
|
1184
|
+
it('rejects an empty `requires.pattern`', () => {
|
|
1185
|
+
const record = regexRecord();
|
|
1186
|
+
record.requires = { pattern: ' ', scope: 'line' };
|
|
1187
|
+
expect(reject(record).keyPath).toBe('requires.pattern');
|
|
1188
|
+
});
|
|
1189
|
+
it('rejects a LIST of `requires` blocks — V1 carries exactly one (§ Design 8)', () => {
|
|
1190
|
+
const record = regexRecord();
|
|
1191
|
+
record.requires = [{ pattern: 'LC_ALL=C', scope: 'line' }];
|
|
1192
|
+
expect(reject(record).keyPath).toBe('requires');
|
|
1193
|
+
});
|
|
1194
|
+
it('rejects a non-integer `curation.baseline5Phase`', () => {
|
|
1195
|
+
const record = astGrepRecord();
|
|
1196
|
+
record.curation = {
|
|
1197
|
+
sourceLesson: 'lesson-77c5e668389fb1a2',
|
|
1198
|
+
curatedBy: 'strategy-gemini',
|
|
1199
|
+
curatedAt: '2026-06-15T14:00:00Z',
|
|
1200
|
+
baseline5Phase: 3.5,
|
|
1201
|
+
};
|
|
1202
|
+
expect(reject(record).keyPath).toBe('curation.baseline5Phase');
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
// ── § Design 1 — carrier-level failures ─────────────────────────────────────
|
|
1206
|
+
describe('§ Design 1 — the carrier: one rule = one YAML document = one file', () => {
|
|
1207
|
+
it('rejects a YAML syntax error, naming the file', () => {
|
|
1208
|
+
const failure = rejectRaw(`schemaVersion: 1${LF}target: [unclosed${LF}`);
|
|
1209
|
+
expect(failure.message).toContain('invalid YAML');
|
|
1210
|
+
expect(failure.keyPath).toBe('(document)');
|
|
1211
|
+
});
|
|
1212
|
+
it('rejects DUPLICATE keys — picking one value silently is the killed class', () => {
|
|
1213
|
+
const failure = rejectRaw([
|
|
1214
|
+
'schemaVersion: 1',
|
|
1215
|
+
'severity: error',
|
|
1216
|
+
'severity: warning',
|
|
1217
|
+
'message: "m"',
|
|
1218
|
+
'target:',
|
|
1219
|
+
' type: regex',
|
|
1220
|
+
' pattern: "x"',
|
|
1221
|
+
' scope:',
|
|
1222
|
+
' fileGlobs: ["**/*.sh"]',
|
|
1223
|
+
'examples:',
|
|
1224
|
+
' - bad: "b"',
|
|
1225
|
+
' good: "g"',
|
|
1226
|
+
'',
|
|
1227
|
+
].join(LF));
|
|
1228
|
+
expect(failure.message).toContain('invalid YAML');
|
|
1229
|
+
expect(failure.message).toContain('unique');
|
|
1230
|
+
});
|
|
1231
|
+
it('rejects a MULTI-document file — one rule is one YAML document is one file', () => {
|
|
1232
|
+
const failure = rejectRaw([
|
|
1233
|
+
'schemaVersion: 1',
|
|
1234
|
+
'severity: error',
|
|
1235
|
+
'message: "first"',
|
|
1236
|
+
'---',
|
|
1237
|
+
'schemaVersion: 1',
|
|
1238
|
+
'severity: error',
|
|
1239
|
+
'message: "second"',
|
|
1240
|
+
'',
|
|
1241
|
+
].join(LF));
|
|
1242
|
+
expect(failure.message).toContain('invalid YAML');
|
|
1243
|
+
expect(failure.message).toContain('multiple documents');
|
|
1244
|
+
expect(failure.keyPath).toBe('(document)');
|
|
1245
|
+
});
|
|
1246
|
+
it('rejects a document that is a LIST, not a mapping', () => {
|
|
1247
|
+
expect(rejectRaw(`- schemaVersion: 1${LF}`).message).toContain('not a YAML mapping');
|
|
1248
|
+
});
|
|
1249
|
+
it('rejects an EMPTY document', () => {
|
|
1250
|
+
expect(rejectRaw('').message).toContain('not a YAML mapping');
|
|
1251
|
+
});
|
|
1252
|
+
it('rejects a scalar document', () => {
|
|
1253
|
+
expect(rejectRaw(`just a string${LF}`).message).toContain('not a YAML mapping');
|
|
1254
|
+
});
|
|
1255
|
+
it('names the file path it was handed, whatever the slug (identity never lives in the filename)', () => {
|
|
1256
|
+
const other = '.totem/rules/some-other-slug.rule.yaml';
|
|
1257
|
+
const record = astGrepRecord();
|
|
1258
|
+
delete record.severity;
|
|
1259
|
+
expect(reject(record, other).filePath).toBe(other);
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
//# sourceMappingURL=rule-record.test.js.map
|