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.
- package/.claude/settings.json +46 -0
- package/.claude/settings.local.json +8 -0
- package/.claude-plugin/hooks/post-edit.sh +34 -0
- package/.claude-plugin/hooks/pre-edit.sh +40 -0
- package/.claude-plugin/hooks/session-start.sh +38 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +47 -0
- package/.github/workflows/ci.yml +81 -0
- package/.prettierignore +1 -0
- package/.vigiles/generated.d.ts +205 -0
- package/CLAUDE.md +95 -0
- package/CLAUDE.md.spec.ts +142 -0
- package/CONTRIBUTING.md +121 -0
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/action.yml +25 -0
- package/dist/action.d.ts +7 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +180 -0
- package/dist/action.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1267 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +650 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/compile.d.ts +101 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +503 -0
- package/dist/compile.js.map +1 -0
- package/dist/evolve.d.ts +132 -0
- package/dist/evolve.d.ts.map +1 -0
- package/dist/evolve.js +599 -0
- package/dist/evolve.js.map +1 -0
- package/dist/freshness.d.ts +67 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +244 -0
- package/dist/freshness.js.map +1 -0
- package/dist/freshness.test.d.ts +2 -0
- package/dist/freshness.test.d.ts.map +1 -0
- package/dist/freshness.test.js +356 -0
- package/dist/freshness.test.js.map +1 -0
- package/dist/generate-types.d.ts +34 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +381 -0
- package/dist/generate-types.js.map +1 -0
- package/dist/inline.d.ts +58 -0
- package/dist/inline.d.ts.map +1 -0
- package/dist/inline.js +142 -0
- package/dist/inline.js.map +1 -0
- package/dist/inline.test.d.ts +5 -0
- package/dist/inline.test.d.ts.map +1 -0
- package/dist/inline.test.js +152 -0
- package/dist/inline.test.js.map +1 -0
- package/dist/linters.d.ts +38 -0
- package/dist/linters.d.ts.map +1 -0
- package/dist/linters.js +588 -0
- package/dist/linters.js.map +1 -0
- package/dist/proofs.d.ts +272 -0
- package/dist/proofs.d.ts.map +1 -0
- package/dist/proofs.js +622 -0
- package/dist/proofs.js.map +1 -0
- package/dist/proofs.test.d.ts +9 -0
- package/dist/proofs.test.d.ts.map +1 -0
- package/dist/proofs.test.js +952 -0
- package/dist/proofs.test.js.map +1 -0
- package/dist/spec.d.ts +258 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +113 -0
- package/dist/spec.js.map +1 -0
- package/dist/spec.test.d.ts +2 -0
- package/dist/spec.test.d.ts.map +1 -0
- package/dist/spec.test.js +1222 -0
- package/dist/spec.test.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +286 -0
- package/dist/validate.js.map +1 -0
- package/dist/validate.test.d.ts +2 -0
- package/dist/validate.test.d.ts.map +1 -0
- package/dist/validate.test.js +531 -0
- package/dist/validate.test.js.map +1 -0
- package/docs/agent-setup.md +85 -0
- package/docs/agent-workflows.md +103 -0
- package/docs/comparison.md +71 -0
- package/docs/freshness.md +124 -0
- package/docs/inline-mode.md +119 -0
- package/docs/linter-support.md +166 -0
- package/docs/spec-format.md +194 -0
- package/eslint.config.mjs +79 -0
- package/examples/CLAUDE.md +54 -0
- package/examples/CLAUDE.md.spec.ts +65 -0
- package/examples/SKILL.md +50 -0
- package/examples/SKILL.md.spec.ts +57 -0
- package/fixtures/example-project/CLAUDE.md +11 -0
- package/fixtures/example-project/package.json +9 -0
- package/fixtures/example-project/src/index.ts +3 -0
- package/fixtures/example-project/src/utils.test.ts +2 -0
- package/fixtures/example-project/src/utils.ts +3 -0
- package/logo.png +0 -0
- package/package.json +42 -0
- package/research/adoption-strategy.md +111 -0
- package/research/agent-integration.md +145 -0
- package/research/ai-code-quality.md +197 -0
- package/research/code-search-for-agents.md +313 -0
- package/research/competitive-landscape.md +163 -0
- package/research/doc-freshness.md +516 -0
- package/research/executable-specs.md +368 -0
- package/research/feature-ideas.md +464 -0
- package/research/formal-proofs-for-agents.md +338 -0
- package/research/fp-for-agent-harness.md +150 -0
- package/research/fp-for-deterministic-ai.md +131 -0
- package/research/self-evolving-specs.md +298 -0
- package/schemas/claude-md-strict.yml +18 -0
- package/schemas/claude-md.yml +6 -0
- package/schemas/skill-strict.yml +12 -0
- package/schemas/skill.yml +5 -0
- package/skills/audit-feedback-loop/SKILL.md +76 -0
- package/skills/edit-spec/SKILL.md +131 -0
- package/skills/enforce-rules-format/SKILL.md +71 -0
- package/skills/generate-logo/SKILL.md +102 -0
- package/skills/generate-rule/SKILL.md +90 -0
- package/skills/linter-docs/clippy.md +241 -0
- package/skills/linter-docs/eslint.md +384 -0
- package/skills/linter-docs/pylint.md +288 -0
- package/skills/linter-docs/rubocop.md +277 -0
- package/skills/linter-docs/ruff.md +187 -0
- package/skills/linter-docs/stylelint.md +247 -0
- package/skills/migrate-to-spec/SKILL.md +124 -0
- package/skills/pr-to-lint-rule/SKILL.md +97 -0
- package/skills/strengthen/SKILL.md +168 -0
- package/src/action.ts +214 -0
- package/src/cli.test.ts +914 -0
- package/src/cli.ts +1631 -0
- package/src/compile.ts +691 -0
- package/src/evolve.ts +781 -0
- package/src/freshness.test.ts +449 -0
- package/src/freshness.ts +299 -0
- package/src/generate-types.ts +448 -0
- package/src/inline.test.ts +206 -0
- package/src/inline.ts +164 -0
- package/src/linters.ts +739 -0
- package/src/proofs.test.ts +1314 -0
- package/src/proofs.ts +849 -0
- package/src/spec.test.ts +1471 -0
- package/src/spec.ts +427 -0
- package/src/types.ts +117 -0
- package/src/validate.test.ts +701 -0
- package/src/validate.ts +381 -0
- 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
|
+
});
|