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,701 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ mkdtempSync,
5
+ writeFileSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ mkdirSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import {
13
+ validate,
14
+ parseRules,
15
+ readInstructionFile,
16
+ validatePaths,
17
+ expandGlobs,
18
+ findInstructionFiles,
19
+ loadConfig,
20
+ } from "./validate.js";
21
+ import type { MarkerType, ParseOptions } from "./types.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // parseRules
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe("parseRules", () => {
28
+ it("should parse enforced rules", () => {
29
+ const rules = parseRules(
30
+ "### Use barrel imports\n**Enforced by:** `eslint/no-restricted-imports`\n**Why:** Consistency.\n",
31
+ );
32
+ assert.equal(rules.length, 1);
33
+ assert.equal(rules[0].title, "Use barrel imports");
34
+ assert.equal(rules[0].enforcement, "enforced");
35
+ assert.equal(rules[0].enforcedBy, "eslint/no-restricted-imports");
36
+ });
37
+
38
+ it("should parse guidance-only rules", () => {
39
+ const rules = parseRules(
40
+ "### Use Tailwind spacing scale\n**Guidance only** — cannot be mechanically enforced\n",
41
+ );
42
+ assert.equal(rules.length, 1);
43
+ assert.equal(rules[0].enforcement, "guidance");
44
+ });
45
+
46
+ it("should parse rules missing annotations", () => {
47
+ const rules = parseRules("### Some rule\n**Why:** Just because.\n");
48
+ assert.equal(rules.length, 1);
49
+ assert.equal(rules[0].enforcement, "missing");
50
+ });
51
+
52
+ it("should track line numbers", () => {
53
+ const rules = parseRules(
54
+ "# Header\n\nSome text\n\n### First rule\n**Enforced by:** `x`\n\n### Second rule\nNo annotation\n",
55
+ );
56
+ assert.equal(rules[0].line, 5);
57
+ assert.equal(rules[1].line, 8);
58
+ });
59
+
60
+ it("should handle multiple rules in sequence", () => {
61
+ const rules = parseRules(
62
+ "### Rule A\n**Enforced by:** `a`\n### Rule B\n**Guidance only**\n### Rule C\nNothing here.\n",
63
+ );
64
+ assert.equal(rules.length, 3);
65
+ assert.equal(rules[0].enforcement, "enforced");
66
+ assert.equal(rules[1].enforcement, "guidance");
67
+ assert.equal(rules[2].enforcement, "missing");
68
+ });
69
+
70
+ it("should not match deeper headings (####)", () => {
71
+ const rules = parseRules(
72
+ "### Real rule\n**Enforced by:** `x`\n#### Not a rule\nSome details.\n",
73
+ );
74
+ assert.equal(rules.length, 1);
75
+ });
76
+
77
+ it("should not match shallower headings (## or #)", () => {
78
+ const rules = parseRules(
79
+ "# Top level\n## Section\n### Actual rule\n**Enforced by:** `x`\n",
80
+ );
81
+ assert.equal(rules.length, 1);
82
+ assert.equal(rules[0].title, "Actual rule");
83
+ });
84
+
85
+ it("should handle empty file", () => {
86
+ const rules = parseRules("");
87
+ assert.equal(rules.length, 0);
88
+ });
89
+
90
+ it("should handle file with no rules", () => {
91
+ const rules = parseRules("# CLAUDE.md\n\nThis project uses TypeScript.\n");
92
+ assert.equal(rules.length, 0);
93
+ });
94
+
95
+ it("should stop looking for annotation at next header", () => {
96
+ const rules = parseRules(
97
+ "### Rule A\nSome text.\nMore text.\n### Rule B\n**Enforced by:** `x`\n",
98
+ );
99
+ assert.equal(rules[0].enforcement, "missing");
100
+ assert.equal(rules[1].enforcement, "enforced");
101
+ });
102
+
103
+ it("should parse disabled rules", () => {
104
+ const rules = parseRules(
105
+ "### Skipped rule\n<!-- vigiles-disable -->\n**Why:** Not relevant here.\n",
106
+ );
107
+ assert.equal(rules.length, 1);
108
+ assert.equal(rules[0].enforcement, "disabled");
109
+ });
110
+
111
+ it("should handle vigiles-disable with extra whitespace", () => {
112
+ const rules = parseRules("### Skipped rule\n<!-- vigiles-disable -->\n");
113
+ assert.equal(rules[0].enforcement, "disabled");
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // parseRules with checkboxes
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("parseRules with checkboxes", () => {
122
+ const opts: ParseOptions = { ruleMarkers: ["checkboxes"] };
123
+ const bothOpts: ParseOptions = { ruleMarkers: ["headings", "checkboxes"] };
124
+
125
+ it("should parse unchecked checkbox with enforced annotation", () => {
126
+ const rules = parseRules(
127
+ "- [ ] Use barrel imports\n**Enforced by:** `eslint/no-restricted-imports`\n",
128
+ opts,
129
+ );
130
+ assert.equal(rules.length, 1);
131
+ assert.equal(rules[0].title, "Use barrel imports");
132
+ assert.equal(rules[0].enforcement, "enforced");
133
+ assert.equal(rules[0].enforcedBy, "eslint/no-restricted-imports");
134
+ });
135
+
136
+ it("should parse checked checkbox (lowercase x) with guidance", () => {
137
+ const rules = parseRules(
138
+ "- [x] Use Tailwind spacing\n**Guidance only** — cannot be enforced\n",
139
+ opts,
140
+ );
141
+ assert.equal(rules.length, 1);
142
+ assert.equal(rules[0].enforcement, "guidance");
143
+ });
144
+
145
+ it("should parse checked checkbox (uppercase X) with disabled", () => {
146
+ const rules = parseRules(
147
+ "- [X] Skipped rule\n<!-- vigiles-disable -->\n",
148
+ opts,
149
+ );
150
+ assert.equal(rules.length, 1);
151
+ assert.equal(rules[0].enforcement, "disabled");
152
+ });
153
+
154
+ it("should detect checkbox rule missing annotation", () => {
155
+ const rules = parseRules("- [ ] Some rule\nJust a description.\n", opts);
156
+ assert.equal(rules.length, 1);
157
+ assert.equal(rules[0].enforcement, "missing");
158
+ });
159
+
160
+ it("should handle multiple checkboxes in sequence", () => {
161
+ const rules = parseRules(
162
+ "- [ ] Rule A\n**Enforced by:** `a`\n- [x] Rule B\n**Guidance only**\n- [ ] Rule C\nNothing.\n",
163
+ opts,
164
+ );
165
+ assert.equal(rules.length, 3);
166
+ assert.equal(rules[0].enforcement, "enforced");
167
+ assert.equal(rules[1].enforcement, "guidance");
168
+ assert.equal(rules[2].enforcement, "missing");
169
+ });
170
+
171
+ it("should track line numbers for checkbox rules", () => {
172
+ const rules = parseRules(
173
+ "# Header\n\nSome text\n\n- [ ] First rule\n**Enforced by:** `x`\n\n- [ ] Second rule\nNo annotation\n",
174
+ opts,
175
+ );
176
+ assert.equal(rules[0].line, 5);
177
+ assert.equal(rules[1].line, 8);
178
+ });
179
+
180
+ it("should not match indented checkboxes", () => {
181
+ const rules = parseRules(
182
+ " - [ ] Indented item\n**Enforced by:** `x`\n",
183
+ opts,
184
+ );
185
+ assert.equal(rules.length, 0);
186
+ });
187
+
188
+ it("should handle mixed headers and checkboxes with both markers", () => {
189
+ const rules = parseRules(
190
+ "### Heading rule\n**Enforced by:** `a`\n- [ ] Checkbox rule\n**Guidance only**\n### Another heading\n**Enforced by:** `b`\n",
191
+ bothOpts,
192
+ );
193
+ assert.equal(rules.length, 3);
194
+ assert.equal(rules[0].title, "Heading rule");
195
+ assert.equal(rules[0].enforcement, "enforced");
196
+ assert.equal(rules[1].title, "Checkbox rule");
197
+ assert.equal(rules[1].enforcement, "guidance");
198
+ assert.equal(rules[2].title, "Another heading");
199
+ assert.equal(rules[2].enforcement, "enforced");
200
+ });
201
+
202
+ it("checkbox should flush previous heading rule", () => {
203
+ const rules = parseRules(
204
+ "### Rule A\nSome text\n- [ ] Rule B\n**Enforced by:** `x`\n",
205
+ bothOpts,
206
+ );
207
+ assert.equal(rules[0].title, "Rule A");
208
+ assert.equal(rules[0].enforcement, "missing");
209
+ assert.equal(rules[1].title, "Rule B");
210
+ assert.equal(rules[1].enforcement, "enforced");
211
+ });
212
+
213
+ it("heading should flush previous checkbox rule", () => {
214
+ const rules = parseRules(
215
+ "- [ ] Rule A\nSome text\n### Rule B\n**Enforced by:** `x`\n",
216
+ bothOpts,
217
+ );
218
+ assert.equal(rules[0].title, "Rule A");
219
+ assert.equal(rules[0].enforcement, "missing");
220
+ assert.equal(rules[1].title, "Rule B");
221
+ assert.equal(rules[1].enforcement, "enforced");
222
+ });
223
+
224
+ it("should ignore checkboxes when only headings marker is enabled", () => {
225
+ const rules = parseRules(
226
+ "- [ ] Checkbox rule\n**Enforced by:** `x`\n### Heading rule\n**Enforced by:** `y`\n",
227
+ { ruleMarkers: ["headings"] as MarkerType[] },
228
+ );
229
+ assert.equal(rules.length, 1);
230
+ assert.equal(rules[0].title, "Heading rule");
231
+ });
232
+
233
+ it("should ignore headings when only checkboxes marker is enabled", () => {
234
+ const rules = parseRules(
235
+ "### Heading rule\n**Enforced by:** `x`\n- [ ] Checkbox rule\n**Enforced by:** `y`\n",
236
+ opts,
237
+ );
238
+ assert.equal(rules.length, 1);
239
+ assert.equal(rules[0].title, "Checkbox rule");
240
+ });
241
+ });
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // validate — core
245
+ // ---------------------------------------------------------------------------
246
+
247
+ describe("validate", () => {
248
+ it("should count enforced, guidance, and missing rules", () => {
249
+ const result = validate(
250
+ "### Rule A\n**Enforced by:** `x`\n### Rule B\n**Guidance only**\n### Rule C\nNothing.\n",
251
+ { rules: { "require-spec": false } },
252
+ );
253
+ assert.equal(result.enforced, 1);
254
+ assert.equal(result.guidanceOnly, 1);
255
+ assert.equal(result.missing, 1);
256
+ assert.equal(result.total, 3);
257
+ });
258
+
259
+ it("should be valid with no errors when require-spec is off", () => {
260
+ const result = validate("### Rule\n**Enforced by:** `x`\n", {
261
+ rules: { "require-spec": false },
262
+ });
263
+ assert.equal(result.valid, true);
264
+ assert.equal(result.errors.length, 0);
265
+ });
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // require-spec rule
270
+ // ---------------------------------------------------------------------------
271
+
272
+ describe("require-spec", () => {
273
+ let tmpDir: string;
274
+
275
+ before(() => {
276
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-require-spec-"));
277
+ });
278
+
279
+ after(() => {
280
+ rmSync(tmpDir, { recursive: true, force: true });
281
+ });
282
+
283
+ it("should error when no .spec.ts file exists", () => {
284
+ const subDir = join(tmpDir, "no-spec");
285
+ mkdirSync(subDir, { recursive: true });
286
+ const mdPath = join(subDir, "CLAUDE.md");
287
+ writeFileSync(mdPath, "# CLAUDE.md\n### Rule\n**Enforced by:** `x`\n");
288
+
289
+ const result = validate("# CLAUDE.md\n### Rule\n**Enforced by:** `x`\n", {
290
+ filePath: mdPath,
291
+ rules: { "require-spec": "error" },
292
+ });
293
+ assert.equal(result.valid, false);
294
+ assert.ok(result.errors.some((e) => e.rule === "require-spec"));
295
+ assert.ok(result.errors[0].message.includes("No spec file"));
296
+ });
297
+
298
+ it("should pass when .spec.ts file exists", () => {
299
+ const subDir = join(tmpDir, "has-spec");
300
+ mkdirSync(subDir, { recursive: true });
301
+ const mdPath = join(subDir, "CLAUDE.md");
302
+ const specPath = join(subDir, "CLAUDE.md.spec.ts");
303
+ writeFileSync(mdPath, "# Test\n");
304
+ writeFileSync(specPath, "export default {};\n");
305
+
306
+ const result = validate("# Test\n", {
307
+ filePath: mdPath,
308
+ rules: { "require-spec": "error" },
309
+ });
310
+ assert.ok(!result.errors.some((e) => e.rule === "require-spec"));
311
+ });
312
+
313
+ it("should be disabled via HTML comment", () => {
314
+ const subDir = join(tmpDir, "disabled");
315
+ mkdirSync(subDir, { recursive: true });
316
+ const mdPath = join(subDir, "CLAUDE.md");
317
+ writeFileSync(mdPath, "<!-- vigiles-disable require-spec -->\n# Test\n");
318
+
319
+ const result = validate("<!-- vigiles-disable require-spec -->\n# Test\n", {
320
+ filePath: mdPath,
321
+ rules: { "require-spec": "error" },
322
+ });
323
+ assert.ok(!result.errors.some((e) => e.rule === "require-spec"));
324
+ });
325
+
326
+ it("should be disabled via config", () => {
327
+ const subDir = join(tmpDir, "config-off");
328
+ mkdirSync(subDir, { recursive: true });
329
+ const mdPath = join(subDir, "CLAUDE.md");
330
+ writeFileSync(mdPath, "# Test\n");
331
+
332
+ const result = validate("# Test\n", {
333
+ filePath: mdPath,
334
+ rules: { "require-spec": false },
335
+ });
336
+ assert.ok(!result.errors.some((e) => e.rule === "require-spec"));
337
+ });
338
+
339
+ it("should not run when filePath is not provided", () => {
340
+ const result = validate("# Test\n", {
341
+ rules: { "require-spec": "error" },
342
+ });
343
+ assert.equal(result.valid, true);
344
+ });
345
+
346
+ it("should warn by default (not error)", () => {
347
+ const subDir = join(tmpDir, "warn-default");
348
+ mkdirSync(subDir, { recursive: true });
349
+ const mdPath = join(subDir, "CLAUDE.md");
350
+ writeFileSync(mdPath, "# Test\n");
351
+
352
+ // Default is "warn" — valid stays true, warning emitted
353
+ const result = validate("# Test\n", { filePath: mdPath });
354
+ assert.equal(result.valid, true);
355
+ assert.ok(result.warnings.some((e) => e.rule === "require-spec"));
356
+ assert.equal(result.errors.length, 0);
357
+ });
358
+
359
+ it("should error when severity is 'error'", () => {
360
+ const subDir = join(tmpDir, "error-mode");
361
+ mkdirSync(subDir, { recursive: true });
362
+ const mdPath = join(subDir, "CLAUDE.md");
363
+ writeFileSync(mdPath, "# Test\n");
364
+
365
+ const result = validate("# Test\n", {
366
+ filePath: mdPath,
367
+ rules: { "require-spec": "error" },
368
+ });
369
+ assert.equal(result.valid, false);
370
+ assert.ok(result.errors.some((e) => e.rule === "require-spec"));
371
+ });
372
+
373
+ it("should warn for SKILL.md when require-skill-spec is warn", () => {
374
+ const subDir = join(tmpDir, "skill-warn");
375
+ mkdirSync(subDir, { recursive: true });
376
+ const mdPath = join(subDir, "SKILL.md");
377
+ writeFileSync(mdPath, "# Test\n");
378
+
379
+ const result = validate("# Test\n", {
380
+ filePath: mdPath,
381
+ rules: { "require-skill-spec": "warn" },
382
+ });
383
+ assert.ok(result.warnings.some((e) => e.rule === "require-skill-spec"));
384
+ assert.equal(result.valid, true);
385
+ });
386
+
387
+ it("should not fire require-spec on SKILL.md", () => {
388
+ const subDir = join(tmpDir, "skill-no-spec");
389
+ mkdirSync(subDir, { recursive: true });
390
+ const mdPath = join(subDir, "SKILL.md");
391
+ writeFileSync(mdPath, "# Test\n");
392
+
393
+ const result = validate("# Test\n", {
394
+ filePath: mdPath,
395
+ rules: { "require-spec": "error" },
396
+ });
397
+ // require-spec only applies to CLAUDE.md/AGENTS.md
398
+ assert.ok(!result.errors.some((e) => e.rule === "require-spec"));
399
+ });
400
+ });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // loadConfig
404
+ // ---------------------------------------------------------------------------
405
+
406
+ describe("loadConfig", () => {
407
+ let tmpDir: string;
408
+ let originalCwd: string;
409
+
410
+ before(() => {
411
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-config-"));
412
+ originalCwd = process.cwd();
413
+ });
414
+
415
+ after(() => {
416
+ process.chdir(originalCwd);
417
+ rmSync(tmpDir, { recursive: true, force: true });
418
+ });
419
+
420
+ it("should return defaults when no config file exists", () => {
421
+ process.chdir(tmpDir);
422
+ const config = loadConfig();
423
+ assert.deepEqual(config.ruleMarkers, ["headings", "checkboxes"]);
424
+ assert.deepEqual(config.rules, {
425
+ "require-spec": "warn",
426
+ "require-skill-spec": "warn",
427
+ freshness: "warn",
428
+ });
429
+ });
430
+
431
+ it("should read .vigilesrc.json", () => {
432
+ const configDir = mkdtempSync(join(tmpdir(), "vigiles-config-"));
433
+ writeFileSync(
434
+ join(configDir, ".vigilesrc.json"),
435
+ JSON.stringify({ ruleMarkers: ["headings", "checkboxes"] }),
436
+ );
437
+ process.chdir(configDir);
438
+ const config = loadConfig();
439
+ assert.deepEqual(config.ruleMarkers, ["headings", "checkboxes"]);
440
+ process.chdir(originalCwd);
441
+ rmSync(configDir, { recursive: true, force: true });
442
+ });
443
+
444
+ it("should allow disabling require-spec via config", () => {
445
+ const configDir = mkdtempSync(join(tmpdir(), "vigiles-config-"));
446
+ writeFileSync(
447
+ join(configDir, ".vigilesrc.json"),
448
+ JSON.stringify({ rules: { "require-spec": false } }),
449
+ );
450
+ process.chdir(configDir);
451
+ const config = loadConfig();
452
+ assert.equal(config.rules["require-spec"], false);
453
+ process.chdir(originalCwd);
454
+ rmSync(configDir, { recursive: true, force: true });
455
+ });
456
+
457
+ it("should fall back to defaults for invalid ruleMarkers", () => {
458
+ const configDir = mkdtempSync(join(tmpdir(), "vigiles-config-"));
459
+ writeFileSync(
460
+ join(configDir, ".vigilesrc.json"),
461
+ JSON.stringify({ ruleMarkers: ["invalid"] }),
462
+ );
463
+ process.chdir(configDir);
464
+ const config = loadConfig();
465
+ assert.deepEqual(config.ruleMarkers, ["headings", "checkboxes"]);
466
+ process.chdir(originalCwd);
467
+ rmSync(configDir, { recursive: true, force: true });
468
+ });
469
+ });
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // readInstructionFile
473
+ // ---------------------------------------------------------------------------
474
+
475
+ describe("readInstructionFile", () => {
476
+ let tmpDir: string;
477
+
478
+ before(() => {
479
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-test-"));
480
+ });
481
+
482
+ after(() => {
483
+ rmSync(tmpDir, { recursive: true, force: true });
484
+ });
485
+
486
+ it("should read a regular file", () => {
487
+ const filePath = join(tmpDir, "regular.md");
488
+ writeFileSync(filePath, "### Rule\n**Enforced by:** `x`\n");
489
+ const { content, skipped } = readInstructionFile(filePath);
490
+ assert.equal(skipped, false);
491
+ assert.notEqual(content, null);
492
+ assert.ok((content as string).includes("### Rule"));
493
+ });
494
+
495
+ it("should return error for missing file", () => {
496
+ const { content, skipped, reason } = readInstructionFile(
497
+ join(tmpDir, "nope.md"),
498
+ );
499
+ assert.equal(content, null);
500
+ assert.equal(skipped, false);
501
+ assert.notEqual(reason, null);
502
+ assert.ok((reason as string).includes("File not found"));
503
+ });
504
+
505
+ it("should skip symlinks by default", () => {
506
+ const realFile = join(tmpDir, "real.md");
507
+ const link = join(tmpDir, "link.md");
508
+ writeFileSync(realFile, "### Rule\n**Enforced by:** `x`\n");
509
+ symlinkSync(realFile, link);
510
+ const { content, skipped, reason } = readInstructionFile(link);
511
+ assert.equal(content, null);
512
+ assert.equal(skipped, true);
513
+ assert.notEqual(reason, null);
514
+ assert.ok((reason as string).includes("symlink"));
515
+ });
516
+
517
+ it("should follow symlinks when opted in", () => {
518
+ const realFile = join(tmpDir, "real2.md");
519
+ const link = join(tmpDir, "link2.md");
520
+ writeFileSync(realFile, "### Rule\n**Enforced by:** `x`\n");
521
+ symlinkSync(realFile, link);
522
+ const { content, skipped } = readInstructionFile(link, {
523
+ followSymlinks: true,
524
+ });
525
+ assert.equal(skipped, false);
526
+ assert.notEqual(content, null);
527
+ assert.ok((content as string).includes("### Rule"));
528
+ });
529
+ });
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // validatePaths
533
+ // ---------------------------------------------------------------------------
534
+
535
+ describe("validatePaths", () => {
536
+ let tmpDir: string;
537
+
538
+ before(() => {
539
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-test-"));
540
+ });
541
+
542
+ after(() => {
543
+ rmSync(tmpDir, { recursive: true, force: true });
544
+ });
545
+
546
+ it("should validate multiple files", () => {
547
+ const file1 = join(tmpDir, "a.md");
548
+ const file2 = join(tmpDir, "b.md");
549
+ writeFileSync(file1, "### Rule A\n**Enforced by:** `x`\n");
550
+ writeFileSync(file2, "### Rule B\n**Guidance only**\n");
551
+
552
+ const { fileResults, valid } = validatePaths([file1, file2], {
553
+ rules: { "require-spec": false },
554
+ });
555
+ assert.equal(valid, true);
556
+ assert.equal(fileResults.length, 2);
557
+ assert.notEqual(fileResults[0].result, null);
558
+ assert.equal(fileResults[0].result?.enforced, 1);
559
+ assert.notEqual(fileResults[1].result, null);
560
+ assert.equal(fileResults[1].result?.guidanceOnly, 1);
561
+ });
562
+
563
+ it("should fail if any file is missing", () => {
564
+ const file1 = join(tmpDir, "exists.md");
565
+ writeFileSync(file1, "### Rule\n**Enforced by:** `x`\n");
566
+
567
+ const { valid } = validatePaths([file1, join(tmpDir, "missing.md")], {
568
+ rules: { "require-spec": false },
569
+ });
570
+ assert.equal(valid, false);
571
+ });
572
+
573
+ it("should skip symlinks by default but not fail", () => {
574
+ const real = join(tmpDir, "real3.md");
575
+ const link = join(tmpDir, "link3.md");
576
+ writeFileSync(real, "### Rule\n**Enforced by:** `x`\n");
577
+ symlinkSync(real, link);
578
+
579
+ const { fileResults, valid } = validatePaths([real, link], {
580
+ rules: { "require-spec": false },
581
+ });
582
+ assert.equal(valid, true);
583
+ assert.equal(fileResults[1].skipped, true);
584
+ });
585
+
586
+ it("should validate symlinks when follow-symlinks is enabled", () => {
587
+ const real = join(tmpDir, "real4.md");
588
+ const link = join(tmpDir, "link4.md");
589
+ writeFileSync(real, "### Rule\n**Enforced by:** `x`\n");
590
+ symlinkSync(real, link);
591
+
592
+ const { fileResults, valid } = validatePaths([link], {
593
+ followSymlinks: true,
594
+ rules: { "require-spec": false },
595
+ });
596
+ assert.equal(valid, true);
597
+ assert.equal(fileResults[0].skipped, false);
598
+ assert.notEqual(fileResults[0].result, null);
599
+ assert.equal(fileResults[0].result?.enforced, 1);
600
+ });
601
+ });
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // expandGlobs
605
+ // ---------------------------------------------------------------------------
606
+
607
+ describe("expandGlobs", () => {
608
+ let tmpDir: string;
609
+
610
+ before(() => {
611
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-glob-"));
612
+ });
613
+
614
+ after(() => {
615
+ rmSync(tmpDir, { recursive: true, force: true });
616
+ });
617
+
618
+ it("should pass through plain paths unchanged", () => {
619
+ const result = expandGlobs(["CLAUDE.md", "foo/AGENTS.md"]);
620
+ assert.deepEqual(result, ["CLAUDE.md", "foo/AGENTS.md"]);
621
+ });
622
+
623
+ it("should expand glob patterns into matching files", () => {
624
+ writeFileSync(join(tmpDir, "a.md"), "# A\n");
625
+ writeFileSync(join(tmpDir, "b.md"), "# B\n");
626
+ writeFileSync(join(tmpDir, "c.txt"), "not md\n");
627
+
628
+ const result = expandGlobs([join(tmpDir, "*.md")]);
629
+ assert.equal(result.length, 2);
630
+ assert.ok(result.some((p) => p.endsWith("a.md")));
631
+ assert.ok(result.some((p) => p.endsWith("b.md")));
632
+ assert.ok(!result.some((p) => p.endsWith("c.txt")));
633
+ });
634
+
635
+ it("should expand recursive globs", () => {
636
+ const subDir = join(tmpDir, "sub");
637
+ mkdirSync(subDir, { recursive: true });
638
+ writeFileSync(join(subDir, "nested.md"), "# Nested\n");
639
+
640
+ const result = expandGlobs([join(tmpDir, "**/*.md")]);
641
+ assert.ok(result.some((p) => p.endsWith("nested.md")));
642
+ });
643
+
644
+ it("should return empty for globs matching nothing", () => {
645
+ const result = expandGlobs([join(tmpDir, "*.nonexistent")]);
646
+ assert.deepEqual(result, []);
647
+ });
648
+
649
+ it("should mix plain paths and globs", () => {
650
+ const result = expandGlobs(["plain.md", join(tmpDir, "*.md")]);
651
+ assert.equal(result[0], "plain.md");
652
+ assert.ok(result.length > 1);
653
+ });
654
+ });
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // findInstructionFiles
658
+ // ---------------------------------------------------------------------------
659
+
660
+ describe("findInstructionFiles", () => {
661
+ let tmpDir: string;
662
+
663
+ before(() => {
664
+ tmpDir = mkdtempSync(join(tmpdir(), "vigiles-find-"));
665
+ });
666
+
667
+ after(() => {
668
+ rmSync(tmpDir, { recursive: true, force: true });
669
+ });
670
+
671
+ it("should return empty when no files exist", () => {
672
+ const result = findInstructionFiles(tmpDir);
673
+ assert.deepEqual(result, []);
674
+ });
675
+
676
+ it("should find CLAUDE.md by default", () => {
677
+ writeFileSync(join(tmpDir, "CLAUDE.md"), "# Test\n");
678
+ const result = findInstructionFiles(tmpDir);
679
+ assert.deepEqual(result, ["CLAUDE.md"]);
680
+ });
681
+
682
+ it("should find custom files list", () => {
683
+ writeFileSync(join(tmpDir, "AGENTS.md"), "# Test\n");
684
+ const result = findInstructionFiles(tmpDir, [
685
+ "CLAUDE.md",
686
+ "AGENTS.md",
687
+ ".cursorrules",
688
+ ]);
689
+ assert.ok(result.includes("CLAUDE.md"));
690
+ assert.ok(result.includes("AGENTS.md"));
691
+ assert.ok(!result.includes(".cursorrules"));
692
+ });
693
+
694
+ it("should only return files that exist", () => {
695
+ const result = findInstructionFiles(tmpDir, [
696
+ "CLAUDE.md",
697
+ "nonexistent.md",
698
+ ]);
699
+ assert.deepEqual(result, ["CLAUDE.md"]);
700
+ });
701
+ });