@mmnto/cli 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 (57) hide show
  1. package/dist/authored-rule-intake-prewindow.test.js +22 -10
  2. package/dist/authored-rule-intake-prewindow.test.js.map +1 -1
  3. package/dist/authored-rule-intake.d.ts +50 -1
  4. package/dist/authored-rule-intake.d.ts.map +1 -1
  5. package/dist/authored-rule-intake.js +311 -22
  6. package/dist/authored-rule-intake.js.map +1 -1
  7. package/dist/commands/compile-noop-refresh.test.js +86 -1
  8. package/dist/commands/compile-noop-refresh.test.js.map +1 -1
  9. package/dist/commands/compile-refresh-manifest.test.js +38 -0
  10. package/dist/commands/compile-refresh-manifest.test.js.map +1 -1
  11. package/dist/commands/compile.d.ts.map +1 -1
  12. package/dist/commands/compile.js +33 -6
  13. package/dist/commands/compile.js.map +1 -1
  14. package/dist/commands/doctor.d.ts +10 -2
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +22 -5
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/doctor.test.js +62 -0
  19. package/dist/commands/doctor.test.js.map +1 -1
  20. package/dist/commands/lesson-archive.test.js +26 -1
  21. package/dist/commands/lesson-archive.test.js.map +1 -1
  22. package/dist/commands/lesson.d.ts.map +1 -1
  23. package/dist/commands/lesson.js +4 -1
  24. package/dist/commands/lesson.js.map +1 -1
  25. package/dist/commands/rule-author-command.test.js +15 -7
  26. package/dist/commands/rule-author-command.test.js.map +1 -1
  27. package/dist/commands/rule-author.test.js +584 -47
  28. package/dist/commands/rule-author.test.js.map +1 -1
  29. package/dist/commands/rule.d.ts.map +1 -1
  30. package/dist/commands/rule.js +67 -2
  31. package/dist/commands/rule.js.map +1 -1
  32. package/dist/commands/rule.test.js +128 -0
  33. package/dist/commands/rule.test.js.map +1 -1
  34. package/dist/commands/run-compiled-rules.d.ts.map +1 -1
  35. package/dist/commands/run-compiled-rules.js +54 -31
  36. package/dist/commands/run-compiled-rules.js.map +1 -1
  37. package/dist/commands/run-compiled-rules.test.js +100 -5
  38. package/dist/commands/run-compiled-rules.test.js.map +1 -1
  39. package/dist/commands/shield.d.ts.map +1 -1
  40. package/dist/commands/shield.js +3 -1
  41. package/dist/commands/shield.js.map +1 -1
  42. package/dist/commands/shield.test.js +50 -0
  43. package/dist/commands/shield.test.js.map +1 -1
  44. package/dist/commands/spine-authored-cert-corpus.d.ts.map +1 -1
  45. package/dist/commands/spine-authored-cert-corpus.js +5 -1
  46. package/dist/commands/spine-authored-cert-corpus.js.map +1 -1
  47. package/dist/commands/spine-authored-cert-corpus.test.js +50 -29
  48. package/dist/commands/spine-authored-cert-corpus.test.js.map +1 -1
  49. package/dist/commands/spine-corpus-disposition-source.d.ts +2 -2
  50. package/dist/commands/spine-freeze-orchestration.test.js +19 -21
  51. package/dist/commands/spine-freeze-orchestration.test.js.map +1 -1
  52. package/dist/commands/verify-manifest.d.ts.map +1 -1
  53. package/dist/commands/verify-manifest.js +59 -9
  54. package/dist/commands/verify-manifest.js.map +1 -1
  55. package/dist/commands/verify-manifest.test.js +243 -3
  56. package/dist/commands/verify-manifest.test.js.map +1 -1
  57. package/package.json +2 -2
@@ -1,43 +1,66 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import * as fs from 'node:fs';
2
3
  import * as os from 'node:os';
3
4
  import * as path from 'node:path';
4
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
6
  import { stringify as yamlStringify } from 'yaml';
6
- import { readAuthoringLedger } from '@mmnto/totem';
7
- import { runRuleAuthor } from '../authored-rule-intake.js';
7
+ import { AuthoredRuleInputSchema, parseRuleRecord, readAuthoringLedger, RuleRecordNoSilentSkipError, RuleRecordParseError, } from '@mmnto/totem';
8
+ import { checkRecordReference, deriveRecordFixtures, RECORD_REFERENCE_RULES, runRuleAuthor, } from '../authored-rule-intake.js';
9
+ let root;
8
10
  let totemDir;
9
11
  let yamlPath;
12
+ let rulesDir;
10
13
  beforeEach(() => {
11
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-ruleauthor-'));
14
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-ruleauthor-'));
12
15
  totemDir = path.join(root, '.totem');
16
+ rulesDir = path.join(totemDir, 'rules');
13
17
  fs.mkdirSync(path.join(totemDir, 'spine'), { recursive: true });
18
+ fs.mkdirSync(rulesDir, { recursive: true });
14
19
  yamlPath = path.join(totemDir, 'spine', 'authored-rules.yaml');
20
+ writeRecord(DEFAULT_RECORD_SLUG);
15
21
  });
16
22
  afterEach(() => {
17
- fs.rmSync(path.dirname(totemDir), { recursive: true, force: true });
23
+ fs.rmSync(root, { recursive: true, force: true });
18
24
  });
