@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.
Files changed (111) hide show
  1. package/dist/artifacts/admission.d.ts +12 -12
  2. package/dist/artifacts/verdict.d.ts +16 -16
  3. package/dist/compile-manifest.d.ts +89 -0
  4. package/dist/compile-manifest.d.ts.map +1 -1
  5. package/dist/compile-manifest.js +180 -31
  6. package/dist/compile-manifest.js.map +1 -1
  7. package/dist/compile-manifest.test.js +169 -1
  8. package/dist/compile-manifest.test.js.map +1 -1
  9. package/dist/compiler-schema.d.ts +1067 -0
  10. package/dist/compiler-schema.d.ts.map +1 -1
  11. package/dist/compiler-schema.js +204 -1
  12. package/dist/compiler-schema.js.map +1 -1
  13. package/dist/compiler-schema.test.js +92 -0
  14. package/dist/compiler-schema.test.js.map +1 -1
  15. package/dist/compiler.d.ts +2 -6
  16. package/dist/compiler.d.ts.map +1 -1
  17. package/dist/compiler.js +6 -17
  18. package/dist/compiler.js.map +1 -1
  19. package/dist/freeze.d.ts +3 -3
  20. package/dist/index.d.ts +12 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/ledger.d.ts +12 -12
  25. package/dist/regex-safety/apply-rules-bounded.d.ts +11 -0
  26. package/dist/regex-safety/apply-rules-bounded.d.ts.map +1 -1
  27. package/dist/regex-safety/apply-rules-bounded.js +59 -5
  28. package/dist/regex-safety/apply-rules-bounded.js.map +1 -1
  29. package/dist/regex-validation.d.ts +7 -0
  30. package/dist/regex-validation.d.ts.map +1 -0
  31. package/dist/regex-validation.js +32 -0
  32. package/dist/regex-validation.js.map +1 -0
  33. package/dist/rule-engine.d.ts +11 -1
  34. package/dist/rule-engine.d.ts.map +1 -1
  35. package/dist/rule-engine.js +125 -18
  36. package/dist/rule-engine.js.map +1 -1
  37. package/dist/spine/authored-rule.d.ts +669 -245
  38. package/dist/spine/authored-rule.d.ts.map +1 -1
  39. package/dist/spine/authored-rule.js +113 -27
  40. package/dist/spine/authored-rule.js.map +1 -1
  41. package/dist/spine/authored-rule.test.js +170 -4
  42. package/dist/spine/authored-rule.test.js.map +1 -1
  43. package/dist/spine/authoring-ledger.d.ts +69 -10
  44. package/dist/spine/authoring-ledger.d.ts.map +1 -1
  45. package/dist/spine/authoring-ledger.js +46 -25
  46. package/dist/spine/authoring-ledger.js.map +1 -1
  47. package/dist/spine/authoring-ledger.test.js +20 -7
  48. package/dist/spine/authoring-ledger.test.js.map +1 -1
  49. package/dist/spine/candidate-rule.d.ts +18 -1
  50. package/dist/spine/candidate-rule.d.ts.map +1 -1
  51. package/dist/spine/candidate-rule.js.map +1 -1
  52. package/dist/spine/cert-corpus-seed.d.ts +8 -8
  53. package/dist/spine/compile.d.ts +4 -1
  54. package/dist/spine/compile.d.ts.map +1 -1
  55. package/dist/spine/compile.js +50 -3
  56. package/dist/spine/compile.js.map +1 -1
  57. package/dist/spine/compile.test.js +189 -23
  58. package/dist/spine/compile.test.js.map +1 -1
  59. package/dist/spine/frozen-split.d.ts +18 -18
  60. package/dist/spine/ledgers.d.ts +24 -24
  61. package/dist/spine/lf-normalize.d.ts +9 -0
  62. package/dist/spine/lf-normalize.d.ts.map +1 -0
  63. package/dist/spine/lf-normalize.js +38 -0
  64. package/dist/spine/lf-normalize.js.map +1 -0
  65. package/dist/spine/preimage-differential.d.ts +3 -1
  66. package/dist/spine/preimage-differential.d.ts.map +1 -1
  67. package/dist/spine/preimage-differential.js +9 -6
  68. package/dist/spine/preimage-differential.js.map +1 -1
  69. package/dist/spine/preimage-differential.test.js +59 -0
  70. package/dist/spine/preimage-differential.test.js.map +1 -1
  71. package/dist/spine/record-exemplars.fixture.d.ts +5 -0
  72. package/dist/spine/record-exemplars.fixture.d.ts.map +1 -0
  73. package/dist/spine/record-exemplars.fixture.js +61 -0
  74. package/dist/spine/record-exemplars.fixture.js.map +1 -0
  75. package/dist/spine/record-lower.d.ts +90 -0
  76. package/dist/spine/record-lower.d.ts.map +1 -0
  77. package/dist/spine/record-lower.js +418 -0
  78. package/dist/spine/record-lower.js.map +1 -0
  79. package/dist/spine/record-lower.test.d.ts +2 -0
  80. package/dist/spine/record-lower.test.d.ts.map +1 -0
  81. package/dist/spine/record-lower.test.js +562 -0
  82. package/dist/spine/record-lower.test.js.map +1 -0
  83. package/dist/spine/record-runtime.d.ts +215 -0
  84. package/dist/spine/record-runtime.d.ts.map +1 -0
  85. package/dist/spine/record-runtime.js +330 -0
  86. package/dist/spine/record-runtime.js.map +1 -0
  87. package/dist/spine/record-runtime.test.d.ts +2 -0
  88. package/dist/spine/record-runtime.test.d.ts.map +1 -0
  89. package/dist/spine/record-runtime.test.js +814 -0
  90. package/dist/spine/record-runtime.test.js.map +1 -0
  91. package/dist/spine/rule-record.d.ts +864 -0
  92. package/dist/spine/rule-record.d.ts.map +1 -0
  93. package/dist/spine/rule-record.js +781 -0
  94. package/dist/spine/rule-record.js.map +1 -0
  95. package/dist/spine/rule-record.test.d.ts +2 -0
  96. package/dist/spine/rule-record.test.d.ts.map +1 -0
  97. package/dist/spine/rule-record.test.js +1262 -0
  98. package/dist/spine/rule-record.test.js.map +1 -0
  99. package/dist/spine/windtunnel-firing.d.ts.map +1 -1
  100. package/dist/spine/windtunnel-firing.js +28 -1
  101. package/dist/spine/windtunnel-firing.js.map +1 -1
  102. package/dist/spine/windtunnel-lock.d.ts +10 -10
  103. package/dist/stage4-verifier.d.ts.map +1 -1
  104. package/dist/stage4-verifier.js +61 -17
  105. package/dist/stage4-verifier.js.map +1 -1
  106. package/dist/sys/glob.d.ts +30 -0
  107. package/dist/sys/glob.d.ts.map +1 -1
  108. package/dist/sys/glob.js +62 -1
  109. package/dist/sys/glob.js.map +1 -1
  110. package/dist/types.d.ts +1 -1
  111. 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