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,914 @@
1
+ /**
2
+ * CLI integration tests — spawn the actual vigiles CLI and verify output.
3
+ *
4
+ * These test the full flow: CLI → init/compile/audit → filesystem output.
5
+ */
6
+ import { describe, it, before, after } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import {
9
+ mkdtempSync,
10
+ writeFileSync,
11
+ readFileSync,
12
+ rmSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ symlinkSync,
16
+ copyFileSync,
17
+ } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+ import { execSync } from "node:child_process";
21
+
22
+ const CLI = resolve(__dirname, "..", "dist", "cli.js");
23
+
24
+ function run(
25
+ args: string,
26
+ cwd: string,
27
+ ): { stdout: string; stderr: string; exitCode: number } {
28
+ try {
29
+ const stdout = execSync(`node ${CLI} ${args}`, {
30
+ cwd,
31
+ encoding: "utf-8",
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ timeout: 30000,
34
+ });
35
+ return { stdout, stderr: "", exitCode: 0 };
36
+ } catch (e: unknown) {
37
+ const err = e as { stdout?: string; stderr?: string; status?: number };
38
+ return {
39
+ stdout: err.stdout ?? "",
40
+ stderr: err.stderr ?? "",
41
+ exitCode: err.status ?? 1,
42
+ };
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // vigiles init
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("CLI: vigiles init", () => {
51
+ let tmpDir: string;
52
+
53
+ before(() => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-init-"));
55
+ });
56
+
57
+ after(() => {
58
+ rmSync(tmpDir, { recursive: true, force: true });
59
+ });
60
+
61
+ it("should create CLAUDE.md.spec.ts by default", () => {
62
+ const { stdout, exitCode } = run("init", tmpDir);
63
+ assert.equal(exitCode, 0);
64
+ assert.ok(stdout.includes("Created CLAUDE.md.spec.ts"));
65
+ assert.ok(existsSync(join(tmpDir, "CLAUDE.md.spec.ts")));
66
+ });
67
+
68
+ it("should not overwrite existing spec", () => {
69
+ // Already created in previous test
70
+ const { stdout } = run("init", tmpDir);
71
+ assert.ok(stdout.includes("already exists"));
72
+ });
73
+
74
+ it("should create AGENTS.md.spec.ts with --target flag", () => {
75
+ const { stdout, exitCode } = run("init --target=AGENTS.md", tmpDir);
76
+ assert.equal(exitCode, 0);
77
+ assert.ok(stdout.includes("Created AGENTS.md.spec.ts"));
78
+ const content = readFileSync(join(tmpDir, "AGENTS.md.spec.ts"), "utf-8");
79
+ assert.ok(content.includes('target: "AGENTS.md"'));
80
+ });
81
+
82
+ it("should create custom target spec", () => {
83
+ const { exitCode } = run("init --target=CODEX.md", tmpDir);
84
+ assert.equal(exitCode, 0);
85
+ assert.ok(existsSync(join(tmpDir, "CODEX.md.spec.ts")));
86
+ const content = readFileSync(join(tmpDir, "CODEX.md.spec.ts"), "utf-8");
87
+ assert.ok(content.includes('target: "CODEX.md"'));
88
+ });
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // vigiles compile
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe("CLI: vigiles compile", () => {
96
+ let tmpDir: string;
97
+
98
+ before(() => {
99
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-compile-"));
100
+ });
101
+
102
+ after(() => {
103
+ rmSync(tmpDir, { recursive: true, force: true });
104
+ });
105
+
106
+ it("should report when no specs are found", () => {
107
+ const { stdout, exitCode } = run("compile", tmpDir);
108
+ assert.equal(exitCode, 0);
109
+ assert.ok(stdout.includes("No .spec.ts files found"));
110
+ });
111
+
112
+ it("should compile a spec", () => {
113
+ const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-"));
114
+ writeFileSync(
115
+ join(tmpDir, "package.json"),
116
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
117
+ );
118
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
119
+ writeFileSync(
120
+ join(tmpDir, "CLAUDE.md.spec.ts"),
121
+ `import { claude, guidance } from "${specSrc}";\nexport default claude({ rules: { r: guidance("test") } });\n`,
122
+ );
123
+ const { stdout, exitCode } = run("compile CLAUDE.md.spec.ts", tmpDir);
124
+ assert.equal(exitCode, 0, stdout);
125
+ assert.ok(stdout.includes("CLAUDE.md.spec.ts"));
126
+ rmSync(tmpDir, { recursive: true, force: true });
127
+ });
128
+
129
+ it("should compile subdirectory spec to same directory", () => {
130
+ const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-subdir-"));
131
+ writeFileSync(
132
+ join(tmpDir, "package.json"),
133
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
134
+ );
135
+ const subDir = join(tmpDir, "examples");
136
+ mkdirSync(subDir);
137
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
138
+ writeFileSync(
139
+ join(subDir, "CLAUDE.md.spec.ts"),
140
+ `import { claude, guidance } from "${specSrc}";\nexport default claude({ rules: { r: guidance("test") } });\n`,
141
+ );
142
+ const { stdout, exitCode } = run(
143
+ "compile examples/CLAUDE.md.spec.ts",
144
+ tmpDir,
145
+ );
146
+ assert.equal(exitCode, 0, stdout);
147
+ // Output should be in examples/, not root
148
+ assert.ok(
149
+ existsSync(join(subDir, "CLAUDE.md")),
150
+ "Expected examples/CLAUDE.md to exist",
151
+ );
152
+ assert.ok(
153
+ !existsSync(join(tmpDir, "CLAUDE.md")),
154
+ "Root CLAUDE.md should NOT exist — spec in subdirectory must write to same directory",
155
+ );
156
+ rmSync(tmpDir, { recursive: true, force: true });
157
+ });
158
+
159
+ it("should not clobber root spec output when compiling all specs", () => {
160
+ const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-noclobber-"));
161
+ writeFileSync(
162
+ join(tmpDir, "package.json"),
163
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
164
+ );
165
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
166
+
167
+ // Root spec
168
+ writeFileSync(
169
+ join(tmpDir, "CLAUDE.md.spec.ts"),
170
+ `import { claude, guidance } from "${specSrc}";\nexport default claude({ rules: { "root-rule": guidance("from root") } });\n`,
171
+ );
172
+ // Subdirectory spec
173
+ const subDir = join(tmpDir, "examples");
174
+ mkdirSync(subDir);
175
+ writeFileSync(
176
+ join(subDir, "CLAUDE.md.spec.ts"),
177
+ `import { claude, guidance } from "${specSrc}";\nexport default claude({ rules: { "sub-rule": guidance("from subdir") } });\n`,
178
+ );
179
+
180
+ const { stdout, exitCode } = run("compile", tmpDir);
181
+ assert.equal(exitCode, 0, stdout);
182
+
183
+ // Root CLAUDE.md should come from root spec
184
+ const rootMd = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
185
+ assert.ok(
186
+ rootMd.includes("from root"),
187
+ "Root CLAUDE.md should contain root spec content",
188
+ );
189
+ assert.ok(
190
+ !rootMd.includes("from subdir"),
191
+ "Root CLAUDE.md must not be overwritten by subdirectory spec",
192
+ );
193
+
194
+ // Subdirectory CLAUDE.md should come from subdirectory spec
195
+ const subMd = readFileSync(join(subDir, "CLAUDE.md"), "utf-8");
196
+ assert.ok(
197
+ subMd.includes("from subdir"),
198
+ "examples/CLAUDE.md should contain subdirectory spec content",
199
+ );
200
+
201
+ rmSync(tmpDir, { recursive: true, force: true });
202
+ });
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // vigiles audit
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe("CLI: vigiles audit", () => {
210
+ let tmpDir: string;
211
+
212
+ before(() => {
213
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-audit-"));
214
+ });
215
+
216
+ after(() => {
217
+ rmSync(tmpDir, { recursive: true, force: true });
218
+ });
219
+
220
+ it("should report when no instruction files are found", () => {
221
+ const { stdout, exitCode } = run("audit", tmpDir);
222
+ assert.equal(exitCode, 0);
223
+ assert.ok(stdout.includes("No compiled instruction files found"));
224
+ });
225
+
226
+ it("should include coverage and strengthen output", () => {
227
+ const { stdout } = run("audit", tmpDir);
228
+ // audit runs discover + strengthen in addition to verification
229
+ assert.ok(
230
+ stdout.includes("coverage") ||
231
+ stdout.includes("Linter") ||
232
+ stdout.includes("No .spec.ts"),
233
+ );
234
+ });
235
+
236
+ it("should detect duplicate rules via NCD", () => {
237
+ const dupDir = mkdtempSync(join(tmpdir(), "vigiles-audit-dup-"));
238
+ try {
239
+ writeFileSync(
240
+ join(dupDir, "package.json"),
241
+ JSON.stringify({ name: "test", scripts: {} }),
242
+ );
243
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
244
+ writeFileSync(
245
+ join(dupDir, "CLAUDE.md.spec.ts"),
246
+ `import { claude, guidance } from "${specSrc}";
247
+ export default claude({
248
+ rules: {
249
+ "use-logger": guidance("Always use the structured logger instead of console.log for production output."),
250
+ "logger-over-console": guidance("Use the structured logger instead of console.log in production code."),
251
+ "unrelated": guidance("Prefer composition over inheritance in class hierarchies."),
252
+ },
253
+ });
254
+ `,
255
+ );
256
+ const { stdout } = run("audit", dupDir);
257
+ // Should detect the two logger rules as near-duplicates
258
+ assert.ok(
259
+ stdout.includes("near-duplicate") || stdout.includes("duplicate"),
260
+ `Expected duplicate detection, got: ${stdout.slice(0, 500)}`,
261
+ );
262
+ } finally {
263
+ rmSync(dupDir, { recursive: true, force: true });
264
+ }
265
+ });
266
+
267
+ it("should skip inline verification for spec-managed files", () => {
268
+ // A file with a sibling .spec.ts (and compiled-from header) must
269
+ // not run inline verification, so literal vigiles:enforce snippets
270
+ // in prose cannot trip audit when the file is spec-managed.
271
+ // We use a sibling-.spec.ts with the spec snippet embedded as a
272
+ // prose section — compile generates the valid hash, then audit
273
+ // must ignore the inline marker in the output.
274
+ const specDir = mkdtempSync(join(tmpdir(), "vigiles-audit-spec-skip-"));
275
+ try {
276
+ writeFileSync(
277
+ join(specDir, "package.json"),
278
+ JSON.stringify({ name: "test", scripts: {} }),
279
+ );
280
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
281
+ writeFileSync(
282
+ join(specDir, "CLAUDE.md.spec.ts"),
283
+ `import { claude, guidance } from "${specSrc}";
284
+ export default claude({
285
+ sections: {
286
+ // A literal enforce marker embedded in prose — would be picked
287
+ // up as an inline rule if audit didn't skip spec-managed files.
288
+ example: 'Example: <!-- vigiles:enforce eslint/total-nonsense "prose" -->',
289
+ },
290
+ rules: {
291
+ "some-rule": guidance("Something."),
292
+ },
293
+ });
294
+ `,
295
+ );
296
+
297
+ // Compile first so CLAUDE.md has a valid hash.
298
+ const compileResult = run("compile", specDir);
299
+ assert.equal(
300
+ compileResult.exitCode,
301
+ 0,
302
+ `compile failed: ${compileResult.stdout}`,
303
+ );
304
+
305
+ const { stdout, exitCode } = run("audit CLAUDE.md", specDir);
306
+ // Should NOT surface the bogus rule as an inline error.
307
+ assert.ok(
308
+ !stdout.includes("total-nonsense"),
309
+ `Spec-managed file should skip inline verification, got: ${stdout.slice(0, 800)}`,
310
+ );
311
+ // And should not have exited with the hard-error code.
312
+ assert.notEqual(
313
+ exitCode,
314
+ 2,
315
+ `Expected no inline errors, got exit ${String(exitCode)}: ${stdout.slice(0, 600)}`,
316
+ );
317
+ } finally {
318
+ rmSync(specDir, { recursive: true, force: true });
319
+ }
320
+ });
321
+ });
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Inline mode E2E
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe("E2E: inline enforcement", () => {
328
+ let inlineDir: string;
329
+
330
+ before(() => {
331
+ inlineDir = mkdtempSync(join(tmpdir(), "vigiles-inline-e2e-"));
332
+ writeFileSync(
333
+ join(inlineDir, "package.json"),
334
+ JSON.stringify({ name: "test-inline", scripts: {} }),
335
+ );
336
+ // Symlink node_modules so checkLinterRule can find ESLint
337
+ // when the spawned CLI runs with cwd=inlineDir.
338
+ symlinkSync(
339
+ resolve(process.cwd(), "node_modules"),
340
+ join(inlineDir, "node_modules"),
341
+ );
342
+ // Also copy eslint config so the config checker can resolve rules
343
+ const eslintConfig = resolve(process.cwd(), "eslint.config.ts");
344
+ if (existsSync(eslintConfig)) {
345
+ copyFileSync(eslintConfig, join(inlineDir, "eslint.config.ts"));
346
+ }
347
+ });
348
+
349
+ after(() => {
350
+ rmSync(inlineDir, { recursive: true, force: true });
351
+ });
352
+
353
+ it("verifies valid inline enforce rules and exits clean", () => {
354
+ writeFileSync(
355
+ join(inlineDir, "CLAUDE.md"),
356
+ `# Project
357
+
358
+ <!-- vigiles:enforce eslint/no-console "Use structured logger" -->
359
+
360
+ All output goes through logger.ts.
361
+ `,
362
+ );
363
+ const { stdout, exitCode } = run("audit CLAUDE.md", inlineDir);
364
+ assert.ok(
365
+ stdout.includes("eslint/no-console"),
366
+ `Expected rule in output, got: ${stdout.slice(0, 600)}`,
367
+ );
368
+ // Exit code 0 means no hard errors (inline rule is valid)
369
+ assert.equal(exitCode, 0, `Expected clean exit, got ${String(exitCode)}`);
370
+ });
371
+
372
+ it("flags a typo'd inline rule with a closest-match suggestion", () => {
373
+ writeFileSync(
374
+ join(inlineDir, "CLAUDE.md"),
375
+ `# Project
376
+
377
+ <!-- vigiles:enforce eslint/no-consol "Typo check" -->
378
+
379
+ Some prose.
380
+ `,
381
+ );
382
+ const { stdout, exitCode } = run("audit CLAUDE.md", inlineDir);
383
+ assert.ok(
384
+ stdout.includes("no-consol"),
385
+ `Expected typo'd rule in output, got: ${stdout.slice(0, 600)}`,
386
+ );
387
+ assert.ok(
388
+ stdout.includes("Did you mean"),
389
+ `Expected closest-match suggestion, got: ${stdout.slice(0, 600)}`,
390
+ );
391
+ assert.equal(exitCode, 2, `Expected exit 2 on inline error`);
392
+ });
393
+
394
+ it("ignores inline markers inside fenced code blocks", () => {
395
+ writeFileSync(
396
+ join(inlineDir, "CLAUDE.md"),
397
+ `# Docs
398
+
399
+ Example usage:
400
+
401
+ \`\`\`md
402
+ <!-- vigiles:enforce eslint/totally-bogus "inside fence" -->
403
+ \`\`\`
404
+
405
+ Real rule:
406
+
407
+ <!-- vigiles:enforce eslint/no-console "outside fence" -->
408
+ `,
409
+ );
410
+ const { stdout, exitCode } = run("audit CLAUDE.md", inlineDir);
411
+ // The bogus rule inside the fence must NOT appear as an error
412
+ assert.ok(
413
+ !stdout.includes("totally-bogus"),
414
+ `Fenced marker should be skipped, got: ${stdout.slice(0, 600)}`,
415
+ );
416
+ assert.ok(
417
+ stdout.includes("no-console"),
418
+ `Real rule should be verified, got: ${stdout.slice(0, 600)}`,
419
+ );
420
+ assert.equal(exitCode, 0);
421
+ });
422
+
423
+ it("reports inline errors in --json output", () => {
424
+ writeFileSync(
425
+ join(inlineDir, "CLAUDE.md"),
426
+ `<!-- vigiles:enforce eslint/fake-rule-xyz "bad" -->`,
427
+ );
428
+ const { stdout, exitCode } = run("audit --json CLAUDE.md", inlineDir);
429
+ const report = JSON.parse(stdout) as {
430
+ inlineErrors: number;
431
+ inlineRules: number;
432
+ };
433
+ assert.ok(report.inlineErrors > 0, "Expected inlineErrors > 0");
434
+ assert.ok(report.inlineRules > 0, "Expected inlineRules > 0");
435
+ assert.equal(exitCode, 2);
436
+ });
437
+
438
+ it("reports inline rules in --summary output", () => {
439
+ writeFileSync(
440
+ join(inlineDir, "CLAUDE.md"),
441
+ `<!-- vigiles:enforce eslint/fake-rule-xyz "bad" -->`,
442
+ );
443
+ const { stdout, exitCode } = run("audit --summary CLAUDE.md", inlineDir);
444
+ assert.ok(
445
+ stdout.includes("inline"),
446
+ `Expected 'inline' in summary, got: ${stdout}`,
447
+ );
448
+ assert.equal(exitCode, 2);
449
+ });
450
+
451
+ it("satisfies require-spec when inline rules are present", () => {
452
+ // A file with inline rules but no .spec.ts should NOT trigger
453
+ // the require-spec validation warning.
454
+ writeFileSync(
455
+ join(inlineDir, "CLAUDE.md"),
456
+ `<!-- vigiles:enforce eslint/no-console "valid" -->
457
+
458
+ # Project
459
+ `,
460
+ );
461
+ const { stdout } = run("audit CLAUDE.md", inlineDir);
462
+ assert.ok(
463
+ !stdout.includes("require-spec"),
464
+ `require-spec should be satisfied by inline rules, got: ${stdout.slice(0, 600)}`,
465
+ );
466
+ });
467
+ });
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // vigiles generate-types
471
+ // ---------------------------------------------------------------------------
472
+
473
+ describe("CLI: vigiles generate-types", () => {
474
+ it("should generate types", () => {
475
+ const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-gen-"));
476
+ writeFileSync(
477
+ join(tmpDir, "package.json"),
478
+ JSON.stringify({ name: "test", scripts: { build: "echo ok" } }),
479
+ );
480
+ const { stdout, exitCode } = run("generate-types", tmpDir);
481
+ assert.equal(exitCode, 0);
482
+ assert.ok(stdout.includes("Generated"));
483
+ rmSync(tmpDir, { recursive: true, force: true });
484
+ });
485
+
486
+ it("should verify freshness with --check", () => {
487
+ // Use a temp dir so we don't modify the project's generated types
488
+ const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-types-check-"));
489
+ writeFileSync(
490
+ join(tmpDir, "package.json"),
491
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
492
+ );
493
+ // Generate types in temp dir
494
+ run("generate-types", tmpDir);
495
+ // Then check — should pass
496
+ const { exitCode } = run("generate-types --check", tmpDir);
497
+ assert.equal(exitCode, 0);
498
+ rmSync(tmpDir, { recursive: true, force: true });
499
+ });
500
+ });
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Multi-target compilation
504
+ // ---------------------------------------------------------------------------
505
+
506
+ describe("CLI: multi-target compile", () => {
507
+ let tmpDir: string;
508
+
509
+ before(() => {
510
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-multi-"));
511
+ // Create a spec with multiple targets
512
+ writeFileSync(
513
+ join(tmpDir, "CLAUDE.md.spec.ts"),
514
+ `import { claude, guidance } from "${resolve(process.cwd(), "src/spec.js")}";
515
+ export default claude({
516
+ target: ["CLAUDE.md", "AGENTS.md"],
517
+ rules: {
518
+ "test-rule": guidance("Test guidance."),
519
+ },
520
+ });
521
+ `,
522
+ );
523
+ });
524
+
525
+ after(() => {
526
+ rmSync(tmpDir, { recursive: true, force: true });
527
+ });
528
+
529
+ it("should compile to multiple targets", () => {
530
+ const { stdout, exitCode } = run("compile CLAUDE.md.spec.ts", tmpDir);
531
+ assert.equal(exitCode, 0);
532
+ assert.ok(stdout.includes("CLAUDE.md"));
533
+ assert.ok(stdout.includes("AGENTS.md"));
534
+
535
+ // Both files should exist
536
+ assert.ok(existsSync(join(tmpDir, "CLAUDE.md")));
537
+ assert.ok(existsSync(join(tmpDir, "AGENTS.md")));
538
+
539
+ // Primary has CLAUDE.md heading
540
+ const claude = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
541
+ assert.ok(claude.includes("# CLAUDE.md"));
542
+
543
+ // Secondary has AGENTS.md heading
544
+ const agents = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
545
+ assert.ok(agents.includes("# AGENTS.md"));
546
+ });
547
+ });
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // vigiles strengthen
551
+ // ---------------------------------------------------------------------------
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // vigiles init (was: vigiles setup)
555
+ // ---------------------------------------------------------------------------
556
+
557
+ describe("CLI: vigiles init (full setup)", () => {
558
+ let tmpDir: string;
559
+
560
+ before(() => {
561
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-setup-"));
562
+ // Need a package.json for generate-types
563
+ writeFileSync(
564
+ join(tmpDir, "package.json"),
565
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
566
+ );
567
+ });
568
+
569
+ after(() => {
570
+ rmSync(tmpDir, { recursive: true, force: true });
571
+ });
572
+
573
+ it("should create spec, generate types, and compile", () => {
574
+ const { stdout, exitCode } = run("init", tmpDir);
575
+ assert.equal(exitCode, 0);
576
+ assert.ok(stdout.includes("Created CLAUDE.md.spec.ts"));
577
+ assert.ok(stdout.includes("Setup complete"));
578
+ assert.ok(existsSync(join(tmpDir, "CLAUDE.md.spec.ts")));
579
+ assert.ok(existsSync(join(tmpDir, ".vigiles/generated.d.ts")));
580
+ });
581
+
582
+ it("should support --target flag", () => {
583
+ const { stdout, exitCode } = run("init --target=AGENTS.md", tmpDir);
584
+ assert.equal(exitCode, 0);
585
+ assert.ok(stdout.includes("AGENTS.md.spec.ts"));
586
+ assert.ok(existsSync(join(tmpDir, "AGENTS.md.spec.ts")));
587
+ });
588
+ });
589
+
590
+ describe("CLI: vigiles init auto-detection", () => {
591
+ let tmpDir: string;
592
+
593
+ before(() => {
594
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-cli-detect-"));
595
+ writeFileSync(
596
+ join(tmpDir, "package.json"),
597
+ JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
598
+ );
599
+ });
600
+
601
+ after(() => {
602
+ rmSync(tmpDir, { recursive: true, force: true });
603
+ });
604
+
605
+ it("should detect existing CLAUDE.md and suggest migration", () => {
606
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# Hand-written\n");
607
+ const { stdout } = run("init", tmpDir);
608
+ assert.ok(stdout.includes("without a spec") || stdout.includes("migrate"));
609
+ });
610
+
611
+ it("should detect .cursorrules and suggest sync tool", () => {
612
+ const dir = mkdtempSync(join(tmpdir(), "vigiles-detect-cursor-"));
613
+ writeFileSync(
614
+ join(dir, "package.json"),
615
+ JSON.stringify({ name: "test", scripts: {} }),
616
+ );
617
+ writeFileSync(join(dir, ".cursorrules"), "Use TypeScript.\n");
618
+ const { stdout } = run("init", dir);
619
+ assert.ok(stdout.includes("Cursor") || stdout.includes("Non-markdown"));
620
+ rmSync(dir, { recursive: true, force: true });
621
+ });
622
+
623
+ it("should detect .claude directory as Claude Code project", () => {
624
+ const dir = mkdtempSync(join(tmpdir(), "vigiles-detect-claude-"));
625
+ writeFileSync(
626
+ join(dir, "package.json"),
627
+ JSON.stringify({ name: "test", scripts: {} }),
628
+ );
629
+ mkdirSync(join(dir, ".claude"), { recursive: true });
630
+ const { stdout } = run("init", dir);
631
+ assert.ok(stdout.includes("Claude Code"));
632
+ rmSync(dir, { recursive: true, force: true });
633
+ });
634
+ });
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // Pre-edit hook (blocks compiled file edits)
638
+ // ---------------------------------------------------------------------------
639
+
640
+ describe("plugin hook: pre-edit.sh", () => {
641
+ let tmpDir: string;
642
+
643
+ before(() => {
644
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-pre-edit-"));
645
+ });
646
+
647
+ after(() => {
648
+ rmSync(tmpDir, { recursive: true, force: true });
649
+ });
650
+
651
+ it("should be executable with valid bash syntax", () => {
652
+ const hookPath = resolve(process.cwd(), ".claude-plugin/hooks/pre-edit.sh");
653
+ assert.ok(existsSync(hookPath));
654
+ execSync(`bash -n ${hookPath}`, { stdio: "pipe" });
655
+ });
656
+
657
+ it("should exit 0 for non-md files", () => {
658
+ const input = JSON.stringify({
659
+ tool_input: { file_path: join(tmpDir, "src/app.ts") },
660
+ });
661
+ const hookPath = resolve(process.cwd(), ".claude-plugin/hooks/pre-edit.sh");
662
+ execSync(`echo '${input}' | bash ${hookPath}`, {
663
+ cwd: tmpDir,
664
+ encoding: "utf-8",
665
+ stdio: ["pipe", "pipe", "pipe"],
666
+ });
667
+ // Should exit 0 (no block)
668
+ assert.ok(true);
669
+ });
670
+
671
+ it("should exit 2 for compiled md files", () => {
672
+ // Create a compiled .md file with vigiles hash
673
+ const mdPath = join(tmpDir, "CLAUDE.md");
674
+ writeFileSync(
675
+ mdPath,
676
+ "<!-- vigiles:sha256:abc123 compiled from CLAUDE.md.spec.ts -->\n# CLAUDE.md\n",
677
+ );
678
+ const input = JSON.stringify({ tool_input: { file_path: mdPath } });
679
+ const hookPath = resolve(process.cwd(), ".claude-plugin/hooks/pre-edit.sh");
680
+ try {
681
+ execSync(`echo '${input}' | bash ${hookPath}`, {
682
+ cwd: tmpDir,
683
+ encoding: "utf-8",
684
+ stdio: ["pipe", "pipe", "pipe"],
685
+ });
686
+ assert.fail("Should have exited with code 2");
687
+ } catch (e: unknown) {
688
+ const err = e as { status?: number; stderr?: string };
689
+ assert.equal(err.status, 2);
690
+ const stderr = err.stderr ?? "";
691
+ assert.ok(
692
+ stderr.includes("CLAUDE.md.spec.ts"),
693
+ "Should mention the spec file",
694
+ );
695
+ assert.ok(
696
+ stderr.includes("BLOCKED"),
697
+ "Should clearly indicate the action was blocked",
698
+ );
699
+ assert.ok(
700
+ stderr.includes("edit-spec"),
701
+ "Should reference the edit-spec skill",
702
+ );
703
+ }
704
+ });
705
+
706
+ it("should exit 0 for non-compiled md files", () => {
707
+ const mdPath = join(tmpDir, "HANDWRITTEN.md");
708
+ writeFileSync(mdPath, "# Hand-written\n\nNo vigiles hash.\n");
709
+ const input = JSON.stringify({ tool_input: { file_path: mdPath } });
710
+ const hookPath = resolve(process.cwd(), ".claude-plugin/hooks/pre-edit.sh");
711
+ execSync(`echo '${input}' | bash ${hookPath}`, {
712
+ cwd: tmpDir,
713
+ encoding: "utf-8",
714
+ stdio: ["pipe", "pipe", "pipe"],
715
+ });
716
+ // Should exit 0 (no block)
717
+ assert.ok(true);
718
+ });
719
+ });
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Plugin hook: post-edit
723
+ // ---------------------------------------------------------------------------
724
+
725
+ describe("plugin hook: post-edit.sh", () => {
726
+ it("should be executable", () => {
727
+ const hookPath = resolve(
728
+ process.cwd(),
729
+ ".claude-plugin/hooks/post-edit.sh",
730
+ );
731
+ assert.ok(existsSync(hookPath));
732
+ // Check it's parseable bash
733
+ try {
734
+ execSync(`bash -n ${hookPath}`, { stdio: "pipe" });
735
+ } catch {
736
+ assert.fail("post-edit.sh has syntax errors");
737
+ }
738
+ });
739
+
740
+ it("should exit cleanly with empty input", () => {
741
+ const hookPath = resolve(
742
+ process.cwd(),
743
+ ".claude-plugin/hooks/post-edit.sh",
744
+ );
745
+ try {
746
+ execSync(`echo '{}' | bash ${hookPath}`, {
747
+ cwd: process.cwd(),
748
+ stdio: "pipe",
749
+ timeout: 5000,
750
+ });
751
+ } catch {
752
+ // Non-zero exit is ok — jq might fail on empty. Just shouldn't hang.
753
+ }
754
+ });
755
+
756
+ it("should match linter config files for type regeneration", () => {
757
+ // Test the case pattern matching by running a dry-run variant:
758
+ // Override npx to just echo, check the routing logic works.
759
+ const hookPath = resolve(
760
+ process.cwd(),
761
+ ".claude-plugin/hooks/post-edit.sh",
762
+ );
763
+
764
+ const configFiles = [
765
+ "eslint.config.mjs",
766
+ ".eslintrc.json",
767
+ "pyproject.toml",
768
+ "Cargo.toml",
769
+ "package.json",
770
+ ".stylelintrc.json",
771
+ ".rubocop.yml",
772
+ ];
773
+
774
+ for (const filename of configFiles) {
775
+ const input = JSON.stringify({
776
+ tool_input: { file_path: `/tmp/${filename}` },
777
+ });
778
+ // The hook should match these files — we can't easily verify it runs
779
+ // generate-types without mocking npx, but we can verify it doesn't crash.
780
+ try {
781
+ execSync(`echo '${input}' | bash ${hookPath}`, {
782
+ cwd: process.cwd(),
783
+ encoding: "utf-8",
784
+ stdio: ["pipe", "pipe", "pipe"],
785
+ timeout: 5000,
786
+ env: { ...process.env, PATH: "/nonexistent" }, // npx won't be found, but routing still works
787
+ });
788
+ } catch {
789
+ // Expected: npx not in PATH or vigiles not available in /tmp.
790
+ // The important thing is the script didn't error on the case match.
791
+ }
792
+ }
793
+ assert.ok(true, "All config files processed without crash");
794
+ });
795
+
796
+ it("should match .spec.ts files for compilation", () => {
797
+ const hookPath = resolve(
798
+ process.cwd(),
799
+ ".claude-plugin/hooks/post-edit.sh",
800
+ );
801
+ const input = JSON.stringify({
802
+ tool_input: { file_path: "/tmp/CLAUDE.md.spec.ts" },
803
+ });
804
+ try {
805
+ execSync(`echo '${input}' | bash ${hookPath}`, {
806
+ cwd: process.cwd(),
807
+ encoding: "utf-8",
808
+ stdio: ["pipe", "pipe", "pipe"],
809
+ timeout: 5000,
810
+ env: { ...process.env, PATH: "/nonexistent" },
811
+ });
812
+ } catch {
813
+ // Expected: npx not found. Routing logic still works.
814
+ }
815
+ assert.ok(true, ".spec.ts file processed without crash");
816
+ });
817
+
818
+ it("should not trigger for unrelated files", () => {
819
+ const hookPath = resolve(
820
+ process.cwd(),
821
+ ".claude-plugin/hooks/post-edit.sh",
822
+ );
823
+ const input = JSON.stringify({
824
+ tool_input: { file_path: "/tmp/src/app.ts" },
825
+ });
826
+ // Should exit 0 quickly — no case match, no npx call.
827
+ execSync(`echo '${input}' | bash ${hookPath}`, {
828
+ cwd: process.cwd(),
829
+ encoding: "utf-8",
830
+ stdio: ["pipe", "pipe", "pipe"],
831
+ timeout: 5000,
832
+ });
833
+ assert.ok(true, "Unrelated file skipped without triggering any action");
834
+ });
835
+ });
836
+
837
+ // ---------------------------------------------------------------------------
838
+ // E2E: fixture project — full adoption flow
839
+ // ---------------------------------------------------------------------------
840
+
841
+ describe("E2E: fixture project adoption", () => {
842
+ const FIXTURE = resolve(__dirname, "..", "fixtures", "example-project");
843
+ let workDir: string;
844
+
845
+ before(() => {
846
+ // Copy fixture to a temp dir so tests don't pollute it
847
+ workDir = mkdtempSync(join(tmpdir(), "vigiles-e2e-"));
848
+ execSync(`cp -r ${FIXTURE}/* ${workDir}/`, { stdio: "pipe" });
849
+ });
850
+
851
+ after(() => {
852
+ rmSync(workDir, { recursive: true, force: true });
853
+ });
854
+
855
+ it("setup detects existing hand-written CLAUDE.md", () => {
856
+ const { stdout } = run("init", workDir);
857
+ // Should detect CLAUDE.md without spec and suggest migration
858
+ assert.ok(
859
+ stdout.includes("without a spec") || stdout.includes("migrate"),
860
+ "Should detect hand-written CLAUDE.md",
861
+ );
862
+ });
863
+
864
+ it("audit detects CLAUDE.md has no vigiles hash", () => {
865
+ const { stdout } = run("audit", workDir);
866
+ // Hand-written CLAUDE.md has no hash — should report it
867
+ assert.ok(
868
+ stdout.includes("no vigiles hash") ||
869
+ stdout.includes("require-spec") ||
870
+ stdout.includes("Verifying"),
871
+ );
872
+ });
873
+
874
+ it("generate-types works in fixture project", () => {
875
+ const { exitCode } = run("generate-types", workDir);
876
+ assert.equal(exitCode, 0);
877
+ assert.ok(existsSync(join(workDir, ".vigiles/generated.d.ts")));
878
+ });
879
+
880
+ it("full flow: write spec → compile → audit passes", () => {
881
+ // Clean slate: remove any existing CLAUDE.md and spec
882
+ const mdPath = join(workDir, "CLAUDE.md");
883
+ const specPath = join(workDir, "CLAUDE.md.spec.ts");
884
+ if (existsSync(mdPath)) rmSync(mdPath);
885
+ if (existsSync(specPath)) rmSync(specPath);
886
+
887
+ // Write a spec that imports from vigiles dist (not node_modules)
888
+ const specSrc = resolve(process.cwd(), "dist", "spec.js");
889
+ writeFileSync(
890
+ specPath,
891
+ `import { claude, guidance } from "${specSrc}";
892
+ export default claude({
893
+ commands: { "npm test": "Run tests" },
894
+ rules: { "be-nice": guidance("Be nice.") },
895
+ });
896
+ `,
897
+ );
898
+
899
+ // Compile
900
+ const compileResult = run("compile CLAUDE.md.spec.ts", workDir);
901
+ assert.equal(compileResult.exitCode, 0, compileResult.stdout);
902
+ assert.ok(existsSync(mdPath));
903
+
904
+ // Compiled file should have vigiles hash
905
+ const content = readFileSync(mdPath, "utf-8");
906
+ assert.ok(content.includes("<!-- vigiles:sha256:"));
907
+
908
+ // Audit should pass (hash valid, spec exists)
909
+ const auditResult = run("audit", workDir);
910
+ assert.ok(
911
+ auditResult.stdout.includes("hash valid") || auditResult.exitCode === 0,
912
+ );
913
+ });
914
+ });