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
package/src/proofs.ts ADDED
@@ -0,0 +1,849 @@
1
+ /**
2
+ * vigiles — Deterministic proof system for self-evolving specifications.
3
+ *
4
+ * Six algorithms that verify spec mutations without any LLM dependency:
5
+ *
6
+ * 1. MonotonicityLattice — rules can only strengthen over time
7
+ * 2. ncd() — information-theoretic duplicate detection
8
+ * 3. BloomFilter — fast approximate set membership
9
+ * 4. fixedPoint() — compilation convergence detection
10
+ * 5. MerkleHistory — tamper-evident spec evolution audit trail
11
+ * 6. propertyTest() — random mutation + invariant checking
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { gzipSync } from "node:zlib";
16
+
17
+ import type { Rule, ClaudeSpec } from "./spec.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // 1. Monotonicity Lattice — partial order on rule strength
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Ordinal strength of each rule kind.
25
+ *
26
+ * guidance (0) < enforce (1)
27
+ *
28
+ * The lattice ensures specs only get stricter over time.
29
+ */
30
+ const STRENGTH: Record<Rule["_kind"], number> = {
31
+ guidance: 0,
32
+ enforce: 1,
33
+ };
34
+
35
+ export interface MonotonicityViolation {
36
+ ruleId: string;
37
+ from: Rule["_kind"];
38
+ to: Rule["_kind"];
39
+ fromStrength: number;
40
+ toStrength: number;
41
+ }
42
+
43
+ export interface MonotonicityResult {
44
+ valid: boolean;
45
+ violations: MonotonicityViolation[];
46
+ added: string[];
47
+ removed: string[];
48
+ strengthened: string[];
49
+ unchanged: string[];
50
+ }
51
+
52
+ /**
53
+ * Check that a spec mutation is monotonic: existing rules only strengthen.
54
+ *
55
+ * - Adding rules: always allowed
56
+ * - Removing rules: flagged (informational, not a violation by default)
57
+ * - Strengthening (guidance → check → enforce): allowed
58
+ * - Weakening (enforce → guidance): violation unless allowWeaken includes the rule ID
59
+ */
60
+ export function checkMonotonicity(
61
+ before: Record<string, Rule>,
62
+ after: Record<string, Rule>,
63
+ options: { allowWeaken?: Set<string> } = {},
64
+ ): MonotonicityResult {
65
+ const violations: MonotonicityViolation[] = [];
66
+ const added: string[] = [];
67
+ const removed: string[] = [];
68
+ const strengthened: string[] = [];
69
+ const unchanged: string[] = [];
70
+
71
+ const allowWeaken = options.allowWeaken ?? new Set<string>();
72
+
73
+ // Check rules present in both versions
74
+ for (const [id, beforeRule] of Object.entries(before)) {
75
+ const afterRule = after[id];
76
+ if (!afterRule) {
77
+ removed.push(id);
78
+ // Removal is a monotonicity violation unless explicitly allowlisted.
79
+ // Without this, a bare `remove` mutation could pass the proof suite
80
+ // on neutral fitness and silently delete constraints, breaking the
81
+ // "rules only strengthen over time" invariant.
82
+ if (!allowWeaken.has(id)) {
83
+ violations.push({
84
+ ruleId: id,
85
+ from: beforeRule._kind,
86
+ to: "guidance", // nominal floor — rule is gone entirely
87
+ fromStrength: STRENGTH[beforeRule._kind],
88
+ toStrength: -1,
89
+ });
90
+ }
91
+ continue;
92
+ }
93
+
94
+ const beforeStrength = STRENGTH[beforeRule._kind];
95
+ const afterStrength = STRENGTH[afterRule._kind];
96
+
97
+ if (afterStrength < beforeStrength && !allowWeaken.has(id)) {
98
+ violations.push({
99
+ ruleId: id,
100
+ from: beforeRule._kind,
101
+ to: afterRule._kind,
102
+ fromStrength: beforeStrength,
103
+ toStrength: afterStrength,
104
+ });
105
+ } else if (afterStrength > beforeStrength) {
106
+ strengthened.push(id);
107
+ } else {
108
+ unchanged.push(id);
109
+ }
110
+ }
111
+
112
+ // New rules
113
+ for (const id of Object.keys(after)) {
114
+ if (!(id in before)) {
115
+ added.push(id);
116
+ }
117
+ }
118
+
119
+ return {
120
+ valid: violations.length === 0,
121
+ violations,
122
+ added,
123
+ removed,
124
+ strengthened,
125
+ unchanged,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Compute the join (least upper bound) of two rule kinds in the lattice.
131
+ * join(guidance, enforce) = enforce
132
+ */
133
+ export function latticeJoin(a: Rule["_kind"], b: Rule["_kind"]): Rule["_kind"] {
134
+ return STRENGTH[a] >= STRENGTH[b] ? a : b;
135
+ }
136
+
137
+ /**
138
+ * Compute the meet (greatest lower bound) of two rule kinds in the lattice.
139
+ * meet(guidance, enforce) = guidance
140
+ */
141
+ export function latticeMeet(a: Rule["_kind"], b: Rule["_kind"]): Rule["_kind"] {
142
+ return STRENGTH[a] <= STRENGTH[b] ? a : b;
143
+ }
144
+
145
+ /** Get the numeric strength of a rule kind. */
146
+ export function ruleStrength(kind: Rule["_kind"]): number {
147
+ return STRENGTH[kind];
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // 2. Normalized Compression Distance (NCD)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Compute the compressed size of a string using gzip.
156
+ * This approximates Kolmogorov complexity — the length of the shortest
157
+ * program that produces the string.
158
+ */
159
+ function compressedSize(s: string): number {
160
+ return gzipSync(Buffer.from(s, "utf-8"), { level: 9 }).length;
161
+ }
162
+
163
+ /**
164
+ * Normalized Compression Distance — information-theoretic similarity.
165
+ *
166
+ * NCD(x, y) = (C(xy) - min(C(x), C(y))) / max(C(x), C(y))
167
+ *
168
+ * Range: [0, 1+ε] where 0 = identical information content.
169
+ * Deterministic. No model dependency. Approximates the universal distance metric.
170
+ *
171
+ * Reference: Li, Chen, Li, Ma, Vitányi (2004) "The Similarity Metric"
172
+ */
173
+ export function ncd(a: string, b: string): number {
174
+ if (a === b) return 0;
175
+ if (a.length === 0 && b.length === 0) return 0;
176
+
177
+ const ca = compressedSize(a);
178
+ const cb = compressedSize(b);
179
+ const cab = compressedSize(a + b);
180
+
181
+ const minC = Math.min(ca, cb);
182
+ const maxC = Math.max(ca, cb);
183
+
184
+ if (maxC === 0) return 0;
185
+ return (cab - minC) / maxC;
186
+ }
187
+
188
+ export interface NCDPair {
189
+ idA: string;
190
+ idB: string;
191
+ distance: number;
192
+ }
193
+
194
+ /**
195
+ * Find all rule pairs with NCD below a similarity threshold.
196
+ * Returns pairs sorted by distance (most similar first).
197
+ */
198
+ export function findSimilarRules(
199
+ rules: Record<string, Rule>,
200
+ threshold: number = 0.5,
201
+ ): NCDPair[] {
202
+ const entries = Object.entries(rules);
203
+ const pairs: NCDPair[] = [];
204
+
205
+ for (let i = 0; i < entries.length; i++) {
206
+ for (let j = i + 1; j < entries.length; j++) {
207
+ const [idA, ruleA] = entries[i];
208
+ const [idB, ruleB] = entries[j];
209
+
210
+ const textA = ruleToText(ruleA);
211
+ const textB = ruleToText(ruleB);
212
+
213
+ const d = ncd(textA, textB);
214
+ if (d < threshold) {
215
+ pairs.push({ idA, idB, distance: d });
216
+ }
217
+ }
218
+ }
219
+
220
+ return pairs.sort((a, b) => a.distance - b.distance);
221
+ }
222
+
223
+ /**
224
+ * Extract the text content of a rule for NCD comparison.
225
+ *
226
+ * Throws a structured error on unknown rule kinds so that the caller (e.g.
227
+ * runProofSuite) can surface a clear proof failure rather than letting an
228
+ * `undefined` propagate into compressedSize and crash the audit.
229
+ */
230
+ function ruleToText(rule: Rule): string {
231
+ switch (rule._kind) {
232
+ case "enforce":
233
+ return `${rule.linterRule} ${rule.why}`;
234
+ case "guidance":
235
+ return rule.text;
236
+ default: {
237
+ const unknown = (rule as { _kind?: unknown })._kind;
238
+ throw new Error(
239
+ `Unknown rule kind "${String(unknown)}" — expected "enforce" or "guidance". ` +
240
+ `Runtime data is out of sync with the Rule type (legacy spec, JS caller, or cast bypass).`,
241
+ );
242
+ }
243
+ }
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // 3. Bloom Filter — probabilistic set membership
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * FNV-1a hash — fast, good distribution, deterministic.
252
+ * Returns a 32-bit unsigned integer.
253
+ */
254
+ function fnv1a(data: string, seed: number = 0): number {
255
+ let hash = 2166136261 ^ seed;
256
+ for (let i = 0; i < data.length; i++) {
257
+ hash ^= data.charCodeAt(i);
258
+ hash = Math.imul(hash, 16777619);
259
+ }
260
+ return hash >>> 0; // ensure unsigned
261
+ }
262
+
263
+ /**
264
+ * Bloom filter — space-efficient probabilistic set membership.
265
+ *
266
+ * Insert elements, then query "is X possibly in the set?"
267
+ * False positives possible. False negatives impossible.
268
+ *
269
+ * Optimal parameters:
270
+ * m = -(n × ln(p)) / (ln2)² (bit array size)
271
+ * k = (m / n) × ln2 (hash function count)
272
+ *
273
+ * Reference: Bloom (1970) "Space/Time Trade-offs in Hash Coding"
274
+ */
275
+ export class BloomFilter {
276
+ private readonly bits: Uint8Array;
277
+ private readonly numHashes: number;
278
+ readonly size: number;
279
+ private _count: number = 0;
280
+
281
+ /**
282
+ * Create a Bloom filter.
283
+ * @param expectedItems Expected number of items to insert
284
+ * @param falsePositiveRate Desired false positive rate (0-1)
285
+ */
286
+ constructor(expectedItems: number, falsePositiveRate: number = 0.01) {
287
+ // m = -(n * ln(p)) / (ln2)^2
288
+ const m = Math.ceil(
289
+ (-expectedItems * Math.log(falsePositiveRate)) / Math.log(2) ** 2,
290
+ );
291
+ // k = (m/n) * ln2
292
+ const k = Math.max(1, Math.round((m / expectedItems) * Math.log(2)));
293
+
294
+ this.size = m;
295
+ this.numHashes = k;
296
+ this.bits = new Uint8Array(Math.ceil(m / 8));
297
+ }
298
+
299
+ /** Number of items inserted. */
300
+ get count(): number {
301
+ return this._count;
302
+ }
303
+
304
+ /** Theoretical false positive rate given current fill. */
305
+ get estimatedFPR(): number {
306
+ const exponent = (-this.numHashes * this._count) / this.size;
307
+ return (1 - Math.exp(exponent)) ** this.numHashes;
308
+ }
309
+
310
+ /** Insert an element. */
311
+ add(item: string): void {
312
+ for (let i = 0; i < this.numHashes; i++) {
313
+ const pos = fnv1a(item, i) % this.size;
314
+ const byteIndex = pos >>> 3;
315
+ const bitIndex = pos & 7;
316
+ this.bits[byteIndex] |= 1 << bitIndex;
317
+ }
318
+ this._count++;
319
+ }
320
+
321
+ /** Query membership. Returns true if the item MIGHT be in the set. */
322
+ has(item: string): boolean {
323
+ for (let i = 0; i < this.numHashes; i++) {
324
+ const pos = fnv1a(item, i) % this.size;
325
+ const byteIndex = pos >>> 3;
326
+ const bitIndex = pos & 7;
327
+ if ((this.bits[byteIndex] & (1 << bitIndex)) === 0) {
328
+ return false; // definitely not in set
329
+ }
330
+ }
331
+ return true; // possibly in set
332
+ }
333
+
334
+ /**
335
+ * Estimate the Jaccard similarity between two Bloom filters.
336
+ * Uses bit-level comparison — no need to know the original elements.
337
+ */
338
+ static jaccardSimilarity(a: BloomFilter, b: BloomFilter): number {
339
+ if (a.size !== b.size) {
340
+ throw new Error("Bloom filters must have the same size for comparison");
341
+ }
342
+
343
+ let intersection = 0;
344
+ let union = 0;
345
+
346
+ for (let i = 0; i < a.bits.length; i++) {
347
+ const and = a.bits[i] & b.bits[i];
348
+ const or = a.bits[i] | b.bits[i];
349
+
350
+ // Count set bits (Brian Kernighan's algorithm)
351
+ let x = and;
352
+ while (x) {
353
+ intersection++;
354
+ x &= x - 1;
355
+ }
356
+ x = or;
357
+ while (x) {
358
+ union++;
359
+ x &= x - 1;
360
+ }
361
+ }
362
+
363
+ return union === 0 ? 1 : intersection / union;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Build a Bloom filter from a rule's tokens.
369
+ * Tokenizes rule text into words and n-grams for fuzzy matching.
370
+ */
371
+ export function ruleToBloomFilter(
372
+ rule: Rule,
373
+ expectedTokens: number = 100,
374
+ ): BloomFilter {
375
+ const text = ruleToText(rule);
376
+ const filter = new BloomFilter(expectedTokens, 0.01);
377
+
378
+ // Word-level tokens
379
+ const words = text.toLowerCase().split(/\W+/).filter(Boolean);
380
+ for (const word of words) {
381
+ filter.add(word);
382
+ }
383
+
384
+ // Character 3-grams for fuzzy matching
385
+ const normalized = text.toLowerCase();
386
+ for (let i = 0; i <= normalized.length - 3; i++) {
387
+ filter.add(normalized.slice(i, i + 3));
388
+ }
389
+
390
+ return filter;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // 4. Fixed-Point Convergence
395
+ // ---------------------------------------------------------------------------
396
+
397
+ export interface FixedPointResult {
398
+ converged: boolean;
399
+ iterations: number;
400
+ /** Hash at each iteration. */
401
+ hashes: string[];
402
+ /** If not converged, the cycle length (0 = no cycle detected within maxIterations). */
403
+ cycleLength: number;
404
+ }
405
+
406
+ /**
407
+ * Detect whether a compile function reaches a fixed point.
408
+ *
409
+ * A fixed point means: applying the function again produces the same output.
410
+ * compile(compile(spec)) === compile(spec)
411
+ *
412
+ * If the function doesn't converge, detects cycles using Floyd's
413
+ * tortoise-and-hare algorithm adapted for hash sequences.
414
+ *
415
+ * @param compileFn A function that takes content and returns new content
416
+ * @param initialContent Starting content
417
+ * @param maxIterations Maximum iterations before declaring divergence
418
+ */
419
+ export function fixedPoint(
420
+ compileFn: (content: string) => string,
421
+ initialContent: string,
422
+ maxIterations: number = 10,
423
+ ): FixedPointResult {
424
+ const hashes: string[] = [];
425
+ let current = initialContent;
426
+
427
+ for (let i = 0; i < maxIterations; i++) {
428
+ const hash = sha256short(current);
429
+ hashes.push(hash);
430
+
431
+ const next = compileFn(current);
432
+ const nextHash = sha256short(next);
433
+
434
+ // Fixed point: output === input
435
+ if (nextHash === hash) {
436
+ return { converged: true, iterations: i + 1, hashes, cycleLength: 0 };
437
+ }
438
+
439
+ // Cycle detection: have we seen this hash before?
440
+ const cycleStart = hashes.indexOf(nextHash);
441
+ if (cycleStart !== -1) {
442
+ return {
443
+ converged: false,
444
+ iterations: i + 1,
445
+ hashes: [...hashes, nextHash],
446
+ cycleLength: i + 1 - cycleStart,
447
+ };
448
+ }
449
+
450
+ current = next;
451
+ }
452
+
453
+ return {
454
+ converged: false,
455
+ iterations: maxIterations,
456
+ hashes,
457
+ cycleLength: 0,
458
+ };
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // 5. Merkle History — content-addressed spec evolution DAG
463
+ // ---------------------------------------------------------------------------
464
+
465
+ export interface Mutation {
466
+ type: "add" | "remove" | "strengthen" | "weaken" | "merge" | "reword";
467
+ ruleIds: string[];
468
+ description: string;
469
+ }
470
+
471
+ export interface ProofReceipt {
472
+ name: string;
473
+ passed: boolean;
474
+ detail?: string;
475
+ }
476
+
477
+ export interface HistoryNode {
478
+ /** SHA-256 hash of this node (covers all fields except `hash` itself). */
479
+ hash: string;
480
+ /** Hash of the parent node ("genesis" for the first node). */
481
+ parentHash: string;
482
+ /** Hash of the compiled spec content at this version. */
483
+ specHash: string;
484
+ /** What mutation was applied. */
485
+ mutation: Mutation;
486
+ /** Proof receipts — which proofs ran and their results. */
487
+ proofs: ProofReceipt[];
488
+ /** Unix timestamp (ms). */
489
+ timestamp: number;
490
+ }
491
+
492
+ /** Compute a short SHA-256 hash (16 hex chars). */
493
+ function sha256short(data: string): string {
494
+ return createHash("sha256").update(data).digest("hex").slice(0, 16);
495
+ }
496
+
497
+ /** Compute the hash of a HistoryNode (excluding the hash field itself). */
498
+ function computeNodeHash(node: Omit<HistoryNode, "hash">): string {
499
+ const payload = JSON.stringify({
500
+ parentHash: node.parentHash,
501
+ specHash: node.specHash,
502
+ mutation: node.mutation,
503
+ proofs: node.proofs,
504
+ timestamp: node.timestamp,
505
+ });
506
+ return sha256short(payload);
507
+ }
508
+
509
+ /**
510
+ * Merkle history — append-only, tamper-evident spec evolution log.
511
+ *
512
+ * Each node's hash covers its parent hash, creating a chain where
513
+ * tampering with any node invalidates all descendants.
514
+ */
515
+
516
+ /**
517
+ * Read-only view of a MerkleHistory — exposes inspection methods but no
518
+ * append/mutation surface. Callers that only need to read the audit trail
519
+ * should depend on this instead of MerkleHistory to prevent bypassing the
520
+ * engine's validation gates.
521
+ */
522
+ export interface ReadonlyMerkleHistory {
523
+ readonly length: number;
524
+ head(): HistoryNode | null;
525
+ getNodes(): readonly HistoryNode[];
526
+ verify(): { valid: boolean; invalidAt: number };
527
+ toJSON(): string;
528
+ }
529
+
530
+ export class MerkleHistory implements ReadonlyMerkleHistory {
531
+ private nodes: HistoryNode[] = [];
532
+
533
+ /** Number of versions in the history. */
534
+ get length(): number {
535
+ return this.nodes.length;
536
+ }
537
+
538
+ /**
539
+ * Get all nodes as a deep defensive copy. Callers cannot mutate node
540
+ * fields (specHash, mutation, proofs) through this API — that would
541
+ * bypass append/proof gates and weaken the tamper-evident guarantees.
542
+ */
543
+ getNodes(): readonly HistoryNode[] {
544
+ return this.nodes.map((n) => ({
545
+ ...n,
546
+ mutation: { ...n.mutation, ruleIds: [...n.mutation.ruleIds] },
547
+ proofs: n.proofs.map((r) => ({ ...r })),
548
+ }));
549
+ }
550
+
551
+ /**
552
+ * Get the latest node, or null if empty. Returned as a deep defensive
553
+ * copy — see getNodes() for rationale.
554
+ */
555
+ head(): HistoryNode | null {
556
+ const last = this.nodes[this.nodes.length - 1];
557
+ if (!last) return null;
558
+ return {
559
+ ...last,
560
+ mutation: { ...last.mutation, ruleIds: [...last.mutation.ruleIds] },
561
+ proofs: last.proofs.map((r) => ({ ...r })),
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Append a new version to the history.
567
+ * Returns the hash of the new node.
568
+ */
569
+ append(specHash: string, mutation: Mutation, proofs: ProofReceipt[]): string {
570
+ const parentHash =
571
+ this.nodes.length > 0
572
+ ? this.nodes[this.nodes.length - 1].hash
573
+ : "genesis";
574
+
575
+ // Clone payloads on write so later caller-side mutation of the supplied
576
+ // mutation/proofs objects can't retroactively alter stored nodes. This
577
+ // is the write-side counterpart to getNodes()/head(), keeping the
578
+ // append-only / tamper-evident contract true under normal usage
579
+ // patterns like "caller reuses and edits a mutation object".
580
+ const clonedMutation: Mutation = {
581
+ ...mutation,
582
+ ruleIds: [...mutation.ruleIds],
583
+ };
584
+ const clonedProofs: ProofReceipt[] = proofs.map((r) => ({ ...r }));
585
+
586
+ const partial = {
587
+ parentHash,
588
+ specHash,
589
+ mutation: clonedMutation,
590
+ proofs: clonedProofs,
591
+ timestamp: Date.now(),
592
+ };
593
+
594
+ const hash = computeNodeHash(partial);
595
+ this.nodes.push({ hash, ...partial });
596
+ return hash;
597
+ }
598
+
599
+ /**
600
+ * Verify the entire chain — every node's hash must be correct,
601
+ * and every parent pointer must match the previous node.
602
+ *
603
+ * Returns the index of the first invalid node, or -1 if valid.
604
+ */
605
+ verify(): { valid: boolean; invalidAt: number } {
606
+ for (let i = 0; i < this.nodes.length; i++) {
607
+ const node = this.nodes[i];
608
+
609
+ // Check hash integrity
610
+ const { hash: _hash, ...rest } = node;
611
+ const expectedHash = computeNodeHash(rest);
612
+ if (node.hash !== expectedHash) {
613
+ return { valid: false, invalidAt: i };
614
+ }
615
+
616
+ // Check parent chain
617
+ if (i === 0) {
618
+ if (node.parentHash !== "genesis") {
619
+ return { valid: false, invalidAt: i };
620
+ }
621
+ } else {
622
+ if (node.parentHash !== this.nodes[i - 1].hash) {
623
+ return { valid: false, invalidAt: i };
624
+ }
625
+ }
626
+ }
627
+ return { valid: true, invalidAt: -1 };
628
+ }
629
+
630
+ /**
631
+ * Serialize the history to JSON for persistence.
632
+ */
633
+ toJSON(): string {
634
+ return JSON.stringify(this.nodes, null, 2);
635
+ }
636
+
637
+ /**
638
+ * Deserialize a history from JSON. Rejects non-array payloads and
639
+ * validates that each entry has the required HistoryNode shape so
640
+ * malformed persisted data can't produce a "valid" history that
641
+ * later crashes on append/verify.
642
+ */
643
+ static fromJSON(json: string): MerkleHistory {
644
+ const parsed: unknown = JSON.parse(json);
645
+ if (!Array.isArray(parsed)) {
646
+ throw new Error(
647
+ `MerkleHistory.fromJSON: expected an array, got ${typeof parsed}`,
648
+ );
649
+ }
650
+ for (let i = 0; i < parsed.length; i++) {
651
+ const node = parsed[i] as Record<string, unknown>;
652
+ if (
653
+ typeof node !== "object" ||
654
+ node === null ||
655
+ typeof node.hash !== "string" ||
656
+ typeof node.parentHash !== "string" ||
657
+ typeof node.specHash !== "string" ||
658
+ typeof node.timestamp !== "number"
659
+ ) {
660
+ throw new Error(
661
+ `MerkleHistory.fromJSON: invalid node at index ${String(i)}`,
662
+ );
663
+ }
664
+ // Validate nested mutation and proofs so later getNodes/head
665
+ // don't crash on missing .ruleIds or .map().
666
+ const mut = node.mutation as Record<string, unknown> | undefined;
667
+ if (
668
+ !mut ||
669
+ typeof mut !== "object" ||
670
+ typeof mut.type !== "string" ||
671
+ !Array.isArray(mut.ruleIds)
672
+ ) {
673
+ throw new Error(
674
+ `MerkleHistory.fromJSON: invalid mutation at node ${String(i)}`,
675
+ );
676
+ }
677
+ if (!Array.isArray(node.proofs)) {
678
+ throw new Error(
679
+ `MerkleHistory.fromJSON: invalid proofs at node ${String(i)}`,
680
+ );
681
+ }
682
+ }
683
+ const history = new MerkleHistory();
684
+ history.nodes = parsed as HistoryNode[];
685
+ return history;
686
+ }
687
+ }
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // 6. Property-Based Testing
691
+ // ---------------------------------------------------------------------------
692
+
693
+ export type MutationGenerator<T> = (value: T, seed: number) => T;
694
+ export type Invariant<T> = (value: T) => boolean;
695
+
696
+ export interface PropertyTestResult<T> {
697
+ passed: boolean;
698
+ iterations: number;
699
+ /** If failed, the mutation sequence that caused the failure. */
700
+ failingSequence?: T[];
701
+ /** If failed, the minimal shrunk counterexample. */
702
+ shrunk?: T;
703
+ /** Name of the invariant that failed. */
704
+ failedInvariant?: string;
705
+ }
706
+
707
+ /**
708
+ * Simple deterministic PRNG (xorshift32) for reproducible tests.
709
+ */
710
+ function xorshift32(state: number): number {
711
+ let x = state;
712
+ x ^= x << 13;
713
+ x ^= x >> 17;
714
+ x ^= x << 5;
715
+ return x >>> 0;
716
+ }
717
+
718
+ /**
719
+ * Property-based testing: generate random mutations, check invariants hold.
720
+ *
721
+ * Inspired by QuickCheck/fast-check. Uses a deterministic PRNG for
722
+ * reproducible failures.
723
+ *
724
+ * @param initial Starting value
725
+ * @param mutate Function that produces a random mutation
726
+ * @param invariants Named invariant functions that must all return true
727
+ * @param options Test parameters
728
+ */
729
+ export function propertyTest<T>(
730
+ initial: T,
731
+ mutate: MutationGenerator<T>,
732
+ invariants: Record<string, Invariant<T>>,
733
+ options: { iterations?: number; seed?: number; sequenceLength?: number } = {},
734
+ ): PropertyTestResult<T> {
735
+ const iterations = options.iterations ?? 100;
736
+ const sequenceLength = options.sequenceLength ?? 5;
737
+ let rng = options.seed ?? 42;
738
+
739
+ for (let i = 0; i < iterations; i++) {
740
+ // Generate a sequence of mutations
741
+ let current = initial;
742
+ const sequence: T[] = [current];
743
+
744
+ for (let j = 0; j < sequenceLength; j++) {
745
+ rng = xorshift32(rng);
746
+ current = mutate(current, rng);
747
+ sequence.push(current);
748
+
749
+ // Check all invariants after each mutation
750
+ for (const [name, check] of Object.entries(invariants)) {
751
+ if (!check(current)) {
752
+ // Shrink: binary search for minimal failing subsequence
753
+ const shrunk = shrinkSequence(
754
+ initial,
755
+ sequence,
756
+ mutate,
757
+ name,
758
+ invariants,
759
+ );
760
+ return {
761
+ passed: false,
762
+ iterations: i + 1,
763
+ failingSequence: sequence,
764
+ shrunk,
765
+ failedInvariant: name,
766
+ };
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ return { passed: true, iterations };
773
+ }
774
+
775
+ /**
776
+ * Shrink a failing sequence to find the minimal counterexample.
777
+ * Uses binary search on the sequence length.
778
+ */
779
+ function shrinkSequence<T>(
780
+ _initial: T,
781
+ sequence: T[],
782
+ _mutate: MutationGenerator<T>,
783
+ invariantName: string,
784
+ invariants: Record<string, Invariant<T>>,
785
+ ): T {
786
+ // Simple shrinking: find the first element in the sequence that fails
787
+ const check = invariants[invariantName];
788
+ for (const item of sequence) {
789
+ if (!check(item)) {
790
+ return item;
791
+ }
792
+ }
793
+ // Shouldn't reach here — return last element
794
+ return sequence[sequence.length - 1];
795
+ }
796
+
797
+ // ---------------------------------------------------------------------------
798
+ // Fitness function for spec evolution
799
+ // ---------------------------------------------------------------------------
800
+
801
+ export interface FitnessResult {
802
+ score: number;
803
+ coverage: number;
804
+ redundancy: number;
805
+ budgetPressure: number;
806
+ }
807
+
808
+ /**
809
+ * Compute the fitness of a spec. Higher is better.
810
+ *
811
+ * fitness = coverage × (1 - redundancy) × (1 - budgetPressure)
812
+ *
813
+ * - coverage: fraction of rules with enforcement (check or enforce)
814
+ * - redundancy: fraction of rule pairs that are near-duplicates (NCD < threshold)
815
+ * - budgetPressure: tokens used / max tokens
816
+ */
817
+ export function fitness(
818
+ spec: ClaudeSpec,
819
+ options: { maxTokens?: number; ncdThreshold?: number } = {},
820
+ ): FitnessResult {
821
+ const maxTokens = options.maxTokens ?? 2000;
822
+ const ncdThreshold = options.ncdThreshold ?? 0.3;
823
+
824
+ const rules = Object.values(spec.rules);
825
+ const total = rules.length;
826
+
827
+ if (total === 0) {
828
+ return { score: 0, coverage: 0, redundancy: 0, budgetPressure: 0 };
829
+ }
830
+
831
+ // Coverage: fraction with teeth (enforce vs guidance)
832
+ const enforced = rules.filter((r) => r._kind === "enforce").length;
833
+ const coverage = enforced / total;
834
+
835
+ // Redundancy: fraction of pairs that are near-duplicates
836
+ const similarPairs = findSimilarRules(spec.rules, ncdThreshold);
837
+ const totalPairs = (total * (total - 1)) / 2;
838
+ const redundancy = totalPairs > 0 ? similarPairs.length / totalPairs : 0;
839
+
840
+ // Budget pressure: rough token estimate (~4 chars per token)
841
+ const ruleTexts = rules.map(ruleToText);
842
+ const totalChars = ruleTexts.reduce((sum, t) => sum + t.length, 0);
843
+ const estimatedTokens = Math.ceil(totalChars / 4);
844
+ const budgetPressure = Math.min(1, estimatedTokens / maxTokens);
845
+
846
+ const score = coverage * (1 - redundancy) * (1 - budgetPressure);
847
+
848
+ return { score, coverage, redundancy, budgetPressure };
849
+ }