vigiles 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude/settings.json +46 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/.claude-plugin/hooks/post-edit.sh +34 -0
  4. package/.claude-plugin/hooks/pre-edit.sh +40 -0
  5. package/.claude-plugin/hooks/session-start.sh +38 -0
  6. package/.claude-plugin/marketplace.json +14 -0
  7. package/.claude-plugin/plugin.json +47 -0
  8. package/.github/workflows/ci.yml +81 -0
  9. package/.prettierignore +1 -0
  10. package/.vigiles/generated.d.ts +205 -0
  11. package/CLAUDE.md +95 -0
  12. package/CLAUDE.md.spec.ts +142 -0
  13. package/CONTRIBUTING.md +121 -0
  14. package/LICENSE +21 -0
  15. package/README.md +377 -0
  16. package/action.yml +25 -0
  17. package/dist/action.d.ts +7 -0
  18. package/dist/action.d.ts.map +1 -0
  19. package/dist/action.js +180 -0
  20. package/dist/action.js.map +1 -0
  21. package/dist/cli.d.ts +12 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +1267 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/cli.test.d.ts +2 -0
  26. package/dist/cli.test.d.ts.map +1 -0
  27. package/dist/cli.test.js +650 -0
  28. package/dist/cli.test.js.map +1 -0
  29. package/dist/compile.d.ts +101 -0
  30. package/dist/compile.d.ts.map +1 -0
  31. package/dist/compile.js +503 -0
  32. package/dist/compile.js.map +1 -0
  33. package/dist/evolve.d.ts +132 -0
  34. package/dist/evolve.d.ts.map +1 -0
  35. package/dist/evolve.js +599 -0
  36. package/dist/evolve.js.map +1 -0
  37. package/dist/freshness.d.ts +67 -0
  38. package/dist/freshness.d.ts.map +1 -0
  39. package/dist/freshness.js +244 -0
  40. package/dist/freshness.js.map +1 -0
  41. package/dist/freshness.test.d.ts +2 -0
  42. package/dist/freshness.test.d.ts.map +1 -0
  43. package/dist/freshness.test.js +356 -0
  44. package/dist/freshness.test.js.map +1 -0
  45. package/dist/generate-types.d.ts +34 -0
  46. package/dist/generate-types.d.ts.map +1 -0
  47. package/dist/generate-types.js +381 -0
  48. package/dist/generate-types.js.map +1 -0
  49. package/dist/inline.d.ts +58 -0
  50. package/dist/inline.d.ts.map +1 -0
  51. package/dist/inline.js +142 -0
  52. package/dist/inline.js.map +1 -0
  53. package/dist/inline.test.d.ts +5 -0
  54. package/dist/inline.test.d.ts.map +1 -0
  55. package/dist/inline.test.js +152 -0
  56. package/dist/inline.test.js.map +1 -0
  57. package/dist/linters.d.ts +38 -0
  58. package/dist/linters.d.ts.map +1 -0
  59. package/dist/linters.js +588 -0
  60. package/dist/linters.js.map +1 -0
  61. package/dist/proofs.d.ts +272 -0
  62. package/dist/proofs.d.ts.map +1 -0
  63. package/dist/proofs.js +622 -0
  64. package/dist/proofs.js.map +1 -0
  65. package/dist/proofs.test.d.ts +9 -0
  66. package/dist/proofs.test.d.ts.map +1 -0
  67. package/dist/proofs.test.js +952 -0
  68. package/dist/proofs.test.js.map +1 -0
  69. package/dist/spec.d.ts +258 -0
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/spec.js +113 -0
  72. package/dist/spec.js.map +1 -0
  73. package/dist/spec.test.d.ts +2 -0
  74. package/dist/spec.test.d.ts.map +1 -0
  75. package/dist/spec.test.js +1222 -0
  76. package/dist/spec.test.js.map +1 -0
  77. package/dist/types.d.ts +101 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +3 -0
  80. package/dist/types.js.map +1 -0
  81. package/dist/validate.d.ts +10 -0
  82. package/dist/validate.d.ts.map +1 -0
  83. package/dist/validate.js +286 -0
  84. package/dist/validate.js.map +1 -0
  85. package/dist/validate.test.d.ts +2 -0
  86. package/dist/validate.test.d.ts.map +1 -0
  87. package/dist/validate.test.js +531 -0
  88. package/dist/validate.test.js.map +1 -0
  89. package/docs/agent-setup.md +85 -0
  90. package/docs/agent-workflows.md +103 -0
  91. package/docs/comparison.md +71 -0
  92. package/docs/freshness.md +124 -0
  93. package/docs/inline-mode.md +119 -0
  94. package/docs/linter-support.md +166 -0
  95. package/docs/spec-format.md +194 -0
  96. package/eslint.config.mjs +79 -0
  97. package/examples/CLAUDE.md +54 -0
  98. package/examples/CLAUDE.md.spec.ts +65 -0
  99. package/examples/SKILL.md +50 -0
  100. package/examples/SKILL.md.spec.ts +57 -0
  101. package/fixtures/example-project/CLAUDE.md +11 -0
  102. package/fixtures/example-project/package.json +9 -0
  103. package/fixtures/example-project/src/index.ts +3 -0
  104. package/fixtures/example-project/src/utils.test.ts +2 -0
  105. package/fixtures/example-project/src/utils.ts +3 -0
  106. package/logo.png +0 -0
  107. package/package.json +42 -0
  108. package/research/adoption-strategy.md +111 -0
  109. package/research/agent-integration.md +145 -0
  110. package/research/ai-code-quality.md +197 -0
  111. package/research/code-search-for-agents.md +313 -0
  112. package/research/competitive-landscape.md +163 -0
  113. package/research/doc-freshness.md +516 -0
  114. package/research/executable-specs.md +368 -0
  115. package/research/feature-ideas.md +464 -0
  116. package/research/formal-proofs-for-agents.md +338 -0
  117. package/research/fp-for-agent-harness.md +150 -0
  118. package/research/fp-for-deterministic-ai.md +131 -0
  119. package/research/self-evolving-specs.md +298 -0
  120. package/schemas/claude-md-strict.yml +18 -0
  121. package/schemas/claude-md.yml +6 -0
  122. package/schemas/skill-strict.yml +12 -0
  123. package/schemas/skill.yml +5 -0
  124. package/skills/audit-feedback-loop/SKILL.md +76 -0
  125. package/skills/edit-spec/SKILL.md +131 -0
  126. package/skills/enforce-rules-format/SKILL.md +71 -0
  127. package/skills/generate-logo/SKILL.md +102 -0
  128. package/skills/generate-rule/SKILL.md +90 -0
  129. package/skills/linter-docs/clippy.md +241 -0
  130. package/skills/linter-docs/eslint.md +384 -0
  131. package/skills/linter-docs/pylint.md +288 -0
  132. package/skills/linter-docs/rubocop.md +277 -0
  133. package/skills/linter-docs/ruff.md +187 -0
  134. package/skills/linter-docs/stylelint.md +247 -0
  135. package/skills/migrate-to-spec/SKILL.md +124 -0
  136. package/skills/pr-to-lint-rule/SKILL.md +97 -0
  137. package/skills/strengthen/SKILL.md +168 -0
  138. package/src/action.ts +214 -0
  139. package/src/cli.test.ts +914 -0
  140. package/src/cli.ts +1631 -0
  141. package/src/compile.ts +691 -0
  142. package/src/evolve.ts +781 -0
  143. package/src/freshness.test.ts +449 -0
  144. package/src/freshness.ts +299 -0
  145. package/src/generate-types.ts +448 -0
  146. package/src/inline.test.ts +206 -0
  147. package/src/inline.ts +164 -0
  148. package/src/linters.ts +739 -0
  149. package/src/proofs.test.ts +1314 -0
  150. package/src/proofs.ts +849 -0
  151. package/src/spec.test.ts +1471 -0
  152. package/src/spec.ts +427 -0
  153. package/src/types.ts +117 -0
  154. package/src/validate.test.ts +701 -0
  155. package/src/validate.ts +381 -0
  156. package/tsconfig.json +23 -0
