@vaubaan/harness 0.0.0-stage → 1.3.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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +190 -2
  4. package/dist/agents/coding.agent.md +183 -0
  5. package/dist/agents/merge.agent.md +139 -0
  6. package/dist/agents/planning.agent.md +194 -0
  7. package/dist/agents/review.agent.md +176 -0
  8. package/dist/cli/index.d.ts +1 -0
  9. package/dist/cli/index.js +2896 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/commands/clean-code.cmd.md +26 -0
  12. package/dist/commands/decision.cmd.md +19 -0
  13. package/dist/commands/doc-audit.cmd.md +24 -0
  14. package/dist/commands/drift.cmd.md +18 -0
  15. package/dist/commands/handbook.cmd.md +21 -0
  16. package/dist/commands/readme.cmd.md +19 -0
  17. package/dist/commands/spec.cmd.md +20 -0
  18. package/dist/commands/test-plan.cmd.md +22 -0
  19. package/dist/commands/workflow.cmd.md +19 -0
  20. package/dist/index.d.ts +36 -0
  21. package/dist/index.js +1673 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/init.d.ts +42 -0
  24. package/dist/init.js +1055 -0
  25. package/dist/init.js.map +1 -0
  26. package/dist/linters/index.d.ts +43 -0
  27. package/dist/linters/index.js +726 -0
  28. package/dist/linters/index.js.map +1 -0
  29. package/dist/parser/index.d.ts +247 -0
  30. package/dist/parser/index.js +114 -0
  31. package/dist/parser/index.js.map +1 -0
  32. package/dist/render-B9_kNtQc.d.ts +50 -0
  33. package/dist/render.d.ts +3 -0
  34. package/dist/render.js +539 -0
  35. package/dist/render.js.map +1 -0
  36. package/dist/skills/clean-code-guardian.skill.md +391 -0
  37. package/dist/skills/decision-record-writer.skill.md +166 -0
  38. package/dist/skills/doc-audit.skill.md +257 -0
  39. package/dist/skills/drift-detector.skill.md +173 -0
  40. package/dist/skills/handbook-writer.skill.md +185 -0
  41. package/dist/skills/readme-writer.skill.md +131 -0
  42. package/dist/skills/spec-writer.skill.md +165 -0
  43. package/dist/skills/test-plan-writer.skill.md +179 -0
  44. package/dist/skills/workflow-writer.skill.md +168 -0
  45. package/dist/templates/code-quality.md.tmpl +57 -0
  46. package/dist/templates/coding-workflow.md.tmpl +88 -0
  47. package/dist/templates/decision.md.tmpl +60 -0
  48. package/dist/templates/e2e-tests.md.tmpl +62 -0
  49. package/dist/templates/handbook.md.tmpl +58 -0
  50. package/dist/templates/integration-tests.md.tmpl +50 -0
  51. package/dist/templates/readme.md.tmpl +51 -0
  52. package/dist/templates/spec.md.tmpl +64 -0
  53. package/package.json +77 -4