19
- const SHA_A = 'a'.repeat(40);
20
- const SHA_B = 'b'.repeat(40);
21
- // §4 FALLBACK: a commit-pair preimageSource (land-then-fix).
22
- const fixture = (pr) => ({
23
- pr,
24
- preimageSource: { kind: 'commit', preimageCommitSha: SHA_B, mergeCommitSha: SHA_A },
25
- filePath: 'src/x.ts',
26
- matchedSpan: 'L1-L2',
27
- contentHash: 'h'.repeat(8),
25
+ // ── Prop 310 § Design 1 — the record carrier the envelope references ──────────
26
+ //
27
+ // The rule half now lives in `.totem/rules/<slug>.rule.yaml`. Every assertion in
28
+ // this file is the one it always made; only the CARRIER moved (OQ-1 records-only).
29
+ // The whitelist row exercised is the shipped `(regex, forbidden-literal-token)`
30
+ // exemplar, so the record declares `target.type: regex` and the intake DERIVES
31
+ // that engine — the envelope never states it.
32
+ const DEFAULT_RECORD_SLUG = 'no-console-log';
33
+ /** The default record body: a regex rule with ONE example pair at ordinal 0. */
34
+ const recordBody = (over = {}) => ({
35
+ schemaVersion: 1,
36
+ severity: 'warning',
37
+ message: 'console.log is banned in production code.',
38
+ target: {
39
+ type: 'regex',
40
+ pattern: 'console\\.log',
41
+ scope: { fileGlobs: ['src/**/*.ts'] },
42
+ },
43
+ examples: [{ bad: 'console.log("dbg")', good: 'logger.debug("dbg")' }],
44
+ ...over,
28
45
  });
29
- // §4 PRIMARY: a lesson-anchored preimageSource (review-caught) the cert-#1 path.
30
- const lessonFixture = (pr) => ({
46
+ /** Write a record file and return the repo-relative reference the envelope uses. */
47
+ function writeRecord(slug, body = recordBody(), opts = {}) {
48
+ const text = yamlStringify(body);
49
+ const target = path.join(rulesDir, `${slug}.rule.yaml`);
50
+ // The slug may carry a directory (`sub/x`) — records nest, and `records_hash`
51
+ // walks recursively.
52
+ fs.mkdirSync(path.dirname(target), { recursive: true });
53
+ fs.writeFileSync(target, opts.crlf === true ? text.replace(/\n/g, '\r\n') : text, 'utf-8');
54
+ return recordRef(slug);
55
+ }
56
+ const recordRef = (slug) => `.totem/rules/${slug}.rule.yaml`;
57
+ /** The § Design 10 fixture: the ADR-112 §6 corpus anchor + an `examples[i]` ordinal reference. */
58
+ const fixture = (pr, example = 0) => ({
31
59
  pr,
32
- preimageSource: {
33
- kind: 'lesson',
34
- lessonRef: 'a1b2c3d4e5f60718',
35
- badExample: 'console.log("dbg")',
36
- goodExample: 'logger.debug("dbg")',
37
- },
38
60
  filePath: 'src/x.ts',
39
61
  matchedSpan: 'L1-L2',
40
62
  contentHash: 'h'.repeat(8),
63
+ example,
41
64
  });
42
65
  // §6 SILENCE-ONLY near-miss (strategy#770): one side, NO `pr`, no bad/good pair.
43
66
  const nearMissFixture = () => ({
@@ -50,9 +73,8 @@ const decidableRule = (over = {}) => ({
50
73
  author: 'alice',
51
74
  authoredAt: '2026-06-27',
52
75
  targetDefect: 'forbidden console.log in prod', // spaces → exercises the injective identity key
53
- declaredEngine: 'regex',
54
76
  structuralClass: 'forbidden-literal-token',
55
- dslSource: 'console\\.log',
77
+ record: recordRef(DEFAULT_RECORD_SLUG),
56
78
  positiveFixtures: [fixture(101)],
57
79
  ...over,
58
80
  });
@@ -99,18 +121,516 @@ describe('runRuleAuthor — FM(d) reject-loud at the reader (the trust boundary)
99
121
  writeYaml([decidableRule({ origin: { kind: 'from-scratch', ruleId: 'x'.repeat(16) } })]);
100
122
  expect(() => run()).toThrow(/producer-owned key/i);
101
123
  });
102
- it('rejects a producer-owned key nested inside preimageSource (recursive scan walks the §4 union FM(d))', () => {
124
+ it('rejects a producer-owned key nested inside a NEGATIVE fixture too (the scan walks every depth)', () => {
125
+ writeYaml([
126
+ decidableRule({
127
+ negativeFixtures: [{ ...nearMissFixture(), routing: 'rag-only' }],
128
+ }),
129
+ ]);
130
+ expect(() => run()).toThrow(/producer-owned key/i);
131
+ });
132
+ });
133
+ // ── Prop 310 § Design 1 (OQ-1 records-only) — the migration rejections ────────
134
+ describe('runRuleAuthor — the pre-slice-3 carrier keys are rejected BY NAME', () => {
135
+ const RECORD_FORM = /\.totem\/rules\/<slug>\.rule\.yaml/;
136
+ it('rejects an inline dslSource, naming the key, the JSON path, and the record form', () => {
137
+ writeYaml([decidableRule({ dslSource: 'console\\.log' })]);
138
+ let thrown;
139
+ try {
140
+ run();
141
+ }
142
+ catch (err) {
143
+ thrown = err;
144
+ }
145
+ const message = thrown.message;
146
+ expect(message).toContain("carries 'dslSource'");
147
+ expect(message).toContain('<root>.rules[0].dslSource');
148
+ expect(message).toMatch(/rules\/<slug>\.rule\.yaml/);
149
+ expect(thrown.code).toBe('CONFIG_INVALID');
150
+ // The recovery hint carries the record form too — the message says WHAT is
151
+ // wrong, the hint says where the value goes.
152
+ expect(thrown.recoveryHint).toMatch(RECORD_FORM);
153
+ });
154
+ it('rejects an inline declaredEngine — the engine is DERIVED from the record’s target.type', () => {
155
+ writeYaml([decidableRule({ declaredEngine: 'regex' })]);
156
+ let thrown;
157
+ try {
158
+ run();
159
+ }
160
+ catch (err) {
161
+ thrown = err;
162
+ }
163
+ const message = thrown.message;
164
+ expect(message).toContain("carries 'declaredEngine'");
165
+ expect(message).toContain('<root>.rules[0].declaredEngine');
166
+ expect(message).toMatch(/rules\/<slug>\.rule\.yaml/);
167
+ expect(thrown.code).toBe('CONFIG_INVALID');
168
+ });
169
+ it('rejects an inline fixture preimageSource AT DEPTH — the envelope side is derived', () => {
103
170
  writeYaml([
104
171
  decidableRule({
105
172
  positiveFixtures: [
106
173
  {
107
174
  ...fixture(101),
108
- preimageSource: { ...fixture(101).preimageSource, ruleId: 'x'.repeat(16) },
175
+ preimageSource: {
176
+ kind: 'lesson',
177
+ lessonRef: 'a1b2c3d4e5f60718',
178
+ badExample: 'console.log("dbg")',
179
+ goodExample: 'logger.debug("dbg")',
180
+ },
109
181
  },
110
182
  ],
111
183
  }),
112
184
  ]);
113
- expect(() => run()).toThrow(/producer-owned key/i);
185
+ let thrown;
186
+ try {
187
+ run();
188
+ }
189
+ catch (err) {
190
+ thrown = err;
191
+ }
192
+ const message = thrown.message;
193
+ expect(message).toContain("carries 'preimageSource'");
194
+ // The JSON path names the DEPTH, which is the whole point of the recursive
195
+ // scan: Zod `.strict()` is not recursive and would have stripped it.
196
+ expect(message).toContain('<root>.rules[0].positiveFixtures[0].preimageSource');
197
+ expect(message).toMatch(/rules\/<slug>\.rule\.yaml/);
198
+ expect(thrown.code).toBe('CONFIG_INVALID');
199
+ });
200
+ it('interpolates the REPO’s totem-dir name into the hint, not a hardcoded `.totem/`', () => {
201
+ // MIN-6: a repo whose config names a different totem directory was being handed
202
+ // a migration instruction that does not resolve there. The hint is derived from
203
+ // the caller's actual `totemDir`.
204
+ const altRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-altdir-'));
205
+ const altTotem = path.join(altRoot, '.mytotem');
206
+ fs.mkdirSync(path.join(altTotem, 'spine'), { recursive: true });
207
+ fs.mkdirSync(path.join(altTotem, 'rules'), { recursive: true });
208
+ fs.writeFileSync(path.join(altTotem, 'spine', 'authored-rules.yaml'), yamlStringify({
209
+ splitRef: 'split-2026-06-27',
210
+ authoredAfterSplit: true,
211
+ heldOutNonInspectionAttestation: true,
212
+ rules: [{ ...decidableRule({ dslSource: 'console\\.log' }) }],
213
+ }), 'utf-8');
214
+ let thrown;
215
+ try {
216
+ runRuleAuthor(altTotem, { judgedBy: 'static-whitelist@test' });
217
+ }
218
+ catch (err) {
219
+ thrown = err;
220
+ }
221
+ expect(thrown.message).toContain('.mytotem/rules/<slug>.rule.yaml');
222
+ expect(thrown.message).not.toContain('.totem/rules/<slug>');
223
+ expect(thrown.recoveryHint).toContain('.mytotem/rules/<slug>.rule.yaml');
224
+ fs.rmSync(altRoot, { recursive: true, force: true });
225
+ });
226
+ it('rejects, never STRIPS — a migrated key does not silently vanish into a successful run', () => {
227
+ writeYaml([decidableRule({ dslSource: 'console\\.log' })]);
228
+ expect(() => run()).toThrow();
229
+ // Nothing was authored: the reject happens before pass 1 constructs anything.
230
+ expect(readAuthoringLedger(totemDir)).toHaveLength(0);
231
+ });
232
+ });
233
+ // ── Prop 310 § Design 1 — the `record:` reference's own shape rules ───────────
234
+ describe('runRuleAuthor — record reference resolution', () => {
235
+ // ── MIN-2: the LEXICAL rule table ──
236
+ //
237
+ // Every verdict here is a property of the reference TEXT, so it is identical on
238
+ // win32 and linux. That is the point: `path.resolve`/`path.relative` diverge on
239
+ // exactly these inputs (a backslash is a separator on one platform and a
240
+ // filename character on the other; a drive-letter absolute inside the root
241
+ // passes containment; the default case-insensitive filesystem resolves
242
+ // `.totem/Rules/…` and linux does not), and the reference is recorded VERBATIM
243
+ // in the tracked authoring-ledger — so an accepted machine-specific form would
244
+ // be committed into a shared attestation.
245
+ //
246
+ // Exercised through `checkRecordReference` (the pure rule) AND through
247
+ // `runRuleAuthor` (the shipping path), so the table cannot pass while the
248
+ // producer bypasses it.
249
+ const LEXICAL_TABLE = [
250
+ { reference: '.totem/rules/no-console-log.rule.yaml', rule: null, why: 'posix relative' },
251
+ {
252
+ reference: '.totem/rules/sub/no-console-log.rule.yaml',
253
+ rule: null,
254
+ why: 'nested subdir — records_hash walks recursively, so nesting stays legal',
255
+ },
256
+ // C-6: a `..` PREFIX is not a `..` SEGMENT. Both of these resolve INSIDE the
257
+ // root and are ordinary file names; a `startsWith('..')` containment check
258
+ // false-rejected them.
259
+ {
260
+ reference: '.totem/rules/..hidden/x.rule.yaml',
261
+ rule: null,
262
+ why: 'directory whose name begins with `..`',
263
+ },
264
+ {
265
+ reference: '.totem/rules/..a.rule.yaml',
266
+ rule: null,
267
+ why: 'file name beginning with `..`',
268
+ },
269
+ { reference: '.totem\\rules\\x.rule.yaml', rule: 'separator', why: 'backslashes' },
270
+ {
271
+ reference: 'C:/totem/.totem/rules/x.rule.yaml',
272
+ rule: 'drive-letter',
273
+ why: 'drive-letter absolute',
274
+ },
275
+ { reference: '/.totem/rules/x.rule.yaml', rule: 'absolute-path', why: 'leading slash' },
276
+ { reference: '//server/.totem/rules/x.rule.yaml', rule: 'unc-path', why: 'UNC path' },
277
+ {
278
+ reference: '.totem/Rules/x.rule.yaml',
279
+ rule: 'records-dir-prefix',
280
+ why: 'records dir case variant',
281
+ },
282
+ {
283
+ reference: '.TOTEM/rules/x.rule.yaml',
284
+ rule: 'records-dir-prefix',
285
+ why: 'totem dir case variant',
286
+ },
287
+ { reference: './.totem/rules/x.rule.yaml', rule: 'records-dir-prefix', why: 'leading ./' },
288
+ { reference: '.totem/rules//x.rule.yaml', rule: 'empty-segment', why: '// mid-path' },
289
+ { reference: '.totem/rules/./x.rule.yaml', rule: 'current-segment', why: '. segment mid-path' },
290
+ { reference: '.totem/rules/../rules/x.rule.yaml', rule: 'parent-segment', why: '.. segment' },
291
+ { reference: '.totem/rules/', rule: 'is-the-records-dir', why: 'equals the rules dir' },
292
+ { reference: '.totem/rules/thing.yaml', rule: 'suffix', why: 'wrong file class' },
293
+ ];
294
+ it.each(LEXICAL_TABLE)('checkRecordReference: $why → $rule', ({ reference, rule }) => {
295
+ // EXACT rule, not merely "rejected": a reference that fails for the wrong
296
+ // reason would send the author to fix the wrong thing.
297
+ expect(checkRecordReference('.totem', reference)).toBe(rule);
298
+ });
299
+ it.each(LEXICAL_TABLE.filter((row) => row.rule !== null))('runRuleAuthor rejects CONFIG_INVALID naming [$rule]: $why', ({ reference, rule }) => {
300
+ // The record the malformed reference "means" EXISTS on disk, so the reject
301
+ // can only be the lexical gate — never a coincidental CONFIG_MISSING.
302
+ writeRecord('x');
303
+ writeYaml([decidableRule({ record: reference })]);
304
+ let thrown;
305
+ try {
306
+ run();
307
+ }
308
+ catch (err) {
309
+ thrown = err;
310
+ }
311
+ expect(thrown.code).toBe('CONFIG_INVALID');
312
+ expect(thrown.message).toContain(`[${rule}]`);
313
+ expect(thrown.message).toContain('.totem/rules/<slug>.rule.yaml');
314
+ });
315
+ it.each(LEXICAL_TABLE.filter((row) => row.rule === null))('runRuleAuthor ACCEPTS: $why', ({ reference }) => {
316
+ // `sub/` is created by `writeRecord` when the slug carries a directory.
317
+ writeRecord(reference.slice('.totem/rules/'.length, -'.rule.yaml'.length));
318
+ writeYaml([decidableRule({ record: reference })]);
319
+ const res = run();
320
+ expect(res.rejected).toEqual([]);
321
+ expect(res.records[0]?.record.path).toBe(reference);
322
+ });
323
+ it('keeps the resolved-containment check as the second line of defence', () => {
324
+ // A reference that is lexically clean for a DIFFERENT totem dir still cannot
325
+ // escape this one's `rules/`. The lexical prefix rule catches it first, which
326
+ // is why the containment check is defence in depth rather than the only gate.
327
+ fs.writeFileSync(path.join(totemDir, 'stray.rule.yaml'), yamlStringify(recordBody()), 'utf-8');
328
+ writeYaml([decidableRule({ record: '.totem/stray.rule.yaml' })]);
329
+ expect(() => run()).toThrow(/records-dir-prefix/);
330
+ });
331
+ it('every RECORD_REFERENCE_RULE has a negative fixture in the table', () => {
332
+ // An unexercised rule is an unpinned rule — the same exhaustiveness guard the
333
+ // glob dialect's conformance suite applies.
334
+ const covered = new Set(LEXICAL_TABLE.map((row) => row.rule).filter((r) => r !== null));
335
+ expect([...covered].sort()).toEqual([...RECORD_REFERENCE_RULES].sort());
336
+ });
337
+ it('CONFIG_MISSING (not CONFIG_INVALID) when the referenced record does not exist', () => {
338
+ writeYaml([decidableRule({ record: recordRef('never-written') })]);
339
+ let thrown;
340
+ try {
341
+ run();
342
+ }
343
+ catch (err) {
344
+ thrown = err;
345
+ }
346
+ expect(thrown.code).toBe('CONFIG_MISSING');
347
+ expect(thrown.message).toMatch(/rule record not found/);
348
+ expect(thrown.message).toContain('never-written.rule.yaml');
349
+ });
350
+ it('propagates a RuleRecordParseError UNWRAPPED — file + key path intact, never a generic reject', () => {
351
+ writeRecord('broken', recordBody({ severity: 'critical' }));
352
+ writeYaml([decidableRule({ record: recordRef('broken') })]);
353
+ let thrown;
354
+ try {
355
+ run();
356
+ }
357
+ catch (err) {
358
+ thrown = err;
359
+ }
360
+ expect(thrown).toBeInstanceOf(RuleRecordParseError);
361
+ expect(thrown.filePath).toBe(recordRef('broken'));
362
+ expect(thrown.keyPath).toBe('severity');
363
+ });
364
+ it('propagates the § Design 2 no-silent-skip subclass unwrapped too (unknown schemaVersion)', () => {
365
+ writeRecord('v2', recordBody({ schemaVersion: 2 }));
366
+ writeYaml([decidableRule({ record: recordRef('v2') })]);
367
+ expect(() => run()).toThrow(RuleRecordNoSilentSkipError);
368
+ });
369
+ });
370
+ // ── Prop 310 § Design 10 — the derived preimage envelope ─────────────────────
371
+ describe('runRuleAuthor — examples ⇄ fixture derivation', () => {
372
+ it('derives preimageSource from `examples[ordinal]` under the (ruleId, ordinal) key', () => {
373
+ writeYaml([decidableRule()]);
374
+ const res = run();
375
+ const record = res.records[0];
376
+ const src = record.provenance.positiveFixtures[0].preimageSource;
377
+ expect(src.kind).toBe('record');
378
+ if (src.kind !== 'record')
379
+ throw new Error('unreachable');
380
+ expect(src.ruleId).toBe(record.ruleId);
381
+ expect(src.ordinal).toBe(0);
382
+ expect(src.badExample).toBe('console.log("dbg")');
383
+ expect(src.goodExample).toBe('logger.debug("dbg")');
384
+ expect(src.pairHash).toBe(record.record.parsed.examplePairHashes[0].hash);
385
+ // The join key carries the REAL minted id, not a placeholder — the derivation
386
+ // runs after the mint for exactly this reason.
387
+ expect(src.ruleId).toMatch(/^[0-9a-f]{16}(?:-[1-9]\d*)?$/);
388
+ });
389
+ it('resolves ordinal 1 to EXACTLY examples[1], never a re-pair', () => {
390
+ writeRecord('two-pairs', recordBody({
391
+ examples: [
392
+ { bad: 'console.log("a")', good: 'logger.debug("a")' },
393
+ { bad: 'console.log("b")', good: 'logger.debug("b")' },
394
+ ],
395
+ }));
396
+ writeYaml([
397
+ decidableRule({
398
+ record: recordRef('two-pairs'),
399
+ positiveFixtures: [fixture(101, 1), fixture(102, 0)],
400
+ }),
401
+ ]);
402
+ const sources = run().records[0].provenance.positiveFixtures.map((f) => f.preimageSource);
403
+ expect(sources.map((s) => (s.kind === 'record' ? [s.ordinal, s.badExample] : null))).toEqual([
404
+ [1, 'console.log("b")'],
405
+ [0, 'console.log("a")'],
406
+ ]);
407
+ });
408
+ it('fails loud on a DANGLING ordinal, naming the rule, the ordinal, and the count', () => {
409
+ writeYaml([decidableRule({ positiveFixtures: [fixture(101, 3)] })]);
410
+ let thrown;
411
+ try {
412
+ run();
413
+ }
414
+ catch (err) {
415
+ thrown = err;
416
+ }
417
+ const message = thrown.message;
418
+ expect(thrown.code).toBe('CONFIG_INVALID');
419
+ expect(message).toContain('example ordinal 3');
420
+ expect(message).toContain('1 example pair(s)');
421
+ expect(message).toContain('alice');
422
+ expect(message).toContain('forbidden console.log in prod');
423
+ expect(message).toContain('DANGLING');
424
+ });
425
+ it('fails loud on a DUPLICATE ordinal across two fixtures of one entry (OQ-2 reject)', () => {
426
+ writeYaml([decidableRule({ positiveFixtures: [fixture(101, 0), fixture(102, 0)] })]);
427
+ let thrown;
428
+ try {
429
+ run();
430
+ }
431
+ catch (err) {
432
+ thrown = err;
433
+ }
434
+ const message = thrown.message;
435
+ expect(thrown.code).toBe('CONFIG_INVALID');
436
+ expect(message).toContain('SAME example ordinal 0');
437
+ expect(message).toContain('alice');
438
+ expect(message).toContain('forbidden console.log in prod');
439
+ });
440
+ it('accepts two fixtures naming DISTINCT ordinals — the reject is duplication, not multiplicity', () => {
441
+ writeRecord('two-pairs', recordBody({
442
+ examples: [
443
+ { bad: 'console.log("a")', good: 'logger.debug("a")' },
444
+ { bad: 'console.log("b")', good: 'logger.debug("b")' },
445
+ ],
446
+ }));
447
+ writeYaml([
448
+ decidableRule({
449
+ record: recordRef('two-pairs'),
450
+ positiveFixtures: [fixture(101, 0), fixture(102, 1)],
451
+ }),
452
+ ]);
453
+ expect(run().records[0].provenance.positiveFixtures).toHaveLength(2);
454
+ });
455
+ it('binds the pair hash by ORDINAL FIELD, not array position (N-3)', () => {
456
+ // `examplePairHashes` is a list of `{ordinal, hash}` records. Indexing it would
457
+ // bind a fixture to the wrong pair the moment the list is filtered or
458
+ // reordered — and a WRONG drift sensor reads as intact, which is worse than a
459
+ // missing one. Pinned by asserting the derived hash equals the entry whose
460
+ // `.ordinal` matches, for a non-zero ordinal.
461
+ writeRecord('two-pairs', recordBody({
462
+ examples: [
463
+ { bad: 'console.log("a")', good: 'logger.debug("a")' },
464
+ { bad: 'console.log("b")', good: 'logger.debug("b")' },
465
+ ],
466
+ }));
467
+ writeYaml([
468
+ decidableRule({ record: recordRef('two-pairs'), positiveFixtures: [fixture(101, 1)] }),
469
+ ]);
470
+ const record = run().records[0];
471
+ const src = record.provenance.positiveFixtures[0].preimageSource;
472
+ const byField = record.record.parsed.examplePairHashes.find((h) => h.ordinal === 1);
473
+ expect(src.kind).toBe('record');
474
+ if (src.kind === 'record')
475
+ expect(src.pairHash).toBe(byField.hash);
476
+ // …and the two pairs really do hash differently, so the lookup is discriminating.
477
+ const other = record.record.parsed.examplePairHashes.find((h) => h.ordinal === 0);
478
+ expect(byField.hash).not.toBe(other.hash);
479
+ });
480
+ it('DISCRIMINATES: a REVERSED examplePairHashes list still binds ordinal 1 to ordinal 1’s hash', () => {
481
+ // The falsifier the row above cannot supply on its own: while the list happens
482
+ // to be in ordinal order, `find(h => h.ordinal === i)` and `[i]` agree, so an
483
+ // index-based lookup would pass it. Reversing the list separates them —
484
+ // `[1]` would return the ordinal-0 entry, so substituting `[ordinal]` FAILS
485
+ // this assertion while the field lookup holds.
486
+ const parsed = parseRuleRecord(yamlStringify(recordBody({
487
+ examples: [
488
+ { bad: 'console.log("a")', good: 'logger.debug("a")' },
489
+ { bad: 'console.log("b")', good: 'logger.debug("b")' },
490
+ ],
491
+ })), recordRef('two-pairs'));
492
+ const reversed = {
493
+ ...parsed,
494
+ examplePairHashes: [...parsed.examplePairHashes].reverse(),
495
+ };
496
+ // Pre-condition: the reversal genuinely moves the entries, so position and
497
+ // field really do disagree here.
498
+ expect(reversed.examplePairHashes[1].ordinal).toBe(0);
499
+ const derived = deriveRecordFixtures(AuthoredRuleInputSchema.parse(decidableRule({ record: recordRef('two-pairs'), positiveFixtures: [fixture(101, 1)] })), { path: recordRef('two-pairs'), contentHash: 'a'.repeat(64), parsed: reversed }, '0123456789abcdef');
500
+ const src = derived[0].preimageSource;
501
+ expect(src.kind).toBe('record');
502
+ if (src.kind !== 'record')
503
+ throw new Error('unreachable');
504
+ expect(src.ordinal).toBe(1);
505
+ // The ordinal-1 hash by FIELD…
506
+ expect(src.pairHash).toBe(parsed.examplePairHashes.find((h) => h.ordinal === 1).hash);
507
+ // …and NOT what `examplePairHashes[1]` holds after the reversal.
508
+ expect(src.pairHash).not.toBe(reversed.examplePairHashes[1].hash);
509
+ });
510
+ it('THROWS on a ParsedRuleRecord carrying no hash for the referenced ordinal (producer bug)', () => {
511
+ // Unreachable through `parseRuleRecord`, which emits one hash per example — so
512
+ // this drives the derivation directly with a hand-built value, the caller class
513
+ // the guard exists for. The removed fallback would have RECOMPUTED the hash,
514
+ // minting a § Design 10 drift sensor from a value nothing attested.
515
+ const parsed = parseRuleRecord(yamlStringify(recordBody()), recordRef('x'));
516
+ const stripped = { ...parsed, examplePairHashes: [] };
517
+ expect(() => deriveRecordFixtures(AuthoredRuleInputSchema.parse(decidableRule()), { path: recordRef('x'), contentHash: 'a'.repeat(64), parsed: stripped }, '0123456789abcdef')).toThrow(/no example-pair hash for ordinal 0/);
518
+ });
519
+ it('drops the envelope-side `example` reference from the DERIVED fixture (one home for the ordinal)', () => {
520
+ writeYaml([decidableRule()]);
521
+ const derived = run().records[0].provenance.positiveFixtures[0];
522
+ expect('example' in derived).toBe(false);
523
+ // …while the ADR-112 §6 corpus anchor rides through untouched.
524
+ expect(derived.pr).toBe(101);
525
+ expect(derived.filePath).toBe('src/x.ts');
526
+ expect(derived.matchedSpan).toBe('L1-L2');
527
+ expect(derived.contentHash).toBe('h'.repeat(8));
528
+ });
529
+ });
530
+ // ── Prop 310 § Design 1/§ Design 3 — the derived engine + record binding ──────
531
+ describe('runRuleAuthor — the engine is derived, the record is content-addressed', () => {
532
+ it('derives declaredEngine from the record’s target.type, never from the envelope', () => {
533
+ writeYaml([decidableRule()]);
534
+ const res = run();
535
+ expect(res.records[0]?.declaredEngine).toBe('regex');
536
+ expect(res.records[0]?.record.parsed.derivedEngine).toBe('regex');
537
+ expect(readAuthoringLedger(totemDir)[0]?.declaredEngine).toBe('regex');
538
+ });
539
+ it('judges the whitelist on the DERIVED engine — an ast-grep record fails the regex-only class', () => {
540
+ // `(regex, forbidden-literal-token)` is whitelisted; `(ast-grep, …)` is not.
541
+ // The author cannot state the engine, so the record's own `target.type` decides.
542
+ writeRecord('ast-grep-rule', recordBody({
543
+ target: {
544
+ type: 'ast-grep',
545
+ language: 'typescript',
546
+ pattern: 'console.log($MSG)',
547
+ scope: { fileGlobs: ['src/**/*.ts'] },
548
+ },
549
+ }));
550
+ writeYaml([decidableRule({ record: recordRef('ast-grep-rule') })]);
551
+ const res = run();
552
+ expect(res.records).toHaveLength(0);
553
+ expect(res.rejected).toHaveLength(1);
554
+ expect(res.rejected[0]?.declaredEngine).toBe('ast-grep');
555
+ expect(res.rejected[0]?.reason).toContain('(ast-grep, forbidden-literal-token)');
556
+ });
557
+ it('an ast-grep record IS decided when its class is whitelisted for that engine', () => {
558
+ writeRecord('ast-grep-rule', recordBody({
559
+ target: {
560
+ type: 'ast-grep',
561
+ language: 'typescript',
562
+ pattern: 'console.log($MSG)',
563
+ scope: { fileGlobs: ['src/**/*.ts'] },
564
+ },
565
+ }));
566
+ writeYaml([
567
+ decidableRule({ record: recordRef('ast-grep-rule'), structuralClass: 'node-shape-presence' }),
568
+ ]);
569
+ const res = run();
570
+ expect(res.rejected).toHaveLength(0);
571
+ expect(res.records[0]?.declaredEngine).toBe('ast-grep');
572
+ expect(res.records[0]?.structuralEligibility.basis).toBe('whitelist:node-shape-presence');
573
+ });
574
+ it('binds the ledger entry to the exact ingested BYTES (sha256 of the LF image)', () => {
575
+ writeYaml([decidableRule()]);
576
+ run();
577
+ const entry = readAuthoringLedger(totemDir)[0];
578
+ const bytes = fs.readFileSync(path.join(rulesDir, `${DEFAULT_RECORD_SLUG}.rule.yaml`), 'utf-8');
579
+ const expected = createHash('sha256').update(bytes.replace(/\r\n/g, '\n')).digest('hex');
580
+ expect(entry.record?.contentHash).toBe(expected);
581
+ expect(entry.record?.path).toBe(recordRef(DEFAULT_RECORD_SLUG));
582
+ });
583
+ it('CRLF twin: identical preimage bytes, identical pairHash, identical ledger contentHash', () => {
584
+ // The NOQ-1 P3 `serialization-admit` class: the LF admit hop is what everything
585
+ // downstream sees, so a Windows-authored record is byte-for-byte the same rule.
586
+ writeYaml([decidableRule()]);
587
+ const lfRun = run();
588
+ const lfEntry = readAuthoringLedger(totemDir)[0];
589
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody(), { crlf: true });
590
+ const crlfRun = run();
591
+ const crlfSrc = crlfRun.records[0].provenance.positiveFixtures[0].preimageSource;
592
+ const lfSrc = lfRun.records[0].provenance.positiveFixtures[0].preimageSource;
593
+ expect(crlfSrc).toEqual(lfSrc);
594
+ expect(crlfRun.records[0].record.contentHash).toBe(lfRun.records[0].record.contentHash);
595
+ // …so the ledger reads UNCHANGED and appends no spurious revision row.
596
+ expect(crlfRun.unchanged).toBe(1);
597
+ expect(crlfRun.revised).toBe(0);
598
+ expect(readAuthoringLedger(totemDir)).toEqual([lfEntry]);
599
+ });
600
+ it('RENAME twin: identical contentHash (path is not material) but a different recorded path', () => {
601
+ writeYaml([decidableRule()]);
602
+ const first = run();
603
+ const firstEntry = readAuthoringLedger(totemDir)[0];
604
+ // Same bytes at a different path. § Design 1: renaming a record changes no
605
+ // identity, so the material hash must not move — only the informational path.
606
+ writeRecord('renamed');
607
+ writeYaml([decidableRule({ record: recordRef('renamed') })]);
608
+ const second = run();
609
+ expect(second.records[0].record.contentHash).toBe(first.records[0].record.contentHash);
610
+ expect(second.records[0].record.path).toBe(recordRef('renamed'));
611
+ expect(second.records[0].ruleId).toBe(first.records[0].ruleId);
612
+ // The material excludes the path ⇒ no revision row is appended for the rename.
613
+ expect(second.unchanged).toBe(1);
614
+ expect(second.revised).toBe(0);
615
+ expect(readAuthoringLedger(totemDir)).toEqual([firstEntry]);
616
+ });
617
+ it('an EXAMPLES edit flips the ledger contentHash → `revised` (the § Design 10 drift sensor)', () => {
618
+ writeYaml([decidableRule()]);
619
+ run();
620
+ const before = readAuthoringLedger(totemDir)[0].contentHash;
621
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody({ examples: [{ bad: 'console.log("EDITED")', good: 'logger.debug("dbg")' }] }));
622
+ const res = run();
623
+ expect(res.revised).toBe(1);
624
+ const ledger = readAuthoringLedger(totemDir);
625
+ expect(ledger).toHaveLength(2);
626
+ expect(ledger[1].contentHash).not.toBe(before);
627
+ expect(ledger[1].ruleId).toBe(ledger[0].ruleId);
628
+ });
629
+ it('the cert path’s verifyOnly REFUSES a record edited since its ledger entry', () => {
630
+ writeYaml([decidableRule()]);
631
+ run();
632
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody({ examples: [{ bad: 'console.log("EDITED")', good: 'logger.debug("dbg")' }] }));
633
+ expect(() => runRuleAuthor(totemDir, { judgedBy: 'static-whitelist@test', verifyOnly: true })).toThrow(/\(revised\)/);
114
634
  });
115
635
  });
116
636
  describe('runRuleAuthor — eligibility re-run OVERWRITES the author claim', () => {
@@ -123,14 +643,15 @@ describe('runRuleAuthor — eligibility re-run OVERWRITES the author claim', ()
123
643
  expect(res.records[0]?.structuralEligibility.basis).toBe('whitelist:forbidden-literal-token');
124
644
  });
125
645
  it('a non-whitelisted structuralClass is REJECTED even though the author declared it', () => {
126
- writeYaml([decidableRule({ structuralClass: 'behavioral-smell', dslSource: 'x' })]);
646
+ writeYaml([decidableRule({ structuralClass: 'behavioral-smell' })]);
127
647
  const res = run();
128
648
  expect(res.records).toHaveLength(0);
129
649
  expect(res.rejected).toHaveLength(1);
130
650
  expect(res.rejected[0]?.structuralClass).toBe('behavioral-smell');
131
651
  });
132
652
  it('an engine/class mismatch (class whitelisted for a different engine) is rejected', () => {
133
- writeYaml([decidableRule({ declaredEngine: 'regex', structuralClass: 'node-shape-presence' })]);
653
+ // The record derives `regex`; `node-shape-presence` is whitelisted for ast-grep only.
654
+ writeYaml([decidableRule({ structuralClass: 'node-shape-presence' })]);
134
655
  expect(run().rejected).toHaveLength(1);
135
656
  });
136
657
  });
@@ -146,10 +667,16 @@ describe('runRuleAuthor — upsert idempotency', () => {
146
667
  expect(second.records[0]?.ruleId).toBe(id);
147
668
  expect(readAuthoringLedger(totemDir)).toHaveLength(1);
148
669
  });
149
- it('a dslSource edit keeps the ruleId + appends a revision', () => {
670
+ it('a matcher edit IN THE RECORD keeps the ruleId + appends a revision', () => {
150
671
  writeYaml([decidableRule()]);
151
672
  const id = run().records[0]?.ruleId;
152
- writeYaml([decidableRule({ dslSource: 'console\\.error' })]);
673
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody({
674
+ target: {
675
+ type: 'regex',
676
+ pattern: 'console\\.error',
677
+ scope: { fileGlobs: ['src/**/*.ts'] },
678
+ },
679
+ }));
153
680
  const res = run();
154
681
  expect(res.revised).toBe(1);
155
682
  expect(res.records[0]?.ruleId).toBe(id);
@@ -164,7 +691,8 @@ describe('runRuleAuthor — upsert idempotency', () => {
164
691
  expect(res.records[0]?.ruleId).not.toBe(id1);
165
692
  });
166
693
  it('rejects a duplicate (author,targetDefect) within one file (spaces would alias under a naive join)', () => {
167
- writeYaml([decidableRule(), decidableRule({ dslSource: 'other' })]);
694
+ writeRecord('other');
695
+ writeYaml([decidableRule(), decidableRule({ record: recordRef('other') })]);
168
696
  expect(() => run()).toThrow(/more than once/i);
169
697
  });
170
698
  });
@@ -200,20 +728,28 @@ describe('runRuleAuthor — codex/agy diff-review folds', () => {
200
728
  expect(ledger[0]?.positiveFixturePrs).toEqual([101]);
201
729
  expect(ledger[0]).not.toHaveProperty('negativeFixturePrs');
202
730
  });
203
- it('authors a lesson-anchored (PRIMARY, review-caught) positive fixture end-to-end (§4 cert-#1 path)', () => {
204
- writeYaml([decidableRule({ positiveFixtures: [lessonFixture(101)] })]);
731
+ it('authors a RECORD-derived positive fixture end-to-end (§4 cert-#1 path, Prop 310 § Design 10)', () => {
732
+ // Was a hand-written lesson-anchored fixture; Amendment 1 makes the record's
733
+ // `examples` block the editable home, so the envelope side is derived instead.
734
+ writeYaml([decidableRule()]);
205
735
  const res = run();
206
736
  expect(res.minted).toBe(1);
207
737
  const src = res.records[0]?.provenance.positiveFixtures[0]?.preimageSource;
208
- expect(src?.kind).toBe('lesson');
209
- if (src?.kind === 'lesson')
210
- expect(src.lessonRef).toBe('a1b2c3d4e5f60718');
738
+ expect(src?.kind).toBe('record');
739
+ if (src?.kind === 'record')
740
+ expect(src.ruleId).toBe(res.records[0]?.ruleId);
211
741
  });
212
742
  it('a revision appends a row carrying the NEW contentHash under the SAME ruleId (agy)', () => {
213
743
  writeYaml([decidableRule()]);
214
744
  run();
215
745
  const before = readAuthoringLedger(totemDir)[0]?.contentHash;
216
- writeYaml([decidableRule({ dslSource: 'console\\.error' })]);
746
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody({
747
+ target: {
748
+ type: 'regex',
749
+ pattern: 'console\\.error',
750
+ scope: { fileGlobs: ['src/**/*.ts'] },
751
+ },
752
+ }));
217
753
  run();
218
754
  const ledger = readAuthoringLedger(totemDir);
219
755
  expect(ledger).toHaveLength(2);
@@ -221,10 +757,7 @@ describe('runRuleAuthor — codex/agy diff-review folds', () => {
221
757
  expect(ledger[1]?.ruleId).toBe(ledger[0]?.ruleId);
222
758
  });
223
759
  it('two distinct decidable rules in one file → 2 records, 2 rows, distinct ids', () => {
224
- writeYaml([
225
- decidableRule(),
226
- decidableRule({ targetDefect: 'another defect', dslSource: 'TODO' }),
227
- ]);
760
+ writeYaml([decidableRule(), decidableRule({ targetDefect: 'another defect' })]);
228
761
  const res = run();
229
762
  expect(res.minted).toBe(2);
230
763
  expect(res.records).toHaveLength(2);
@@ -300,11 +833,18 @@ describe('runRuleAuthor — verifyOnly no-mint precondition (ADR-112 §8, strate
300
833
  expect(verify).toThrow(/\(minted\)/); // the action is named explicitly
301
834
  expect(readAuthoringLedger(totemDir)).toHaveLength(0); // zero writes on the throw (no drift, Tenet-4)
302
835
  });
303
- it('a would-REVISE rule (dslSource edit since authoring) fails loud identically to mint; ledger unmutated (Q2 revised)', () => {
836
+ it('a would-REVISE rule (record edited since authoring) fails loud identically to mint; ledger unmutated (Q2 revised)', () => {
304
837
  writeYaml([decidableRule()]);
305
838
  run(); // author the original
306
839
  const before = snapshot();
307
- writeYaml([decidableRule({ dslSource: 'console\\.error' })]); // YAML diverged from the recorded entry
840
+ // The RECORD diverged from the bytes the recorded entry attested.
841
+ writeRecord(DEFAULT_RECORD_SLUG, recordBody({
842
+ target: {
843
+ type: 'regex',
844
+ pattern: 'console\\.error',
845
+ scope: { fileGlobs: ['src/**/*.ts'] },
846
+ },
847
+ }));
308
848
  expect(verify).toThrow(/\(revised\)/); // revise is forbidden identically to mint (Q2)
309
849
  expect(snapshot()).toBe(before); // no revision row appended (read-only)
310
850
  });
@@ -312,10 +852,7 @@ describe('runRuleAuthor — verifyOnly no-mint precondition (ADR-112 §8, strate
312
852
  writeYaml([decidableRule()]);
313
853
  run(); // author rule #1
314
854
  const before = snapshot();
315
- writeYaml([
316
- decidableRule(),
317
- decidableRule({ targetDefect: 'another defect', dslSource: 'TODO' }),
318
- ]);
855
+ writeYaml([decidableRule(), decidableRule({ targetDefect: 'another defect' })]);
319
856
  expect(verify).toThrow(/NOT the first author/i);
320
857
  expect(snapshot()).toBe(before); // the unchanged rule did not mask the new one; zero writes overall
321
858
  });