@@ -0,0 +1,1471 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ enforce,
6
+ guidance,
7
+ file,
8
+ cmd,
9
+ ref,
10
+ instructions,
11
+ claude,
12
+ skill,
13
+ } from "./spec.js";
14
+ import type {
15
+ SpecPath,
16
+ OutputPath,
17
+ StrictLinterRule,
18
+ StrictFile,
19
+ StrictCmd,
20
+ RawSpec,
21
+ RefsValidated,
22
+ LintersVerified,
23
+ ReadyToEmit,
24
+ KnownLinterRules,
25
+ ClaudeSpec,
26
+ } from "./spec.js";
27
+
28
+ import {
29
+ compileClaude,
30
+ compileSkill,
31
+ computeHash,
32
+ addHash,
33
+ verifyHash,
34
+ checkFileHash,
35
+ estimateTokens,
36
+ adoptDiff,
37
+ } from "./compile.js";
38
+
39
+ import { generateTypes } from "./generate-types.js";
40
+ import { checkLinterRule } from "./linters.js";
41
+
42
+ import { writeFileSync, mkdirSync, rmSync } from "node:fs";
43
+ import { join } from "node:path";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Builder tests
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("enforce()", () => {
50
+ it("creates an enforce rule", () => {
51
+ const rule = enforce("eslint/no-console", "Use structured logger.");
52
+ assert.equal(rule._kind, "enforce");
53
+ assert.equal(rule.linterRule, "eslint/no-console");
54
+ assert.equal(rule.why, "Use structured logger.");
55
+ });
56
+
57
+ it("accepts scoped plugins", () => {
58
+ const rule = enforce(
59
+ "@typescript-eslint/no-explicit-any",
60
+ "Degrades type safety.",
61
+ );
62
+ assert.equal(rule.linterRule, "@typescript-eslint/no-explicit-any");
63
+ });
64
+
65
+ it("accepts all supported linters", () => {
66
+ const linters = [
67
+ "eslint/no-console",
68
+ "ruff/T201",
69
+ "clippy/unwrap_used",
70
+ "pylint/C0114",
71
+ "rubocop/Style/FrozenStringLiteral",
72
+ "stylelint/color-no-invalid-hex",
73
+ ] as const;
74
+ for (const linter of linters) {
75
+ const rule = enforce(linter, "test");
76
+ assert.equal(rule._kind, "enforce");
77
+ }
78
+ });
79
+ });
80
+
81
+ describe("guidance()", () => {
82
+ it("creates a guidance rule", () => {
83
+ const rule = guidance("Google unfamiliar APIs first.");
84
+ assert.equal(rule._kind, "guidance");
85
+ assert.equal(rule.text, "Google unfamiliar APIs first.");
86
+ });
87
+ });
88
+
89
+ describe("reference helpers", () => {
90
+ it("file() creates a file ref", () => {
91
+ const r = file("src/validate.ts");
92
+ assert.equal(r._ref, "file");
93
+ assert.equal(r.path, "src/validate.ts");
94
+ });
95
+
96
+ it("cmd() creates a cmd ref", () => {
97
+ const r = cmd("npm test");
98
+ assert.equal(r._ref, "cmd");
99
+ assert.equal(r.command, "npm test");
100
+ });
101
+
102
+ it("ref() creates a skill ref", () => {
103
+ const r = ref("skills/other/SKILL.md");
104
+ assert.equal(r._ref, "skill");
105
+ assert.equal(r.path, "skills/other/SKILL.md");
106
+ });
107
+ });
108
+
109
+ describe("instructions tagged template", () => {
110
+ it("interleaves strings and refs", () => {
111
+ const result = instructions`Check ${file("foo.ts")} and run ${cmd("npm test")}.`;
112
+ assert.equal(result.length, 5);
113
+ assert.equal(typeof result[0], "string");
114
+ assert.equal((result[1] as { _ref: string })._ref, "file");
115
+ assert.equal(typeof result[2], "string");
116
+ assert.equal((result[3] as { _ref: string })._ref, "cmd");
117
+ assert.equal(typeof result[4], "string");
118
+ });
119
+ });
120
+
121
+ describe("claude()", () => {
122
+ it("creates a claude spec with correct type tag", () => {
123
+ const spec = claude({
124
+ rules: {
125
+ "no-console": enforce("eslint/no-console", "Use logger."),
126
+ },
127
+ });
128
+ assert.equal(spec._specType, "claude");
129
+ assert.ok(spec.rules["no-console"]);
130
+ });
131
+ });
132
+
133
+ describe("skill()", () => {
134
+ it("creates a skill spec with correct type tag", () => {
135
+ const spec = skill({
136
+ name: "test-skill",
137
+ description: "A test skill",
138
+ body: "Do the thing.",
139
+ });
140
+ assert.equal(spec._specType, "skill");
141
+ assert.equal(spec.name, "test-skill");
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Compiler tests
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe("compileClaude()", () => {
150
+ it("compiles a minimal spec to markdown", () => {
151
+ const spec = claude({
152
+ rules: {
153
+ "no-console": enforce("eslint/no-console", "Use structured logger."),
154
+ "research-first": guidance("Google unfamiliar APIs first."),
155
+ },
156
+ });
157
+ const { markdown, errors } = compileClaude(spec);
158
+ assert.ok(markdown.includes("<!-- vigiles:sha256:"));
159
+ assert.ok(markdown.includes("# CLAUDE.md"));
160
+ assert.ok(markdown.includes("### No Console"));
161
+ assert.ok(markdown.includes("**Enforced by:** `eslint/no-console`"));
162
+ assert.ok(markdown.includes("**Why:** Use structured logger."));
163
+ assert.ok(markdown.includes("### Research First"));
164
+ assert.ok(
165
+ markdown.includes("**Guidance only** — Google unfamiliar APIs first."),
166
+ );
167
+ assert.equal(errors.length, 0);
168
+ });
169
+
170
+ it("includes commands section", () => {
171
+ const spec = claude({
172
+ commands: { "npm test": "Run tests" },
173
+ rules: {},
174
+ });
175
+ const { markdown } = compileClaude(spec, { basePath: process.cwd() });
176
+ assert.ok(markdown.includes("## Commands"));
177
+ assert.ok(markdown.includes("`npm test` — Run tests"));
178
+ });
179
+
180
+ it("includes key files section", () => {
181
+ const spec = claude({
182
+ keyFiles: { "src/spec.ts": "Spec system" },
183
+ rules: {},
184
+ });
185
+ const { markdown } = compileClaude(spec, { basePath: process.cwd() });
186
+ assert.ok(markdown.includes("## Key Files"));
187
+ assert.ok(markdown.includes("`src/spec.ts` — Spec system"));
188
+ });
189
+
190
+ it("reports errors for missing key files", () => {
191
+ const spec = claude({
192
+ keyFiles: { "src/nonexistent-file-xyz.ts": "Does not exist" },
193
+ rules: {},
194
+ });
195
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
196
+ assert.equal(errors.length, 1);
197
+ assert.equal(errors[0].type, "stale-file");
198
+ });
199
+
200
+ it("reports errors for missing npm scripts", () => {
201
+ const spec = claude({
202
+ commands: { "npm run nonexistent-script-xyz": "Does not exist" },
203
+ rules: {},
204
+ });
205
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
206
+ assert.equal(errors.length, 1);
207
+ assert.equal(errors[0].type, "stale-command");
208
+ });
209
+
210
+ it("includes sections in output", () => {
211
+ const spec = claude({
212
+ sections: {
213
+ architecture: "TypeScript strict-mode codebase.",
214
+ },
215
+ rules: {},
216
+ });
217
+ const { markdown } = compileClaude(spec);
218
+ assert.ok(markdown.includes("## Architecture"));
219
+ assert.ok(markdown.includes("TypeScript strict-mode codebase."));
220
+ });
221
+
222
+ it("enforces maxRules limit", () => {
223
+ const rules: Record<string, ReturnType<typeof guidance>> = {};
224
+ for (let i = 0; i < 5; i++) {
225
+ rules[`rule-${String(i)}`] = guidance("test");
226
+ }
227
+ const spec = claude({ rules });
228
+ const { errors } = compileClaude(spec, { maxRules: 3 });
229
+ assert.equal(errors.length, 1);
230
+ assert.ok(errors[0].message.includes("exceeds maxRules"));
231
+ });
232
+
233
+ it("returns linterResults for enforce rules", () => {
234
+ const spec = claude({
235
+ rules: {
236
+ "no-console": enforce("eslint/no-console", "Use logger."),
237
+ },
238
+ });
239
+ // eslint is installed in this project, so this should work
240
+ const { linterResults } = compileClaude(spec, { basePath: process.cwd() });
241
+ assert.equal(linterResults.length, 1);
242
+ assert.equal(linterResults[0].linter, "eslint");
243
+ assert.equal(linterResults[0].rule, "no-console");
244
+ assert.equal(linterResults[0].exists, true);
245
+ });
246
+ });
247
+
248
+ describe("compileSkill()", () => {
249
+ it("compiles a skill with string body", () => {
250
+ const spec = skill({
251
+ name: "test-skill",
252
+ description: "A test skill",
253
+ body: "Do the thing.\n\n## Step 1\nDo step 1.",
254
+ });
255
+ const { markdown, errors } = compileSkill(spec);
256
+ assert.ok(markdown.includes("<!-- vigiles:sha256:"));
257
+ assert.ok(markdown.includes("name: test-skill\n"));
258
+ assert.ok(markdown.includes("description: A test skill"));
259
+ assert.ok(markdown.includes("Do the thing."));
260
+ assert.equal(errors.length, 0);
261
+ });
262
+
263
+ it("compiles a skill with tagged template body", () => {
264
+ const spec = skill({
265
+ name: "test-skill",
266
+ description: "A test skill",
267
+ body: instructions`Check ${file("package.json")} and run ${cmd("npm test")}.`,
268
+ });
269
+ const { markdown } = compileSkill(spec, { basePath: process.cwd() });
270
+ assert.ok(markdown.includes("`package.json`"));
271
+ assert.ok(markdown.includes("`npm test`"));
272
+ });
273
+
274
+ it("reports errors for missing file refs in body", () => {
275
+ const spec = skill({
276
+ name: "test-skill",
277
+ description: "A test skill",
278
+ body: instructions`Check ${file("nonexistent-xyz.ts")}.`,
279
+ });
280
+ const { errors } = compileSkill(spec, { basePath: process.cwd() });
281
+ assert.equal(errors.length, 1);
282
+ assert.equal(errors[0].type, "stale-file");
283
+ });
284
+
285
+ it("includes frontmatter fields", () => {
286
+ const spec = skill({
287
+ name: "my-skill",
288
+ description: "My skill desc",
289
+ disableModelInvocation: true,
290
+ argumentHint: "<some arg>",
291
+ body: "Instructions here.",
292
+ });
293
+ const { markdown } = compileSkill(spec);
294
+ assert.ok(markdown.includes("disable-model-invocation: true"));
295
+ assert.ok(markdown.includes("argument-hint: <some arg>"));
296
+ });
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Hash tests
301
+ // ---------------------------------------------------------------------------
302
+
303
+ describe("hash utilities", () => {
304
+ it("computeHash is deterministic", () => {
305
+ const a = computeHash("hello world");
306
+ const b = computeHash("hello world");
307
+ assert.equal(a, b);
308
+ });
309
+
310
+ it("computeHash differs for different content", () => {
311
+ const a = computeHash("hello");
312
+ const b = computeHash("world");
313
+ assert.notEqual(a, b);
314
+ });
315
+
316
+ it("addHash + verifyHash roundtrips", () => {
317
+ const content = "# CLAUDE.md\nSome content.\n";
318
+ const hashed = addHash(content, "CLAUDE.md.spec.ts");
319
+ const result = verifyHash(hashed);
320
+ assert.ok(result);
321
+ assert.equal(result.valid, true);
322
+ assert.equal(result.specFile, "CLAUDE.md.spec.ts");
323
+ });
324
+
325
+ it("verifyHash detects tampering", () => {
326
+ const content = "# CLAUDE.md\nOriginal content.\n";
327
+ const hashed = addHash(content, "CLAUDE.md.spec.ts");
328
+ const tampered = hashed.replace("Original content", "Modified content");
329
+ const result = verifyHash(tampered);
330
+ assert.ok(result);
331
+ assert.equal(result.valid, false);
332
+ });
333
+
334
+ it("verifyHash returns null for files without hash", () => {
335
+ const result = verifyHash("# Just a regular file.\n");
336
+ assert.equal(result, null);
337
+ });
338
+
339
+ it("checkFileHash works on real files", () => {
340
+ const tmpDir = join(process.cwd(), ".vigiles-test-tmp");
341
+ mkdirSync(tmpDir, { recursive: true });
342
+ try {
343
+ const content = "# Test\n";
344
+ const hashed = addHash(content, "test.spec.ts");
345
+ const filePath = join(tmpDir, "test.md");
346
+ writeFileSync(filePath, hashed);
347
+
348
+ const result = checkFileHash(filePath);
349
+ assert.equal(result.hasHash, true);
350
+ assert.equal(result.valid, true);
351
+ assert.equal(result.specFile, "test.spec.ts");
352
+ } finally {
353
+ rmSync(tmpDir, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ it("checkFileHash returns hasHash=false for nonexistent files", () => {
358
+ const result = checkFileHash("/tmp/vigiles-nonexistent-file.md");
359
+ assert.equal(result.hasHash, false);
360
+ assert.equal(result.valid, false);
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Token estimation tests
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("estimateTokens()", () => {
369
+ it("estimates ~1 token per 4 chars", () => {
370
+ const tokens = estimateTokens("a".repeat(100));
371
+ assert.equal(tokens, 25);
372
+ });
373
+
374
+ it("rounds up", () => {
375
+ const tokens = estimateTokens("abc");
376
+ assert.equal(tokens, 1);
377
+ });
378
+ });
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // maxTokens tests
382
+ // ---------------------------------------------------------------------------
383
+
384
+ describe("maxTokens budget", () => {
385
+ it("errors when compiled output exceeds maxTokens", () => {
386
+ const spec = claude({
387
+ sections: { prose: "x".repeat(1000) },
388
+ rules: {},
389
+ });
390
+ const { errors, tokens } = compileClaude(spec, { maxTokens: 100 });
391
+ assert.ok(tokens > 100);
392
+ assert.ok(errors.some((e) => e.type === "budget-exceeded"));
393
+ });
394
+
395
+ it("passes when under budget", () => {
396
+ const spec = claude({
397
+ rules: { test: guidance("Short.") },
398
+ });
399
+ const { errors } = compileClaude(spec, { maxTokens: 10000 });
400
+ assert.ok(!errors.some((e) => e.type === "budget-exceeded"));
401
+ });
402
+
403
+ it("throws on unknown rule kinds rather than silently dropping them", () => {
404
+ // Simulate a legacy compiled artifact or JS caller bypassing the type
405
+ const spec = {
406
+ _specType: "claude",
407
+ rules: {
408
+ legacy: { _kind: "check", text: "stale" },
409
+ },
410
+ } as unknown as ClaudeSpec;
411
+
412
+ assert.throws(
413
+ () => compileClaude(spec),
414
+ /Unknown rule kind "check"/,
415
+ "compileRule must fail fast on unknown kinds so constraints cannot be silently dropped",
416
+ );
417
+ });
418
+ });
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Section guardrails (headers + length)
422
+ // ---------------------------------------------------------------------------
423
+
424
+ describe("section guardrails", () => {
425
+ it("errors when section contains a top-level header", () => {
426
+ const spec = claude({
427
+ sections: { about: "Some intro\n# Overview\nMore text" },
428
+ rules: {},
429
+ });
430
+ const { errors } = compileClaude(spec);
431
+ assert.ok(errors.some((e) => e.type === "section-has-header"));
432
+ });
433
+
434
+ it("errors when section contains a second-level header", () => {
435
+ const spec = claude({
436
+ sections: { about: "Some intro\n## Subsection\nMore text" },
437
+ rules: {},
438
+ });
439
+ const { errors } = compileClaude(spec);
440
+ assert.ok(errors.some((e) => e.type === "section-has-header"));
441
+ });
442
+
443
+ it("allows ### and deeper headers in sections", () => {
444
+ const spec = claude({
445
+ sections: { about: "Some intro\n### Detail\nMore text" },
446
+ rules: {},
447
+ });
448
+ const { errors } = compileClaude(spec);
449
+ assert.ok(!errors.some((e) => e.type === "section-has-header"));
450
+ });
451
+
452
+ it("does not flag # inside code fences", () => {
453
+ const spec = claude({
454
+ sections: { about: "Example:\n```\n# this is a comment\n```" },
455
+ rules: {},
456
+ });
457
+ const { errors } = compileClaude(spec);
458
+ assert.ok(!errors.some((e) => e.type === "section-has-header"));
459
+ });
460
+
461
+ it("does not flag # inside tilde code fences", () => {
462
+ const spec = claude({
463
+ sections: { about: "Example:\n~~~\n## heading in fence\n~~~" },
464
+ rules: {},
465
+ });
466
+ const { errors } = compileClaude(spec);
467
+ assert.ok(!errors.some((e) => e.type === "section-has-header"));
468
+ });
469
+
470
+ it("flags # after code fence closes", () => {
471
+ const spec = claude({
472
+ sections: {
473
+ about: "Example:\n```\n# safe\n```\n# not safe",
474
+ },
475
+ rules: {},
476
+ });
477
+ const { errors } = compileClaude(spec);
478
+ assert.ok(errors.some((e) => e.type === "section-has-header"));
479
+ });
480
+
481
+ it("errors when section exceeds maxSectionLines", () => {
482
+ const longContent = Array.from(
483
+ { length: 50 },
484
+ (_, i) => `Line ${String(i + 1)}`,
485
+ ).join("\n");
486
+ const spec = claude({
487
+ sections: { wall: longContent },
488
+ rules: {},
489
+ });
490
+ const { errors } = compileClaude(spec, { maxSectionLines: 20 });
491
+ assert.ok(errors.some((e) => e.type === "section-too-long"));
492
+ assert.ok(errors[0].message.includes("50 lines"));
493
+ assert.ok(errors[0].message.includes("max 20"));
494
+ });
495
+
496
+ it("passes when section is at exact maxSectionLines boundary", () => {
497
+ const content = Array.from(
498
+ { length: 20 },
499
+ (_, i) => `Line ${String(i + 1)}`,
500
+ ).join("\n");
501
+ const spec = claude({
502
+ sections: { ok: content },
503
+ rules: {},
504
+ });
505
+ const { errors } = compileClaude(spec, { maxSectionLines: 20 });
506
+ assert.ok(!errors.some((e) => e.type === "section-too-long"));
507
+ });
508
+
509
+ it("skips maxSectionLines check when not configured", () => {
510
+ const longContent = Array.from(
511
+ { length: 100 },
512
+ (_, i) => `Line ${String(i + 1)}`,
513
+ ).join("\n");
514
+ const spec = claude({
515
+ sections: { wall: longContent },
516
+ rules: {},
517
+ });
518
+ const { errors } = compileClaude(spec);
519
+ assert.ok(!errors.some((e) => e.type === "section-too-long"));
520
+ });
521
+ });
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Reserved section keys (#4)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ describe("reserved section keys", () => {
528
+ it("errors when section key is 'commands'", () => {
529
+ const spec = claude({
530
+ sections: { commands: "Should use the commands field instead." },
531
+ rules: {},
532
+ });
533
+ const { errors } = compileClaude(spec);
534
+ assert.ok(errors.some((e) => e.type === "reserved-section-key"));
535
+ });
536
+
537
+ it("errors when section key is 'rules'", () => {
538
+ const spec = claude({
539
+ sections: { rules: "Should use the rules field instead." },
540
+ rules: {},
541
+ });
542
+ const { errors } = compileClaude(spec);
543
+ assert.ok(errors.some((e) => e.type === "reserved-section-key"));
544
+ });
545
+
546
+ it("errors when section key is 'keyFiles'", () => {
547
+ const spec = claude({
548
+ sections: { keyFiles: "Should use the keyFiles field instead." },
549
+ rules: {},
550
+ });
551
+ const { errors } = compileClaude(spec);
552
+ assert.ok(errors.some((e) => e.type === "reserved-section-key"));
553
+ });
554
+
555
+ it("allows non-reserved section keys", () => {
556
+ const spec = claude({
557
+ sections: { architecture: "This is fine." },
558
+ rules: {},
559
+ });
560
+ const { errors } = compileClaude(spec);
561
+ assert.ok(!errors.some((e) => e.type === "reserved-section-key"));
562
+ });
563
+ });
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Per-spec maxSectionLines (#5)
567
+ // ---------------------------------------------------------------------------
568
+
569
+ describe("per-spec maxSectionLines", () => {
570
+ it("uses spec.maxSectionLines when set", () => {
571
+ const longContent = Array.from(
572
+ { length: 30 },
573
+ (_, i) => `Line ${String(i + 1)}`,
574
+ ).join("\n");
575
+ const spec = claude({
576
+ sections: { wall: longContent },
577
+ maxSectionLines: 20,
578
+ rules: {},
579
+ });
580
+ const { errors } = compileClaude(spec);
581
+ assert.ok(errors.some((e) => e.type === "section-too-long"));
582
+ });
583
+
584
+ it("compile option overrides spec maxSectionLines", () => {
585
+ const longContent = Array.from(
586
+ { length: 30 },
587
+ (_, i) => `Line ${String(i + 1)}`,
588
+ ).join("\n");
589
+ const spec = claude({
590
+ sections: { wall: longContent },
591
+ maxSectionLines: 50, // spec says 50, which would pass
592
+ rules: {},
593
+ });
594
+ // But compile option says 20, which is stricter
595
+ // Actually spec takes precedence — let's verify:
596
+ const { errors } = compileClaude(spec, { maxSectionLines: 10 });
597
+ // spec.maxSectionLines (50) takes precedence over options (10)
598
+ assert.ok(!errors.some((e) => e.type === "section-too-long"));
599
+ });
600
+
601
+ it("falls back to compile option when spec has no maxSectionLines", () => {
602
+ const longContent = Array.from(
603
+ { length: 30 },
604
+ (_, i) => `Line ${String(i + 1)}`,
605
+ ).join("\n");
606
+ const spec = claude({
607
+ sections: { wall: longContent },
608
+ rules: {},
609
+ });
610
+ const { errors } = compileClaude(spec, { maxSectionLines: 20 });
611
+ assert.ok(errors.some((e) => e.type === "section-too-long"));
612
+ });
613
+ });
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // Sections with file() refs
617
+ // ---------------------------------------------------------------------------
618
+
619
+ describe("sections with refs", () => {
620
+ it("compiles sections with file() refs and validates them", () => {
621
+ const spec = claude({
622
+ sections: {
623
+ architecture: instructions`Core engine in ${file("src/spec.ts")}.`,
624
+ },
625
+ rules: {},
626
+ });
627
+ const { markdown, errors } = compileClaude(spec, {
628
+ basePath: process.cwd(),
629
+ });
630
+ assert.ok(markdown.includes("`src/spec.ts`"));
631
+ assert.equal(errors.length, 0);
632
+ });
633
+
634
+ it("reports stale file refs in sections", () => {
635
+ const spec = claude({
636
+ sections: {
637
+ architecture: instructions`See ${file("src/nonexistent-xyz.ts")}.`,
638
+ },
639
+ rules: {},
640
+ });
641
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
642
+ assert.equal(errors.length, 1);
643
+ assert.equal(errors[0].type, "stale-file");
644
+ });
645
+ });
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // generate-types tests
649
+ // ---------------------------------------------------------------------------
650
+
651
+ describe("generateTypes()", () => {
652
+ it("discovers eslint rules from this project", () => {
653
+ const result = generateTypes({ basePath: process.cwd() });
654
+ const eslint = result.linters.find((l) => l.linter === "eslint");
655
+ assert.ok(eslint, "ESLint should be detected");
656
+ assert.ok(eslint.rules.length > 0, "Should find enabled rules");
657
+ assert.ok(
658
+ eslint.rules.includes("no-unused-vars"),
659
+ "Should include no-unused-vars",
660
+ );
661
+ });
662
+
663
+ it("discovers npm scripts", () => {
664
+ const result = generateTypes({ basePath: process.cwd() });
665
+ assert.ok(result.scripts.includes("build"));
666
+ assert.ok(result.scripts.includes("test"));
667
+ });
668
+
669
+ it("discovers project files", () => {
670
+ const result = generateTypes({ basePath: process.cwd() });
671
+ assert.ok(result.files.includes("src/spec.ts"));
672
+ assert.ok(result.files.includes("src/compile.ts"));
673
+ });
674
+
675
+ it("generates valid .d.ts content", () => {
676
+ const result = generateTypes({ basePath: process.cwd() });
677
+ assert.ok(result.dts.includes('declare module "vigiles/generated"'));
678
+ assert.ok(result.dts.includes("export type EslintRule"));
679
+ assert.ok(result.dts.includes("export type NpmScript"));
680
+ assert.ok(result.dts.includes("export type ProjectFile"));
681
+ });
682
+
683
+ it("generates vigiles/spec augmentation for KnownLinterRules", () => {
684
+ const result = generateTypes({ basePath: process.cwd() });
685
+ assert.ok(
686
+ result.dts.includes('declare module "vigiles/spec"'),
687
+ "Should augment vigiles/spec",
688
+ );
689
+ assert.ok(
690
+ result.dts.includes("interface KnownLinterRules"),
691
+ "Should populate KnownLinterRules",
692
+ );
693
+ assert.ok(
694
+ result.dts.includes('"eslint"'),
695
+ "Should include eslint key in KnownLinterRules",
696
+ );
697
+ });
698
+
699
+ it("generates KnownProjectFiles augmentation", () => {
700
+ const result = generateTypes({ basePath: process.cwd() });
701
+ assert.ok(
702
+ result.dts.includes("interface KnownProjectFiles"),
703
+ "Should populate KnownProjectFiles",
704
+ );
705
+ });
706
+
707
+ it("generates KnownNpmScripts augmentation", () => {
708
+ const result = generateTypes({ basePath: process.cwd() });
709
+ assert.ok(
710
+ result.dts.includes("interface KnownNpmScripts"),
711
+ "Should populate KnownNpmScripts",
712
+ );
713
+ });
714
+
715
+ it("respects custom file globs", () => {
716
+ const result = generateTypes({
717
+ basePath: process.cwd(),
718
+ fileGlobs: ["examples/**/*"],
719
+ });
720
+ assert.ok(result.files.some((f) => f.startsWith("examples/")));
721
+ assert.ok(!result.files.some((f) => f.startsWith("src/")));
722
+ });
723
+
724
+ it("generates syntactically valid .d.ts", () => {
725
+ const result = generateTypes({ basePath: process.cwd() });
726
+
727
+ // Write only the .d.ts and type-check it in isolation
728
+ const tmpDir = join(process.cwd(), ".vigiles-test-types-tmp");
729
+ mkdirSync(tmpDir, { recursive: true });
730
+ try {
731
+ writeFileSync(join(tmpDir, "generated.d.ts"), result.dts);
732
+ writeFileSync(
733
+ join(tmpDir, "tsconfig.json"),
734
+ JSON.stringify({
735
+ compilerOptions: { strict: true, noEmit: true },
736
+ include: ["generated.d.ts"],
737
+ }),
738
+ );
739
+
740
+ const { execSync } =
741
+ require("node:child_process") as typeof import("node:child_process");
742
+ execSync("npx tsc --noEmit", {
743
+ cwd: tmpDir,
744
+ stdio: ["pipe", "pipe", "pipe"],
745
+ timeout: 15000,
746
+ });
747
+ } finally {
748
+ rmSync(tmpDir, { recursive: true, force: true });
749
+ }
750
+ });
751
+ });
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // Linter integration tests (checkLinterRule)
755
+ // ---------------------------------------------------------------------------
756
+
757
+ describe("checkLinterRule()", () => {
758
+ it("detects eslint built-in rules", () => {
759
+ const result = checkLinterRule("eslint/no-console", process.cwd());
760
+ assert.equal(result.exists, true);
761
+ assert.equal(result.linter, "eslint");
762
+ assert.equal(result.rule, "no-console");
763
+ });
764
+
765
+ it("errors on nonexistent eslint rule", () => {
766
+ const result = checkLinterRule(
767
+ "eslint/completely-fake-rule-xyz",
768
+ process.cwd(),
769
+ );
770
+ assert.equal(result.exists, false);
771
+ assert.ok(result.error?.includes("completely-fake-rule-xyz"));
772
+ });
773
+
774
+ it("detects ruff rules via CLI", () => {
775
+ const result = checkLinterRule("ruff/E501", process.cwd());
776
+ assert.equal(result.exists, true);
777
+ assert.equal(result.linter, "ruff");
778
+ });
779
+
780
+ it("errors on nonexistent ruff rule", () => {
781
+ const result = checkLinterRule("ruff/FAKE999", process.cwd());
782
+ assert.equal(result.exists, false);
783
+ assert.ok(result.error?.includes("FAKE999"));
784
+ });
785
+
786
+ it("detects clippy rules via CLI", () => {
787
+ const result = checkLinterRule("clippy/needless_return", process.cwd());
788
+ assert.equal(result.exists, true);
789
+ assert.equal(result.linter, "clippy");
790
+ });
791
+
792
+ it("errors on nonexistent clippy lint", () => {
793
+ const result = checkLinterRule(
794
+ "clippy/completely_fake_lint_xyz",
795
+ process.cwd(),
796
+ );
797
+ assert.equal(result.exists, false);
798
+ assert.ok(result.error?.includes("completely_fake_lint_xyz"));
799
+ });
800
+
801
+ it("detects pylint rules via CLI", () => {
802
+ const result = checkLinterRule("pylint/C0301", process.cwd());
803
+ assert.equal(result.exists, true);
804
+ assert.equal(result.linter, "pylint");
805
+ });
806
+
807
+ it("errors on nonexistent pylint rule", () => {
808
+ const result = checkLinterRule("pylint/ZZZZ9999", process.cwd());
809
+ assert.equal(result.exists, false);
810
+ assert.ok(result.error?.includes("ZZZZ9999"));
811
+ });
812
+
813
+ it("detects rubocop cops via CLI", () => {
814
+ const result = checkLinterRule(
815
+ "rubocop/Style/FrozenStringLiteralComment",
816
+ process.cwd(),
817
+ );
818
+ assert.equal(result.exists, true);
819
+ assert.equal(result.linter, "rubocop");
820
+ });
821
+
822
+ it("errors on nonexistent rubocop cop", () => {
823
+ const result = checkLinterRule(
824
+ "rubocop/Fake/NonExistentCop",
825
+ process.cwd(),
826
+ );
827
+ assert.equal(result.exists, false);
828
+ assert.ok(result.error?.includes("Fake/NonExistentCop"));
829
+ });
830
+
831
+ it("rejects unsafe rule names", () => {
832
+ const result = checkLinterRule(
833
+ "eslint/no-console; rm -rf /",
834
+ process.cwd(),
835
+ );
836
+ assert.equal(result.exists, false);
837
+ assert.ok(result.error?.includes("Invalid rule reference"));
838
+ });
839
+
840
+ it("handles unknown linters gracefully", () => {
841
+ const result = checkLinterRule("unknown-tool/some-rule", process.cwd());
842
+ assert.equal(result.exists, false);
843
+ assert.ok(result.error?.includes("Unknown linter"));
844
+ });
845
+
846
+ it("checks custom rulesDir", () => {
847
+ const tmpDir = join(process.cwd(), ".vigiles-test-linter-tmp");
848
+ const rulesDir = join(tmpDir, "my-rules");
849
+ mkdirSync(rulesDir, { recursive: true });
850
+ try {
851
+ writeFileSync(join(rulesDir, "check-foo.js"), "module.exports = {};\n");
852
+
853
+ const found = checkLinterRule("my-tool/check-foo", tmpDir, {
854
+ linters: { "my-tool": { rulesDir: "my-rules" } },
855
+ });
856
+ assert.equal(found.exists, true);
857
+
858
+ const missing = checkLinterRule("my-tool/check-bar", tmpDir, {
859
+ linters: { "my-tool": { rulesDir: "my-rules" } },
860
+ });
861
+ assert.equal(missing.exists, false);
862
+ } finally {
863
+ rmSync(tmpDir, { recursive: true, force: true });
864
+ }
865
+ });
866
+ });
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // End-to-end: enforce() → compileClaude → linter verification
870
+ // ---------------------------------------------------------------------------
871
+
872
+ describe("enforce() linter integration in compileClaude", () => {
873
+ it("verifies eslint rules during compilation", () => {
874
+ const spec = claude({
875
+ rules: {
876
+ "no-console": enforce("eslint/no-console", "Use logger."),
877
+ },
878
+ });
879
+ const { errors, linterResults } = compileClaude(spec, {
880
+ basePath: process.cwd(),
881
+ });
882
+ assert.equal(linterResults.length, 1);
883
+ assert.equal(linterResults[0].exists, true);
884
+ assert.equal(errors.filter((e) => e.type === "invalid-rule").length, 0);
885
+ });
886
+
887
+ it("errors on nonexistent linter rule during compilation", () => {
888
+ const spec = claude({
889
+ rules: {
890
+ fake: enforce("eslint/completely-fake-xyz", "Doesn't exist."),
891
+ },
892
+ });
893
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
894
+ assert.ok(errors.some((e) => e.type === "invalid-rule"));
895
+ assert.ok(errors.some((e) => e.message.includes("completely-fake-xyz")));
896
+ });
897
+
898
+ it("respects catalogOnly option", () => {
899
+ const spec = claude({
900
+ rules: {
901
+ "no-console": enforce("eslint/no-console", "Use logger."),
902
+ },
903
+ });
904
+ const { linterResults } = compileClaude(spec, {
905
+ basePath: process.cwd(),
906
+ catalogOnly: true,
907
+ });
908
+ assert.equal(linterResults.length, 1);
909
+ assert.equal(linterResults[0].exists, true);
910
+ });
911
+ });
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // Linter verification escape hatches
915
+ // ---------------------------------------------------------------------------
916
+
917
+ describe("linter verification disable options", () => {
918
+ it("per-rule: verify: false skips linter check", () => {
919
+ const spec = claude({
920
+ rules: {
921
+ "fake-rule": enforce("eslint/totally-fake-xyz", "Doesn't exist.", {
922
+ verify: false,
923
+ }),
924
+ },
925
+ });
926
+ const { errors, linterResults } = compileClaude(spec, {
927
+ basePath: process.cwd(),
928
+ });
929
+ // Should NOT produce errors or linter results — verification skipped
930
+ assert.equal(linterResults.length, 0);
931
+ assert.ok(!errors.some((e) => e.type === "invalid-rule"));
932
+ });
933
+
934
+ it("per-rule: verify: true (default) checks linter", () => {
935
+ const spec = claude({
936
+ rules: {
937
+ "fake-rule": enforce("eslint/totally-fake-xyz", "Doesn't exist."),
938
+ },
939
+ });
940
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
941
+ assert.ok(errors.some((e) => e.type === "invalid-rule"));
942
+ });
943
+
944
+ it("global: verifyLinters: false skips ALL linter checks", () => {
945
+ const spec = claude({
946
+ rules: {
947
+ "fake-a": enforce("eslint/fake-a-xyz", "Nope."),
948
+ "fake-b": enforce("ruff/FAKE999", "Nope."),
949
+ "real-rule": enforce("eslint/no-console", "Use logger."),
950
+ },
951
+ });
952
+ const { errors, linterResults } = compileClaude(spec, {
953
+ basePath: process.cwd(),
954
+ verifyLinters: false,
955
+ });
956
+ // No linter results at all — everything skipped
957
+ assert.equal(linterResults.length, 0);
958
+ assert.ok(!errors.some((e) => e.type === "invalid-rule"));
959
+ });
960
+
961
+ it("per-linter: false skips that linter only", () => {
962
+ const spec = claude({
963
+ rules: {
964
+ "eslint-fake": enforce("eslint/fake-xyz", "Nope."),
965
+ "ruff-fake": enforce("ruff/FAKE999", "Nope."),
966
+ },
967
+ });
968
+ const { errors, linterResults } = compileClaude(spec, {
969
+ basePath: process.cwd(),
970
+ linterModes: { eslint: false },
971
+ });
972
+ // eslint skipped, ruff still checked
973
+ assert.ok(!linterResults.some((r) => r.linter === "eslint"));
974
+ assert.ok(linterResults.some((r) => r.linter === "ruff"));
975
+ // Only ruff error, not eslint
976
+ assert.ok(errors.some((e) => e.message.includes("FAKE999")));
977
+ assert.ok(!errors.some((e) => e.message.includes("fake-xyz")));
978
+ });
979
+
980
+ it("per-linter: catalog-only skips config check", () => {
981
+ const spec = claude({
982
+ rules: {
983
+ "no-console": enforce("eslint/no-console", "Use logger."),
984
+ },
985
+ });
986
+ const { linterResults } = compileClaude(spec, {
987
+ basePath: process.cwd(),
988
+ linterModes: { eslint: "catalog-only" },
989
+ });
990
+ assert.equal(linterResults.length, 1);
991
+ assert.equal(linterResults[0].exists, true);
992
+ // In catalog-only mode, config-enabled check is skipped
993
+ });
994
+
995
+ it("per-rule verify: false takes priority over global verifyLinters: true", () => {
996
+ const spec = claude({
997
+ rules: {
998
+ "skip-this": enforce("eslint/fake-xyz", "Skip.", { verify: false }),
999
+ "check-this": enforce("eslint/no-console", "Check."),
1000
+ },
1001
+ });
1002
+ const { linterResults } = compileClaude(spec, {
1003
+ basePath: process.cwd(),
1004
+ });
1005
+ // Only the verified rule produces a result
1006
+ assert.equal(linterResults.length, 1);
1007
+ assert.equal(linterResults[0].rule, "no-console");
1008
+ });
1009
+
1010
+ it("global verifyLinters: false overrides per-linter modes", () => {
1011
+ const spec = claude({
1012
+ rules: {
1013
+ "no-console": enforce("eslint/no-console", "Use logger."),
1014
+ },
1015
+ });
1016
+ const { linterResults } = compileClaude(spec, {
1017
+ basePath: process.cwd(),
1018
+ verifyLinters: false,
1019
+ linterModes: { eslint: true },
1020
+ });
1021
+ // Global kill switch wins
1022
+ assert.equal(linterResults.length, 0);
1023
+ });
1024
+ });
1025
+
1026
+ // ---------------------------------------------------------------------------
1027
+ // adoptDiff() tests
1028
+ // ---------------------------------------------------------------------------
1029
+
1030
+ describe("adoptDiff()", () => {
1031
+ it("detects unchanged compiled file", () => {
1032
+ const spec = claude({
1033
+ rules: { test: guidance("Hello.") },
1034
+ });
1035
+ const tmpDir = join(process.cwd(), ".vigiles-test-adopt-tmp");
1036
+ mkdirSync(tmpDir, { recursive: true });
1037
+ try {
1038
+ const { markdown } = compileClaude(spec, {
1039
+ basePath: tmpDir,
1040
+ specFile: "CLAUDE.md.spec.ts",
1041
+ });
1042
+ writeFileSync(join(tmpDir, "CLAUDE.md"), markdown);
1043
+
1044
+ const result = adoptDiff("CLAUDE.md", spec, tmpDir);
1045
+ assert.equal(result.hasHash, true);
1046
+ assert.equal(result.valid, true);
1047
+ assert.equal(result.changed, false);
1048
+ } finally {
1049
+ rmSync(tmpDir, { recursive: true, force: true });
1050
+ }
1051
+ });
1052
+
1053
+ it("detects manually edited file", () => {
1054
+ const spec = claude({
1055
+ rules: { test: guidance("Hello.") },
1056
+ });
1057
+ const tmpDir = join(process.cwd(), ".vigiles-test-adopt-edit-tmp");
1058
+ mkdirSync(tmpDir, { recursive: true });
1059
+ try {
1060
+ const { markdown } = compileClaude(spec, {
1061
+ basePath: tmpDir,
1062
+ specFile: "CLAUDE.md.spec.ts",
1063
+ });
1064
+ // Manually add a line
1065
+ const tampered =
1066
+ markdown + "\n### Hand-written rule\nSome extra content.\n";
1067
+ writeFileSync(join(tmpDir, "CLAUDE.md"), tampered);
1068
+
1069
+ const result = adoptDiff("CLAUDE.md", spec, tmpDir);
1070
+ assert.equal(result.hasHash, true);
1071
+ assert.equal(result.valid, false);
1072
+ assert.equal(result.changed, true);
1073
+ assert.ok(result.addedLines.some((l) => l.includes("Hand-written rule")));
1074
+ } finally {
1075
+ rmSync(tmpDir, { recursive: true, force: true });
1076
+ }
1077
+ });
1078
+
1079
+ it("handles file without hash", () => {
1080
+ const spec = claude({ rules: {} });
1081
+ const tmpDir = join(process.cwd(), ".vigiles-test-adopt-nohash-tmp");
1082
+ mkdirSync(tmpDir, { recursive: true });
1083
+ try {
1084
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# Hand-written\n");
1085
+ const result = adoptDiff("CLAUDE.md", spec, tmpDir);
1086
+ assert.equal(result.hasHash, false);
1087
+ } finally {
1088
+ rmSync(tmpDir, { recursive: true, force: true });
1089
+ }
1090
+ });
1091
+ });
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // Type system features (#1, #5, #6, #7)
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ describe("type exports", () => {
1098
+ it("exports strict type aliases", () => {
1099
+ // Verify the types exist and are usable at runtime (type-only check
1100
+ // happens at tsc time, but we can verify the imports resolve).
1101
+ void ("eslint/no-console" as StrictLinterRule);
1102
+ void ("src/spec.ts" as StrictFile);
1103
+ void ("npm run build" as StrictCmd);
1104
+ assert.ok(true, "strict types are importable");
1105
+ });
1106
+
1107
+ it("exports augmentation interfaces (empty by default)", () => {
1108
+ // Without generated types, the interfaces have no keys.
1109
+ // This test just verifies they're importable.
1110
+ type HasNoKeys = [keyof KnownLinterRules] extends [never] ? true : false;
1111
+ const result: HasNoKeys = true;
1112
+ assert.equal(result, true);
1113
+ });
1114
+
1115
+ it("exports phantom type brands", () => {
1116
+ // Verify pipeline stage types are importable
1117
+ void (null as unknown as RawSpec);
1118
+ void (null as unknown as RefsValidated);
1119
+ void (null as unknown as LintersVerified);
1120
+ void (null as unknown as ReadyToEmit);
1121
+ assert.ok(true, "phantom types are importable");
1122
+ });
1123
+
1124
+ it("claude() stores maxSectionLines on the spec", () => {
1125
+ const spec = claude({
1126
+ sections: { about: "Hello" },
1127
+ maxSectionLines: 25,
1128
+ rules: {},
1129
+ });
1130
+ assert.equal(spec.maxSectionLines, 25);
1131
+ });
1132
+
1133
+ it("claude() without sections has no maxSectionLines", () => {
1134
+ const spec = claude({ rules: {} });
1135
+ assert.equal(spec.maxSectionLines, undefined);
1136
+ });
1137
+
1138
+ it("SpecPath and OutputPath are inverse type-level operations", () => {
1139
+ // SpecPath<"CLAUDE.md"> = "CLAUDE.md.spec.ts"
1140
+ void ("CLAUDE.md.spec.ts" as SpecPath<"CLAUDE.md">);
1141
+ // OutputPath<"CLAUDE.md.spec.ts"> = "CLAUDE.md"
1142
+ void ("CLAUDE.md" as OutputPath<"CLAUDE.md.spec.ts">);
1143
+ assert.ok(true, "spec path types are importable and correct");
1144
+ });
1145
+ });
1146
+
1147
+ // ---------------------------------------------------------------------------
1148
+ // Spec file naming convention (#11)
1149
+ // ---------------------------------------------------------------------------
1150
+
1151
+ describe("spec file naming convention", () => {
1152
+ it("accepts valid CLAUDE.md.spec.ts name", () => {
1153
+ const spec = claude({ rules: {} });
1154
+ const { errors } = compileClaude(spec, {
1155
+ specFile: "CLAUDE.md.spec.ts",
1156
+ });
1157
+ assert.ok(!errors.some((e) => e.type === "spec-name-mismatch"));
1158
+ });
1159
+
1160
+ it("accepts valid nested path spec name", () => {
1161
+ const spec = claude({ rules: {} });
1162
+ const { errors } = compileClaude(spec, {
1163
+ specFile: "src/CLAUDE.md.spec.ts",
1164
+ });
1165
+ assert.ok(!errors.some((e) => e.type === "spec-name-mismatch"));
1166
+ });
1167
+
1168
+ it("errors when spec file does not end with .spec.ts", () => {
1169
+ const spec = claude({ rules: {} });
1170
+ const { errors } = compileClaude(spec, {
1171
+ specFile: "CLAUDE.md.ts",
1172
+ });
1173
+ assert.ok(errors.some((e) => e.type === "spec-name-mismatch"));
1174
+ });
1175
+
1176
+ it("errors when spec file does not match target", () => {
1177
+ const spec = claude({ rules: {} });
1178
+ const { errors } = compileClaude(spec, {
1179
+ specFile: "AGENTS.md.spec.ts",
1180
+ });
1181
+ // Default target is CLAUDE.md, but spec says AGENTS.md
1182
+ assert.ok(errors.some((e) => e.type === "spec-name-mismatch"));
1183
+ assert.ok(errors[0].message.includes("doesn't match"));
1184
+ });
1185
+
1186
+ it("accepts SKILL.md.spec.ts for skills", () => {
1187
+ const spec = skill({
1188
+ name: "test",
1189
+ description: "Test skill",
1190
+ body: "Do the thing.",
1191
+ });
1192
+ const { errors } = compileSkill(spec, {
1193
+ specFile: "skills/test/SKILL.md.spec.ts",
1194
+ });
1195
+ assert.ok(!errors.some((e) => e.type === "spec-name-mismatch"));
1196
+ });
1197
+
1198
+ it("errors for skills with wrong spec name", () => {
1199
+ const spec = skill({
1200
+ name: "test",
1201
+ description: "Test skill",
1202
+ body: "Do the thing.",
1203
+ });
1204
+ const { errors } = compileSkill(spec, {
1205
+ specFile: "skills/test/skill.spec.ts",
1206
+ });
1207
+ assert.ok(errors.some((e) => e.type === "spec-name-mismatch"));
1208
+ });
1209
+ });
1210
+
1211
+ // ---------------------------------------------------------------------------
1212
+ // Output target
1213
+ // ---------------------------------------------------------------------------
1214
+
1215
+ describe("output target", () => {
1216
+ it("defaults to CLAUDE.md heading", () => {
1217
+ const spec = claude({ rules: {} });
1218
+ const { markdown } = compileClaude(spec);
1219
+ assert.ok(markdown.includes("# CLAUDE.md"));
1220
+ });
1221
+
1222
+ it("uses custom target for heading", () => {
1223
+ const spec = claude({ target: "AGENTS.md", rules: {} });
1224
+ const { markdown } = compileClaude(spec);
1225
+ assert.ok(markdown.includes("# AGENTS.md"));
1226
+ assert.ok(!markdown.includes("# CLAUDE.md"));
1227
+ });
1228
+
1229
+ it("defaults specFile based on target", () => {
1230
+ const spec = claude({ target: "AGENTS.md", rules: {} });
1231
+ // Without explicit specFile, it should derive from target
1232
+ const { markdown } = compileClaude(spec);
1233
+ assert.ok(markdown.includes("compiled from AGENTS.md.spec.ts"));
1234
+ });
1235
+
1236
+ it("accepts AGENTS.md.spec.ts naming for AGENTS.md target", () => {
1237
+ const spec = claude({ target: "AGENTS.md", rules: {} });
1238
+ const { errors } = compileClaude(spec, {
1239
+ specFile: "AGENTS.md.spec.ts",
1240
+ });
1241
+ assert.ok(!errors.some((e) => e.type === "spec-name-mismatch"));
1242
+ });
1243
+
1244
+ it("accepts custom target name", () => {
1245
+ const spec = claude({ target: "CODEX.md", rules: {} });
1246
+ const { markdown } = compileClaude(spec);
1247
+ assert.ok(markdown.includes("# CODEX.md"));
1248
+ });
1249
+
1250
+ it("returns all targets from array", () => {
1251
+ const spec = claude({
1252
+ target: ["CLAUDE.md", "AGENTS.md"],
1253
+ rules: {},
1254
+ });
1255
+ const { targets, markdown } = compileClaude(spec);
1256
+ assert.deepEqual(targets, ["CLAUDE.md", "AGENTS.md"]);
1257
+ // Primary target is first in array
1258
+ assert.ok(markdown.includes("# CLAUDE.md"));
1259
+ });
1260
+
1261
+ it("returns single target in targets array", () => {
1262
+ const spec = claude({ target: "AGENTS.md", rules: {} });
1263
+ const { targets } = compileClaude(spec);
1264
+ assert.deepEqual(targets, ["AGENTS.md"]);
1265
+ });
1266
+
1267
+ it("defaults targets to CLAUDE.md", () => {
1268
+ const spec = claude({ rules: {} });
1269
+ const { targets } = compileClaude(spec);
1270
+ assert.deepEqual(targets, ["CLAUDE.md"]);
1271
+ });
1272
+ });
1273
+
1274
+ // ---------------------------------------------------------------------------
1275
+ // Edge cases: empty inputs, boundaries, special characters
1276
+ // ---------------------------------------------------------------------------
1277
+
1278
+ describe("edge cases", () => {
1279
+ it("compileClaude with empty spec produces valid markdown", () => {
1280
+ const spec = claude({ rules: {} });
1281
+ const { markdown, errors, tokens } = compileClaude(spec);
1282
+ assert.ok(markdown.includes("# CLAUDE.md"));
1283
+ assert.equal(errors.length, 0);
1284
+ assert.ok(tokens > 0);
1285
+ });
1286
+
1287
+ it("compileClaude with only sections (no rules)", () => {
1288
+ const spec = claude({
1289
+ sections: { about: "This is a project." },
1290
+ rules: {},
1291
+ });
1292
+ const { markdown } = compileClaude(spec);
1293
+ assert.ok(markdown.includes("## About"));
1294
+ assert.ok(!markdown.includes("## Rules"));
1295
+ });
1296
+
1297
+ it("maxRules at exact boundary passes", () => {
1298
+ const rules: Record<string, ReturnType<typeof guidance>> = {};
1299
+ for (let i = 0; i < 3; i++) {
1300
+ rules[`rule-${String(i)}`] = guidance("test");
1301
+ }
1302
+ const spec = claude({ rules });
1303
+ const { errors } = compileClaude(spec, { maxRules: 3 });
1304
+ assert.ok(!errors.some((e) => e.type === "invalid-rule"));
1305
+ });
1306
+
1307
+ it("maxTokens at exact boundary passes", () => {
1308
+ const spec = claude({ rules: { a: guidance("x") } });
1309
+ const { tokens, errors } = compileClaude(spec, { maxTokens: 99999 });
1310
+ // Should pass — output is small
1311
+ assert.ok(!errors.some((e) => e.type === "budget-exceeded"));
1312
+ assert.ok(tokens > 0);
1313
+ });
1314
+
1315
+ it("estimateTokens with empty string returns 0", () => {
1316
+ assert.equal(estimateTokens(""), 0);
1317
+ });
1318
+
1319
+ it("computeHash with empty string is deterministic", () => {
1320
+ const a = computeHash("");
1321
+ const b = computeHash("");
1322
+ assert.equal(a, b);
1323
+ assert.ok(a.length > 0);
1324
+ });
1325
+
1326
+ it("rule ID with underscores compiles to title case", () => {
1327
+ const spec = claude({
1328
+ rules: { no_console_log: guidance("Don't.") },
1329
+ });
1330
+ const { markdown } = compileClaude(spec);
1331
+ assert.ok(markdown.includes("### No Console Log"));
1332
+ });
1333
+
1334
+ it("rule ID with hyphens compiles to title case", () => {
1335
+ const spec = claude({
1336
+ rules: { "barrel-imports-only": guidance("Use barrels.") },
1337
+ });
1338
+ const { markdown } = compileClaude(spec);
1339
+ assert.ok(markdown.includes("### Barrel Imports Only"));
1340
+ });
1341
+
1342
+ it("compileSkill with empty body", () => {
1343
+ const spec = skill({
1344
+ name: "empty",
1345
+ description: "Nothing",
1346
+ body: "",
1347
+ });
1348
+ const { markdown } = compileSkill(spec);
1349
+ assert.ok(markdown.includes("name: empty"));
1350
+ assert.ok(markdown.includes("description: Nothing"));
1351
+ });
1352
+
1353
+ it("verifyHash with malformed hash line returns null", () => {
1354
+ const result = verifyHash("<!-- vigiles:sha256:tooshort -->\n# Content\n");
1355
+ // Hash must match the full regex pattern
1356
+ assert.equal(result, null);
1357
+ });
1358
+
1359
+ it("multiple enforce rules all get linter-checked", () => {
1360
+ const spec = claude({
1361
+ rules: {
1362
+ "rule-a": enforce("eslint/no-console", "A"),
1363
+ "rule-b": enforce("eslint/no-debugger", "B"),
1364
+ "rule-c": guidance("Not checked."),
1365
+ },
1366
+ });
1367
+ const { linterResults } = compileClaude(spec, {
1368
+ basePath: process.cwd(),
1369
+ });
1370
+ // Only enforce() rules produce linter results
1371
+ assert.equal(linterResults.length, 2);
1372
+ assert.ok(linterResults.every((r) => r.exists));
1373
+ });
1374
+
1375
+ it("sections with file() ref to nonexistent file reports error", () => {
1376
+ const spec = claude({
1377
+ sections: {
1378
+ arch: instructions`See ${file("totally-fake-file-xyz.ts")}.`,
1379
+ },
1380
+ rules: {},
1381
+ });
1382
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
1383
+ assert.ok(errors.some((e) => e.type === "stale-file"));
1384
+ });
1385
+
1386
+ it("cmd() validation catches missing npm scripts in commands", () => {
1387
+ const spec = claude({
1388
+ commands: {
1389
+ "npm run nonexistent-xyz": "Does not exist",
1390
+ },
1391
+ rules: {},
1392
+ });
1393
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
1394
+ assert.ok(errors.some((e) => e.type === "stale-command"));
1395
+ });
1396
+
1397
+ it("cmd() validation passes for real npm scripts", () => {
1398
+ const spec = claude({
1399
+ commands: { "npm test": "Run tests", "npm run build": "Build" },
1400
+ rules: {},
1401
+ });
1402
+ const { errors } = compileClaude(spec, { basePath: process.cwd() });
1403
+ assert.ok(!errors.some((e) => e.type === "stale-command"));
1404
+ });
1405
+ });
1406
+
1407
+ // ---------------------------------------------------------------------------
1408
+ // End-to-end roundtrip: compile → hash → verify → adopt
1409
+ // ---------------------------------------------------------------------------
1410
+
1411
+ describe("compile → hash → verify → adopt roundtrip", () => {
1412
+ it("full lifecycle works end-to-end", () => {
1413
+ const spec = claude({
1414
+ commands: { "npm test": "Run tests" },
1415
+ keyFiles: { "src/spec.ts": "Spec system" },
1416
+ sections: { about: "A test project." },
1417
+ rules: {
1418
+ "no-console": enforce("eslint/no-console", "Use logger."),
1419
+ "no-unused": enforce("eslint/no-unused-vars", "Keep code clean."),
1420
+ "be-nice": guidance("Be nice to contributors."),
1421
+ },
1422
+ });
1423
+
1424
+ const tmpDir = join(process.cwd(), ".vigiles-test-roundtrip-tmp");
1425
+ mkdirSync(tmpDir, { recursive: true });
1426
+ try {
1427
+ // Step 1: Compile
1428
+ const { markdown, errors, linterResults, tokens } = compileClaude(spec, {
1429
+ basePath: process.cwd(),
1430
+ specFile: "CLAUDE.md.spec.ts",
1431
+ });
1432
+ assert.equal(errors.length, 0);
1433
+ assert.ok(tokens > 0);
1434
+ assert.ok(linterResults.length > 0);
1435
+
1436
+ // Step 2: Write compiled output
1437
+ const outPath = join(tmpDir, "CLAUDE.md");
1438
+ writeFileSync(outPath, markdown);
1439
+
1440
+ // Step 3: Verify hash
1441
+ const hashResult = checkFileHash(outPath);
1442
+ assert.equal(hashResult.hasHash, true);
1443
+ assert.equal(hashResult.valid, true);
1444
+ assert.equal(hashResult.specFile, "CLAUDE.md.spec.ts");
1445
+
1446
+ // Step 4: Adopt shows no changes
1447
+ const adoptResult = adoptDiff("CLAUDE.md", spec, tmpDir);
1448
+ assert.equal(adoptResult.valid, true);
1449
+ assert.equal(adoptResult.changed, false);
1450
+
1451
+ // Step 5: Manually edit the file
1452
+ const tampered = markdown.replace(
1453
+ "Be nice to contributors.",
1454
+ "Be VERY nice to contributors.",
1455
+ );
1456
+ writeFileSync(outPath, tampered);
1457
+
1458
+ // Step 6: Hash should now fail
1459
+ const hashResult2 = checkFileHash(outPath);
1460
+ assert.equal(hashResult2.valid, false);
1461
+
1462
+ // Step 7: Adopt detects the change
1463
+ const adoptResult2 = adoptDiff("CLAUDE.md", spec, tmpDir);
1464
+ assert.equal(adoptResult2.valid, false);
1465
+ assert.equal(adoptResult2.changed, true);
1466
+ assert.ok(adoptResult2.addedLines.some((l) => l.includes("VERY nice")));
1467
+ } finally {
1468
+ rmSync(tmpDir, { recursive: true, force: true });
1469
+ }
1470
+ });
1471
+ });