@@ -0,0 +1,726 @@
1
+ // src/linters/util.ts
2
+ import { execFileSync } from "child_process";
3
+ import { existsSync, readFileSync, statSync } from "fs";
4
+ import { join, relative } from "path";
5
+ import fg from "fast-glob";
6
+ import matter from "gray-matter";
7
+ function fileExists(repoRoot, relativePath) {
8
+ return existsSync(join(repoRoot, relativePath));
9
+ }
10
+ function readText(repoRoot, relativePath) {
11
+ return readFileSync(join(repoRoot, relativePath), "utf8");
12
+ }
13
+ function listMarkdown(repoRoot, dir) {
14
+ const cwd = join(repoRoot, dir);
15
+ if (!existsSync(cwd)) return [];
16
+ return fg.sync("**/*.md", { cwd, absolute: false, ignore: ["**/node_modules/**", "**/dist/**"] }).filter((f) => !f.endsWith("README.md")).map((f) => join(dir, f));
17
+ }
18
+ function dirExists(repoRoot, relativePath) {
19
+ const full = join(repoRoot, relativePath);
20
+ if (!existsSync(full)) return false;
21
+ return statSync(full).isDirectory();
22
+ }
23
+ function parseFrontmatter(text) {
24
+ const m = matter(text);
25
+ return { data: m.data, body: m.content };
26
+ }
27
+ function gitLastCommitForPath(repoRoot, relativePath) {
28
+ try {
29
+ const out = execFileSync("git", ["log", "-1", "--format=%H:%ct", "--", relativePath], {
30
+ cwd: repoRoot,
31
+ encoding: "utf8",
32
+ stdio: ["ignore", "pipe", "ignore"]
33
+ }).trim();
34
+ if (!out) return void 0;
35
+ const idx = out.indexOf(":");
36
+ if (idx < 0) return void 0;
37
+ const hash = out.slice(0, idx);
38
+ const ts = Number.parseInt(out.slice(idx + 1), 10);
39
+ return { hash, timestamp: ts };
40
+ } catch {
41
+ return void 0;
42
+ }
43
+ }
44
+ function gitCommitsSince(repoRoot, relativePath, sinceCommit) {
45
+ try {
46
+ const out = execFileSync(
47
+ "git",
48
+ ["log", `${sinceCommit}..HEAD`, "--format=%H", "--", relativePath],
49
+ {
50
+ cwd: repoRoot,
51
+ encoding: "utf8",
52
+ stdio: ["ignore", "pipe", "ignore"]
53
+ }
54
+ ).trim();
55
+ if (!out) return 0;
56
+ return out.split("\n").filter(Boolean).length;
57
+ } catch {
58
+ return 0;
59
+ }
60
+ }
61
+ function findLineNumber(text, needle) {
62
+ const lines = text.split("\n");
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const line = lines[i] ?? "";
65
+ if (typeof needle === "string" ? line.includes(needle) : needle.test(line)) {
66
+ return i + 1;
67
+ }
68
+ }
69
+ return void 0;
70
+ }
71
+
72
+ // src/linters/adr-no-tracks.ts
73
+ var ADR_DIR = "docs/decisions";
74
+ var adrNoTracks = {
75
+ id: "lint:adr-no-tracks",
76
+ pillars: ["decisions"],
77
+ defaultSeverity: "error",
78
+ suggestedSkills: ["/decision"],
79
+ run(repoRoot) {
80
+ const issues = [];
81
+ const files = listMarkdown(repoRoot, ADR_DIR);
82
+ for (const f of files) {
83
+ const text = readText(repoRoot, f);
84
+ const { data } = parseFrontmatter(text);
85
+ if (!("tracks" in data)) continue;
86
+ const value = data.tracks;
87
+ if (Array.isArray(value) && value.length === 0) continue;
88
+ issues.push({
89
+ linter: this.id,
90
+ severity: "error",
91
+ file: f,
92
+ message: "ADRs must not declare `tracks:` (or must set it to []). ADRs document a decision frozen in time, not the current state of code \u2014 use status: superseded to mark them obsolete instead."
93
+ });
94
+ }
95
+ return issues;
96
+ }
97
+ };
98
+
99
+ // src/linters/cardinal-files.ts
100
+ var REQUIRED_FILES = [
101
+ "docs/tests/integration-tests.md",
102
+ "docs/tests/e2e-tests.md",
103
+ "docs/workflow/coding-workflow.md"
104
+ ];
105
+ var cardinalFiles = {
106
+ id: "lint:cardinal-files",
107
+ pillars: ["tests", "workflow"],
108
+ defaultSeverity: "error",
109
+ suggestedSkills: ["/test-plan", "/workflow"],
110
+ run(repoRoot) {
111
+ const issues = [];
112
+ for (const f of REQUIRED_FILES) {
113
+ if (!fileExists(repoRoot, f)) {
114
+ issues.push({
115
+ linter: this.id,
116
+ severity: "error",
117
+ file: f,
118
+ message: `missing required cardinal file: ${f}`
119
+ });
120
+ }
121
+ }
122
+ return issues;
123
+ }
124
+ };
125
+
126
+ // src/linters/config.ts
127
+ import { cosmiconfigSync } from "cosmiconfig";
128
+ var DEFAULT_CONFIG = { ignore: [] };
129
+ function loadLintConfig(repoRoot) {
130
+ const explorer = cosmiconfigSync("harness", {
131
+ searchPlaces: [
132
+ "package.json",
133
+ ".harnessrc",
134
+ ".harnessrc.json",
135
+ ".harnessrc.yaml",
136
+ ".harnessrc.yml",
137
+ "harness.config.js",
138
+ "harness.config.cjs"
139
+ ]
140
+ });
141
+ const result = explorer.search(repoRoot);
142
+ if (!result || result.isEmpty) return DEFAULT_CONFIG;
143
+ const raw = result.config;
144
+ if (!raw) return DEFAULT_CONFIG;
145
+ const lint = raw.lint;
146
+ if (!lint) return DEFAULT_CONFIG;
147
+ const ignore = lint.ignore;
148
+ if (!Array.isArray(ignore)) return DEFAULT_CONFIG;
149
+ return { ignore: ignore.filter((x) => typeof x === "string") };
150
+ }
151
+
152
+ // src/linters/cross-links.ts
153
+ import { existsSync as existsSync2 } from "fs";
154
+ import { dirname, isAbsolute, join as join2, resolve } from "path";
155
+ import fg2 from "fast-glob";
156
+ var LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
157
+ function maskCodeSpans(text) {
158
+ return text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " ")).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length));
159
+ }
160
+ function isExternalOrAnchor(url) {
161
+ if (url.startsWith("http://") || url.startsWith("https://")) return true;
162
+ if (url.startsWith("mailto:")) return true;
163
+ if (url.startsWith("#")) return true;
164
+ return false;
165
+ }
166
+ function stripFragment(url) {
167
+ const i = url.indexOf("#");
168
+ return i < 0 ? url : url.slice(0, i);
169
+ }
170
+ function resolveLink(repoRoot, sourceFile, link) {
171
+ const noFrag = stripFragment(link);
172
+ if (isAbsolute(noFrag)) return join2(repoRoot, noFrag);
173
+ return resolve(dirname(join2(repoRoot, sourceFile)), noFrag);
174
+ }
175
+ var crossLinks = {
176
+ id: "lint:cross-links",
177
+ pillars: ["all"],
178
+ defaultSeverity: "error",
179
+ run(repoRoot, ctx) {
180
+ const extra = ctx?.config.ignore ?? [];
181
+ const issues = [];
182
+ const allDocs = fg2.sync("**/*.md", {
183
+ cwd: repoRoot,
184
+ absolute: false,
185
+ ignore: [
186
+ "**/node_modules/**",
187
+ "**/dist/**",
188
+ ".harness-overrides/**",
189
+ "skills/**",
190
+ "agents/**",
191
+ "commands/**",
192
+ "templates/**",
193
+ "**/*.skill.md",
194
+ "**/*.cmd.md",
195
+ "**/*.agent.md",
196
+ ...extra
197
+ ]
198
+ });
199
+ for (const f of allDocs) {
200
+ const text = maskCodeSpans(readText(repoRoot, f));
201
+ const lines = text.split("\n");
202
+ for (let lineNo = 0; lineNo < lines.length; lineNo++) {
203
+ const line = lines[lineNo] ?? "";
204
+ LINK_REGEX.lastIndex = 0;
205
+ let match;
206
+ match = LINK_REGEX.exec(line);
207
+ while (match !== null) {
208
+ const url = match[2];
209
+ if (url && !isExternalOrAnchor(url)) {
210
+ const noFrag = stripFragment(url);
211
+ if (noFrag !== "" && !noFrag.startsWith("data:")) {
212
+ const target = resolveLink(repoRoot, f, url);
213
+ if (!existsSync2(target)) {
214
+ issues.push({
215
+ linter: this.id,
216
+ severity: "error",
217
+ file: f,
218
+ line: lineNo + 1,
219
+ message: `broken link: ${url}`
220
+ });
221
+ }
222
+ }
223
+ }
224
+ match = LINK_REGEX.exec(line);
225
+ }
226
+ }
227
+ }
228
+ return issues;
229
+ }
230
+ };
231
+
232
+ // src/linters/decision-schema.ts
233
+ var REQUIRED_SECTIONS = [
234
+ { label: "Context", pattern: /^##\s+(Context|Contexte)\b/m },
235
+ { label: "Decision", pattern: /^##\s+(Decision|D[ée]cision)\b/m },
236
+ { label: "Consequences", pattern: /^##\s+(Consequences|Cons[ée]quences)\b/m },
237
+ {
238
+ label: "Rejected alternatives",
239
+ pattern: /^##\s+(Rejected\s+alternatives|Alternatives(\s+rejet[ée]es)?)\b/im
240
+ }
241
+ ];
242
+ var decisionSchema = {
243
+ id: "lint:decision-schema",
244
+ pillars: ["decisions"],
245
+ defaultSeverity: "error",
246
+ suggestedSkills: ["/decision"],
247
+ run(repoRoot) {
248
+ const issues = [];
249
+ const files = listMarkdown(repoRoot, "docs/decisions");
250
+ for (const f of files) {
251
+ const text = readText(repoRoot, f);
252
+ for (const { label, pattern } of REQUIRED_SECTIONS) {
253
+ if (!pattern.test(text)) {
254
+ issues.push({
255
+ linter: this.id,
256
+ severity: "error",
257
+ file: f,
258
+ message: `missing required section: "${label}"`
259
+ });
260
+ }
261
+ }
262
+ }
263
+ return issues;
264
+ }
265
+ };
266
+
267
+ // src/linters/doc-layout.ts
268
+ var REQUIRED_DIRS = [
269
+ "docs/spec",
270
+ "docs/decisions",
271
+ "docs/handbook",
272
+ "docs/tests",
273
+ "docs/workflow"
274
+ ];
275
+ var docLayout = {
276
+ id: "lint:doc-layout",
277
+ pillars: ["all"],
278
+ defaultSeverity: "error",
279
+ run(repoRoot) {
280
+ const issues = [];
281
+ for (const dir of REQUIRED_DIRS) {
282
+ if (!dirExists(repoRoot, dir)) {
283
+ issues.push({
284
+ linter: this.id,
285
+ severity: "error",
286
+ file: dir,
287
+ message: `missing required directory: ${dir}/`
288
+ });
289
+ }
290
+ }
291
+ return issues;
292
+ }
293
+ };
294
+
295
+ // src/linters/frontmatter-tracks.ts
296
+ import { existsSync as existsSync3 } from "fs";
297
+ import { join as join3 } from "path";
298
+ import fg3 from "fast-glob";
299
+ var TRACKED_DIRS = ["docs/handbook", "docs/tests", "docs/workflow"];
300
+ function trackedPathExists(repoRoot, pattern) {
301
+ if (pattern.includes("*")) {
302
+ return fg3.sync(pattern, { cwd: repoRoot, absolute: false }).length > 0;
303
+ }
304
+ return existsSync3(join3(repoRoot, pattern));
305
+ }
306
+ var frontmatterTracks = {
307
+ id: "lint:frontmatter-tracks",
308
+ pillars: ["handbook", "tests", "workflow"],
309
+ defaultSeverity: "error",
310
+ suggestedSkills: ["/handbook", "/test-plan", "/workflow"],
311
+ run(repoRoot) {
312
+ const issues = [];
313
+ for (const dir of TRACKED_DIRS) {
314
+ const files = listMarkdown(repoRoot, dir);
315
+ for (const f of files) {
316
+ const text = readText(repoRoot, f);
317
+ const { data } = parseFrontmatter(text);
318
+ if (!Array.isArray(data.tracks)) {
319
+ issues.push({
320
+ linter: this.id,
321
+ severity: "error",
322
+ file: f,
323
+ message: "missing frontmatter `tracks:` array"
324
+ });
325
+ continue;
326
+ }
327
+ for (const tracked of data.tracks) {
328
+ if (typeof tracked !== "string") {
329
+ issues.push({
330
+ linter: this.id,
331
+ severity: "error",
332
+ file: f,
333
+ message: "`tracks` entries must be strings"
334
+ });
335
+ continue;
336
+ }
337
+ if (!trackedPathExists(repoRoot, tracked)) {
338
+ issues.push({
339
+ linter: this.id,
340
+ severity: "error",
341
+ file: f,
342
+ message: `tracked path does not exist: ${tracked}`
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return issues;
349
+ }
350
+ };
351
+
352
+ // src/linters/gitignore-harness.ts
353
+ var REQUIRED_ENTRIES = [
354
+ ".harness-overrides/",
355
+ ".harness-manifest.json",
356
+ ".claude/",
357
+ ".codex/",
358
+ ".opencode/",
359
+ ".agents/",
360
+ ".vaubaan/",
361
+ "AGENTS.md",
362
+ "CLAUDE.md",
363
+ ".mcp.json"
364
+ ];
365
+ var gitignoreHarness = {
366
+ id: "lint:gitignore-harness",
367
+ pillars: ["repo-root"],
368
+ defaultSeverity: "error",
369
+ run(repoRoot) {
370
+ const issues = [];
371
+ if (!fileExists(repoRoot, ".gitignore")) {
372
+ issues.push({
373
+ linter: this.id,
374
+ severity: "error",
375
+ file: ".gitignore",
376
+ message: ".gitignore is missing at repo root"
377
+ });
378
+ return issues;
379
+ }
380
+ const text = readText(repoRoot, ".gitignore");
381
+ const lines = new Set(
382
+ text.split("\n").map((l) => l.trim()).filter(Boolean)
383
+ );
384
+ for (const entry of REQUIRED_ENTRIES) {
385
+ const variants = [entry, entry.replace(/\/$/, "")];
386
+ if (!variants.some((v) => lines.has(v))) {
387
+ issues.push({
388
+ linter: this.id,
389
+ severity: "error",
390
+ file: ".gitignore",
391
+ message: `.gitignore must contain "${entry}"`
392
+ });
393
+ }
394
+ }
395
+ return issues;
396
+ }
397
+ };
398
+
399
+ // src/linters/orphan-decisions.ts
400
+ import { basename } from "path";
401
+ import fg4 from "fast-glob";
402
+ var orphanDecisions = {
403
+ id: "lint:orphan-decisions",
404
+ pillars: ["decisions"],
405
+ defaultSeverity: "warning",
406
+ suggestedSkills: ["/handbook"],
407
+ run(repoRoot, ctx) {
408
+ const extra = ctx?.config.ignore ?? [];
409
+ const issues = [];
410
+ const adrs = listMarkdown(repoRoot, "docs/decisions");
411
+ if (adrs.length === 0) return issues;
412
+ const otherDocs = fg4.sync("**/*.md", {
413
+ cwd: repoRoot,
414
+ absolute: false,
415
+ ignore: [
416
+ "**/node_modules/**",
417
+ "**/dist/**",
418
+ ".harness-overrides/**",
419
+ "docs/decisions/**",
420
+ "skills/**",
421
+ "agents/**",
422
+ "commands/**",
423
+ "templates/**",
424
+ ...extra
425
+ ]
426
+ });
427
+ const referenceCorpus = otherDocs.map((f) => readText(repoRoot, f)).join("\n");
428
+ for (const adr of adrs) {
429
+ const filename = basename(adr);
430
+ const stem = filename.replace(/\.md$/, "");
431
+ const referenced = referenceCorpus.includes(filename) || referenceCorpus.includes(stem);
432
+ if (!referenced) {
433
+ issues.push({
434
+ linter: this.id,
435
+ severity: "warning",
436
+ file: adr,
437
+ message: "ADR is not referenced by any other doc file (orphan)"
438
+ });
439
+ }
440
+ }
441
+ return issues;
442
+ }
443
+ };
444
+
445
+ // src/linters/quality-schema.ts
446
+ var QUALITY_DIR = "docs/quality";
447
+ var CARDINAL_FILE = `${QUALITY_DIR}/code-quality.md`;
448
+ var REQUIRED_SECTIONS2 = [
449
+ {
450
+ label: "Mechanical checks",
451
+ pattern: /^##\s+(Mechanical\s+checks|V[ée]rifications\s+m[ée]caniques)\s*$/im
452
+ },
453
+ { label: "Project rules", pattern: /^##\s+(Project\s+rules|R[èe]gles\s+du\s+projet)\s*$/im },
454
+ {
455
+ label: "Accepted deviations",
456
+ pattern: /^##\s+(Accepted\s+deviations|[ÉE]carts\s+assum[ée]s)\s*$/im
457
+ }
458
+ ];
459
+ var qualitySchema = {
460
+ id: "lint:quality-schema",
461
+ pillars: ["quality"],
462
+ defaultSeverity: "error",
463
+ run(repoRoot) {
464
+ if (!dirExists(repoRoot, QUALITY_DIR)) return [];
465
+ if (!fileExists(repoRoot, CARDINAL_FILE)) {
466
+ return [
467
+ {
468
+ linter: this.id,
469
+ severity: "error",
470
+ file: CARDINAL_FILE,
471
+ message: `missing required cardinal file: ${CARDINAL_FILE}`
472
+ }
473
+ ];
474
+ }
475
+ const text = readText(repoRoot, CARDINAL_FILE);
476
+ return REQUIRED_SECTIONS2.filter(({ pattern }) => !pattern.test(text)).map(({ label }) => ({
477
+ linter: this.id,
478
+ severity: "error",
479
+ file: CARDINAL_FILE,
480
+ message: `missing required section: "${label}"`
481
+ }));
482
+ }
483
+ };
484
+
485
+ // src/linters/readme-presence.ts
486
+ var readmePresence = {
487
+ id: "lint:readme-presence",
488
+ pillars: ["readme"],
489
+ defaultSeverity: "error",
490
+ suggestedSkills: ["/readme"],
491
+ run(repoRoot) {
492
+ if (fileExists(repoRoot, "README.md")) return [];
493
+ return [
494
+ {
495
+ linter: this.id,
496
+ severity: "error",
497
+ file: "README.md",
498
+ message: "README.md is missing at repo root"
499
+ }
500
+ ];
501
+ }
502
+ };
503
+
504
+ // src/linters/readme-sections.ts
505
+ var REQUIRED_SECTIONS3 = [
506
+ /^#\s+\S/m,
507
+ /^##?\s+(What is this|Quick start)/im,
508
+ /^##?\s+(Quick start|Install|Getting started)/im
509
+ ];
510
+ var REQUIRED_LABELS = ["title (# H1)", "What is this / intro", "Quick start / Install"];
511
+ var readmeSections = {
512
+ id: "lint:readme-sections",
513
+ pillars: ["readme"],
514
+ defaultSeverity: "error",
515
+ suggestedSkills: ["/readme"],
516
+ run(repoRoot) {
517
+ if (!fileExists(repoRoot, "README.md")) return [];
518
+ const text = readText(repoRoot, "README.md");
519
+ const issues = [];
520
+ for (let i = 0; i < REQUIRED_SECTIONS3.length; i++) {
521
+ const pattern = REQUIRED_SECTIONS3[i];
522
+ const label = REQUIRED_LABELS[i];
523
+ if (pattern && !pattern.test(text)) {
524
+ issues.push({
525
+ linter: this.id,
526
+ severity: "error",
527
+ file: "README.md",
528
+ message: `missing required section: ${label}`
529
+ });
530
+ }
531
+ }
532
+ return issues;
533
+ }
534
+ };
535
+
536
+ // src/linters/spec-has-content.ts
537
+ var specHasContent = {
538
+ id: "lint:spec-has-content",
539
+ pillars: ["spec"],
540
+ defaultSeverity: "warning",
541
+ suggestedSkills: ["/spec"],
542
+ run(repoRoot) {
543
+ if (!dirExists(repoRoot, "docs/spec")) return [];
544
+ const files = listMarkdown(repoRoot, "docs/spec");
545
+ if (files.length > 0) return [];
546
+ return [
547
+ {
548
+ linter: this.id,
549
+ severity: "warning",
550
+ file: "docs/spec",
551
+ message: "docs/spec/ has no markdown files \u2014 write at least one spec document"
552
+ }
553
+ ];
554
+ }
555
+ };
556
+
557
+ // src/linters/spec-tech-agnostic.ts
558
+ function maskFencedBlocks(text) {
559
+ return text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " "));
560
+ }
561
+ var TECH_BLOCKLIST = [
562
+ { pattern: /\bNext\.?js\b/i, keyword: "Next.js" },
563
+ { pattern: /\bSupabase\b/i, keyword: "Supabase" },
564
+ { pattern: /\bVercel\b/i, keyword: "Vercel" },
565
+ { pattern: /\bReact\b/, keyword: "React" },
566
+ { pattern: /\bTypeScript\b/, keyword: "TypeScript" },
567
+ { pattern: /\bDocker\b/, keyword: "Docker" },
568
+ { pattern: /\bKubernetes\b/i, keyword: "Kubernetes" },
569
+ { pattern: /\bPostgres(QL)?\b/i, keyword: "Postgres" },
570
+ { pattern: /\bMySQL\b/i, keyword: "MySQL" },
571
+ { pattern: /\bRedis\b/i, keyword: "Redis" },
572
+ { pattern: /\bnpm\b/, keyword: "npm" },
573
+ { pattern: /\bpnpm\b/, keyword: "pnpm" },
574
+ { pattern: /\byarn\b/, keyword: "yarn" }
575
+ ];
576
+ var specTechAgnostic = {
577
+ id: "lint:spec-tech-agnostic",
578
+ pillars: ["spec"],
579
+ defaultSeverity: "error",
580
+ suggestedSkills: ["/spec"],
581
+ run(repoRoot) {
582
+ const issues = [];
583
+ const files = listMarkdown(repoRoot, "docs/spec");
584
+ for (const f of files) {
585
+ const text = maskFencedBlocks(readText(repoRoot, f));
586
+ for (const { pattern, keyword } of TECH_BLOCKLIST) {
587
+ if (pattern.test(text)) {
588
+ const issue = {
589
+ linter: this.id,
590
+ severity: "error",
591
+ file: f,
592
+ message: `forbidden tech keyword "${keyword}" \u2014 should be in handbook/ or decisions/`
593
+ };
594
+ const line = findLineNumber(text, pattern);
595
+ if (line !== void 0) issue.line = line;
596
+ issues.push(issue);
597
+ }
598
+ }
599
+ }
600
+ return issues;
601
+ }
602
+ };
603
+
604
+ // src/linters/tracks-drift.ts
605
+ import fg5 from "fast-glob";
606
+ var TRACKED_DIRS2 = ["docs/handbook", "docs/tests", "docs/workflow"];
607
+ var DEFAULT_DRIFT_THRESHOLD = 10;
608
+ function expandGlob(repoRoot, pattern) {
609
+ if (pattern.includes("*")) {
610
+ return fg5.sync(pattern, { cwd: repoRoot, absolute: false });
611
+ }
612
+ return [pattern];
613
+ }
614
+ var tracksDrift = {
615
+ id: "lint:tracks-drift",
616
+ pillars: ["handbook", "tests", "workflow"],
617
+ defaultSeverity: "warning",
618
+ suggestedSkills: ["/drift", "/handbook"],
619
+ run(repoRoot) {
620
+ const issues = [];
621
+ for (const dir of TRACKED_DIRS2) {
622
+ const files = listMarkdown(repoRoot, dir);
623
+ for (const f of files) {
624
+ const text = readText(repoRoot, f);
625
+ const { data } = parseFrontmatter(text);
626
+ if (!Array.isArray(data.tracks)) continue;
627
+ const docCommit = gitLastCommitForPath(repoRoot, f);
628
+ if (!docCommit) continue;
629
+ const threshold = typeof data.driftThreshold === "number" && data.driftThreshold > 0 ? data.driftThreshold : DEFAULT_DRIFT_THRESHOLD;
630
+ let driftCount = 0;
631
+ for (const tracked of data.tracks) {
632
+ if (typeof tracked !== "string") continue;
633
+ const targets = expandGlob(repoRoot, tracked);
634
+ for (const t of targets.length > 0 ? targets : [tracked]) {
635
+ driftCount += gitCommitsSince(repoRoot, t, docCommit.hash);
636
+ }
637
+ }
638
+ if (driftCount > threshold) {
639
+ issues.push({
640
+ linter: this.id,
641
+ severity: "warning",
642
+ file: f,
643
+ message: `tracked files changed in ${driftCount} commits since doc last edit (threshold ${threshold})`
644
+ });
645
+ }
646
+ }
647
+ }
648
+ return issues;
649
+ }
650
+ };
651
+
652
+ // src/linters/index.ts
653
+ var ALL_LINTERS = [
654
+ readmePresence,
655
+ readmeSections,
656
+ docLayout,
657
+ cardinalFiles,
658
+ decisionSchema,
659
+ adrNoTracks,
660
+ frontmatterTracks,
661
+ tracksDrift,
662
+ specTechAgnostic,
663
+ specHasContent,
664
+ crossLinks,
665
+ orphanDecisions,
666
+ gitignoreHarness,
667
+ qualitySchema
668
+ ];
669
+ function runLinters(repoRoot, opts = {}) {
670
+ const config = loadLintConfig(repoRoot);
671
+ const ctx = { repoRoot, config };
672
+ const filtered = ALL_LINTERS.filter((l) => {
673
+ if (opts.only && !opts.only.includes(l.id)) return false;
674
+ if (opts.pillar && !l.pillars.includes(opts.pillar) && !l.pillars.includes("all")) return false;
675
+ return true;
676
+ });
677
+ const issues = [];
678
+ for (const linter of filtered) {
679
+ try {
680
+ issues.push(...linter.run(repoRoot, ctx));
681
+ } catch (err) {
682
+ issues.push({
683
+ linter: linter.id,
684
+ severity: "error",
685
+ file: "<internal>",
686
+ message: `linter crashed: ${err instanceof Error ? err.message : String(err)}`
687
+ });
688
+ }
689
+ }
690
+ const errors = issues.filter((i) => i.severity === "error").length;
691
+ const warnings = issues.filter((i) => i.severity === "warning").length;
692
+ return {
693
+ version: 1,
694
+ summary: { errors, warnings, pass: errors === 0 },
695
+ issues
696
+ };
697
+ }
698
+ function suggestedSkillsFor(linterId) {
699
+ const linter = ALL_LINTERS.find((l) => l.id === linterId);
700
+ return linter?.suggestedSkills ?? [];
701
+ }
702
+ function formatReportText(report) {
703
+ const lines = [];
704
+ for (const issue of report.issues) {
705
+ const location = issue.line !== void 0 ? `:${issue.line}` : "";
706
+ lines.push(`${issue.file}${location}: ${issue.severity}[${issue.linter}]
707
+ ${issue.message}`);
708
+ const hints = suggestedSkillsFor(issue.linter);
709
+ if (hints.length > 0) {
710
+ lines.push(` \u2192 Try: ${hints.join(", ")}`);
711
+ }
712
+ }
713
+ const { errors, warnings, pass } = report.summary;
714
+ lines.push("");
715
+ lines.push(
716
+ `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}. ${pass ? "\u2714 CI passes." : "\u2718 CI fails."}`
717
+ );
718
+ return lines.join("\n");
719
+ }
720
+ export {
721
+ ALL_LINTERS,
722
+ formatReportText,
723
+ loadLintConfig,
724
+ runLinters
725
+ };
726
+ //# sourceMappingURL=index.js.map