@mmnto/totem 1.118.1 → 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/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,781 @@
|
|
|
1
|
+
// ─── Prop 310 V1 record grammar — the parser (slice 1 of the V1 build) ───────
|
|
2
|
+
//
|
|
3
|
+
// One rule = one YAML document = one file at `.totem/rules/<slug>.rule.yaml`
|
|
4
|
+
// (Prop 310 § Design 1, R10). This module is the RECORD half only: the closed-key
|
|
5
|
+
// grammar (§§ Design 2–9), the normative glob dialect (§ Design 7), and the pure
|
|
6
|
+
// `ParsedRuleRecord` value (§ Design 3 + Amendment 1 item 3). Lowering into the
|
|
7
|
+
// existing compile seam is slice 2; intake, the record→envelope derivation, and
|
|
8
|
+
// manifest attestation are slice 3.
|
|
9
|
+
//
|
|
10
|
+
// § Design 14 flips normativity: THIS SPEC IS NORMATIVE AND THE PARSER IS ITS
|
|
11
|
+
// IMPLEMENTATION — where the two diverge, this file is the bug. The conformance
|
|
12
|
+
// suite (`rule-record.test.ts`) pins one negative fixture per banned-silent
|
|
13
|
+
// behaviour (silent default / silent skip / silent expansion / silent promotion)
|
|
14
|
+
// plus one per dialect rule.
|
|
15
|
+
//
|
|
16
|
+
// Explicit-or-error throughout (§ Design 4): NO field has a default anywhere in
|
|
17
|
+
// the grammar, and an unknown `schemaVersion` / `target.type` / reserved
|
|
18
|
+
// construct hits the § Design 2 no-silent-skip gate — V1 declares no degraded
|
|
19
|
+
// mode, so it fails loud rather than dropping a rule.
|
|
20
|
+
//
|
|
21
|
+
// Identity is producer-owned (§ Design 3 / R17): no author-set `id`, `ruleId`, or
|
|
22
|
+
// per-rule `version:`, and `declaredEngine` is DERIVED from `target.type` here,
|
|
23
|
+
// never author-mirrored. The derivation does not make the shipped engine-binding
|
|
24
|
+
// assert tautological — its other side is the payload's actual compilation path
|
|
25
|
+
// (slice 2), so a yaml-fence-under-`regex` divergence still fails loud there.
|
|
26
|
+
//
|
|
27
|
+
// Determinism (§ Design 3): no module-level mutable state, no clock, no
|
|
28
|
+
// randomness — the same bytes always yield a deep-equal `ParsedRuleRecord`.
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { parse as parseYaml } from 'yaml';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { canonicalStringify } from '../compile-manifest.js';
|
|
33
|
+
import { SHA256_HEX_RE } from '../compiler-schema.js';
|
|
34
|
+
import { TotemParseError } from '../errors.js';
|
|
35
|
+
import { lfDeepNormalize } from './lf-normalize.js';
|
|
36
|
+
// ── Error classes ────────────────────────────────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// Every record-grammar failure is a hard error naming the FILE and the RECORD
|
|
39
|
+
// KEY PATH (§ Failure modes: "hard error, file+path named"). The three subclasses
|
|
40
|
+
// exist so the § Design 2 no-silent-skip gate, the § Design 4 inexpressible-key
|
|
41
|
+
// rejection, and the prototype-machinery security floor are each MECHANICALLY
|
|
42
|
+
// distinguishable from a generic schema failure and from each other — "distinct
|
|
43
|
+
// diagnostic" is a testable property, not a wording preference.
|
|
44
|
+
/** Base: any Prop 310 record-grammar parse failure. Carries file + key path. */
|
|
45
|
+
export class RuleRecordParseError extends TotemParseError {
|
|
46
|
+
filePath;
|
|
47
|
+
keyPath;
|
|
48
|
+
constructor(filePath, keyPath, detail, recoveryHint, cause) {
|
|
49
|
+
super(`${filePath}: ${keyPath} — ${detail}`, recoveryHint, cause);
|
|
50
|
+
this.name = 'RuleRecordParseError';
|
|
51
|
+
this.filePath = filePath;
|
|
52
|
+
this.keyPath = keyPath;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* § Design 2 (R5) — unknown `schemaVersion`, unknown `target.type`, or a
|
|
57
|
+
* reserved-unimplemented construct (`requires.scope: block`, § Design 8). A V1
|
|
58
|
+
* consumer that cannot declare a degraded mode enumerating every skipped rule
|
|
59
|
+
* MUST fail; Prop 270 §6.3's warn-and-ignore is superseded (a silent
|
|
60
|
+
* warn-and-ignore is a fail-open rule drop in a governance compiler).
|
|
61
|
+
*/
|
|
62
|
+
export class RuleRecordNoSilentSkipError extends RuleRecordParseError {
|
|
63
|
+
construct;
|
|
64
|
+
constructor(filePath, construct, detail) {
|
|
65
|
+
super(filePath, construct, detail, 'V1 declares no degraded mode — bump the grammar version or fix the record; a consumer may never skip the rule silently (Prop 310 § Design 2).');
|
|
66
|
+
this.name = 'RuleRecordNoSilentSkipError';
|
|
67
|
+
this.construct = construct;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* § Design 4 — a producer-owned / intake-seam key is INEXPRESSIBLE in a record at
|
|
72
|
+
* any depth. Distinct from a generic unknown-key rejection: the author needs to
|
|
73
|
+
* be told the key belongs to the ADR-112 producer or the intake invocation
|
|
74
|
+
* (§ Design 1), not that they typo'd a grammar field.
|
|
75
|
+
*/
|
|
76
|
+
export class RuleRecordProducerKeyError extends RuleRecordParseError {
|
|
77
|
+
producerKey;
|
|
78
|
+
constructor(filePath, keyPath, producerKey) {
|
|
79
|
+
super(filePath, keyPath, `'${producerKey}' is producer-owned / intake-seam metadata and is INEXPRESSIBLE in a rule record (Prop 310 § Design 1/§ Design 3/§ Design 4)`, producerKeyRecoveryHint(keyPath, producerKey));
|
|
80
|
+
this.name = 'RuleRecordProducerKeyError';
|
|
81
|
+
this.producerKey = producerKey;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A mapping key that is JS prototype machinery. NOT a § Design 4 violation —
|
|
86
|
+
* these are rejected on security grounds, not because a producer owns them — so
|
|
87
|
+
* it carries its own diagnostic and never says "producer-owned".
|
|
88
|
+
*/
|
|
89
|
+
export class RuleRecordPrototypeKeyError extends RuleRecordParseError {
|
|
90
|
+
prototypeKey;
|
|
91
|
+
constructor(filePath, keyPath, prototypeKey) {
|
|
92
|
+
super(filePath, keyPath, `'${prototypeKey}' is JS prototype machinery and is rejected as a mapping key at ANY depth — the censused ast-grep payload vocabulary contains no such construct, so a record carrying one is an injection vector, never a rule (prototype-pollution floor)`, 'Remove the key. A rule that genuinely needs to match a literal named `__proto__` expresses it as a pattern VALUE, never as a mapping key.');
|
|
93
|
+
this.name = 'RuleRecordPrototypeKeyError';
|
|
94
|
+
this.prototypeKey = prototypeKey;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const PRODUCER_KEY_BASE_HINT = 'Remove the key. Identity is minted by the ADR-112 producer; authoring metadata and attestations are supplied at the `totem rule author` intake seam, never in-record.';
|
|
98
|
+
// The one key a COMPLETE ast-grep config file carries at its own top level that
|
|
99
|
+
// is ALSO inexpressible here: a pasted whole config is the likely authoring
|
|
100
|
+
// mistake behind a hit at this depth, and the generic "producer-owned" hint would
|
|
101
|
+
// leave the author with nowhere to put the value. `language` is deliberately NOT
|
|
102
|
+
// part of this test — the hint fires only from `RuleRecordProducerKeyError`,
|
|
103
|
+
// which is thrown only for `RULE_RECORD_INEXPRESSIBLE_KEYS` members, and
|
|
104
|
+
// `language` is not one (it is a LEGAL record key, at `target.language`). The
|
|
105
|
+
// hint's advice still names it, because a pasted config carries both halves and
|
|
106
|
+
// the author needs to know where each one goes.
|
|
107
|
+
const PASTED_AST_GREP_CONFIG_KEY = 'id';
|
|
108
|
+
function producerKeyRecoveryHint(keyPath, producerKey) {
|
|
109
|
+
if (keyPath.startsWith('target.rule.') && producerKey === PASTED_AST_GREP_CONFIG_KEY) {
|
|
110
|
+
return `${PRODUCER_KEY_BASE_HINT} This looks like a PASTED complete ast-grep config: such a file carries \`id\`/\`language\` at its own top level, but \`target.rule\` carries ONLY the rule payload — \`id\` is producer-minted (§ Design 3) and the language belongs at \`target.language\` (§ Design 4/§ Design 6).`;
|
|
111
|
+
}
|
|
112
|
+
return PRODUCER_KEY_BASE_HINT;
|
|
113
|
+
}
|
|
114
|
+
// ── § Design 4 — the inexpressible key set ───────────────────────────────────
|
|
115
|
+
//
|
|
116
|
+
// The census-constraint-5 producer-owned set, plus `provenance` (the ADR-112
|
|
117
|
+
// producer's own OUTPUT field — a semantic collision no shipped gate catches,
|
|
118
|
+
// which is why the grammar must close it), plus `id` / `version` /
|
|
119
|
+
// `declaredEngine` (§ Design 3 / R17), plus ADR-112's intake-seam authoring
|
|
120
|
+
// metadata (§ Design 1). Scanned at EVERY depth: `.strict()` alone is not
|
|
121
|
+
// recursive across an opaque payload, and § Design 4 says "inexpressible at any
|
|
122
|
+
// depth" — the same discipline the shipped intake reject list applies.
|
|
123
|
+
/** § Design 4 — keys a record may never carry, at any depth. */
|
|
124
|
+
export const RULE_RECORD_INEXPRESSIBLE_KEYS = new Set([
|
|
125
|
+
// census constraint 5 — the ADR-112 producer's own verdict/identity/routing fields
|
|
126
|
+
'structuralEligibility',
|
|
127
|
+
'decidable',
|
|
128
|
+
'judgedBy',
|
|
129
|
+
'basis',
|
|
130
|
+
'ruleId',
|
|
131
|
+
'authoringLedgerRef',
|
|
132
|
+
'classifierDisposition',
|
|
133
|
+
'disposition',
|
|
134
|
+
'routing',
|
|
135
|
+
'unverified',
|
|
136
|
+
// the producer's discriminated-union output field (§ Design 4's named addition)
|
|
137
|
+
'provenance',
|
|
138
|
+
// § Design 3 / R17 — identity is never authored; the engine is never mirrored
|
|
139
|
+
'id',
|
|
140
|
+
'version',
|
|
141
|
+
'declaredEngine',
|
|
142
|
+
// § Design 1 — ADR-112 per-rule authoring metadata lives at the intake seam
|
|
143
|
+
'author',
|
|
144
|
+
'authoredAt',
|
|
145
|
+
'targetDefect',
|
|
146
|
+
'structuralClass',
|
|
147
|
+
'positiveFixtures',
|
|
148
|
+
'negativeFixtures',
|
|
149
|
+
'origin',
|
|
150
|
+
]);
|
|
151
|
+
// ONE path grammar across every diagnostic: dot-form at every depth, array
|
|
152
|
+
// ordinals included (`examples.0.bad`), matching how Zod renders `issue.path`.
|
|
153
|
+
// Two renderings of the same address would make a diagnostic ungreppable.
|
|
154
|
+
function joinKeyPath(at, segment) {
|
|
155
|
+
return at === '' ? String(segment) : `${at}.${segment}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* SECURITY FLOOR — mapping keys that are JS prototype machinery, rejected at any
|
|
159
|
+
* depth including the opaque `target.rule` interior. DELIBERATELY a separate set
|
|
160
|
+
* from `RULE_RECORD_INEXPRESSIBLE_KEYS`: that one is spec-defined at exactly 21
|
|
161
|
+
* producer-owned keys (§ Design 4) and size-guarded, while these three are not a
|
|
162
|
+
* grammar rule at all.
|
|
163
|
+
*
|
|
164
|
+
* The floor is needed because IR-2 keeps the payload interior OPAQUE: `yaml`
|
|
165
|
+
* materializes `__proto__` as an OWN ENUMERABLE property, so it survives into
|
|
166
|
+
* whatever slice 2's lowering builds — a spread carries it forward as a real key,
|
|
167
|
+
* and an `Object.assign` reparents the destination's prototype while silently
|
|
168
|
+
* DROPPING the key. Both are structural corruption a parser must not hand
|
|
169
|
+
* downstream. No legitimate ast-grep construct is named any of these.
|
|
170
|
+
*/
|
|
171
|
+
export const RULE_RECORD_FORBIDDEN_PROTOTYPE_KEYS = new Set([
|
|
172
|
+
'__proto__',
|
|
173
|
+
'constructor',
|
|
174
|
+
'prototype',
|
|
175
|
+
]);
|
|
176
|
+
/**
|
|
177
|
+
* Walk the whole document once, enforcing the three whole-key-space rules that
|
|
178
|
+
* `.strict()` cannot: the prototype-machinery SECURITY FLOOR, § Design 4's
|
|
179
|
+
* INEXPRESSIBLE key set, and finiteness. All three bind at EVERY depth —
|
|
180
|
+
* `.strict()` is not recursive and does not reach into the opaque payload at all.
|
|
181
|
+
*
|
|
182
|
+
* `ancestors` is the DFS ancestor stack, not a visit log: YAML resolves a
|
|
183
|
+
* RECURSIVE anchor (`target: &t` … `rule: {loop: *t}`) into a genuinely cyclic
|
|
184
|
+
* object, which un-guarded recursion turns into a raw `RangeError` — a crash, not
|
|
185
|
+
* the `RuleRecordParseError` this module's totality contract promises. Tracking
|
|
186
|
+
* ANCESTORS (added on entry, removed on exit) detects a true cycle while leaving a
|
|
187
|
+
* legal SHARED anchor — the same node aliased twice as siblings, a DAG, not a
|
|
188
|
+
* cycle — free to validate; a visit-once log would misdiagnose that as a cycle.
|
|
189
|
+
*/
|
|
190
|
+
function assertRecordKeySpace(filePath, value, at, ancestors) {
|
|
191
|
+
if (value === null || typeof value !== 'object')
|
|
192
|
+
return;
|
|
193
|
+
if (ancestors.has(value)) {
|
|
194
|
+
throw new RuleRecordParseError(filePath, at === '' ? '(document)' : at, 'cyclic YAML anchor — a record is a finite tree; a recursive alias can never be lowered totally or hashed (Prop 310 § Design 1/§ Design 12)', 'Remove the recursive `&anchor` / `*alias` reference and write the payload out explicitly.');
|
|
195
|
+
}
|
|
196
|
+
ancestors.add(value);
|
|
197
|
+
if (Array.isArray(value)) {
|
|
198
|
+
value.forEach((entry, i) => assertRecordKeySpace(filePath, entry, joinKeyPath(at, i), ancestors));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
for (const [key, child] of Object.entries(value)) {
|
|
202
|
+
const keyPath = joinKeyPath(at, key);
|
|
203
|
+
// Security floor FIRST, and before descending: a prototype-machinery key is
|
|
204
|
+
// rejected on its own grounds, never reported as a § Design 4 violation.
|
|
205
|
+
if (RULE_RECORD_FORBIDDEN_PROTOTYPE_KEYS.has(key)) {
|
|
206
|
+
throw new RuleRecordPrototypeKeyError(filePath, keyPath, key);
|
|
207
|
+
}
|
|
208
|
+
if (RULE_RECORD_INEXPRESSIBLE_KEYS.has(key)) {
|
|
209
|
+
throw new RuleRecordProducerKeyError(filePath, keyPath, key);
|
|
210
|
+
}
|
|
211
|
+
assertRecordKeySpace(filePath, child, keyPath, ancestors);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
ancestors.delete(value);
|
|
215
|
+
}
|
|
216
|
+
// ── § Design 7 — the normative glob dialect ──────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// From V1 the dialect is SPEC, not sanitizer behaviour: it binds the record path
|
|
219
|
+
// only, while `sanitizeFileGlobs`' tolerant behaviour for the frozen legacy
|
|
220
|
+
// lesson corpus is untouched (§ Design 7 conformance scope). A glob is retained
|
|
221
|
+
// BYTE-VERBATIM — no expansion, no promotion, no normalization of any kind: a
|
|
222
|
+
// glob means what it says, so `*.ts` is root-level and tree-wide is written
|
|
223
|
+
// `**/*.ts` (§ Design 7 "No silent promotion").
|
|
224
|
+
/**
|
|
225
|
+
* The closed set of § Design 7 rules a glob can violate. A runtime value, not a
|
|
226
|
+
* bare type union, so the conformance suite can assert every rule has a negative
|
|
227
|
+
* fixture — an unexercised rule is an unpinned rule (§ Design 14).
|
|
228
|
+
*/
|
|
229
|
+
export const GLOB_DIALECT_RULES = [
|
|
230
|
+
'empty',
|
|
231
|
+
'surrounding-whitespace',
|
|
232
|
+
'separator',
|
|
233
|
+
'absolute-path',
|
|
234
|
+
'drive-letter',
|
|
235
|
+
'brace-expansion',
|
|
236
|
+
'negation',
|
|
237
|
+
'regex-syntax',
|
|
238
|
+
'empty-segment',
|
|
239
|
+
'current-segment',
|
|
240
|
+
'parent-segment',
|
|
241
|
+
'embedded-globstar',
|
|
242
|
+
'adjacent-globstar',
|
|
243
|
+
];
|
|
244
|
+
// `.` is DELIBERATELY absent — extension forms (`*.ts`) are the dialect's own
|
|
245
|
+
// allowed shape.
|
|
246
|
+
//
|
|
247
|
+
// The GROUNDS for banning the rest, per Prop 310 Amendment 2 (which promoted this
|
|
248
|
+
// strict set to ruled spec text): the dialect's no-silent-transform discipline,
|
|
249
|
+
// extended to AMBIGUITY AVOIDANCE. A character that reads as a regex construct
|
|
250
|
+
// to an author, and as a literal to this matcher, is a glob whose meaning depends
|
|
251
|
+
// on which of the two the reader has in mind — and that is precisely the class
|
|
252
|
+
// § Design 7 exists to remove, whether or not the glob would have matched
|
|
253
|
+
// anything.
|
|
254
|
+
//
|
|
255
|
+
// Match-impossibility is claimed for NO character here. `$types.ts` and
|
|
256
|
+
// `(legacy)/util.ts` are perfectly ordinary path names, so a `$` or `(` glob can
|
|
257
|
+
// and does match real trees; an earlier framing of this comment said otherwise
|
|
258
|
+
// and over-claimed. The ban is not "this can never match" — it is "this must not
|
|
259
|
+
// mean two things".
|
|
260
|
+
//
|
|
261
|
+
// The escape hatch is a FUTURE QUOTING CONSTRUCT arriving by `schemaVersion`
|
|
262
|
+
// bump, never a silent literal reading of a banned character. Until then, a path
|
|
263
|
+
// that genuinely contains one of these is out of the dialect's reach, and that
|
|
264
|
+
// cost is named rather than papered over.
|
|
265
|
+
const REGEX_SYNTAX_CHARS = ['[', ']', '(', ')', '?', '+', '|', '^', '$'];
|
|
266
|
+
const DRIVE_LETTER_RE = /^[A-Za-z]:/;
|
|
267
|
+
// The relative-navigation segments a git-tracked, repo-relative path NAME never
|
|
268
|
+
// contains (§ Design 7). WHOLE segments only: a dot inside a segment is an
|
|
269
|
+
// ordinary literal (`.github`, `a..b`, and every `*.ts` extension form).
|
|
270
|
+
const CURRENT_SEGMENT = '.';
|
|
271
|
+
const PARENT_SEGMENT = '..';
|
|
272
|
+
/**
|
|
273
|
+
* § Design 7 — validate one glob against the normative dialect. Returns the first
|
|
274
|
+
* violation (checks run in a fixed order, so the diagnostic is deterministic) or
|
|
275
|
+
* `null` when the glob is dialect-clean. Pure: it never rewrites the glob.
|
|
276
|
+
*
|
|
277
|
+
* Allowed: literal segments; `*` as a single-segment wildcard (including
|
|
278
|
+
* extension forms like `*.ts`); `**` as a whole-segment globstar, non-adjacent
|
|
279
|
+
* and non-nested. Anything outside that closed set is a parse error — an empty
|
|
280
|
+
* segment is not a literal segment, so it is rejected too.
|
|
281
|
+
*/
|
|
282
|
+
export function checkGlobDialect(glob) {
|
|
283
|
+
if (glob.trim().length === 0) {
|
|
284
|
+
return {
|
|
285
|
+
rule: 'empty',
|
|
286
|
+
message: 'empty glob — the dialect admits literal segments, `*`, and `**` only (Prop 310 § Design 7)',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (glob !== glob.trim()) {
|
|
290
|
+
return {
|
|
291
|
+
rule: 'surrounding-whitespace',
|
|
292
|
+
message: 'leading/trailing whitespace — matching is against git-tracked path NAMES, which never carry it, so the glob can only ever match nothing (Prop 310 § Design 7); a glob is retained byte-verbatim, so the parser will not trim it for you',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (glob.includes('\\')) {
|
|
296
|
+
return {
|
|
297
|
+
rule: 'separator',
|
|
298
|
+
message: 'backslash in a glob — records use `/` exclusively; matchers normalize host separators at evaluation (Prop 310 § Design 7 Windows semantics)',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (glob.startsWith('/')) {
|
|
302
|
+
return {
|
|
303
|
+
rule: 'absolute-path',
|
|
304
|
+
message: 'absolute path — globs are repo-relative (Prop 310 § Design 7 Windows semantics)',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (DRIVE_LETTER_RE.test(glob)) {
|
|
308
|
+
return {
|
|
309
|
+
rule: 'drive-letter',
|
|
310
|
+
message: 'drive letter — globs are repo-relative (Prop 310 § Design 7 Windows semantics)',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (glob.includes('{') || glob.includes('}')) {
|
|
314
|
+
return {
|
|
315
|
+
rule: 'brace-expansion',
|
|
316
|
+
message: 'brace expansion — braces are OUT of the normative dialect; expansion is a silent transform of authored intent, so write each glob explicitly (Prop 310 § Design 7 brace ruling)',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (glob.includes('!')) {
|
|
320
|
+
return {
|
|
321
|
+
rule: 'negation',
|
|
322
|
+
message: '`!`-negation — exclusion is structural: put a POSITIVE-form glob in `excludeGlobs` (Prop 310 § Design 7)',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const regexChar = REGEX_SYNTAX_CHARS.find((c) => glob.includes(c));
|
|
326
|
+
if (regexChar !== undefined) {
|
|
327
|
+
return {
|
|
328
|
+
rule: 'regex-syntax',
|
|
329
|
+
message: `regex syntax '${regexChar}' in a glob — regex constructs are banned in the dialect (Prop 310 § Design 7)`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const segments = glob.split('/');
|
|
333
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
334
|
+
const segment = segments[i];
|
|
335
|
+
if (segment.length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
rule: 'empty-segment',
|
|
338
|
+
message: 'empty path segment — an empty segment is not a literal segment (Prop 310 § Design 7)',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (segment === CURRENT_SEGMENT) {
|
|
342
|
+
return {
|
|
343
|
+
rule: 'current-segment',
|
|
344
|
+
message: '`.` segment — globs are repo-relative and match against git-tracked path names, which never contain a `.` segment, so the glob can only ever match nothing; drop the `./` (Prop 310 § Design 7 Windows semantics)',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (segment === PARENT_SEGMENT) {
|
|
348
|
+
return {
|
|
349
|
+
rule: 'parent-segment',
|
|
350
|
+
message: '`..` segment — globs are repo-relative and match against git-tracked path names, which never contain `..`, so the glob can only ever match nothing (Prop 310 § Design 7 Windows semantics)',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (segment.includes('**') && segment !== '**') {
|
|
354
|
+
return {
|
|
355
|
+
rule: 'embedded-globstar',
|
|
356
|
+
message: `globstar embedded in segment '${segment}' — \`**\` is whole-segment only, never nested inside a segment (Prop 310 § Design 7)`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (segment === '**' && i > 0 && segments[i - 1] === '**') {
|
|
360
|
+
return {
|
|
361
|
+
rule: 'adjacent-globstar',
|
|
362
|
+
message: 'adjacent globstars — `**` segments may not be adjacent (Prop 310 § Design 7)',
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
// ── The record schema (§§ Design 2–9) ────────────────────────────────────────
|
|
369
|
+
/** § Design 2 — the only `schemaVersion` this V1 grammar admits. */
|
|
370
|
+
export const RULE_RECORD_SCHEMA_VERSION = 1;
|
|
371
|
+
// Non-mutating emptiness check, matching the shipped spine discipline: a record
|
|
372
|
+
// value is never trimmed on the way in (an exemplar is certification preimage
|
|
373
|
+
// material — § Design 10 — so a transform would move the hash basis).
|
|
374
|
+
//
|
|
375
|
+
// `label` is the ORDINAL-FREE dot-form address, never bracket-form: the one path
|
|
376
|
+
// grammar above governs rendered MESSAGES too, not just `keyPath`, and the
|
|
377
|
+
// concrete ordinal is already carried by the Zod issue path the renderer prefixes
|
|
378
|
+
// (`examples.0.bad: examples.bad must be non-empty`).
|
|
379
|
+
const nonEmpty = (label) => z.string().refine((s) => s.trim().length > 0, { message: `${label} must be non-empty` });
|
|
380
|
+
/** § Design 4 — closed severity vocabulary; NO default (the census's silent warning-default is killed). */
|
|
381
|
+
export const RuleSeveritySchema = z.enum(['error', 'warning']);
|
|
382
|
+
/**
|
|
383
|
+
* § Design 6 (R16) — the V1 authoring enum. `ast` is dropped from the AUTHORING
|
|
384
|
+
* surface (0 of 485 corpus rules) while reader/compiled enums keep it inert;
|
|
385
|
+
* `rego` and ADR-109's action-rule type join by grammar version bump.
|
|
386
|
+
*/
|
|
387
|
+
export const RuleTargetTypeSchema = z.enum(['ast-grep', 'regex']);
|
|
388
|
+
/**
|
|
389
|
+
* § Design 6 (R16) — the S-expression tier. Reader/compiled enums keep it as an
|
|
390
|
+
* inert legacy member with a deprecation diagnostic; the AUTHORING surface drops
|
|
391
|
+
* it, and unlike `rego` / ADR-109's action-rule type it does NOT return by version
|
|
392
|
+
* bump. Exported with its diagnostic so the conformance suite pins the wording:
|
|
393
|
+
* pointing an author at a bump that will never carry `ast` is a wrong answer, not
|
|
394
|
+
* a terse one.
|
|
395
|
+
*/
|
|
396
|
+
export const LEGACY_INERT_ENGINE = 'ast';
|
|
397
|
+
export const LEGACY_ENGINE_DETAIL = '`ast` (the S-expression tier) is DROPPED from the V1 authoring surface and does NOT return by version bump — 0 of the 485 corpus rules used it. Reader and compiled enums keep `ast` as an inert legacy member with a deprecation diagnostic, but no record may declare it (§ Design 6, R16)';
|
|
398
|
+
const _ruleTargetTypeSubsetCheck = true;
|
|
399
|
+
/**
|
|
400
|
+
* § Design 8 — the absence unit. The closed name space RESERVES `line | block |
|
|
401
|
+
* file`; `line` and `file` are implemented at V1 and `block` is
|
|
402
|
+
* reserved-unimplemented pending a language adapter's boundary definition, so it
|
|
403
|
+
* hits the § Design 2 gate rather than parsing. NO default — omitting `scope:`
|
|
404
|
+
* is a parse error (a file-as-default is a silent default reborn).
|
|
405
|
+
*/
|
|
406
|
+
export const RequiresScopeSchema = z.enum(['line', 'file']);
|
|
407
|
+
/** § Design 8 — the reserved-unimplemented member of the `requires.scope` name space. */
|
|
408
|
+
export const REQUIRES_SCOPE_RESERVED = 'block';
|
|
409
|
+
// § Design 7 — every glob entry is dialect-validated at parse; the value itself is
|
|
410
|
+
// carried through byte-verbatim.
|
|
411
|
+
const GlobSchema = z.string().superRefine((value, ctx) => {
|
|
412
|
+
const violation = checkGlobDialect(value);
|
|
413
|
+
if (violation !== null) {
|
|
414
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: violation.message });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
/** § Design 4 — `target.scope`: positive globs only, with first-class structural exclusions. */
|
|
418
|
+
export const RuleScopeSchema = z
|
|
419
|
+
.object({
|
|
420
|
+
fileGlobs: z
|
|
421
|
+
.array(GlobSchema)
|
|
422
|
+
.min(1, { message: 'target.scope.fileGlobs must declare ≥1 glob (Prop 310 § Design 5)' }),
|
|
423
|
+
/**
|
|
424
|
+
* Applied as `positiveMatch && !excludeMatch` — positive-form entries, never
|
|
425
|
+
* `!`-negation. Min-1 WHEN PRESENT: an empty list is a silent no-op, and
|
|
426
|
+
* "no exclusions" is expressed by OMITTING the key (§ Design 4 — no key in
|
|
427
|
+
* this grammar carries a do-nothing value).
|
|
428
|
+
*/
|
|
429
|
+
excludeGlobs: z
|
|
430
|
+
.array(GlobSchema)
|
|
431
|
+
.min(1, {
|
|
432
|
+
message: 'target.scope.excludeGlobs must declare ≥1 glob when present — omit the key to declare no exclusions (Prop 310 § Design 4)',
|
|
433
|
+
})
|
|
434
|
+
.optional(),
|
|
435
|
+
})
|
|
436
|
+
.strict();
|
|
437
|
+
/**
|
|
438
|
+
* IR-2 — the ast-grep compound payload INTERIOR is opaque at parse. § Design 4
|
|
439
|
+
* calls it a "NapiConfig subset"; structural NapiConfig validation is the
|
|
440
|
+
* COMPILE-stage engine gate (§ Failure modes: "Payload fails engine gate
|
|
441
|
+
* (safe-regex2, NapiConfig validation) … hard error at intake preflight"), which
|
|
442
|
+
* is slice 2. The record grammar closes its OWN key space, not the engine's — so
|
|
443
|
+
* the interior is carried verbatim and only its shape (a non-empty mapping) is
|
|
444
|
+
* checked here. The § Design 4 inexpressible-key scan still descends into it.
|
|
445
|
+
*/
|
|
446
|
+
const AstGrepRulePayloadSchema = z.record(z.unknown()).refine((v) => Object.keys(v).length > 0, {
|
|
447
|
+
message: 'target.rule must be a non-empty compound payload',
|
|
448
|
+
});
|
|
449
|
+
/**
|
|
450
|
+
* § Design 4 — `target`. `language` is REQUIRED for ast-grep and FORBIDDEN for
|
|
451
|
+
* regex (§ Design 6: one declared language, validated under exactly that grammar
|
|
452
|
+
* — replacing the try-each-glob-derived-Lang cross-grammar acceptance). The
|
|
453
|
+
* payload is exactly one of `pattern` (flat) or `rule` (compound) for ast-grep,
|
|
454
|
+
* and `pattern` only for regex.
|
|
455
|
+
*/
|
|
456
|
+
export const RuleTargetSchema = z
|
|
457
|
+
.object({
|
|
458
|
+
type: RuleTargetTypeSchema,
|
|
459
|
+
/** § Design 6 — an identifier TOKEN at parse; it resolves against the Map-backed registry at compile (slice 2), never a spec-frozen enum. */
|
|
460
|
+
language: nonEmpty('target.language').optional(),
|
|
461
|
+
pattern: nonEmpty('target.pattern').optional(),
|
|
462
|
+
rule: AstGrepRulePayloadSchema.optional(),
|
|
463
|
+
scope: RuleScopeSchema,
|
|
464
|
+
})
|
|
465
|
+
.strict()
|
|
466
|
+
.superRefine((target, ctx) => {
|
|
467
|
+
const custom = z.ZodIssueCode.custom;
|
|
468
|
+
if (target.type === 'ast-grep' && target.language === undefined) {
|
|
469
|
+
ctx.addIssue({
|
|
470
|
+
code: custom,
|
|
471
|
+
path: ['language'],
|
|
472
|
+
message: 'target.language is REQUIRED for `type: ast-grep` — undefined-for-type is an error, never a default (Prop 310 § Design 4/§ Design 6)',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (target.type === 'regex' && target.language !== undefined) {
|
|
476
|
+
ctx.addIssue({
|
|
477
|
+
code: custom,
|
|
478
|
+
path: ['language'],
|
|
479
|
+
message: 'target.language is FORBIDDEN for `type: regex` — a regex payload has no grammar binding (Prop 310 § Design 4)',
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
const hasPattern = target.pattern !== undefined;
|
|
483
|
+
const hasRule = target.rule !== undefined;
|
|
484
|
+
if (target.type === 'regex' && hasRule) {
|
|
485
|
+
ctx.addIssue({
|
|
486
|
+
code: custom,
|
|
487
|
+
path: ['rule'],
|
|
488
|
+
message: 'target.rule is FORBIDDEN for `type: regex` — the compound payload belongs to ast-grep (Prop 310 § Design 4)',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (target.type === 'regex' && !hasPattern) {
|
|
492
|
+
ctx.addIssue({
|
|
493
|
+
code: custom,
|
|
494
|
+
path: ['pattern'],
|
|
495
|
+
message: 'target.pattern is REQUIRED for `type: regex` (Prop 310 § Design 4/§ Design 5 mandatory set)',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
if (target.type === 'ast-grep' && hasPattern && hasRule) {
|
|
499
|
+
ctx.addIssue({
|
|
500
|
+
code: custom,
|
|
501
|
+
path: ['rule'],
|
|
502
|
+
message: 'target carries BOTH `pattern` and `rule` — an ast-grep payload is exactly one of them (Prop 310 § Design 4)',
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (target.type === 'ast-grep' && !hasPattern && !hasRule) {
|
|
506
|
+
ctx.addIssue({
|
|
507
|
+
code: custom,
|
|
508
|
+
path: ['pattern'],
|
|
509
|
+
message: 'target carries NEITHER `pattern` nor `rule` — an ast-grep payload is exactly one of them (Prop 310 § Design 4/§ Design 5 mandatory set)',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
/**
|
|
514
|
+
* § Design 5/§ Design 10 — one bad/good exemplar pair. IR-1: both sides are
|
|
515
|
+
* non-empty. `examples` is certification's PRIMARY PREIMAGE SOURCE (ADR-112 §4)
|
|
516
|
+
* and Amendment 1 makes the record the EDITABLE home, so a zero-byte exemplar
|
|
517
|
+
* cannot serve as the fire-on-bad ∧ silent-on-good differential it exists to be.
|
|
518
|
+
*
|
|
519
|
+
* Named `RuleRecordExample`, not `RuleExample`: the shipped LEGACY
|
|
520
|
+
* `RuleExamples` (`lesson-pattern.ts`, on the same barrel) is the hit/miss shape
|
|
521
|
+
* of the frozen lesson path this grammar supersedes, and two barrel exports one
|
|
522
|
+
* character apart is a mis-import waiting to happen.
|
|
523
|
+
*/
|
|
524
|
+
export const RuleRecordExampleSchema = z
|
|
525
|
+
.object({
|
|
526
|
+
bad: nonEmpty('examples.bad'),
|
|
527
|
+
good: nonEmpty('examples.good'),
|
|
528
|
+
})
|
|
529
|
+
.strict();
|
|
530
|
+
/**
|
|
531
|
+
* § Design 8 — the absence / must-contain block. Fires on a `target` match at
|
|
532
|
+
* locus L iff `requires.pattern` does NOT match within the declared scope
|
|
533
|
+
* containing L. V1 carries EXACTLY ONE block (multi-requires is a version-bump
|
|
534
|
+
* extension), so this is a single mapping, never a list.
|
|
535
|
+
*/
|
|
536
|
+
export const RuleRequiresSchema = z
|
|
537
|
+
.object({
|
|
538
|
+
/** A safe-regex2-gated regex evaluated TEXTUALLY at the declared scope (gate is slice 2), independent of `target.type`. */
|
|
539
|
+
pattern: nonEmpty('requires.pattern'),
|
|
540
|
+
scope: RequiresScopeSchema,
|
|
541
|
+
})
|
|
542
|
+
.strict();
|
|
543
|
+
/**
|
|
544
|
+
* § Design 4 (R8) — Prop 270 §8's curation-provenance block, carried under the
|
|
545
|
+
* name `curation:` (renamed there because `provenance` is the ADR-112 producer's
|
|
546
|
+
* own output field). Keys are camelCase, normalized from Prop 270 §8's snake_case
|
|
547
|
+
* — a casing normalization, not a semantic change. The block is optional for
|
|
548
|
+
* direct-authored rules and REQUIRED for Baseline-5-curated ones.
|
|
549
|
+
*
|
|
550
|
+
* SHAPE, per OPERATOR RULING 2026-08-21 (superseding IR-4's build-time
|
|
551
|
+
* complete-or-absent reading, which rejected a lone `sourceLesson`):
|
|
552
|
+
*
|
|
553
|
+
* - `sourceLesson` is REQUIRED whenever the block is present. It is § Design 1's
|
|
554
|
+
* record→lesson link — the one thing every curated rule has, since a curated
|
|
555
|
+
* rule always derives from a lesson — so a block without it is incoherent.
|
|
556
|
+
* - `curatedBy` / `curatedAt` / `baseline5Phase` are the Baseline-5 PROCESS
|
|
557
|
+
* trio (Prop 270 §8), optional as a GROUP but ALL-OR-NONE together: a
|
|
558
|
+
* direct-authored rule carries the link alone, a Baseline-5-curated rule
|
|
559
|
+
* carries the whole process record, and a PARTIAL trio is a parse error
|
|
560
|
+
* naming the missing members.
|
|
561
|
+
*
|
|
562
|
+
* No defaults are introduced by the relaxation, and the key space stays closed —
|
|
563
|
+
* "optional" here means absent, never silently filled in.
|
|
564
|
+
*/
|
|
565
|
+
/** Prop 270 §8's Baseline-5 process trio — optional as a group, all-or-none together. */
|
|
566
|
+
export const CURATION_PROCESS_FIELDS = ['curatedBy', 'curatedAt', 'baseline5Phase'];
|
|
567
|
+
export const RuleCurationSchema = z
|
|
568
|
+
.object({
|
|
569
|
+
/** § Design 1 — the record→lesson link. Required whenever the block is present. */
|
|
570
|
+
sourceLesson: nonEmpty('curation.sourceLesson'),
|
|
571
|
+
curatedBy: nonEmpty('curation.curatedBy').optional(),
|
|
572
|
+
curatedAt: nonEmpty('curation.curatedAt').optional(),
|
|
573
|
+
baseline5Phase: z
|
|
574
|
+
.number()
|
|
575
|
+
.int({ message: 'curation.baseline5Phase must be an integer' })
|
|
576
|
+
.optional(),
|
|
577
|
+
})
|
|
578
|
+
.strict()
|
|
579
|
+
.superRefine((curation, ctx) => {
|
|
580
|
+
// All-or-none, not "any subset": a half-filled process record would leave a
|
|
581
|
+
// reader guessing which half is authoritative, and the grammar carries no
|
|
582
|
+
// defaults to fill the gap. Every MISSING member is named, so the author is
|
|
583
|
+
// told what to add rather than that something is wrong.
|
|
584
|
+
const missing = CURATION_PROCESS_FIELDS.filter((field) => curation[field] === undefined);
|
|
585
|
+
if (missing.length === 0 || missing.length === CURATION_PROCESS_FIELDS.length)
|
|
586
|
+
return;
|
|
587
|
+
for (const field of missing) {
|
|
588
|
+
ctx.addIssue({
|
|
589
|
+
code: z.ZodIssueCode.custom,
|
|
590
|
+
path: [field],
|
|
591
|
+
message: `curation.${field} is REQUIRED once any Baseline-5 process field is present — the trio (${CURATION_PROCESS_FIELDS.join(', ')}) is all-or-none (Prop 310 § Design 4 / Prop 270 §8; operator ruling 2026-08-21)`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
/**
|
|
596
|
+
* § Design 9 (Amendment R4) — the optional classification block. Parsed under its
|
|
597
|
+
* closed keys and retained VERBATIM; never evaluated at V1. The snake_case name
|
|
598
|
+
* is a deliberate, named exception to the grammar's camelCase (§ Design 4): the
|
|
599
|
+
* key is already reserved in shipped forward-compat readers, so renaming it would
|
|
600
|
+
* buy consistency at the price of a pointless migration. NEVER rename it.
|
|
601
|
+
*/
|
|
602
|
+
export const VerificationShadowSchema = z
|
|
603
|
+
.object({
|
|
604
|
+
type: nonEmpty('verification_shadow.type'),
|
|
605
|
+
source: nonEmpty('verification_shadow.source'),
|
|
606
|
+
})
|
|
607
|
+
.strict();
|
|
608
|
+
/**
|
|
609
|
+
* Prop 310 § Design 4 — the V1 rule record. `.strict()` at every depth of the
|
|
610
|
+
* record's own key space; no field anywhere has a default. The § Design 5
|
|
611
|
+
* mandatory set is `schemaVersion`, `severity`, `message`, `target.type`,
|
|
612
|
+
* `target.<payload>`, `target.scope.fileGlobs`, and a non-empty `examples`.
|
|
613
|
+
*/
|
|
614
|
+
export const RuleRecordSchema = z
|
|
615
|
+
.object({
|
|
616
|
+
schemaVersion: z.literal(RULE_RECORD_SCHEMA_VERSION),
|
|
617
|
+
severity: RuleSeveritySchema,
|
|
618
|
+
/** REQUIRED, non-empty (R8) — the census found `message` on 4 of 140 hand-authored lessons. */
|
|
619
|
+
message: nonEmpty('message'),
|
|
620
|
+
recoveryHint: nonEmpty('recoveryHint').optional(),
|
|
621
|
+
target: RuleTargetSchema,
|
|
622
|
+
examples: z.array(RuleRecordExampleSchema).min(1, {
|
|
623
|
+
message: 'examples must carry ≥1 bad/good pair — it is certification’s primary preimage source (Prop 310 § Design 5)',
|
|
624
|
+
}),
|
|
625
|
+
requires: RuleRequiresSchema.optional(),
|
|
626
|
+
curation: RuleCurationSchema.optional(),
|
|
627
|
+
verification_shadow: VerificationShadowSchema.optional(),
|
|
628
|
+
})
|
|
629
|
+
.strict();
|
|
630
|
+
/**
|
|
631
|
+
* Amendment 1 item 3 — the § Design 10 per-pair content hash is CR-BLIND,
|
|
632
|
+
* computed over the LF-image of its material, so a CRLF-authored and an
|
|
633
|
+
* LF-authored variant of the same exemplar hash IDENTICALLY. The trial's P3 row
|
|
634
|
+
* (`serialization-admit`) is exactly the class this defeats: a writer that
|
|
635
|
+
* escape-smuggles `\r` past the admit hop must not fire a false drift alarm.
|
|
636
|
+
*
|
|
637
|
+
* This is a DIFFERENT EDGE from the authoring-ledger's material hash (Amendment 1
|
|
638
|
+
* item 3's own note) — different inputs, different consumers, different drift
|
|
639
|
+
* meaning. Only the LF-image PRIMITIVE is shared, single-homed at
|
|
640
|
+
* `lfDeepNormalize` (Tenet 20): mirroring the normalizer would let the two edges
|
|
641
|
+
* drift apart on the one thing they must agree about.
|
|
642
|
+
*/
|
|
643
|
+
export function ruleExamplePairHash(example) {
|
|
644
|
+
return createHash('sha256')
|
|
645
|
+
.update(canonicalStringify(lfDeepNormalize({ bad: example.bad, good: example.good })))
|
|
646
|
+
.digest('hex');
|
|
647
|
+
}
|
|
648
|
+
/** One `examples[i]` ordinal and its § Design 10 drift-sensor digest. */
|
|
649
|
+
export const RuleExamplePairHashSchema = z
|
|
650
|
+
.object({
|
|
651
|
+
ordinal: z.number().int().nonnegative(),
|
|
652
|
+
hash: z.string().regex(SHA256_HEX_RE, {
|
|
653
|
+
message: 'example pair hash must be a sha256 hex digest',
|
|
654
|
+
}),
|
|
655
|
+
})
|
|
656
|
+
.strict();
|
|
657
|
+
/**
|
|
658
|
+
* The `ParsedRuleRecord` value as a Zod schema — the parser's OUTPUT expressed at
|
|
659
|
+
* a schema boundary so `AuthoredRuleRecord` can carry it under closed keys like
|
|
660
|
+
* every other field on that envelope (slice 3, § Data model deltas).
|
|
661
|
+
*
|
|
662
|
+
* NOT a second grammar: it is composed from the SAME `RuleRecordSchema` and
|
|
663
|
+
* `RuleTargetTypeSchema` the parser validates with, plus the pair-hash shape, so
|
|
664
|
+
* there is nothing here that could accept a record the parser rejects.
|
|
665
|
+
*
|
|
666
|
+
* IN-MEMORY ONLY, and never hash material: the ledger binds the record by its
|
|
667
|
+
* file `contentHash`, so re-serialising this value into a hash basis would be a
|
|
668
|
+
* second statement of the same bytes (Tenet 20) that could drift from them.
|
|
669
|
+
*/
|
|
670
|
+
export const ParsedRuleRecordSchema = z
|
|
671
|
+
.object({
|
|
672
|
+
record: RuleRecordSchema,
|
|
673
|
+
derivedEngine: RuleTargetTypeSchema,
|
|
674
|
+
examplePairHashes: z.array(RuleExamplePairHashSchema),
|
|
675
|
+
})
|
|
676
|
+
.strict();
|
|
677
|
+
const _parsedRuleRecordSchemaCheck = true;
|
|
678
|
+
const FIX_THE_RECORD = 'Fix the record file — the V1 grammar is explicit-or-error at every key (Prop 310 § Design 4).';
|
|
679
|
+
/**
|
|
680
|
+
* Parse one `.totem/rules/<slug>.rule.yaml` document into a validated
|
|
681
|
+
* `ParsedRuleRecord`. Pure and total in the § Design 12 sense: it either returns
|
|
682
|
+
* a fully-validated value or THROWS a `RuleRecordParseError` naming the file and
|
|
683
|
+
* the offending record key path. There is no partial, degraded, or
|
|
684
|
+
* best-effort result — V1 declares no degraded mode (§ Design 2).
|
|
685
|
+
*
|
|
686
|
+
* Gate order is load-bearing: the § Design 2 no-silent-skip probes and the
|
|
687
|
+
* § Design 4 inexpressible-key scan run BEFORE full schema validation, so an
|
|
688
|
+
* unknown version / type / reserved construct and a producer-owned key each
|
|
689
|
+
* surface their OWN diagnostic instead of a misleading shape failure against a
|
|
690
|
+
* V1-shaped schema.
|
|
691
|
+
*/
|
|
692
|
+
export function parseRuleRecord(content, filePath) {
|
|
693
|
+
// ── YAML parse ── syntax errors and DUPLICATE KEYS are hard errors naming the
|
|
694
|
+
// file (`yaml`'s `uniqueKeys` default): a duplicate key would silently pick one
|
|
695
|
+
// value, which is the silent-transform class this grammar exists to kill.
|
|
696
|
+
//
|
|
697
|
+
// NO parse options are passed, so the alias-expansion ceiling the cycle/DAG
|
|
698
|
+
// scan below inherits is `yaml`'s own `maxAliasCount` default of 100 — if a
|
|
699
|
+
// later slice passes options here, or the `yaml` major moves, that bound moves
|
|
700
|
+
// with it and the ancestor-stack guard's cost profile must be re-verified.
|
|
701
|
+
let doc;
|
|
702
|
+
try {
|
|
703
|
+
doc = parseYaml(content);
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
throw new RuleRecordParseError(filePath, '(document)', `invalid YAML: ${err instanceof Error ? err.message : String(err)}`, 'Fix the YAML syntax; duplicate keys are rejected too — one rule is one well-formed YAML document (Prop 310 § Design 1).', err);
|
|
707
|
+
}
|
|
708
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
709
|
+
throw new RuleRecordParseError(filePath, '(document)', 'record is not a YAML mapping — one rule is one YAML document (Prop 310 § Design 1)', FIX_THE_RECORD);
|
|
710
|
+
}
|
|
711
|
+
const raw = doc;
|
|
712
|
+
// ── § Design 2 — the no-silent-skip version gate, FIRST ──
|
|
713
|
+
// Ahead of the § Design 4 key scan on purpose: the inexpressible SET is V1's,
|
|
714
|
+
// so it may only be applied to a document that declares V1. A future version
|
|
715
|
+
// could re-admit a key V1 reserves — `version:` itself is § Design 3's named
|
|
716
|
+
// "can return by version bump" candidate — and diagnosing such a record as
|
|
717
|
+
// producer-owned would name the wrong defect. The version answers "whose rules
|
|
718
|
+
// apply" and therefore has to answer first. (The `target.type` gate stays AFTER
|
|
719
|
+
// the scan: an unknown type does not change WHICH grammar version's key set is
|
|
720
|
+
// in force, so no analogous misdiagnosis exists there.)
|
|
721
|
+
const rawVersion = raw.schemaVersion;
|
|
722
|
+
if (rawVersion === undefined) {
|
|
723
|
+
throw new RuleRecordParseError(filePath, 'schemaVersion', `every record opens with \`schemaVersion: ${RULE_RECORD_SCHEMA_VERSION}\` (Prop 310 § Design 2/§ Design 5)`, FIX_THE_RECORD);
|
|
724
|
+
}
|
|
725
|
+
if (typeof rawVersion !== 'number' ||
|
|
726
|
+
!Number.isInteger(rawVersion) ||
|
|
727
|
+
rawVersion !== RULE_RECORD_SCHEMA_VERSION) {
|
|
728
|
+
throw new RuleRecordNoSilentSkipError(filePath, 'schemaVersion', `unknown schemaVersion ${JSON.stringify(rawVersion)} — this consumer implements version ${RULE_RECORD_SCHEMA_VERSION} only`);
|
|
729
|
+
}
|
|
730
|
+
// ── Whole-key-space scan: prototype floor + § Design 4 + cycle guard ──
|
|
731
|
+
assertRecordKeySpace(filePath, raw, '', new WeakSet());
|
|
732
|
+
// ── § Design 2/§ Design 6 — the no-silent-skip target-type gate ──
|
|
733
|
+
const rawTarget = raw.target;
|
|
734
|
+
if (rawTarget !== null && typeof rawTarget === 'object' && !Array.isArray(rawTarget)) {
|
|
735
|
+
const rawType = rawTarget.type;
|
|
736
|
+
// R16 — `ast` is not "not yet"; it is DROPPED from the authoring surface and
|
|
737
|
+
// does not return by version bump. Telling the author otherwise would send
|
|
738
|
+
// them to wait for a bump that will never carry it. The comparison case-folds
|
|
739
|
+
// and trims for DIAGNOSTIC SELECTION ONLY — `AST` / `Ast` / `ast ` are the
|
|
740
|
+
// same authoring mistake and deserve the same answer. ADMISSION is unchanged:
|
|
741
|
+
// it runs on the RAW value against the closed, case-sensitive V1 enum below,
|
|
742
|
+
// so a near-miss is still rejected, just told the truth about why.
|
|
743
|
+
if (typeof rawType === 'string' &&
|
|
744
|
+
rawType.trim().toLowerCase() === LEGACY_INERT_ENGINE &&
|
|
745
|
+
!RuleTargetTypeSchema.safeParse(rawType).success) {
|
|
746
|
+
throw new RuleRecordNoSilentSkipError(filePath, 'target.type', LEGACY_ENGINE_DETAIL);
|
|
747
|
+
}
|
|
748
|
+
if (rawType !== undefined && !RuleTargetTypeSchema.safeParse(rawType).success) {
|
|
749
|
+
throw new RuleRecordNoSilentSkipError(filePath, 'target.type', `unknown target.type ${JSON.stringify(rawType)} — the V1 enum is \`ast-grep | regex\`; \`rego\` and ADR-109's action-rule type join by grammar version bump (§ Design 6)`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// ── § Design 8 — `scope: block` is reserved-unimplemented, not a parse value ──
|
|
753
|
+
const rawRequires = raw.requires;
|
|
754
|
+
if (rawRequires !== null && typeof rawRequires === 'object' && !Array.isArray(rawRequires)) {
|
|
755
|
+
const rawScope = rawRequires.scope;
|
|
756
|
+
if (rawScope === REQUIRES_SCOPE_RESERVED) {
|
|
757
|
+
throw new RuleRecordNoSilentSkipError(filePath, 'requires.scope', '`scope: block` is RESERVED-UNIMPLEMENTED at V1 — the name space reserves `line | block | file`, and `block` awaits a language adapter’s boundary definition (§ Design 8)');
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// ── Full closed-key validation (§§ Design 4–9) + the § Design 7 glob dialect ──
|
|
761
|
+
const parsed = RuleRecordSchema.safeParse(raw);
|
|
762
|
+
if (!parsed.success) {
|
|
763
|
+
const issues = parsed.error.issues;
|
|
764
|
+
const first = issues[0];
|
|
765
|
+
const keyPath = first !== undefined && first.path.length > 0 ? first.path.join('.') : '(root)';
|
|
766
|
+
const detail = issues
|
|
767
|
+
.map((i) => `${i.path.length > 0 ? i.path.join('.') : '(root)'}: ${i.message}`)
|
|
768
|
+
.join('; ');
|
|
769
|
+
throw new RuleRecordParseError(filePath, keyPath, detail, FIX_THE_RECORD, parsed.error);
|
|
770
|
+
}
|
|
771
|
+
const record = parsed.data;
|
|
772
|
+
return {
|
|
773
|
+
record,
|
|
774
|
+
derivedEngine: record.target.type,
|
|
775
|
+
examplePairHashes: record.examples.map((example, ordinal) => ({
|
|
776
|
+
ordinal,
|
|
777
|
+
hash: ruleExamplePairHash(example),
|
|
778
|
+
})),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
//# sourceMappingURL=rule-record.js.map
|