vigiles 2.0.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 (156) hide show
  1. package/.claude/settings.json +46 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/.claude-plugin/hooks/post-edit.sh +34 -0
  4. package/.claude-plugin/hooks/pre-edit.sh +40 -0
  5. package/.claude-plugin/hooks/session-start.sh +38 -0
  6. package/.claude-plugin/marketplace.json +14 -0
  7. package/.claude-plugin/plugin.json +47 -0
  8. package/.github/workflows/ci.yml +81 -0
  9. package/.prettierignore +1 -0
  10. package/.vigiles/generated.d.ts +205 -0
  11. package/CLAUDE.md +95 -0
  12. package/CLAUDE.md.spec.ts +142 -0
  13. package/CONTRIBUTING.md +121 -0
  14. package/LICENSE +21 -0
  15. package/README.md +377 -0
  16. package/action.yml +25 -0
  17. package/dist/action.d.ts +7 -0
  18. package/dist/action.d.ts.map +1 -0
  19. package/dist/action.js +180 -0
  20. package/dist/action.js.map +1 -0
  21. package/dist/cli.d.ts +12 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +1267 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/cli.test.d.ts +2 -0
  26. package/dist/cli.test.d.ts.map +1 -0
  27. package/dist/cli.test.js +650 -0
  28. package/dist/cli.test.js.map +1 -0
  29. package/dist/compile.d.ts +101 -0
  30. package/dist/compile.d.ts.map +1 -0
  31. package/dist/compile.js +503 -0
  32. package/dist/compile.js.map +1 -0
  33. package/dist/evolve.d.ts +132 -0
  34. package/dist/evolve.d.ts.map +1 -0
  35. package/dist/evolve.js +599 -0
  36. package/dist/evolve.js.map +1 -0
  37. package/dist/freshness.d.ts +67 -0
  38. package/dist/freshness.d.ts.map +1 -0
  39. package/dist/freshness.js +244 -0
  40. package/dist/freshness.js.map +1 -0
  41. package/dist/freshness.test.d.ts +2 -0
  42. package/dist/freshness.test.d.ts.map +1 -0
  43. package/dist/freshness.test.js +356 -0
  44. package/dist/freshness.test.js.map +1 -0
  45. package/dist/generate-types.d.ts +34 -0
  46. package/dist/generate-types.d.ts.map +1 -0
  47. package/dist/generate-types.js +381 -0
  48. package/dist/generate-types.js.map +1 -0
  49. package/dist/inline.d.ts +58 -0
  50. package/dist/inline.d.ts.map +1 -0
  51. package/dist/inline.js +142 -0
  52. package/dist/inline.js.map +1 -0
  53. package/dist/inline.test.d.ts +5 -0
  54. package/dist/inline.test.d.ts.map +1 -0
  55. package/dist/inline.test.js +152 -0
  56. package/dist/inline.test.js.map +1 -0
  57. package/dist/linters.d.ts +38 -0
  58. package/dist/linters.d.ts.map +1 -0
  59. package/dist/linters.js +588 -0
  60. package/dist/linters.js.map +1 -0
  61. package/dist/proofs.d.ts +272 -0
  62. package/dist/proofs.d.ts.map +1 -0
  63. package/dist/proofs.js +622 -0
  64. package/dist/proofs.js.map +1 -0
  65. package/dist/proofs.test.d.ts +9 -0
  66. package/dist/proofs.test.d.ts.map +1 -0
  67. package/dist/proofs.test.js +952 -0
  68. package/dist/proofs.test.js.map +1 -0
  69. package/dist/spec.d.ts +258 -0
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/spec.js +113 -0
  72. package/dist/spec.js.map +1 -0
  73. package/dist/spec.test.d.ts +2 -0
  74. package/dist/spec.test.d.ts.map +1 -0
  75. package/dist/spec.test.js +1222 -0
  76. package/dist/spec.test.js.map +1 -0
  77. package/dist/types.d.ts +101 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +3 -0
  80. package/dist/types.js.map +1 -0
  81. package/dist/validate.d.ts +10 -0
  82. package/dist/validate.d.ts.map +1 -0
  83. package/dist/validate.js +286 -0
  84. package/dist/validate.js.map +1 -0
  85. package/dist/validate.test.d.ts +2 -0
  86. package/dist/validate.test.d.ts.map +1 -0
  87. package/dist/validate.test.js +531 -0
  88. package/dist/validate.test.js.map +1 -0
  89. package/docs/agent-setup.md +85 -0
  90. package/docs/agent-workflows.md +103 -0
  91. package/docs/comparison.md +71 -0
  92. package/docs/freshness.md +124 -0
  93. package/docs/inline-mode.md +119 -0
  94. package/docs/linter-support.md +166 -0
  95. package/docs/spec-format.md +194 -0
  96. package/eslint.config.mjs +79 -0
  97. package/examples/CLAUDE.md +54 -0
  98. package/examples/CLAUDE.md.spec.ts +65 -0
  99. package/examples/SKILL.md +50 -0
  100. package/examples/SKILL.md.spec.ts +57 -0
  101. package/fixtures/example-project/CLAUDE.md +11 -0
  102. package/fixtures/example-project/package.json +9 -0
  103. package/fixtures/example-project/src/index.ts +3 -0
  104. package/fixtures/example-project/src/utils.test.ts +2 -0
  105. package/fixtures/example-project/src/utils.ts +3 -0
  106. package/logo.png +0 -0
  107. package/package.json +42 -0
  108. package/research/adoption-strategy.md +111 -0
  109. package/research/agent-integration.md +145 -0
  110. package/research/ai-code-quality.md +197 -0
  111. package/research/code-search-for-agents.md +313 -0
  112. package/research/competitive-landscape.md +163 -0
  113. package/research/doc-freshness.md +516 -0
  114. package/research/executable-specs.md +368 -0
  115. package/research/feature-ideas.md +464 -0
  116. package/research/formal-proofs-for-agents.md +338 -0
  117. package/research/fp-for-agent-harness.md +150 -0
  118. package/research/fp-for-deterministic-ai.md +131 -0
  119. package/research/self-evolving-specs.md +298 -0
  120. package/schemas/claude-md-strict.yml +18 -0
  121. package/schemas/claude-md.yml +6 -0
  122. package/schemas/skill-strict.yml +12 -0
  123. package/schemas/skill.yml +5 -0
  124. package/skills/audit-feedback-loop/SKILL.md +76 -0
  125. package/skills/edit-spec/SKILL.md +131 -0
  126. package/skills/enforce-rules-format/SKILL.md +71 -0
  127. package/skills/generate-logo/SKILL.md +102 -0
  128. package/skills/generate-rule/SKILL.md +90 -0
  129. package/skills/linter-docs/clippy.md +241 -0
  130. package/skills/linter-docs/eslint.md +384 -0
  131. package/skills/linter-docs/pylint.md +288 -0
  132. package/skills/linter-docs/rubocop.md +277 -0
  133. package/skills/linter-docs/ruff.md +187 -0
  134. package/skills/linter-docs/stylelint.md +247 -0
  135. package/skills/migrate-to-spec/SKILL.md +124 -0
  136. package/skills/pr-to-lint-rule/SKILL.md +97 -0
  137. package/skills/strengthen/SKILL.md +168 -0
  138. package/src/action.ts +214 -0
  139. package/src/cli.test.ts +914 -0
  140. package/src/cli.ts +1631 -0
  141. package/src/compile.ts +691 -0
  142. package/src/evolve.ts +781 -0
  143. package/src/freshness.test.ts +449 -0
  144. package/src/freshness.ts +299 -0
  145. package/src/generate-types.ts +448 -0
  146. package/src/inline.test.ts +206 -0
  147. package/src/inline.ts +164 -0
  148. package/src/linters.ts +739 -0
  149. package/src/proofs.test.ts +1314 -0
  150. package/src/proofs.ts +849 -0
  151. package/src/spec.test.ts +1471 -0
  152. package/src/spec.ts +427 -0
  153. package/src/types.ts +117 -0
  154. package/src/validate.test.ts +701 -0
  155. package/src/validate.ts +381 -0
  156. package/tsconfig.json +23 -0
