@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.
- package/dist/authored-rule-intake-prewindow.test.js +22 -10
- package/dist/authored-rule-intake-prewindow.test.js.map +1 -1
- package/dist/authored-rule-intake.d.ts +50 -1
- package/dist/authored-rule-intake.d.ts.map +1 -1
- package/dist/authored-rule-intake.js +311 -22
- package/dist/authored-rule-intake.js.map +1 -1
- package/dist/commands/compile-noop-refresh.test.js +86 -1
- package/dist/commands/compile-noop-refresh.test.js.map +1 -1
- package/dist/commands/compile-refresh-manifest.test.js +38 -0
- package/dist/commands/compile-refresh-manifest.test.js.map +1 -1
- package/dist/commands/compile.d.ts.map +1 -1
- package/dist/commands/compile.js +33 -6
- package/dist/commands/compile.js.map +1 -1
- package/dist/commands/doctor.d.ts +10 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +22 -5
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +62 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/lesson-archive.test.js +26 -1
- package/dist/commands/lesson-archive.test.js.map +1 -1
- package/dist/commands/lesson.d.ts.map +1 -1
- package/dist/commands/lesson.js +4 -1
- package/dist/commands/lesson.js.map +1 -1
- package/dist/commands/rule-author-command.test.js +15 -7
- package/dist/commands/rule-author-command.test.js.map +1 -1
- package/dist/commands/rule-author.test.js +584 -47
- package/dist/commands/rule-author.test.js.map +1 -1
- package/dist/commands/rule.d.ts.map +1 -1
- package/dist/commands/rule.js +67 -2
- package/dist/commands/rule.js.map +1 -1
- package/dist/commands/rule.test.js +128 -0
- package/dist/commands/rule.test.js.map +1 -1
- package/dist/commands/run-compiled-rules.d.ts.map +1 -1
- package/dist/commands/run-compiled-rules.js +54 -31
- package/dist/commands/run-compiled-rules.js.map +1 -1
- package/dist/commands/run-compiled-rules.test.js +100 -5
- package/dist/commands/run-compiled-rules.test.js.map +1 -1
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +3 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +50 -0
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.d.ts.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.js +5 -1
- package/dist/commands/spine-authored-cert-corpus.js.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.test.js +50 -29
- package/dist/commands/spine-authored-cert-corpus.test.js.map +1 -1
- package/dist/commands/spine-corpus-disposition-source.d.ts +2 -2
- package/dist/commands/spine-freeze-orchestration.test.js +19 -21
- package/dist/commands/spine-freeze-orchestration.test.js.map +1 -1
- package/dist/commands/verify-manifest.d.ts.map +1 -1
- package/dist/commands/verify-manifest.js +59 -9
- package/dist/commands/verify-manifest.js.map +1 -1
- package/dist/commands/verify-manifest.test.js +243 -3
- package/dist/commands/verify-manifest.test.js.map +1 -1
- 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
|
-
|
|
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(
|
|
23
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
18
24
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
|
|
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('
|
|
209
|
-
if (src?.kind === '
|
|
210
|
-
expect(src.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
});
|