@mmnto/totem 2.14.0 → 2.16.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/compile-manifest-mode.test.d.ts +2 -0
- package/dist/compile-manifest-mode.test.d.ts.map +1 -0
- package/dist/compile-manifest-mode.test.js +47 -0
- package/dist/compile-manifest-mode.test.js.map +1 -0
- package/dist/compile-manifest.d.ts +4 -0
- package/dist/compile-manifest.d.ts.map +1 -1
- package/dist/compile-manifest.js +10 -1
- package/dist/compile-manifest.js.map +1 -1
- package/dist/compile-manifest.test.js +37 -0
- package/dist/compile-manifest.test.js.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +10 -2
- package/dist/compiler.js.map +1 -1
- package/dist/errors.d.ts +11 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/exporter.d.ts +64 -1
- package/dist/exporter.d.ts.map +1 -1
- package/dist/exporter.js +93 -10
- package/dist/exporter.js.map +1 -1
- package/dist/exporter.test.js +64 -1
- package/dist/exporter.test.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/rule-tier.d.ts +25 -0
- package/dist/rule-tier.d.ts.map +1 -0
- package/dist/rule-tier.js +32 -0
- package/dist/rule-tier.js.map +1 -0
- package/dist/rule-tier.test.d.ts +2 -0
- package/dist/rule-tier.test.d.ts.map +1 -0
- package/dist/rule-tier.test.js +107 -0
- package/dist/rule-tier.test.js.map +1 -0
- package/dist/spine/serving-plan.d.ts +227 -0
- package/dist/spine/serving-plan.d.ts.map +1 -0
- package/dist/spine/serving-plan.js +503 -0
- package/dist/spine/serving-plan.js.map +1 -0
- package/dist/spine/serving-plan.test.d.ts +2 -0
- package/dist/spine/serving-plan.test.d.ts.map +1 -0
- package/dist/spine/serving-plan.test.js +646 -0
- package/dist/spine/serving-plan.test.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
// ─── The serving writer's planner (B2 of mmnto-ai/totem#3036, S6 step 2) ─────
|
|
2
|
+
//
|
|
3
|
+
// `planServingWrite` is PURE: it takes the serving file's plain rows, the
|
|
4
|
+
// authoring ledger, the ingested records and the validated input, and returns
|
|
5
|
+
// the new rows plus the row-level report. These tests pin the placement, the
|
|
6
|
+
// identity of every untouched row, the re-run fixpoint, the blocking-set report
|
|
7
|
+
// and every refusal the planner owns (mmnto-ai/totem#3045 folded in as the
|
|
8
|
+
// stale-floor refusal).
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { stringify as yamlStringify } from 'yaml';
|
|
11
|
+
import { compileRuleRecord } from './record-lower.js';
|
|
12
|
+
import { parseRuleRecord } from './rule-record.js';
|
|
13
|
+
import { assertServingInputSupported, parseStrictInstant, planServingWrite, ServingInputSchema, } from './serving-plan.js';
|
|
14
|
+
const AS_OF = '2026-10-10T02:18:13.000Z';
|
|
15
|
+
const SHA = 'f'.repeat(64);
|
|
16
|
+
const LEGACY_A = 'aaaaaaaaaaaaaaaa';
|
|
17
|
+
const LEGACY_B = 'bbbbbbbbbbbbbbbb';
|
|
18
|
+
const ARCHIVE_C = 'cccccccccccccccc';
|
|
19
|
+
const ARCHIVED_D = 'dddddddddddddddd';
|
|
20
|
+
const CARRIED_E = 'eeeeeeeeeeeeeeee';
|
|
21
|
+
const RULE_A = '1111111111111111';
|
|
22
|
+
const RULE_B1 = '2222222222222222';
|
|
23
|
+
const RULE_B2 = '3333333333333333';
|
|
24
|
+
const RULE_N = '4444444444444444';
|
|
25
|
+
function recordText(opts) {
|
|
26
|
+
const target = opts.engine === 'regex'
|
|
27
|
+
? { type: 'regex', pattern: 'forbiddenCall', scope: { fileGlobs: ['**/*.ts'] } }
|
|
28
|
+
: {
|
|
29
|
+
type: 'ast-grep',
|
|
30
|
+
language: 'typescript',
|
|
31
|
+
pattern: 'execSync($$$ARGS)',
|
|
32
|
+
scope: { fileGlobs: ['**/*.ts'] },
|
|
33
|
+
};
|
|
34
|
+
return yamlStringify({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
severity: opts.severity,
|
|
37
|
+
message: `A ${opts.engine} rule`,
|
|
38
|
+
target,
|
|
39
|
+
...(opts.requiresLine ? { requires: { pattern: 'execFileSync', scope: 'line' } } : {}),
|
|
40
|
+
examples: [
|
|
41
|
+
opts.engine === 'regex'
|
|
42
|
+
? { bad: 'forbiddenCall();', good: 'allowedCall();' }
|
|
43
|
+
: { bad: "execSync('git status');", good: "execFileSync('git', ['status']);" },
|
|
44
|
+
],
|
|
45
|
+
...(opts.sourceLesson !== undefined ? { curation: { sourceLesson: opts.sourceLesson } } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function record(ruleId, path, opts) {
|
|
49
|
+
return {
|
|
50
|
+
ruleId,
|
|
51
|
+
path,
|
|
52
|
+
contentHash: ruleId.slice(0, 1).repeat(64),
|
|
53
|
+
parsed: parseRuleRecord(recordText(opts), path),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function ledgerRow(rec, authoredAt = '2026-09-23') {
|
|
57
|
+
// The planner reads ruleId and the record block only (authoredAt is carried
|
|
58
|
+
// to show it is NOT read); the rest of
|
|
59
|
+
// the §8 row is irrelevant to it and is cast past the type.
|
|
60
|
+
return {
|
|
61
|
+
ruleId: rec.ruleId,
|
|
62
|
+
authoredAt,
|
|
63
|
+
record: { path: rec.path, contentHash: rec.contentHash },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function legacyRow(lessonHash, extra = {}) {
|
|
67
|
+
return {
|
|
68
|
+
lessonHash,
|
|
69
|
+
lessonHeading: `Legacy ${lessonHash}`,
|
|
70
|
+
pattern: 'dummy-never-matches',
|
|
71
|
+
message: `Legacy ${lessonHash}`,
|
|
72
|
+
engine: 'regex',
|
|
73
|
+
compiledAt: '2026-01-01T00:00:00Z',
|
|
74
|
+
...extra,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function fixture(legacyRow_ = 'archive') {
|
|
78
|
+
const recA = record(RULE_A, '.totem/rules/a.rule.yaml', {
|
|
79
|
+
sourceLesson: `lesson-${LEGACY_A}`,
|
|
80
|
+
engine: 'ast-grep',
|
|
81
|
+
severity: 'error',
|
|
82
|
+
});
|
|
83
|
+
const recB1 = record(RULE_B1, '.totem/rules/b1.rule.yaml', {
|
|
84
|
+
sourceLesson: `lesson-${LEGACY_B}`,
|
|
85
|
+
engine: 'regex',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
});
|
|
88
|
+
const recB2 = record(RULE_B2, '.totem/rules/b2.rule.yaml', {
|
|
89
|
+
sourceLesson: `lesson-${LEGACY_B}`,
|
|
90
|
+
engine: 'regex',
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
});
|
|
93
|
+
const recN = record(RULE_N, '.totem/rules/n.rule.yaml', {
|
|
94
|
+
engine: 'regex',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
});
|
|
97
|
+
const records = [recA, recB1, recB2, recN];
|
|
98
|
+
// A row whose keys are deliberately NOT in schema order: the planner must
|
|
99
|
+
// carry it as the same object, untouched.
|
|
100
|
+
const oddOrder = {
|
|
101
|
+
message: 'Odd order',
|
|
102
|
+
lessonHash: CARRIED_E,
|
|
103
|
+
engine: 'ast-grep',
|
|
104
|
+
lessonHeading: 'Odd order',
|
|
105
|
+
pattern: '',
|
|
106
|
+
astGrepPattern: 'foo($A)',
|
|
107
|
+
compiledAt: '2026-01-01T00:00:00Z',
|
|
108
|
+
};
|
|
109
|
+
const rows = [
|
|
110
|
+
legacyRow(LEGACY_A),
|
|
111
|
+
oddOrder,
|
|
112
|
+
legacyRow(LEGACY_B, { engine: 'ast-grep', astGrepPattern: 'bar($A)', pattern: '' }),
|
|
113
|
+
legacyRow(ARCHIVE_C),
|
|
114
|
+
legacyRow(ARCHIVED_D, {
|
|
115
|
+
status: 'archived',
|
|
116
|
+
archivedReason: 'Stage 4 silenced it',
|
|
117
|
+
archivedAt: '2026-02-02T00:00:00Z',
|
|
118
|
+
}),
|
|
119
|
+
];
|
|
120
|
+
const input = {
|
|
121
|
+
schema: 'serving-input/1',
|
|
122
|
+
asOf: AS_OF,
|
|
123
|
+
source: 'a test fixture',
|
|
124
|
+
dispositions: { legacyRow: legacyRow_, stamp: 'none' },
|
|
125
|
+
// Each floor record names the content hash it was scored against.
|
|
126
|
+
floor: [
|
|
127
|
+
{
|
|
128
|
+
legacyHash: LEGACY_A,
|
|
129
|
+
records: [{ path: recA.path, ruleId: RULE_A, contentHash: recA.contentHash }],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
legacyHash: LEGACY_B,
|
|
133
|
+
records: [
|
|
134
|
+
{ path: recB1.path, ruleId: RULE_B1, contentHash: recB1.contentHash },
|
|
135
|
+
{ path: recB2.path, ruleId: RULE_B2, contentHash: recB2.contentHash },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{ records: [{ path: recN.path, ruleId: RULE_N, contentHash: recN.contentHash }] },
|
|
139
|
+
],
|
|
140
|
+
archive: [
|
|
141
|
+
{ lessonHash: ARCHIVE_C, reason: 'no-intent', ref: 'curation row 7' },
|
|
142
|
+
{ lessonHash: ARCHIVED_D, reason: 'no-intent', ref: 'curation row 8' },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
return { rows, records, ledger: records.map((r) => ledgerRow(r)), input };
|
|
146
|
+
}
|
|
147
|
+
function plan(f) {
|
|
148
|
+
return planServingWrite({
|
|
149
|
+
rows: f.rows,
|
|
150
|
+
ledger: f.ledger,
|
|
151
|
+
records: f.records,
|
|
152
|
+
input: f.input,
|
|
153
|
+
inputSha256: SHA,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function lowered(rec) {
|
|
157
|
+
const out = compileRuleRecord(rec.parsed, { ruleId: rec.ruleId, now: AS_OF });
|
|
158
|
+
if (out.kind !== 'compiled')
|
|
159
|
+
throw new Error(out.reason);
|
|
160
|
+
return out.rule;
|
|
161
|
+
}
|
|
162
|
+
describe('planServingWrite: lowering and placement (mmnto-ai/totem#3036 B2)', () => {
|
|
163
|
+
it('a lowered row carries the ruleId as its lessonHash and deep-equals what compileRuleRecord returns for asOf', () => {
|
|
164
|
+
const f = fixture();
|
|
165
|
+
const result = plan(f);
|
|
166
|
+
const row = result.rows.find((r) => r.lessonHash === RULE_A);
|
|
167
|
+
expect(row).toBeDefined();
|
|
168
|
+
expect(row).toEqual(lowered(f.records[0]));
|
|
169
|
+
});
|
|
170
|
+
it('under archive the legacy row stays at its index, archived with a reason naming its record, and the lowered row follows it', () => {
|
|
171
|
+
const f = fixture('archive');
|
|
172
|
+
const result = plan(f);
|
|
173
|
+
expect(result.rows[0]).toMatchObject({
|
|
174
|
+
lessonHash: LEGACY_A,
|
|
175
|
+
status: 'archived',
|
|
176
|
+
archivedReason: `served by record ${RULE_A}`,
|
|
177
|
+
archivedAt: AS_OF,
|
|
178
|
+
});
|
|
179
|
+
expect(result.rows[1].lessonHash).toBe(RULE_A);
|
|
180
|
+
// The input row object itself is never mutated (the planner is pure).
|
|
181
|
+
expect(f.rows[0].status).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
it('under archive leaves a legacy row already archived with another reason byte-untouched, reported as already-archived with its record ids', () => {
|
|
184
|
+
const f = fixture('archive');
|
|
185
|
+
f.rows[0] = legacyRow(LEGACY_A, {
|
|
186
|
+
status: 'archived',
|
|
187
|
+
archivedReason: 'an earlier human reason',
|
|
188
|
+
archivedAt: '2026-03-03T00:00:00Z',
|
|
189
|
+
});
|
|
190
|
+
const result = plan(f);
|
|
191
|
+
expect(result.rows[0]).toBe(f.rows[0]);
|
|
192
|
+
expect(result.rows[0]).toMatchObject({
|
|
193
|
+
archivedReason: 'an earlier human reason',
|
|
194
|
+
archivedAt: '2026-03-03T00:00:00Z',
|
|
195
|
+
});
|
|
196
|
+
expect(result.rows[1].lessonHash).toBe(RULE_A);
|
|
197
|
+
expect(result.report.rows).toContainEqual(expect.objectContaining({
|
|
198
|
+
class: 'floor-legacy',
|
|
199
|
+
action: 'already-archived',
|
|
200
|
+
lessonHash: LEGACY_A,
|
|
201
|
+
ruleId: RULE_A,
|
|
202
|
+
}));
|
|
203
|
+
});
|
|
204
|
+
it('under remove the legacy row is gone and the lowered rows sit at its index', () => {
|
|
205
|
+
const f = fixture('remove');
|
|
206
|
+
const result = plan(f);
|
|
207
|
+
const ids = result.rows.map((r) => r.lessonHash);
|
|
208
|
+
expect(ids).toEqual([RULE_A, CARRIED_E, RULE_B1, RULE_B2, ARCHIVE_C, ARCHIVED_D, RULE_N]);
|
|
209
|
+
});
|
|
210
|
+
it('a two-record floor entry yields two rows from one legacy row, in record order, and the reason names both', () => {
|
|
211
|
+
const f = fixture('archive');
|
|
212
|
+
const result = plan(f);
|
|
213
|
+
const ids = result.rows.map((r) => r.lessonHash);
|
|
214
|
+
expect(ids).toEqual([
|
|
215
|
+
LEGACY_A,
|
|
216
|
+
RULE_A,
|
|
217
|
+
CARRIED_E,
|
|
218
|
+
LEGACY_B,
|
|
219
|
+
RULE_B1,
|
|
220
|
+
RULE_B2,
|
|
221
|
+
ARCHIVE_C,
|
|
222
|
+
ARCHIVED_D,
|
|
223
|
+
RULE_N,
|
|
224
|
+
]);
|
|
225
|
+
expect(result.rows[3].archivedReason).toBe(`served by records ${RULE_B1}, ${RULE_B2}`);
|
|
226
|
+
});
|
|
227
|
+
it('a floor entry with no legacyHash appends its lowered rows at the end, in floor order', () => {
|
|
228
|
+
const f = fixture('archive');
|
|
229
|
+
const result = plan(f);
|
|
230
|
+
expect(result.rows[result.rows.length - 1].lessonHash).toBe(RULE_N);
|
|
231
|
+
});
|
|
232
|
+
it('an archive[] row is archived with `<reason> (<ref>)`; one already archived is the same object and reported', () => {
|
|
233
|
+
const f = fixture('archive');
|
|
234
|
+
const result = plan(f);
|
|
235
|
+
const c = result.rows.find((r) => r.lessonHash === ARCHIVE_C);
|
|
236
|
+
expect(c).toMatchObject({
|
|
237
|
+
status: 'archived',
|
|
238
|
+
archivedReason: 'no-intent (curation row 7)',
|
|
239
|
+
archivedAt: AS_OF,
|
|
240
|
+
});
|
|
241
|
+
const d = result.rows.find((r) => r.lessonHash === ARCHIVED_D);
|
|
242
|
+
expect(d).toBe(f.rows[4]);
|
|
243
|
+
expect(result.report.rows).toContainEqual(expect.objectContaining({
|
|
244
|
+
class: 'already-archived',
|
|
245
|
+
action: 'none',
|
|
246
|
+
lessonHash: ARCHIVED_D,
|
|
247
|
+
}));
|
|
248
|
+
});
|
|
249
|
+
it('every untouched row is the same object reference in the output', () => {
|
|
250
|
+
const f = fixture('archive');
|
|
251
|
+
const result = plan(f);
|
|
252
|
+
expect(result.rows.find((r) => r.lessonHash === CARRIED_E)).toBe(f.rows[1]);
|
|
253
|
+
});
|
|
254
|
+
it('planning the result again with the same input reports no change and returns every row as the same object', () => {
|
|
255
|
+
const f = fixture('archive');
|
|
256
|
+
const first = plan(f);
|
|
257
|
+
expect(first.changed).toBe(true);
|
|
258
|
+
const second = planServingWrite({
|
|
259
|
+
rows: first.rows,
|
|
260
|
+
ledger: f.ledger,
|
|
261
|
+
records: f.records,
|
|
262
|
+
input: f.input,
|
|
263
|
+
inputSha256: SHA,
|
|
264
|
+
});
|
|
265
|
+
expect(second.changed).toBe(false);
|
|
266
|
+
second.rows.forEach((r, i) => expect(r).toBe(first.rows[i]));
|
|
267
|
+
});
|
|
268
|
+
it('the remove disposition is a fixpoint too: the legacy row is absent on the re-run and the lowered rows are already there', () => {
|
|
269
|
+
const f = fixture('remove');
|
|
270
|
+
const first = plan(f);
|
|
271
|
+
const second = planServingWrite({
|
|
272
|
+
rows: first.rows,
|
|
273
|
+
ledger: f.ledger,
|
|
274
|
+
records: f.records,
|
|
275
|
+
input: f.input,
|
|
276
|
+
inputSha256: SHA,
|
|
277
|
+
});
|
|
278
|
+
expect(second.changed).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
describe('planServingWrite: the report (mmnto-ai/totem#3036 B2)', () => {
|
|
282
|
+
it('reports the blocking set before and after from isBlockingRule: the error ast-grep record joins it, the regex ones do not', () => {
|
|
283
|
+
const f = fixture('archive');
|
|
284
|
+
const { report } = plan(f);
|
|
285
|
+
// Sorted hashes. Before: LEGACY_B and CARRIED_E are active ast-grep rows at the default severity.
|
|
286
|
+
expect(report.blocking.before).toEqual([LEGACY_B, CARRIED_E]);
|
|
287
|
+
// After: LEGACY_B is archived; RULE_A (ast-grep, error) joins; RULE_B1 (regex, error) does not.
|
|
288
|
+
expect(report.blocking.after).toEqual([RULE_A, CARRIED_E]);
|
|
289
|
+
});
|
|
290
|
+
it('carries the input sha256 and asOf, the active and archived counts, and class counts that add up to the row counts', () => {
|
|
291
|
+
const f = fixture('archive');
|
|
292
|
+
const { report, rows } = plan(f);
|
|
293
|
+
expect(report.inputSha256).toBe(SHA);
|
|
294
|
+
expect(report.asOf).toBe(AS_OF);
|
|
295
|
+
expect(report.rowsBefore).toBe(5);
|
|
296
|
+
expect(report.rowsAfter).toBe(rows.length);
|
|
297
|
+
expect(report.active).toEqual({ before: 4, after: 5 });
|
|
298
|
+
expect(report.archived).toEqual({ before: 1, after: 4 });
|
|
299
|
+
expect(report.totals).toEqual({
|
|
300
|
+
'floor-legacy': 2,
|
|
301
|
+
lowered: 4,
|
|
302
|
+
archive: 1,
|
|
303
|
+
'already-archived': 1,
|
|
304
|
+
carried: 1,
|
|
305
|
+
});
|
|
306
|
+
const t = report.totals;
|
|
307
|
+
expect(t.carried + t.archive + t['already-archived'] + t['floor-legacy'] + t.lowered).toBe(report.rowsAfter);
|
|
308
|
+
});
|
|
309
|
+
it('under remove the class counts still add up: removed legacy rows count before, not after', () => {
|
|
310
|
+
const f = fixture('remove');
|
|
311
|
+
const { report } = plan(f);
|
|
312
|
+
expect(report.rowsBefore).toBe(5);
|
|
313
|
+
expect(report.rowsAfter).toBe(7);
|
|
314
|
+
expect(report.removed).toBe(2);
|
|
315
|
+
const t = report.totals;
|
|
316
|
+
expect(t.carried + t.archive + t['already-archived'] + t.lowered).toBe(report.rowsAfter);
|
|
317
|
+
expect(t.carried + t.archive + t['already-archived'] + t['floor-legacy'] + report.loweredExisting).toBe(report.rowsBefore);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
describe('planServingWrite: refusals, before any write (mmnto-ai/totem#3036 B2)', () => {
|
|
321
|
+
it('refuses stamp: advisory (the mode is not built)', () => {
|
|
322
|
+
const f = fixture();
|
|
323
|
+
f.input.dispositions.stamp = 'advisory';
|
|
324
|
+
expect(() => plan(f)).toThrow(/advisory/);
|
|
325
|
+
expect(() => assertServingInputSupported(f.input)).toThrow(expect.objectContaining({ code: 'SERVING_INPUT_INVALID' }));
|
|
326
|
+
});
|
|
327
|
+
it('refuses a floor record with no effective ledger row', () => {
|
|
328
|
+
const f = fixture();
|
|
329
|
+
f.ledger = f.ledger.filter((e) => e.ruleId !== RULE_A);
|
|
330
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
331
|
+
code: 'SERVING_RECORD_MISMATCH',
|
|
332
|
+
message: expect.stringContaining(RULE_A),
|
|
333
|
+
}));
|
|
334
|
+
});
|
|
335
|
+
it('refuses a ledger row with no record block', () => {
|
|
336
|
+
const f = fixture();
|
|
337
|
+
f.ledger[0] = { ...f.ledger[0], record: undefined };
|
|
338
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
339
|
+
code: 'SERVING_RECORD_MISMATCH',
|
|
340
|
+
message: expect.stringContaining('record block'),
|
|
341
|
+
}));
|
|
342
|
+
});
|
|
343
|
+
it('refuses a ledger row whose record.path differs from the floor path', () => {
|
|
344
|
+
const f = fixture();
|
|
345
|
+
f.ledger[0] = {
|
|
346
|
+
...f.ledger[0],
|
|
347
|
+
record: { path: '.totem/rules/other.rule.yaml', contentHash: f.records[0].contentHash },
|
|
348
|
+
};
|
|
349
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
350
|
+
code: 'SERVING_RECORD_MISMATCH',
|
|
351
|
+
message: expect.stringContaining('other.rule.yaml'),
|
|
352
|
+
}));
|
|
353
|
+
});
|
|
354
|
+
it('refuses a record whose content hash differs from its ledger row', () => {
|
|
355
|
+
const f = fixture();
|
|
356
|
+
f.records[0] = { ...f.records[0], contentHash: '9'.repeat(64) };
|
|
357
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
358
|
+
code: 'SERVING_RECORD_MISMATCH',
|
|
359
|
+
message: expect.stringContaining('content hash'),
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
it('requires contentHash on every floor record, naming the field and where its value comes from', () => {
|
|
363
|
+
const f = fixture();
|
|
364
|
+
const res = ServingInputSchema.safeParse({
|
|
365
|
+
...f.input,
|
|
366
|
+
floor: [{ legacyHash: LEGACY_A, records: [{ path: 'p', ruleId: RULE_A }] }],
|
|
367
|
+
});
|
|
368
|
+
expect(res.success).toBe(false);
|
|
369
|
+
if (!res.success) {
|
|
370
|
+
expect(res.error.issues[0].path).toEqual(['floor', 0, 'records', 0, 'contentHash']);
|
|
371
|
+
expect(res.error.issues[0].message).toBe("floor[].records[].contentHash is required: the floor file must carry the ledger's record.contentHash for each record");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
it('binds a floor record by content (the whole stale-floor check): a match passes, another revision refuses', () => {
|
|
375
|
+
const f = fixture();
|
|
376
|
+
f.input.floor[0].records[0] = {
|
|
377
|
+
...f.input.floor[0].records[0],
|
|
378
|
+
contentHash: f.records[0].contentHash,
|
|
379
|
+
};
|
|
380
|
+
expect(() => plan(f)).not.toThrow();
|
|
381
|
+
f.input.floor[0].records[0] = {
|
|
382
|
+
...f.input.floor[0].records[0],
|
|
383
|
+
contentHash: 'e'.repeat(64),
|
|
384
|
+
};
|
|
385
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
386
|
+
code: 'SERVING_RECORD_MISMATCH',
|
|
387
|
+
message: expect.stringContaining('the floor was built against another revision of this record'),
|
|
388
|
+
}));
|
|
389
|
+
expect(ServingInputSchema.safeParse({
|
|
390
|
+
...f.input,
|
|
391
|
+
floor: [{ records: [{ path: 'p', ruleId: RULE_A, contentHash: 'not-hex' }] }],
|
|
392
|
+
}).success).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
it('refuses a record the lowering rejects, with the lowering reason', () => {
|
|
395
|
+
const f = fixture();
|
|
396
|
+
f.records[0] = {
|
|
397
|
+
...f.records[0],
|
|
398
|
+
parsed: parseRuleRecord(recordText({
|
|
399
|
+
sourceLesson: `lesson-${LEGACY_A}`,
|
|
400
|
+
engine: 'ast-grep',
|
|
401
|
+
severity: 'error',
|
|
402
|
+
requiresLine: true,
|
|
403
|
+
}), f.records[0].path),
|
|
404
|
+
};
|
|
405
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
406
|
+
code: 'COMPILE_FAILED',
|
|
407
|
+
message: expect.stringContaining('requires.scope'),
|
|
408
|
+
}));
|
|
409
|
+
});
|
|
410
|
+
it('refuses a legacyHash that is not in the file', () => {
|
|
411
|
+
const f = fixture();
|
|
412
|
+
f.rows = f.rows.filter((r) => r.lessonHash !== LEGACY_A);
|
|
413
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
414
|
+
code: 'SERVING_INPUT_INVALID',
|
|
415
|
+
message: expect.stringContaining(LEGACY_A),
|
|
416
|
+
}));
|
|
417
|
+
});
|
|
418
|
+
it('refuses an archive lessonHash that is not in the file', () => {
|
|
419
|
+
const f = fixture();
|
|
420
|
+
f.rows = f.rows.filter((r) => r.lessonHash !== ARCHIVE_C);
|
|
421
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
422
|
+
code: 'SERVING_INPUT_INVALID',
|
|
423
|
+
message: expect.stringContaining(ARCHIVE_C),
|
|
424
|
+
}));
|
|
425
|
+
});
|
|
426
|
+
it('refuses a hash named in both the floor and archive lists', () => {
|
|
427
|
+
const f = fixture();
|
|
428
|
+
f.input.archive.push({ lessonHash: LEGACY_A, reason: 'no-intent', ref: 'x' });
|
|
429
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
430
|
+
code: 'SERVING_INPUT_INVALID',
|
|
431
|
+
message: expect.stringContaining('both'),
|
|
432
|
+
}));
|
|
433
|
+
});
|
|
434
|
+
it('refuses a legacyHash that disagrees with the record curation.sourceLesson', () => {
|
|
435
|
+
const f = fixture();
|
|
436
|
+
f.records[0] = record(RULE_A, f.records[0].path, {
|
|
437
|
+
sourceLesson: `lesson-${'0'.repeat(16)}`,
|
|
438
|
+
engine: 'ast-grep',
|
|
439
|
+
severity: 'error',
|
|
440
|
+
});
|
|
441
|
+
f.ledger[0] = ledgerRow(f.records[0]);
|
|
442
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
443
|
+
code: 'SERVING_INPUT_INVALID',
|
|
444
|
+
message: expect.stringContaining('sourceLesson'),
|
|
445
|
+
}));
|
|
446
|
+
});
|
|
447
|
+
it('refuses a record-path row in the file that the floor does not name', () => {
|
|
448
|
+
const f = fixture();
|
|
449
|
+
f.rows.push({ ...legacyRow('9999999999999999'), examples: [{ bad: 'x', good: 'y' }] });
|
|
450
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
451
|
+
code: 'SERVING_INPUT_INVALID',
|
|
452
|
+
message: expect.stringContaining('9999999999999999'),
|
|
453
|
+
}));
|
|
454
|
+
});
|
|
455
|
+
it('refuses a serving file that holds two rows with one lessonHash', () => {
|
|
456
|
+
const f = fixture();
|
|
457
|
+
f.rows.push(legacyRow(CARRIED_E));
|
|
458
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
459
|
+
code: 'PARSE_FAILED',
|
|
460
|
+
message: expect.stringContaining(CARRIED_E),
|
|
461
|
+
}));
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
describe('ServingInputSchema (mmnto-ai/totem#3036 B2)', () => {
|
|
465
|
+
it('accepts the fixture input and refuses an unknown key, a missing disposition and a bad asOf', () => {
|
|
466
|
+
const f = fixture();
|
|
467
|
+
expect(ServingInputSchema.safeParse(f.input).success).toBe(true);
|
|
468
|
+
expect(ServingInputSchema.safeParse({ ...f.input, extra: 1 }).success).toBe(false);
|
|
469
|
+
expect(ServingInputSchema.safeParse({ ...f.input, dispositions: { legacyRow: 'archive' } }).success).toBe(false);
|
|
470
|
+
expect(ServingInputSchema.safeParse({ ...f.input, asOf: 'yesterday' }).success).toBe(false);
|
|
471
|
+
expect(ServingInputSchema.safeParse({
|
|
472
|
+
...f.input,
|
|
473
|
+
floor: [{ legacyHash: LEGACY_A, records: [{ path: 'p', ruleId: RULE_A, extra: true }] }],
|
|
474
|
+
}).success).toBe(false);
|
|
475
|
+
});
|
|
476
|
+
it('asOf must be a strict instant: seconds and an explicit zone (falsification fold F6)', () => {
|
|
477
|
+
const f = fixture();
|
|
478
|
+
const ok = (asOf) => ServingInputSchema.safeParse({ ...f.input, asOf }).success;
|
|
479
|
+
// The file of record's form, and the other zone spellings.
|
|
480
|
+
expect(ok('2026-10-10T02:18:13.000Z')).toBe(true);
|
|
481
|
+
expect(ok('2026-10-10T02:18:13Z')).toBe(true);
|
|
482
|
+
expect(ok('2026-10-10T02:18:13+14:00')).toBe(true);
|
|
483
|
+
expect(ok('2026-10-10T02:18:13.5-05:30')).toBe(true);
|
|
484
|
+
// Zone-less (local time), date-only, and every non-ISO form Date.parse accepts.
|
|
485
|
+
for (const bad of [
|
|
486
|
+
'2026-10-10T02:18:13',
|
|
487
|
+
'2026-10-10',
|
|
488
|
+
'10/10/2026',
|
|
489
|
+
'October 10, 2026',
|
|
490
|
+
'2026',
|
|
491
|
+
'Sat Oct 10 2026',
|
|
492
|
+
'2026-10-10 02:18',
|
|
493
|
+
]) {
|
|
494
|
+
expect(ok(bad)).toBe(false);
|
|
495
|
+
}
|
|
496
|
+
const res = ServingInputSchema.safeParse({ ...f.input, asOf: '2026-10-10T02:18:13' });
|
|
497
|
+
expect(res.success ? '' : res.error.issues[0].message).toContain('YYYY-MM-DDTHH:MM:SS');
|
|
498
|
+
});
|
|
499
|
+
it('parseStrictInstant reads the accepted forms as UTC and refuses the rest', () => {
|
|
500
|
+
expect(parseStrictInstant('2026-10-10T02:18:13.000Z')).toBe(Date.UTC(2026, 9, 10, 2, 18, 13));
|
|
501
|
+
expect(parseStrictInstant('2026-10-10T02:18:13')).toBeUndefined();
|
|
502
|
+
expect(parseStrictInstant('2026-10-10')).toBeUndefined();
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
describe('planServingWrite: falsification fold (mmnto-ai/totem#3036 B2)', () => {
|
|
506
|
+
it('F2: under archive an absent legacy row refuses even when the lowered rows are present', () => {
|
|
507
|
+
const f = fixture('archive');
|
|
508
|
+
const first = plan(f);
|
|
509
|
+
f.rows = first.rows.filter((r) => r.lessonHash !== LEGACY_A);
|
|
510
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
511
|
+
code: 'SERVING_INPUT_INVALID',
|
|
512
|
+
message: expect.stringContaining(`legacyHash ${LEGACY_A}, which is not in the serving file`),
|
|
513
|
+
}));
|
|
514
|
+
});
|
|
515
|
+
it('F2: under remove an absent legacy row passes when every ruleId of its entry is present (the re-run)', () => {
|
|
516
|
+
const f = fixture('remove');
|
|
517
|
+
f.rows = plan(f).rows;
|
|
518
|
+
expect(plan(f).changed).toBe(false);
|
|
519
|
+
// ...but not when one of the entry's lowered rows is missing.
|
|
520
|
+
f.rows = f.rows.filter((r) => r.lessonHash !== RULE_B2);
|
|
521
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({ message: expect.stringContaining(LEGACY_B) }));
|
|
522
|
+
});
|
|
523
|
+
it('F3: an archive[] entry naming a floor ruleId refuses as in both lists, on a first run and on a re-run', () => {
|
|
524
|
+
const f = fixture('archive');
|
|
525
|
+
const result = plan(f);
|
|
526
|
+
f.input.archive.push({ lessonHash: RULE_A, reason: 'no-intent', ref: 'probe' });
|
|
527
|
+
const expected = expect.objectContaining({
|
|
528
|
+
code: 'SERVING_INPUT_INVALID',
|
|
529
|
+
message: expect.stringContaining(`${RULE_A} in both floor[] (as a record ruleId) and archive[]`),
|
|
530
|
+
});
|
|
531
|
+
expect(() => plan(f)).toThrow(expected);
|
|
532
|
+
f.rows = result.rows;
|
|
533
|
+
expect(() => plan(f)).toThrow(expected);
|
|
534
|
+
});
|
|
535
|
+
it('F5: a named record row a human archived after serving refuses, naming the row and its reason', () => {
|
|
536
|
+
const f = fixture('archive');
|
|
537
|
+
const result = plan(f);
|
|
538
|
+
f.rows = result.rows.map((r) => r.lessonHash === RULE_A
|
|
539
|
+
? { ...r, status: 'archived', archivedReason: 'silenced by a human' }
|
|
540
|
+
: r);
|
|
541
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
542
|
+
code: 'SERVING_INPUT_INVALID',
|
|
543
|
+
message: expect.stringMatching(new RegExp(`${RULE_A}.*silenced by a human`)),
|
|
544
|
+
}));
|
|
545
|
+
});
|
|
546
|
+
it('F5: a replace lists the fields that differ (a new asOf moves compiledAt and createdAt)', () => {
|
|
547
|
+
const f = fixture('archive');
|
|
548
|
+
f.rows = plan(f).rows;
|
|
549
|
+
f.input.asOf = '2026-10-11T00:00:00.000Z';
|
|
550
|
+
const result = plan(f);
|
|
551
|
+
expect(result.changed).toBe(true);
|
|
552
|
+
const row = result.report.rows.find((r) => r.lessonHash === RULE_A);
|
|
553
|
+
expect(row).toMatchObject({ class: 'lowered', action: 'replace' });
|
|
554
|
+
expect(row.changedFields).toEqual(['compiledAt', 'createdAt']);
|
|
555
|
+
});
|
|
556
|
+
it('F5: a replace on a hand-edited record row names the edited field', () => {
|
|
557
|
+
const f = fixture('archive');
|
|
558
|
+
f.rows = plan(f).rows.map((r) => r.lessonHash === RULE_A ? { ...r, severity: 'warning' } : r);
|
|
559
|
+
const row = plan(f).report.rows.find((r) => r.lessonHash === RULE_A);
|
|
560
|
+
expect(row).toMatchObject({ action: 'replace', changedFields: ['severity'] });
|
|
561
|
+
});
|
|
562
|
+
it('F8: refuses a legacyHash named in two floor entries', () => {
|
|
563
|
+
const f = fixture();
|
|
564
|
+
f.input.floor[2] = { legacyHash: LEGACY_A, records: f.input.floor[2].records };
|
|
565
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
566
|
+
code: 'SERVING_INPUT_INVALID',
|
|
567
|
+
message: expect.stringContaining(`legacyHash ${LEGACY_A} in more than one floor entry`),
|
|
568
|
+
}));
|
|
569
|
+
});
|
|
570
|
+
it('F8: refuses a ruleId named twice in the floor', () => {
|
|
571
|
+
const f = fixture();
|
|
572
|
+
f.input.floor[2] = {
|
|
573
|
+
records: [
|
|
574
|
+
{
|
|
575
|
+
path: '.totem/rules/a.rule.yaml',
|
|
576
|
+
ruleId: RULE_A,
|
|
577
|
+
contentHash: f.records[0].contentHash,
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
};
|
|
581
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
582
|
+
code: 'SERVING_INPUT_INVALID',
|
|
583
|
+
message: expect.stringContaining(`ruleId ${RULE_A} more than once`),
|
|
584
|
+
}));
|
|
585
|
+
});
|
|
586
|
+
it('F8: refuses an archive lessonHash named twice', () => {
|
|
587
|
+
const f = fixture();
|
|
588
|
+
f.input.archive.push({ lessonHash: ARCHIVE_C, reason: 'again', ref: 'x' });
|
|
589
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
590
|
+
code: 'SERVING_INPUT_INVALID',
|
|
591
|
+
message: expect.stringContaining(`archive lessonHash ${ARCHIVE_C} more than once`),
|
|
592
|
+
}));
|
|
593
|
+
});
|
|
594
|
+
it('F8: refuses a floor ruleId that collides with a non-record row', () => {
|
|
595
|
+
const f = fixture();
|
|
596
|
+
f.rows.push(legacyRow(RULE_N));
|
|
597
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
598
|
+
code: 'SERVING_INPUT_INVALID',
|
|
599
|
+
message: expect.stringContaining(`ruleId ${RULE_N} collides with a non-record row`),
|
|
600
|
+
}));
|
|
601
|
+
});
|
|
602
|
+
for (const status of ['untested-against-codebase', 'pending-verification']) {
|
|
603
|
+
it(`R2-F3: a named record row with status ${status} refuses, naming the status`, () => {
|
|
604
|
+
const f = fixture('archive');
|
|
605
|
+
f.rows = plan(f).rows.map((r) => (r.lessonHash === RULE_A ? { ...r, status } : r));
|
|
606
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
607
|
+
code: 'SERVING_INPUT_INVALID',
|
|
608
|
+
message: expect.stringContaining(`${RULE_A}, which the floor names, is inactive (status: ${status})`),
|
|
609
|
+
}));
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
it('R2-F3: an archived named record row names its status and archivedReason', () => {
|
|
613
|
+
const f = fixture('archive');
|
|
614
|
+
f.rows = plan(f).rows.map((r) => r.lessonHash === RULE_A
|
|
615
|
+
? { ...r, status: 'archived', archivedReason: 'silenced by a human' }
|
|
616
|
+
: r);
|
|
617
|
+
expect(() => plan(f)).toThrow(expect.objectContaining({
|
|
618
|
+
message: expect.stringContaining('(status: archived, archivedReason: silenced by a human)'),
|
|
619
|
+
}));
|
|
620
|
+
});
|
|
621
|
+
it('R2-F4: impossible calendar values refuse; a real leap day passes', () => {
|
|
622
|
+
for (const bad of ['2026-02-30T00:00:00Z', '2026-10-10T24:00:00Z', '2026-02-29T00:00:00Z']) {
|
|
623
|
+
expect(parseStrictInstant(bad)).toBeUndefined();
|
|
624
|
+
}
|
|
625
|
+
expect(parseStrictInstant('2028-02-29T00:00:00Z')).toBe(Date.UTC(2028, 1, 29));
|
|
626
|
+
// An offset shifts the instant, not the written digits.
|
|
627
|
+
expect(parseStrictInstant('2026-10-10T02:18:13+14:00')).toBe(Date.UTC(2026, 9, 9, 12, 18, 13));
|
|
628
|
+
const f = fixture();
|
|
629
|
+
const res = ServingInputSchema.safeParse({ ...f.input, asOf: '2026-02-30T00:00:00Z' });
|
|
630
|
+
expect(res.success ? '' : res.error.issues[0].message).toContain("'2026-02-30T00:00:00Z'");
|
|
631
|
+
});
|
|
632
|
+
it('R2-F5: a lowered row whose keys are only reordered is a replace with changedFields (key order)', () => {
|
|
633
|
+
const f = fixture('archive');
|
|
634
|
+
f.rows = plan(f).rows.map((r) => {
|
|
635
|
+
if (r.lessonHash !== RULE_A)
|
|
636
|
+
return r;
|
|
637
|
+
const reversed = {};
|
|
638
|
+
for (const k of Object.keys(r).reverse())
|
|
639
|
+
reversed[k] = r[k];
|
|
640
|
+
return reversed;
|
|
641
|
+
});
|
|
642
|
+
const row = plan(f).report.rows.find((r) => r.lessonHash === RULE_A);
|
|
643
|
+
expect(row).toMatchObject({ action: 'replace', changedFields: ['(key order)'] });
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
//# sourceMappingURL=serving-plan.test.js.map
|