@@ -0,0 +1,1314 @@
1
+ /**
2
+ * Tests for the proof system and evolution engine.
3
+ *
4
+ * Covers: monotonicity lattice, NCD, Bloom filter, fixed-point convergence,
5
+ * Merkle history, property-based testing, fitness function, and the
6
+ * evolution engine.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ checkMonotonicity,
14
+ latticeJoin,
15
+ latticeMeet,
16
+ ruleStrength,
17
+ ncd,
18
+ findSimilarRules,
19
+ BloomFilter,
20
+ ruleToBloomFilter,
21
+ fixedPoint,
22
+ MerkleHistory,
23
+ propertyTest,
24
+ fitness,
25
+ type Mutation,
26
+ type ProofReceipt,
27
+ } from "./proofs.js";
28
+
29
+ import { applyMutation, runProofSuite, EvolutionEngine } from "./evolve.js";
30
+
31
+ import type { Rule, ClaudeSpec } from "./spec.js";
32
+ import { enforce, guidance } from "./spec.js";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function makeRules(): Record<string, Rule> {
39
+ return {
40
+ "no-console": enforce("eslint/no-console", "Use structured logger."),
41
+ "no-unused-vars": enforce("eslint/no-unused-vars", "Keep code clean."),
42
+ "google-first": guidance("Google unfamiliar APIs before implementing."),
43
+ };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // 1. Monotonicity Lattice
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("MonotonicityLattice", () => {
51
+ it("detects no violations when rules only strengthen", () => {
52
+ const before: Record<string, Rule> = {
53
+ rule1: guidance("Do X."),
54
+ };
55
+ const after: Record<string, Rule> = {
56
+ rule1: enforce("eslint/no-console", "Do X."),
57
+ };
58
+
59
+ const result = checkMonotonicity(before, after);
60
+ assert.equal(result.valid, true);
61
+ assert.equal(result.violations.length, 0);
62
+ assert.deepEqual(result.strengthened, ["rule1"]);
63
+ });
64
+
65
+ it("detects violation when rule weakens", () => {
66
+ const before: Record<string, Rule> = {
67
+ rule1: enforce("eslint/no-console", "Do X."),
68
+ };
69
+ const after: Record<string, Rule> = {
70
+ rule1: guidance("Do X."),
71
+ };
72
+
73
+ const result = checkMonotonicity(before, after);
74
+ assert.equal(result.valid, false);
75
+ assert.equal(result.violations.length, 1);
76
+ assert.equal(result.violations[0].ruleId, "rule1");
77
+ assert.equal(result.violations[0].from, "enforce");
78
+ assert.equal(result.violations[0].to, "guidance");
79
+ });
80
+
81
+ it("allows weakening with explicit allowWeaken", () => {
82
+ const before: Record<string, Rule> = {
83
+ rule1: enforce("eslint/no-console", "Do X."),
84
+ };
85
+ const after: Record<string, Rule> = {
86
+ rule1: guidance("Do X."),
87
+ };
88
+
89
+ const result = checkMonotonicity(before, after, {
90
+ allowWeaken: new Set(["rule1"]),
91
+ });
92
+ assert.equal(result.valid, true);
93
+ });
94
+
95
+ it("tracks added and removed rules", () => {
96
+ const before: Record<string, Rule> = {
97
+ existing: guidance("Stay."),
98
+ };
99
+ const after: Record<string, Rule> = {
100
+ existing: guidance("Stay."),
101
+ newRule: enforce("eslint/no-console", "New."),
102
+ };
103
+
104
+ const result = checkMonotonicity(before, after);
105
+ assert.deepEqual(result.added, ["newRule"]);
106
+ assert.deepEqual(result.removed, []);
107
+ assert.deepEqual(result.unchanged, ["existing"]);
108
+ });
109
+
110
+ it("tracks removed rules and reports them as violations", () => {
111
+ const before: Record<string, Rule> = {
112
+ willRemove: guidance("Bye."),
113
+ stays: guidance("Stay."),
114
+ };
115
+ const after: Record<string, Rule> = {
116
+ stays: guidance("Stay."),
117
+ };
118
+
119
+ const result = checkMonotonicity(before, after);
120
+ assert.deepEqual(result.removed, ["willRemove"]);
121
+ // Removal without allowlist must fail monotonicity, otherwise a pure
122
+ // `remove` mutation could bypass the "only strengthen" invariant.
123
+ assert.equal(result.valid, false);
124
+ assert.equal(result.violations.length, 1);
125
+ assert.equal(result.violations[0].ruleId, "willRemove");
126
+ });
127
+
128
+ it("allows removal when explicitly allowlisted", () => {
129
+ const before: Record<string, Rule> = {
130
+ deprecated: guidance("Old."),
131
+ };
132
+ const after: Record<string, Rule> = {};
133
+
134
+ const result = checkMonotonicity(before, after, {
135
+ allowWeaken: new Set(["deprecated"]),
136
+ });
137
+ assert.deepEqual(result.removed, ["deprecated"]);
138
+ assert.equal(result.valid, true);
139
+ assert.equal(result.violations.length, 0);
140
+ });
141
+
142
+ it("latticeJoin returns the stronger kind", () => {
143
+ assert.equal(latticeJoin("guidance", "enforce"), "enforce");
144
+ assert.equal(latticeJoin("guidance", "guidance"), "guidance");
145
+ assert.equal(latticeJoin("enforce", "enforce"), "enforce");
146
+ });
147
+
148
+ it("latticeMeet returns the weaker kind", () => {
149
+ assert.equal(latticeMeet("guidance", "enforce"), "guidance");
150
+ assert.equal(latticeMeet("enforce", "guidance"), "guidance");
151
+ assert.equal(latticeMeet("enforce", "enforce"), "enforce");
152
+ });
153
+
154
+ it("ruleStrength returns correct ordinals", () => {
155
+ assert.equal(ruleStrength("guidance"), 0);
156
+ assert.equal(ruleStrength("enforce"), 1);
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // 2. NCD
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe("NCD", () => {
165
+ it("returns 0 for identical strings", () => {
166
+ assert.equal(ncd("hello world", "hello world"), 0);
167
+ });
168
+
169
+ it("returns low distance for similar strings", () => {
170
+ const d = ncd(
171
+ "Use structured logger instead of console.log",
172
+ "Always use the structured logger, never console.log",
173
+ );
174
+ assert.ok(d < 0.7, `Expected < 0.7, got ${d}`);
175
+ });
176
+
177
+ it("returns high distance for unrelated strings", () => {
178
+ const d = ncd(
179
+ "Use structured logger instead of console.log",
180
+ "Deploy to Kubernetes using Helm charts with rolling updates",
181
+ );
182
+ assert.ok(d > 0.5, `Expected > 0.5, got ${d}`);
183
+ });
184
+
185
+ it("is approximately symmetric", () => {
186
+ const a = "first string content here";
187
+ const b = "second different string content";
188
+ const d1 = ncd(a, b);
189
+ const d2 = ncd(b, a);
190
+ // gzip is not perfectly symmetric (concatenation order affects compression),
191
+ // but the difference should be small
192
+ assert.ok(
193
+ Math.abs(d1 - d2) < 0.1,
194
+ `Expected approximately symmetric: ${d1} vs ${d2}`,
195
+ );
196
+ });
197
+
198
+ it("handles empty strings", () => {
199
+ assert.equal(ncd("", ""), 0);
200
+ // One empty, one non-empty
201
+ const d = ncd("", "some content");
202
+ assert.ok(d >= 0, `Expected non-negative: ${d}`);
203
+ });
204
+
205
+ it("findSimilarRules detects near-duplicates", () => {
206
+ const rules: Record<string, Rule> = {
207
+ "no-console": enforce("eslint/no-console", "Use structured logger."),
208
+ "use-logger": guidance(
209
+ "Use the structured logger instead of console.log.",
210
+ ),
211
+ "no-unused": enforce("eslint/no-unused-vars", "Keep code clean."),
212
+ };
213
+
214
+ const pairs = findSimilarRules(rules, 0.8);
215
+ // no-console and use-logger should be similar
216
+ assert.ok(pairs.length >= 0); // NCD with short strings may not catch this
217
+ // The test validates the function runs without error
218
+ });
219
+
220
+ it("findSimilarRules throws a structured error on unknown rule kinds", () => {
221
+ // Simulate a legacy spec / JS caller that bypassed the TS type
222
+ const rules = {
223
+ legacy: { _kind: "check", text: "stale" },
224
+ modern: guidance("Fresh."),
225
+ } as unknown as Record<string, Rule>;
226
+
227
+ assert.throws(
228
+ () => findSimilarRules(rules, 0.8),
229
+ /Unknown rule kind "check"/,
230
+ );
231
+ });
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // 3. Bloom Filter
236
+ // ---------------------------------------------------------------------------
237
+
238
+ describe("BloomFilter", () => {
239
+ it("has no false negatives", () => {
240
+ const filter = new BloomFilter(100, 0.01);
241
+ const items = ["apple", "banana", "cherry", "date", "elderberry"];
242
+
243
+ for (const item of items) {
244
+ filter.add(item);
245
+ }
246
+
247
+ // All inserted items must be found
248
+ for (const item of items) {
249
+ assert.equal(filter.has(item), true, `Should find "${item}"`);
250
+ }
251
+ });
252
+
253
+ it("has reasonable false positive rate", () => {
254
+ const n = 1000;
255
+ const filter = new BloomFilter(n, 0.01);
256
+
257
+ // Insert n items
258
+ for (let i = 0; i < n; i++) {
259
+ filter.add(`item-${i}`);
260
+ }
261
+
262
+ // Test with items NOT in the set
263
+ let falsePositives = 0;
264
+ const testCount = 10000;
265
+ for (let i = 0; i < testCount; i++) {
266
+ if (filter.has(`not-in-set-${i}`)) {
267
+ falsePositives++;
268
+ }
269
+ }
270
+
271
+ const fpr = falsePositives / testCount;
272
+ // Allow up to 5% FPR (generous margin over theoretical 1%)
273
+ assert.ok(fpr < 0.05, `FPR too high: ${(fpr * 100).toFixed(1)}%`);
274
+ });
275
+
276
+ it("tracks count", () => {
277
+ const filter = new BloomFilter(100);
278
+ assert.equal(filter.count, 0);
279
+ filter.add("a");
280
+ filter.add("b");
281
+ assert.equal(filter.count, 2);
282
+ });
283
+
284
+ it("computes Jaccard similarity for identical filters", () => {
285
+ const a = new BloomFilter(100, 0.01);
286
+ const b = new BloomFilter(100, 0.01);
287
+
288
+ // Same items in both
289
+ for (const item of ["x", "y", "z"]) {
290
+ a.add(item);
291
+ b.add(item);
292
+ }
293
+
294
+ const sim = BloomFilter.jaccardSimilarity(a, b);
295
+ assert.equal(sim, 1, "Identical filters should have Jaccard similarity 1");
296
+ });
297
+
298
+ it("computes low Jaccard similarity for disjoint filters", () => {
299
+ const a = new BloomFilter(100, 0.01);
300
+ const b = new BloomFilter(100, 0.01);
301
+
302
+ a.add("alpha");
303
+ a.add("beta");
304
+ b.add("gamma");
305
+ b.add("delta");
306
+
307
+ const sim = BloomFilter.jaccardSimilarity(a, b);
308
+ assert.ok(sim < 0.5, `Expected low similarity, got ${sim}`);
309
+ });
310
+
311
+ it("rejects comparison of different-sized filters", () => {
312
+ const a = new BloomFilter(100, 0.01);
313
+ const b = new BloomFilter(200, 0.01);
314
+ assert.throws(() => BloomFilter.jaccardSimilarity(a, b));
315
+ });
316
+
317
+ it("ruleToBloomFilter creates a filter from rule content", () => {
318
+ const rule = enforce("eslint/no-console", "Use structured logger.");
319
+ const filter = ruleToBloomFilter(rule);
320
+ assert.ok(filter.count > 0);
321
+ assert.ok(filter.has("console"));
322
+ assert.ok(filter.has("logger"));
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // 4. Fixed-Point Convergence
328
+ // ---------------------------------------------------------------------------
329
+
330
+ describe("fixedPoint", () => {
331
+ it("detects immediate convergence (idempotent function)", () => {
332
+ const result = fixedPoint(
333
+ (content) => content, // identity function — immediate fixed point
334
+ "hello",
335
+ );
336
+ assert.equal(result.converged, true);
337
+ assert.equal(result.iterations, 1);
338
+ });
339
+
340
+ it("detects convergence after mutations", () => {
341
+ let calls = 0;
342
+ const result = fixedPoint((content) => {
343
+ calls++;
344
+ // Converges after 3 iterations
345
+ if (calls < 3) return content + "x";
346
+ return content;
347
+ }, "start");
348
+ assert.equal(result.converged, true);
349
+ assert.equal(result.iterations, 3);
350
+ });
351
+
352
+ it("detects cycles", () => {
353
+ const result = fixedPoint(
354
+ (content) => (content === "A" ? "B" : "A"), // oscillates
355
+ "A",
356
+ 20,
357
+ );
358
+ assert.equal(result.converged, false);
359
+ assert.ok(result.cycleLength > 0, "Should detect a cycle");
360
+ });
361
+
362
+ it("reports divergence when max iterations exceeded", () => {
363
+ let counter = 0;
364
+ const result = fixedPoint(
365
+ () => `unique-${counter++}`, // never repeats
366
+ "start",
367
+ 5,
368
+ );
369
+ assert.equal(result.converged, false);
370
+ assert.equal(result.iterations, 5);
371
+ });
372
+ });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // 5. Merkle History
376
+ // ---------------------------------------------------------------------------
377
+
378
+ describe("MerkleHistory", () => {
379
+ it("starts empty", () => {
380
+ const history = new MerkleHistory();
381
+ assert.equal(history.length, 0);
382
+ assert.equal(history.head(), null);
383
+ });
384
+
385
+ it("appends nodes with correct parent chain", () => {
386
+ const history = new MerkleHistory();
387
+
388
+ const hash1 = history.append(
389
+ "spec-v1",
390
+ { type: "add", ruleIds: ["rule1"], description: "Add rule1" },
391
+ [{ name: "monotonicity", passed: true }],
392
+ );
393
+ assert.ok(hash1.length > 0);
394
+ assert.equal(history.length, 1);
395
+
396
+ const hash2 = history.append(
397
+ "spec-v2",
398
+ { type: "strengthen", ruleIds: ["rule1"], description: "Strengthen" },
399
+ [{ name: "monotonicity", passed: true }],
400
+ );
401
+ assert.ok(hash2 !== hash1);
402
+ assert.equal(history.length, 2);
403
+
404
+ // First node's parent is genesis
405
+ const nodes = history.getNodes();
406
+ assert.equal(nodes[0].parentHash, "genesis");
407
+ assert.equal(nodes[1].parentHash, hash1);
408
+ });
409
+
410
+ it("verifies valid chain", () => {
411
+ const history = new MerkleHistory();
412
+ history.append(
413
+ "v1",
414
+ { type: "add", ruleIds: ["r1"], description: "Add" },
415
+ [],
416
+ );
417
+ history.append(
418
+ "v2",
419
+ { type: "add", ruleIds: ["r2"], description: "Add" },
420
+ [],
421
+ );
422
+
423
+ const result = history.verify();
424
+ assert.equal(result.valid, true);
425
+ assert.equal(result.invalidAt, -1);
426
+ });
427
+
428
+ it("serializes and deserializes", () => {
429
+ const history = new MerkleHistory();
430
+ history.append(
431
+ "v1",
432
+ { type: "add", ruleIds: ["r1"], description: "Genesis" },
433
+ [{ name: "test", passed: true }],
434
+ );
435
+
436
+ const json = history.toJSON();
437
+ const restored = MerkleHistory.fromJSON(json);
438
+
439
+ assert.equal(restored.length, 1);
440
+ assert.equal(restored.verify().valid, true);
441
+ assert.equal(restored.head()?.specHash, "v1");
442
+ });
443
+
444
+ it("returns defensive copies from head()", () => {
445
+ const history = new MerkleHistory();
446
+ history.append(
447
+ "v1",
448
+ { type: "add", ruleIds: ["r1"], description: "Genesis" },
449
+ [{ name: "test", passed: true }],
450
+ );
451
+
452
+ const head1 = history.head();
453
+ assert.ok(head1);
454
+ // Tamper with the returned node
455
+ head1.specHash = "tampered";
456
+ head1.mutation.description = "tampered";
457
+ head1.proofs[0].passed = false;
458
+
459
+ // Internal state is unchanged
460
+ const head2 = history.head();
461
+ assert.equal(head2?.specHash, "v1");
462
+ assert.equal(head2?.mutation.description, "Genesis");
463
+ assert.equal(head2?.proofs[0].passed, true);
464
+ });
465
+
466
+ it("clones append payloads so later caller-side mutation cannot alter stored nodes", () => {
467
+ const history = new MerkleHistory();
468
+ const mutation: Mutation = {
469
+ type: "add",
470
+ ruleIds: ["r1"],
471
+ description: "Original",
472
+ };
473
+ const proofs: ProofReceipt[] = [{ name: "test", passed: true }];
474
+
475
+ history.append("v1", mutation, proofs);
476
+
477
+ // Tamper with the objects the caller passed in. A naive implementation
478
+ // would retroactively alter the stored node because it kept the
479
+ // references.
480
+ mutation.description = "Tampered";
481
+ mutation.ruleIds.push("r2");
482
+ proofs[0].passed = false;
483
+ proofs.push({ name: "injected", passed: false });
484
+
485
+ // Stored history should reflect the state at append time.
486
+ const stored = history.head();
487
+ assert.ok(stored);
488
+ assert.equal(stored.mutation.description, "Original");
489
+ assert.deepEqual(stored.mutation.ruleIds, ["r1"]);
490
+ assert.equal(stored.proofs.length, 1);
491
+ assert.equal(stored.proofs[0].passed, true);
492
+ // Chain must still verify — proof that the stored hash matches the
493
+ // stored payload, not the tampered payload.
494
+ assert.equal(history.verify().valid, true);
495
+ });
496
+ });
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // 6. Property-Based Testing
500
+ // ---------------------------------------------------------------------------
501
+
502
+ describe("propertyTest", () => {
503
+ it("passes when invariant always holds", () => {
504
+ const result = propertyTest(
505
+ 0,
506
+ (n: number, seed: number) => n + (seed % 10), // always increases
507
+ {
508
+ "non-negative": (n: number) => n >= 0,
509
+ },
510
+ { iterations: 50, sequenceLength: 3 },
511
+ );
512
+ assert.equal(result.passed, true);
513
+ });
514
+
515
+ it("detects invariant violation", () => {
516
+ const result = propertyTest(
517
+ 100,
518
+ (n: number, seed: number) => n - (seed % 200), // can go negative
519
+ {
520
+ "non-negative": (n: number) => n >= 0,
521
+ },
522
+ { iterations: 100, seed: 12345 },
523
+ );
524
+ assert.equal(result.passed, false);
525
+ assert.equal(result.failedInvariant, "non-negative");
526
+ assert.ok(result.shrunk !== undefined);
527
+ assert.ok(result.shrunk < 0, "Shrunk value should be negative");
528
+ });
529
+
530
+ it("is deterministic with same seed", () => {
531
+ const run = (seed: number) =>
532
+ propertyTest(
533
+ 0,
534
+ (n: number, s: number) => n + (s % 100) - 50,
535
+ { positive: (n: number) => n >= 0 },
536
+ { iterations: 20, seed },
537
+ );
538
+
539
+ const r1 = run(42);
540
+ const r2 = run(42);
541
+ assert.equal(r1.passed, r2.passed);
542
+ assert.equal(r1.iterations, r2.iterations);
543
+ });
544
+ });
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Fitness function
548
+ // ---------------------------------------------------------------------------
549
+
550
+ const mkSpec = (rules: Record<string, Rule>): ClaudeSpec => ({
551
+ _specType: "claude",
552
+ rules,
553
+ });
554
+
555
+ describe("fitness", () => {
556
+ it("returns 0 for empty spec", () => {
557
+ const result = fitness(mkSpec({}));
558
+ assert.equal(result.score, 0);
559
+ assert.equal(result.coverage, 0);
560
+ });
561
+
562
+ it("scores higher for more enforcement", () => {
563
+ const allGuidance = fitness(
564
+ mkSpec({
565
+ a: guidance("Do X."),
566
+ b: guidance("Do Y."),
567
+ }),
568
+ );
569
+
570
+ const allEnforced = fitness(
571
+ mkSpec({
572
+ a: enforce("eslint/no-console", "Do X."),
573
+ b: enforce("eslint/no-unused-vars", "Do Y."),
574
+ }),
575
+ );
576
+
577
+ assert.ok(
578
+ allEnforced.score > allGuidance.score,
579
+ `Enforced (${allEnforced.score}) should score higher than guidance (${allGuidance.score})`,
580
+ );
581
+ });
582
+
583
+ it("computes coverage correctly", () => {
584
+ const result = fitness(
585
+ mkSpec({
586
+ a: enforce("eslint/no-console", "X"),
587
+ b: guidance("Y"),
588
+ c: enforce("eslint/no-unused-vars", "Z"),
589
+ }),
590
+ );
591
+
592
+ // 2 out of 3 are enforced
593
+ assert.ok(
594
+ Math.abs(result.coverage - 2 / 3) < 0.01,
595
+ `Expected coverage ~0.667, got ${result.coverage}`,
596
+ );
597
+ });
598
+ });
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // Mutation application
602
+ // ---------------------------------------------------------------------------
603
+
604
+ describe("applyMutation", () => {
605
+ it("adds a new rule", () => {
606
+ const rules = makeRules();
607
+ const { rules: next, error } = applyMutation(rules, {
608
+ type: "add",
609
+ ruleId: "new-rule",
610
+ rule: guidance("Be careful."),
611
+ });
612
+
613
+ assert.equal(error, undefined);
614
+ assert.ok("new-rule" in next);
615
+ assert.equal(next["new-rule"]._kind, "guidance");
616
+ });
617
+
618
+ it("rejects adding duplicate rule", () => {
619
+ const rules = makeRules();
620
+ const { error } = applyMutation(rules, {
621
+ type: "add",
622
+ ruleId: "no-console",
623
+ rule: guidance("Duplicate."),
624
+ });
625
+
626
+ assert.ok(error);
627
+ assert.ok(error.reason.includes("already exists"));
628
+ });
629
+
630
+ it("removes a rule", () => {
631
+ const rules = makeRules();
632
+ const { rules: next, error } = applyMutation(rules, {
633
+ type: "remove",
634
+ ruleId: "google-first",
635
+ });
636
+
637
+ assert.equal(error, undefined);
638
+ assert.ok(!("google-first" in next));
639
+ });
640
+
641
+ it("strengthens guidance to enforce", () => {
642
+ const rules = makeRules();
643
+ const { rules: next, error } = applyMutation(rules, {
644
+ type: "strengthen",
645
+ ruleId: "google-first",
646
+ linterRule: "eslint/no-restricted-imports",
647
+ });
648
+
649
+ assert.equal(error, undefined);
650
+ assert.equal(next["google-first"]._kind, "enforce");
651
+ });
652
+
653
+ it("rejects strengthening already-enforce rule", () => {
654
+ const rules = makeRules();
655
+ const { error } = applyMutation(rules, {
656
+ type: "strengthen",
657
+ ruleId: "no-console",
658
+ });
659
+
660
+ assert.ok(error);
661
+ assert.ok(error.reason.includes("maximum strength"));
662
+ });
663
+
664
+ it("weakens enforce to guidance", () => {
665
+ const rules = makeRules();
666
+ const { rules: next, error } = applyMutation(rules, {
667
+ type: "weaken",
668
+ ruleId: "no-console",
669
+ justification: "Too restrictive for dev builds.",
670
+ });
671
+
672
+ assert.equal(error, undefined);
673
+ assert.equal(next["no-console"]._kind, "guidance");
674
+ });
675
+
676
+ it("rewords a rule", () => {
677
+ const rules = makeRules();
678
+ const { rules: next, error } = applyMutation(rules, {
679
+ type: "reword",
680
+ ruleId: "google-first",
681
+ newText: "Always search docs before coding.",
682
+ });
683
+
684
+ assert.equal(error, undefined);
685
+ const rule = next["google-first"];
686
+ assert.equal(rule._kind, "guidance");
687
+ if (rule._kind === "guidance") {
688
+ assert.equal(rule.text, "Always search docs before coding.");
689
+ }
690
+ });
691
+
692
+ it("merges two rules", () => {
693
+ const rules = makeRules();
694
+ const { rules: next, error } = applyMutation(rules, {
695
+ type: "merge",
696
+ sourceIds: ["no-console", "google-first"],
697
+ mergedId: "combined-rule",
698
+ mergedRule: enforce(
699
+ "eslint/no-console",
700
+ "Use logger. Also search docs first.",
701
+ ),
702
+ });
703
+
704
+ assert.equal(error, undefined);
705
+ assert.ok(!("no-console" in next));
706
+ assert.ok(!("google-first" in next));
707
+ assert.ok("combined-rule" in next);
708
+ });
709
+
710
+ it("rejects merge when both source IDs are the same", () => {
711
+ const rules = makeRules();
712
+ const { rules: next, error } = applyMutation(rules, {
713
+ type: "merge",
714
+ sourceIds: ["no-console", "no-console"],
715
+ mergedId: "combined-rule",
716
+ mergedRule: enforce("eslint/no-console", "Use logger."),
717
+ });
718
+
719
+ assert.ok(error !== undefined);
720
+ assert.match(error.reason, /distinct source rules/);
721
+ // Original rules are untouched
722
+ assert.ok("no-console" in next);
723
+ assert.ok(!("combined-rule" in next));
724
+ });
725
+
726
+ it("rejects merge that would overwrite an unrelated mergedId", () => {
727
+ const rules: Record<string, Rule> = {
728
+ "no-console": enforce("eslint/no-console", "No console output."),
729
+ "no-eval": enforce("eslint/no-eval", "Never eval user input."),
730
+ existing: enforce("eslint/no-var", "Unrelated rule."),
731
+ };
732
+ const { rules: next, error } = applyMutation(rules, {
733
+ type: "merge",
734
+ sourceIds: ["no-console", "no-eval"],
735
+ mergedId: "existing", // collides with an unrelated rule
736
+ mergedRule: enforce("eslint/no-console", "Combined."),
737
+ });
738
+
739
+ assert.ok(error !== undefined);
740
+ assert.match(error.reason, /collides with an existing unrelated rule/);
741
+ // Nothing was removed or replaced
742
+ assert.ok("no-console" in next);
743
+ assert.ok("no-eval" in next);
744
+ assert.equal(next.existing._kind, "enforce");
745
+ });
746
+
747
+ it("allows merge to re-use one of the source IDs as mergedId", () => {
748
+ const rules: Record<string, Rule> = {
749
+ "no-console": enforce("eslint/no-console", "No console."),
750
+ "use-logger": guidance("Use the logger."),
751
+ };
752
+ const { rules: next, error } = applyMutation(rules, {
753
+ type: "merge",
754
+ sourceIds: ["no-console", "use-logger"],
755
+ mergedId: "no-console", // rename-in-place
756
+ mergedRule: enforce("eslint/no-console", "Merged."),
757
+ });
758
+
759
+ assert.equal(error, undefined);
760
+ assert.ok("no-console" in next);
761
+ assert.ok(!("use-logger" in next));
762
+ });
763
+
764
+ it("rejects merge that weakens enforcement to guidance", () => {
765
+ const rules: Record<string, Rule> = {
766
+ "no-console": enforce("eslint/no-console", "No console output."),
767
+ "no-eval": enforce("eslint/no-eval", "Never eval user input."),
768
+ };
769
+ const { rules: next, error } = applyMutation(rules, {
770
+ type: "merge",
771
+ sourceIds: ["no-console", "no-eval"],
772
+ mergedId: "general-safety",
773
+ // Both sources are enforce; this guidance merge is a silent downgrade.
774
+ mergedRule: guidance("Avoid dangerous globals."),
775
+ });
776
+
777
+ assert.ok(error !== undefined);
778
+ assert.match(error.reason, /weaker than source rules/);
779
+ assert.ok("no-console" in next);
780
+ assert.ok("no-eval" in next);
781
+ assert.ok(!("general-safety" in next));
782
+ });
783
+
784
+ it("does not share rule references with the caller's mutation object", () => {
785
+ const rule = enforce("eslint/no-eval", "Original reason.");
786
+ const { rules: next } = applyMutation(
787
+ {},
788
+ {
789
+ type: "add",
790
+ ruleId: "new-rule",
791
+ rule,
792
+ },
793
+ );
794
+
795
+ // Mutate the caller's rule after the fact — engine state must be
796
+ // unaffected because add should have cloned it.
797
+ (rule as { why: string }).why = "Tampered reason.";
798
+
799
+ const stored = next["new-rule"];
800
+ assert.ok(stored._kind === "enforce");
801
+ assert.equal(stored.why, "Original reason.");
802
+ });
803
+ });
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Proof Suite
807
+ // ---------------------------------------------------------------------------
808
+
809
+ describe("runProofSuite", () => {
810
+ it("passes for valid strengthening mutation", () => {
811
+ const before: Record<string, Rule> = {
812
+ rule1: guidance("Do X."),
813
+ };
814
+ const after: Record<string, Rule> = {
815
+ rule1: enforce("eslint/no-console", "Do X."),
816
+ };
817
+
818
+ const result = runProofSuite(before, after);
819
+ assert.equal(result.passed, true);
820
+ assert.ok(result.receipts.every((r) => r.passed));
821
+ });
822
+
823
+ it("fails for weakening without allowWeaken", () => {
824
+ const before: Record<string, Rule> = {
825
+ rule1: enforce("eslint/no-console", "Do X."),
826
+ };
827
+ const after: Record<string, Rule> = {
828
+ rule1: guidance("Do X."),
829
+ };
830
+
831
+ const result = runProofSuite(before, after);
832
+ assert.equal(result.passed, false);
833
+ const mono = result.receipts.find((r) => r.name === "monotonicity");
834
+ assert.ok(mono);
835
+ assert.equal(mono.passed, false);
836
+ });
837
+
838
+ it("ignores pre-existing NCD duplicates when grading a fresh mutation", () => {
839
+ // before already has two near-duplicate rules — the change being
840
+ // proposed is unrelated. A naive `findSimilarRules(after)` would
841
+ // flag the baseline pair and fail the proof, blocking every
842
+ // unrelated mutation in a repo with historical duplication.
843
+ const dup1 =
844
+ "Always use the structured logger instead of console.log for output.";
845
+ const dup2 =
846
+ "Use the structured logger module instead of console.log for output.";
847
+ const before: Record<string, Rule> = {
848
+ "use-logger-a": guidance(dup1),
849
+ "use-logger-b": guidance(dup2),
850
+ };
851
+ const after: Record<string, Rule> = {
852
+ ...before,
853
+ // Add a completely unrelated rule.
854
+ "compose-over-inherit": guidance(
855
+ "Prefer composition over inheritance in class hierarchies.",
856
+ ),
857
+ };
858
+
859
+ const result = runProofSuite(before, after);
860
+ const ncd = result.receipts.find((r) => r.name === "ncd-dedup");
861
+ assert.ok(ncd);
862
+ assert.equal(
863
+ ncd.passed,
864
+ true,
865
+ `ncd-dedup should ignore pre-existing duplicates; got: ${ncd.detail ?? ""}`,
866
+ );
867
+ });
868
+
869
+ it("ignores bloom overlap against rules removed by the mutation", () => {
870
+ // A merge removes two source rules and adds a merged rule whose
871
+ // tokens overlap heavily with the sources (by construction). If
872
+ // the bloom baseline still contains the removed sources, the
873
+ // merged rule would collide against its own sources and the
874
+ // merge would be rejected.
875
+ const before: Record<string, Rule> = {
876
+ "no-console": enforce("eslint/no-console", "Use structured logger."),
877
+ "use-logger": guidance(
878
+ "Always route application output through the structured logger.",
879
+ ),
880
+ };
881
+ const after: Record<string, Rule> = {
882
+ "logger-policy": enforce(
883
+ "eslint/no-console",
884
+ "Use structured logger for all application output.",
885
+ ),
886
+ };
887
+
888
+ // Allow the two source removals so monotonicity passes.
889
+ const result = runProofSuite(before, after, {
890
+ allowWeaken: new Set(["no-console", "use-logger"]),
891
+ });
892
+ const bloom = result.receipts.find((r) => r.name === "bloom-overlap");
893
+ assert.ok(bloom);
894
+ assert.equal(
895
+ bloom.passed,
896
+ true,
897
+ `bloom-overlap should skip removed rules; got: ${bloom.detail ?? ""}`,
898
+ );
899
+ });
900
+
901
+ it("returns a structured failure receipt when fitness throws on bad data", () => {
902
+ const before: Record<string, Rule> = {
903
+ rule1: guidance("Do X."),
904
+ };
905
+ // Inject a legacy rule shape that breaks ruleToText/findSimilarRules
906
+ const after = {
907
+ rule1: guidance("Do X."),
908
+ legacy: { _kind: "check", text: "stale" },
909
+ } as unknown as Record<string, Rule>;
910
+
911
+ // Must not throw — should surface as a failed proof receipt instead
912
+ const result = runProofSuite(before, after);
913
+ assert.equal(result.passed, false);
914
+ const failedReceipts = result.receipts.filter((r) => !r.passed);
915
+ assert.ok(
916
+ failedReceipts.some((r) => /Unknown rule kind/.test(r.detail ?? "")),
917
+ `Expected at least one receipt to mention the unknown kind; got: ${JSON.stringify(result.receipts)}`,
918
+ );
919
+ // Fitness must still be a well-formed object (neutral fallback)
920
+ assert.equal(typeof result.fitness.score, "number");
921
+ });
922
+ });
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Evolution Engine
926
+ // ---------------------------------------------------------------------------
927
+
928
+ describe("EvolutionEngine", () => {
929
+ it("accepts a valid add mutation", () => {
930
+ const engine = new EvolutionEngine(
931
+ { rule1: guidance("Existing.") },
932
+ { acceptNeutral: true },
933
+ );
934
+
935
+ const result = engine.propose({
936
+ type: "add",
937
+ ruleId: "rule2",
938
+ rule: enforce("eslint/no-console", "New enforced rule."),
939
+ });
940
+
941
+ assert.equal(result.accepted, true);
942
+ assert.ok("rule2" in engine.getRules());
943
+ assert.ok(result.historyHash);
944
+ });
945
+
946
+ it("accepts a valid merge mutation without pre-allowlisting sources", () => {
947
+ const engine = new EvolutionEngine(
948
+ {
949
+ "rule-a": enforce(
950
+ "eslint/no-console",
951
+ "Never use console for application output.",
952
+ ),
953
+ "rule-b": guidance(
954
+ "Route application output through the structured logger module.",
955
+ ),
956
+ },
957
+ { acceptNeutral: true },
958
+ );
959
+
960
+ // A merge removes both sources by design — the engine should add those
961
+ // source IDs to a per-call allowWeaken set so monotonicity does not
962
+ // reject the removal.
963
+ const result = engine.propose({
964
+ type: "merge",
965
+ sourceIds: ["rule-a", "rule-b"],
966
+ mergedId: "rule-ab",
967
+ mergedRule: enforce(
968
+ "eslint/no-console",
969
+ "Never use console — route output through the structured logger.",
970
+ ),
971
+ });
972
+
973
+ assert.equal(
974
+ result.accepted,
975
+ true,
976
+ `Merge should pass proofs without a pre-seeded allowWeaken; got: ${result.error ?? "(no error)"}`,
977
+ );
978
+ const rules = engine.getRules();
979
+ assert.ok("rule-ab" in rules);
980
+ assert.ok(!("rule-a" in rules));
981
+ assert.ok(!("rule-b" in rules));
982
+ });
983
+
984
+ it("isolates Merkle history receipts from the returned proof result", () => {
985
+ const engine = new EvolutionEngine(
986
+ { rule1: guidance("Existing.") },
987
+ { acceptNeutral: true },
988
+ );
989
+
990
+ const result = engine.propose({
991
+ type: "add",
992
+ ruleId: "rule2",
993
+ rule: enforce("eslint/no-console", "New enforced rule."),
994
+ });
995
+
996
+ assert.equal(result.accepted, true);
997
+ const historyBefore = engine.getHistory().getNodes();
998
+ const receiptsBefore = historyBefore[historyBefore.length - 1].proofs.map(
999
+ (r) => ({ ...r }),
1000
+ );
1001
+
1002
+ // Tamper with the returned proof receipts — the Merkle-recorded
1003
+ // receipts must not change, because propose() should defensively
1004
+ // copy before writing to history.
1005
+ for (const r of result.proofs.receipts) {
1006
+ r.passed = false;
1007
+ r.detail = "tampered";
1008
+ }
1009
+
1010
+ const historyAfter = engine.getHistory().getNodes();
1011
+ const storedReceipts = historyAfter[historyAfter.length - 1].proofs;
1012
+ assert.deepEqual(storedReceipts, receiptsBefore);
1013
+ });
1014
+
1015
+ it("acceptNeutral still rejects mutations that strictly decrease fitness", () => {
1016
+ // acceptNeutral means "accept mutations with equal score", not
1017
+ // "accept any mutation". Previously, setting acceptNeutral
1018
+ // short-circuited the fitness check entirely, so this test would
1019
+ // have incorrectly accepted.
1020
+ const engine = new EvolutionEngine(
1021
+ { rule1: enforce("eslint/no-console", "Important structured log.") },
1022
+ { acceptNeutral: true },
1023
+ );
1024
+
1025
+ // Adding a guidance rule drops coverage from 1/1 to 1/2, a strict
1026
+ // regression. Must be rejected even with acceptNeutral: true.
1027
+ const result = engine.propose({
1028
+ type: "add",
1029
+ ruleId: "rule2",
1030
+ rule: guidance("Just some advice."),
1031
+ });
1032
+
1033
+ assert.equal(result.accepted, false);
1034
+ assert.ok(
1035
+ result.error?.includes("Fitness decreased"),
1036
+ `Expected fitness decrease error, got: ${result.error ?? "(none)"}`,
1037
+ );
1038
+ });
1039
+
1040
+ it("clones allowWeaken on construction so later caller mutations cannot alter policy", () => {
1041
+ const allow = new Set<string>(); // initially empty
1042
+ const engine = new EvolutionEngine(
1043
+ { rule1: enforce("eslint/no-console", "Keep it.") },
1044
+ { allowWeaken: allow, acceptNeutral: true },
1045
+ );
1046
+
1047
+ // Mutate the caller's set AFTER construction — a naive reference-store
1048
+ // would let this add rule1 to the engine's allowWeaken and let the
1049
+ // next weaken mutation pass.
1050
+ allow.add("rule1");
1051
+
1052
+ const result = engine.propose({
1053
+ type: "weaken",
1054
+ ruleId: "rule1",
1055
+ justification: "Trying to sneak through.",
1056
+ });
1057
+ assert.equal(
1058
+ result.accepted,
1059
+ false,
1060
+ "Engine must not see caller-side mutations to allowWeaken after construction",
1061
+ );
1062
+ });
1063
+
1064
+ it("getRules returns a deep defensive copy that does not alter engine state", () => {
1065
+ const engine = new EvolutionEngine({
1066
+ rule1: enforce("eslint/no-console", "Original reason."),
1067
+ });
1068
+
1069
+ const snapshot = engine.getRules();
1070
+ // Tamper with the returned rule
1071
+ const r = snapshot.rule1;
1072
+ assert.equal(r._kind, "enforce");
1073
+ if (r._kind === "enforce") {
1074
+ (r as { why: string }).why = "Tampered reason.";
1075
+ }
1076
+
1077
+ // Engine state must be unchanged
1078
+ const fresh = engine.getRules();
1079
+ const f = fresh.rule1;
1080
+ assert.equal(f._kind, "enforce");
1081
+ if (f._kind === "enforce") {
1082
+ assert.equal(f.why, "Original reason.");
1083
+ }
1084
+ });
1085
+
1086
+ it("rejects construction with a tampered supplied history", () => {
1087
+ const history = new MerkleHistory();
1088
+ history.append(
1089
+ "v1",
1090
+ { type: "add", ruleIds: ["rule1"], description: "Genesis" },
1091
+ [{ name: "genesis", passed: true }],
1092
+ );
1093
+
1094
+ // Corrupt the serialized form, then rehydrate
1095
+ const serialized = history.toJSON();
1096
+ const parsed = JSON.parse(serialized) as { specHash: string }[];
1097
+ parsed[0].specHash = "tampered";
1098
+ const corrupted = MerkleHistory.fromJSON(JSON.stringify(parsed));
1099
+
1100
+ assert.throws(
1101
+ () =>
1102
+ new EvolutionEngine(
1103
+ { rule1: guidance("Existing.") },
1104
+ { history: corrupted },
1105
+ ),
1106
+ /invalid at node/,
1107
+ );
1108
+ });
1109
+
1110
+ it("rejects construction when supplied history head does not match rules", () => {
1111
+ // Build a history whose head corresponds to rule set A
1112
+ const rulesA = { rule1: guidance("Version A.") };
1113
+ const engineA = new EvolutionEngine(rulesA);
1114
+ const historyA = engineA.getHistory();
1115
+
1116
+ // Try to construct a new engine with rule set B but history A
1117
+ const rulesB = { rule1: guidance("Version B — different.") };
1118
+ // The ReadonlyMerkleHistory returned by getHistory is the same underlying
1119
+ // instance; cast back to MerkleHistory for the test
1120
+ const historyInstance = historyA as unknown as MerkleHistory;
1121
+ assert.throws(
1122
+ () => new EvolutionEngine(rulesB, { history: historyInstance }),
1123
+ /head does not match initialRules/,
1124
+ );
1125
+ });
1126
+
1127
+ it("snapshots supplied history so caller cannot append post-construction", () => {
1128
+ // Build a history, pass it to the engine, then append to the
1129
+ // caller's reference — the engine must not see the new node.
1130
+ const rules: Record<string, Rule> = {
1131
+ rule1: guidance("Existing."),
1132
+ };
1133
+ const sourceHistory = new MerkleHistory();
1134
+ const specHash = "a" + "0".repeat(63); // placeholder; real hash unused for this test
1135
+ // We need the head to match, so let the engine build genesis
1136
+ // itself — pass an empty history.
1137
+ const engine = new EvolutionEngine(rules, { history: sourceHistory });
1138
+
1139
+ // Sanity: engine's history has exactly the genesis node.
1140
+ const before = engine.getHistory();
1141
+ assert.equal(before.length, 1);
1142
+
1143
+ // Inject a forged entry into the caller's original reference.
1144
+ sourceHistory.append(
1145
+ specHash,
1146
+ { type: "remove", ruleIds: ["rule1"], description: "Forged" },
1147
+ [{ name: "forged", passed: false }],
1148
+ );
1149
+
1150
+ // The engine must not see it — its history is a snapshot.
1151
+ const after = engine.getHistory();
1152
+ assert.equal(after.length, 1);
1153
+ assert.notEqual(after.head()?.mutation.description, "Forged");
1154
+ });
1155
+
1156
+ it("accepts supplied history when rule keys are reordered", () => {
1157
+ // Build an engine + history with rules in one insertion order.
1158
+ const rulesOrderA: Record<string, Rule> = {
1159
+ first: enforce("eslint/no-console", "No console."),
1160
+ second: guidance("Use the logger."),
1161
+ };
1162
+ const engineA = new EvolutionEngine(rulesOrderA);
1163
+ const history = engineA.getHistory() as unknown as MerkleHistory;
1164
+
1165
+ // Rebuild the same rules in a different insertion order. Without a
1166
+ // canonical hash, JSON.stringify would produce a different string and
1167
+ // the constructor would falsely throw "head does not match".
1168
+ const rulesOrderB: Record<string, Rule> = {
1169
+ second: guidance("Use the logger."),
1170
+ first: enforce("eslint/no-console", "No console."),
1171
+ };
1172
+ assert.doesNotThrow(
1173
+ () => new EvolutionEngine(rulesOrderB, { history }),
1174
+ "Canonical hashing should tolerate key-order differences",
1175
+ );
1176
+ });
1177
+
1178
+ it("returns a structured rejection when baseline fitness throws", () => {
1179
+ // Manually build an engine with bad rules, bypassing the normal
1180
+ // validation path. This simulates a legacy compiled spec or JS caller
1181
+ // sneaking a _kind:"check" object into engine state.
1182
+ const engine = new EvolutionEngine({
1183
+ rule1: guidance("Real rule."),
1184
+ });
1185
+ // Tamper with internal state via cast
1186
+ (engine as unknown as { rules: Record<string, Rule> }).rules = {
1187
+ legacy: { _kind: "check", text: "stale" } as unknown as Rule,
1188
+ };
1189
+
1190
+ const result = engine.propose({
1191
+ type: "add",
1192
+ ruleId: "rule2",
1193
+ rule: guidance("New."),
1194
+ });
1195
+ assert.equal(result.accepted, false);
1196
+ assert.ok(
1197
+ /Baseline fitness failed/.test(result.error ?? ""),
1198
+ `Expected structured rejection; got: ${result.error ?? "(none)"}`,
1199
+ );
1200
+ });
1201
+
1202
+ it("rejects weakening mutation", () => {
1203
+ const engine = new EvolutionEngine({
1204
+ rule1: enforce("eslint/no-console", "Important."),
1205
+ });
1206
+
1207
+ const result = engine.propose({
1208
+ type: "weaken",
1209
+ ruleId: "rule1",
1210
+ justification: "Not needed.",
1211
+ });
1212
+
1213
+ assert.equal(result.accepted, false);
1214
+ // Original rule unchanged
1215
+ assert.equal(engine.getRules()["rule1"]._kind, "enforce");
1216
+ });
1217
+
1218
+ it("accepts strengthening mutation", () => {
1219
+ const engine = new EvolutionEngine(
1220
+ { rule1: guidance("Do X.") },
1221
+ { acceptNeutral: true },
1222
+ );
1223
+
1224
+ const result = engine.propose({
1225
+ type: "strengthen",
1226
+ ruleId: "rule1",
1227
+ linterRule: "eslint/no-console",
1228
+ });
1229
+
1230
+ assert.equal(result.accepted, true);
1231
+ assert.equal(engine.getRules()["rule1"]._kind, "enforce");
1232
+ assert.ok(result.afterFitness.coverage > result.beforeFitness.coverage);
1233
+ });
1234
+
1235
+ it("maintains Merkle history across mutations", () => {
1236
+ const engine = new EvolutionEngine(
1237
+ { rule1: guidance("Always validate user input before processing.") },
1238
+ { acceptNeutral: true },
1239
+ );
1240
+
1241
+ engine.propose({
1242
+ type: "add",
1243
+ ruleId: "rule2",
1244
+ rule: enforce(
1245
+ "eslint/no-console",
1246
+ "Use structured logger for all output.",
1247
+ ),
1248
+ });
1249
+ engine.propose({
1250
+ type: "strengthen",
1251
+ ruleId: "rule1",
1252
+ linterRule: "eslint/no-eval",
1253
+ });
1254
+
1255
+ const history = engine.getHistory();
1256
+ // Genesis + 2 mutations = 3 nodes
1257
+ assert.equal(history.length, 3);
1258
+ assert.equal(history.verify().valid, true);
1259
+ });
1260
+
1261
+ it("proposeAll stops on first rejection by default", () => {
1262
+ const engine = new EvolutionEngine(
1263
+ { rule1: enforce("eslint/no-console", "Important.") },
1264
+ { acceptNeutral: true },
1265
+ );
1266
+
1267
+ const results = engine.proposeAll([
1268
+ { type: "weaken", ruleId: "rule1", justification: "test" }, // rejected
1269
+ {
1270
+ type: "add",
1271
+ ruleId: "rule2",
1272
+ rule: guidance("Would succeed."),
1273
+ }, // never reached
1274
+ ]);
1275
+
1276
+ assert.equal(results.length, 1);
1277
+ assert.equal(results[0].accepted, false);
1278
+ });
1279
+
1280
+ it("proposeAll continues on reject when configured", () => {
1281
+ // Start with mixed rules so the second mutation (strengthen) actually
1282
+ // improves fitness, which is required now that acceptNeutral correctly
1283
+ // rejects regressions instead of short-circuiting.
1284
+ const engine = new EvolutionEngine(
1285
+ {
1286
+ rule1: guidance("Prefer structured logging over print statements."),
1287
+ rule2: enforce("eslint/no-eval", "Never eval user input for security."),
1288
+ },
1289
+ { acceptNeutral: true },
1290
+ );
1291
+
1292
+ const results = engine.proposeAll(
1293
+ [
1294
+ // rejected: weakening rule2 fails monotonicity
1295
+ { type: "weaken", ruleId: "rule2", justification: "test" },
1296
+ // accepted: strengthening rule1 improves coverage 50% → 100%
1297
+ {
1298
+ type: "strengthen",
1299
+ ruleId: "rule1",
1300
+ linterRule: "eslint/no-console",
1301
+ },
1302
+ ],
1303
+ { continueOnReject: true },
1304
+ );
1305
+
1306
+ assert.equal(results.length, 2);
1307
+ assert.equal(results[0].accepted, false);
1308
+ assert.equal(
1309
+ results[1].accepted,
1310
+ true,
1311
+ `Strengthen should improve coverage and pass fitness; error: ${results[1].error ?? "(none)"}`,
1312
+ );
1313
+ });
1314
+ });