docguard-cli 0.28.0 → 0.30.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 (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -23,7 +23,12 @@
23
23
  */
24
24
 
25
25
  import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync } from 'node:fs';
26
- import { resolve, join } from 'node:path';
26
+ import { resolve, join, relative, dirname, basename, extname } from 'node:path';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
29
+ import { isGitRepo } from '../shared-git.mjs';
30
+ import { walkFiles } from '../shared-ignore.mjs';
31
+ import { readScannable } from '../shared-source.mjs';
27
32
 
28
33
  // ──── Spec Kit Mandatory Sections ────
29
34
  // Based on spec-kit's spec-template.md, plan-template.md, tasks-template.md
@@ -293,6 +298,304 @@ function validateTasksQuality(tasksPath) {
293
298
  return issues;
294
299
  }
295
300
 
301
+ // ──── Phantom-Completion Detection (SPK008/SPK009, v0.30) ────
302
+ //
303
+ // A `- [x]` in tasks.md is a CLAIM that work landed. An agent (or human) that
304
+ // checks a task without landing the artifact corrupts the project's memory:
305
+ // every later session trusts the checkbox and skips the work. This check
306
+ // verifies the claim deterministically — "lie detection" for tasks.md.
307
+ //
308
+ // PRECISION-FIRST DESIGN — a false accusation of lying is worse than a miss:
309
+ // • Only tasks that make a FALSIFIABLE artifact claim can be flagged: the
310
+ // task line must name at least one repo-relative path (slashed, or with an
311
+ // explicit trailing `/` for directories). Prose-only tasks ("Review the
312
+ // approach"), bare filenames ("buildspec.yml" may describe the DOMAIN, not
313
+ // a deliverable), and tasks whose only reference is a task ID are counted
314
+ // as unverifiable and never flagged — flagging them is FP soup.
315
+ // • A task is phantom only when EVERY evidence tier comes up empty:
316
+ // a. any named path exists (project root or the feature dir);
317
+ // b. a named basename exists anywhere in the repo (file was moved);
318
+ // c. a backticked code symbol from the task line appears in source;
319
+ // d. sibling plan.md/spec.md name an existing deliverable that the task
320
+ // text also mentions;
321
+ // e. the task ID (T001…) appears in a source/test file annotation;
322
+ // f. the task ID appears in a git commit message (skipped silently when
323
+ // the project is not a git repo or git is unavailable).
324
+ // Evidence false-positives are SAFE (they suppress an accusation), so the
325
+ // tiers are deliberately generous.
326
+
327
+ const CHECKED_TASK_RE = /^\s*-\s*\[[xX]\]\s*(T\d{3,4})?\s*(.+)$/;
328
+ const MAX_PHANTOM_FINDINGS = 10;
329
+
330
+ /** Code-ish extensions whose content can carry task-ID / symbol evidence. */
331
+ const EVIDENCE_CODE_EXTS = new Set([
332
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
333
+ '.py', '.go', '.rs', '.java', '.rb', '.php', '.kt', '.swift',
334
+ '.c', '.h', '.cpp', '.hpp', '.cs', '.sh', '.sql',
335
+ '.yml', '.yaml', '.json', '.toml',
336
+ ]);
337
+
338
+ /**
339
+ * Parse checked tasks (`- [x] T001 …`) out of a tasks.md.
340
+ * Returns [{ id, text, line }] with 1-based line numbers. The task ID is also
341
+ * recovered from a leading bold/decorated form (`**T001**`) when the plain
342
+ * position doesn't match.
343
+ */
344
+ export function parseCheckedTasks(content) {
345
+ const tasks = [];
346
+ const lines = content.split('\n');
347
+ for (let i = 0; i < lines.length; i++) {
348
+ const m = lines[i].match(CHECKED_TASK_RE);
349
+ if (!m) continue;
350
+ const text = m[2].trim();
351
+ let id = m[1] || null;
352
+ if (!id) {
353
+ const decorated = text.match(/^[*_~[(]*\s*(T\d{3,4})\b/);
354
+ if (decorated) id = decorated[1];
355
+ }
356
+ tasks.push({ id, text, line: i + 1 });
357
+ }
358
+ return tasks;
359
+ }
360
+
361
+ /**
362
+ * Extract path-like tokens from a task line.
363
+ *
364
+ * `claims` — slashed paths (or explicit `dir/` syntax): falsifiable
365
+ * deliverable claims. Only these can convict.
366
+ * `soft` — bare filenames (`CHANGELOG.md`) and extension-less slashed tokens
367
+ * (`cli/commands`): positive evidence ONLY, never grounds for flagging —
368
+ * prose mentions of foreign/domain filenames (and "and/or"-style prose
369
+ * slashes, which the charset+extension rules reject as claims) must not
370
+ * convict.
371
+ *
372
+ * Rejected outright (unverifiable or unsafe to resolve): tokens with spaces,
373
+ * globs/placeholders, absolute paths (URL routes like `/api/users` are not
374
+ * repo files), and `..` segments (never resolve outside the project).
375
+ */
376
+ export function extractPathTokens(text) {
377
+ const claims = new Set();
378
+ const soft = new Set();
379
+ const consider = (tokRaw) => {
380
+ let tok = tokRaw.trim().replace(/^\.\//, '').replace(/:\d+(?::\d+)?$/, '');
381
+ if (!tok || tok.length > 200 || /\s/.test(tok)) return;
382
+ if (/[*?{}<>|]/.test(tok)) return;
383
+ if (tok.startsWith('/')) return;
384
+ if (/(^|\/)\.\.(\/|$)/.test(tok)) return;
385
+ const isDirSyntax = tok.endsWith('/');
386
+ tok = tok.replace(/\/+$/, '');
387
+ if (!tok || !/^\.?[\w@][\w@./-]*$/.test(tok)) return;
388
+ if (tok.includes('/')) {
389
+ const last = tok.slice(tok.lastIndexOf('/') + 1);
390
+ if (/\.[A-Za-z]\w{0,9}$/.test(last) || isDirSyntax) claims.add(tok);
391
+ else soft.add(tok);
392
+ } else if (/\.[A-Za-z]\w{0,9}$/.test(tok)) {
393
+ soft.add(tok); // bare filename — rejects version numbers like `18.0`
394
+ }
395
+ };
396
+ for (const m of text.matchAll(/`([^`]+)`/g)) consider(m[1]);
397
+ for (const m of text.matchAll(/(?:^|[\s("'[])((?:[\w@.-]+\/)+[\w@.-]+\/?)/g)) consider(m[1]);
398
+ return { claims, soft };
399
+ }
400
+
401
+ /**
402
+ * Backticked identifier-like tokens (`globMatch(relPath, patterns)` → globMatch,
403
+ * `IGNORE_DIRS` → IGNORE_DIRS). Used as an evidence tier: a task that names a
404
+ * function/constant that exists in source was plainly not skipped. Min length 4
405
+ * keeps trivially-common words from being extracted at all (an over-match here
406
+ * only suppresses an accusation, so the filter is intentionally loose).
407
+ */
408
+ export function extractSymbolTokens(text) {
409
+ const out = new Set();
410
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
411
+ const tok = m[1].trim();
412
+ const call = tok.match(/^([A-Za-z_$][\w$]*)\s*\(/);
413
+ if (call && call[1].length >= 4) { out.add(call[1]); continue; }
414
+ if (/^[A-Za-z_$][\w$]*$/.test(tok) && tok.length >= 4) out.add(tok);
415
+ }
416
+ return out;
417
+ }
418
+
419
+ function escapeRe(s) {
420
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
421
+ }
422
+
423
+ /** Does any of the named tokens exist relative to root or the feature dir? */
424
+ function anyPathExists(projectDir, featureDir, tokens) {
425
+ for (const tok of tokens) {
426
+ if (existsSync(resolve(projectDir, tok))) return true;
427
+ if (featureDir && existsSync(resolve(featureDir, tok))) return true;
428
+ }
429
+ return false;
430
+ }
431
+
432
+ /**
433
+ * Existing deliverable paths named by the feature's sibling plan.md/spec.md.
434
+ * Returns { paths: Set<string>, basenames: Set<string> } — only tokens that
435
+ * exist on disk count (a plan mentioning a path is design, not evidence;
436
+ * a plan mentioning an EXISTING path ties the deliverable to reality).
437
+ */
438
+ function collectSiblingArtifacts(projectDir, spec) {
439
+ const paths = new Set();
440
+ const basenames = new Set();
441
+ const featureDir = spec.tasksPath ? dirname(spec.tasksPath) : null;
442
+ for (const p of [spec.planPath, spec.specPath]) {
443
+ if (!p) continue;
444
+ let content;
445
+ try { content = readFileSync(p, 'utf-8'); } catch { continue; }
446
+ const { claims, soft } = extractPathTokens(content);
447
+ for (const tok of [...claims, ...soft]) {
448
+ if (existsSync(resolve(projectDir, tok)) || (featureDir && existsSync(resolve(featureDir, tok)))) {
449
+ paths.add(tok);
450
+ const base = basename(tok);
451
+ if (base.length >= 5) basenames.add(base); // ≥5 avoids `a.ts`-scale collisions
452
+ }
453
+ }
454
+ }
455
+ return { paths, basenames };
456
+ }
457
+
458
+ /**
459
+ * ONE repo walk that resolves every deferred needle at once: basenames of
460
+ * needed files (any extension, so a moved deliverable still evidences), and
461
+ * task-IDs / code symbols inside code-ish files. Dot-entries are skipped
462
+ * (so specs' own markdown never self-evidences) except .github, whose
463
+ * workflows are legitimate deliverables.
464
+ */
465
+ function scanRepoForEvidence(projectDir, needles) {
466
+ const found = { basenames: new Set(), symbols: new Set(), ids: new Set() };
467
+ const symbolRes = new Map();
468
+ for (const sym of needles.symbols) symbolRes.set(sym, new RegExp(`\\b${escapeRe(sym)}\\b`));
469
+ const wantContent = needles.ids.size > 0 || symbolRes.size > 0;
470
+ walkFiles(projectDir, (absPath) => {
471
+ const base = basename(absPath);
472
+ if (needles.basenames.has(base)) found.basenames.add(base);
473
+ if (!wantContent) return;
474
+ if (!EVIDENCE_CODE_EXTS.has(extname(absPath).toLowerCase())) return;
475
+ if (found.ids.size === needles.ids.size && found.symbols.size === symbolRes.size) return;
476
+ const content = readScannable(absPath);
477
+ if (!content) return;
478
+ if (needles.ids.size > found.ids.size) {
479
+ for (const m of content.matchAll(/\bT\d{3,4}\b/g)) {
480
+ if (needles.ids.has(m[0])) found.ids.add(m[0]);
481
+ }
482
+ }
483
+ for (const [sym, re] of symbolRes) {
484
+ if (!found.symbols.has(sym) && re.test(content)) found.symbols.add(sym);
485
+ }
486
+ }, { keepDot: (entry) => entry === '.github' });
487
+ return found;
488
+ }
489
+
490
+ /**
491
+ * Is the task ID referenced in a commit message? `--grep` narrows in git;
492
+ * the JS word-boundary re-check drops substring hits (T001 vs T0010).
493
+ * `--format=%B` (not --oneline) so a body-only mention still counts.
494
+ * Fails closed to `false` — a missing git binary or empty history just
495
+ * means this tier contributes no evidence.
496
+ */
497
+ function taskIdInGitLog(projectDir, id) {
498
+ try {
499
+ const out = execFileSync(
500
+ 'git',
501
+ ['log', `--grep=${id}`, '--format=%B'],
502
+ { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 }
503
+ );
504
+ return new RegExp(`\\b${id}(?!\\d)`).test(out);
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Run phantom-completion detection across all detected features.
512
+ *
513
+ * Two passes: cheap per-task tiers first (named paths on disk, sibling
514
+ * artifacts); tasks still unevidenced go into ONE batched repo scan
515
+ * (basenames + symbols + task-ID annotations), then a per-ID git-log lookup
516
+ * (cached; skipped silently when not a git repo).
517
+ *
518
+ * @returns {Array<{spec, relPath, checkedCount, unverifiableCount,
519
+ * phantoms: Array<{id, text, line, tiers: string[]}>}>}
520
+ */
521
+ export function detectPhantomCompletions(projectDir, specs) {
522
+ const results = [];
523
+ const pending = [];
524
+ const siblingCache = new Map();
525
+
526
+ for (const spec of specs) {
527
+ if (!spec.hasTasks || !spec.tasksPath) continue;
528
+ let content;
529
+ try { content = readFileSync(spec.tasksPath, 'utf-8'); } catch { continue; } // SPK006 covers unreadable
530
+ const tasks = parseCheckedTasks(content);
531
+ const entry = {
532
+ spec,
533
+ relPath: relative(projectDir, spec.tasksPath),
534
+ checkedCount: tasks.length,
535
+ unverifiableCount: 0,
536
+ phantoms: [],
537
+ };
538
+ results.push(entry);
539
+ if (tasks.length === 0) continue;
540
+
541
+ const featureDir = dirname(spec.tasksPath);
542
+ for (const task of tasks) {
543
+ const { claims, soft } = extractPathTokens(task.text);
544
+ if (claims.size === 0) { entry.unverifiableCount++; continue; } // no falsifiable claim
545
+ // Tier a: any named path exists (claims convict; soft tokens only evidence)
546
+ if (anyPathExists(projectDir, featureDir, claims) || anyPathExists(projectDir, featureDir, soft)) continue;
547
+ // Tier d: sibling plan/spec name an existing deliverable this task mentions
548
+ if (!siblingCache.has(featureDir)) siblingCache.set(featureDir, collectSiblingArtifacts(projectDir, spec));
549
+ const sibling = siblingCache.get(featureDir);
550
+ const hasSiblingDocs = Boolean(spec.planPath || spec.specPath);
551
+ let rescued = false;
552
+ for (const p of sibling.paths) { if (task.text.includes(p)) { rescued = true; break; } }
553
+ if (!rescued) for (const b of sibling.basenames) { if (task.text.includes(b)) { rescued = true; break; } }
554
+ if (rescued) continue;
555
+ // Defer tiers b/c/e/f to the batched repo scan + git lookup
556
+ pending.push({
557
+ entry, task, hasSiblingDocs,
558
+ basenames: new Set([...claims].map((t) => basename(t)).concat([...soft].filter((t) => !t.includes('/')))),
559
+ symbols: extractSymbolTokens(task.text),
560
+ });
561
+ }
562
+ }
563
+
564
+ if (pending.length > 0) {
565
+ const needles = { basenames: new Set(), symbols: new Set(), ids: new Set() };
566
+ for (const p of pending) {
567
+ for (const b of p.basenames) needles.basenames.add(b);
568
+ for (const s of p.symbols) needles.symbols.add(s);
569
+ if (p.task.id) needles.ids.add(p.task.id);
570
+ }
571
+ const found = scanRepoForEvidence(projectDir, needles);
572
+ let repo = null; // lazy: only shell out when an ID actually needs the git tier
573
+ const gitCache = new Map();
574
+ for (const p of pending) {
575
+ if ([...p.basenames].some((b) => found.basenames.has(b))) continue; // tier b: moved file
576
+ if ([...p.symbols].some((s) => found.symbols.has(s))) continue; // tier c: symbol landed
577
+ if (p.task.id && found.ids.has(p.task.id)) continue; // tier e: source annotation
578
+ let gitTierRan = false;
579
+ if (p.task.id) {
580
+ if (repo === null) repo = isGitRepo(projectDir);
581
+ if (repo) {
582
+ gitTierRan = true;
583
+ if (!gitCache.has(p.task.id)) gitCache.set(p.task.id, taskIdInGitLog(projectDir, p.task.id));
584
+ if (gitCache.get(p.task.id)) continue; // tier f: commit trail
585
+ }
586
+ }
587
+ const tiers = ['named paths', 'repo file names'];
588
+ if (p.hasSiblingDocs) tiers.push('plan/spec artifacts');
589
+ if (p.symbols.size > 0) tiers.push('code symbols');
590
+ if (p.task.id) tiers.push('task-ID in source');
591
+ if (gitTierRan) tiers.push('git log');
592
+ p.entry.phantoms.push({ id: p.task.id, text: p.task.text, line: p.task.line, tiers });
593
+ }
594
+ }
595
+
596
+ return results;
597
+ }
598
+
296
599
  // ──── CDD Mapping ────
297
600
 
298
601
  const SPECKIT_CDD_MAP = {
@@ -385,95 +688,207 @@ export function generateFromSpecKit(projectDir, config, flags) {
385
688
  * - Checks constitution → AGENTS.md mapping
386
689
  *
387
690
  * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
691
+ *
692
+ * v0.29: migrated to structured findings (SPK001–SPK007). Messages are
693
+ * byte-identical to the legacy strings — resultFromFindings derives the
694
+ * errors/warnings arrays from the same findings, so counts, exit codes, and
695
+ * existing tests are unaffected; guard just renders richer output.
696
+ *
697
+ * v0.30: adds phantom-completion detection (SPK008, elision SPK009) — tasks
698
+ * marked [x] whose named deliverables don't exist and have no other
699
+ * implementation evidence (see the Phantom-Completion Detection section
700
+ * above for the tier design). Opt out per-project with
701
+ * `"specKit": { "phantomCheck": false }` in .docguard.json.
388
702
  */
389
703
  export function validateSpecKitIntegration(projectDir, config) {
390
- const results = { errors: [], warnings: [], passed: 0, total: 0 };
704
+ const findings = [];
705
+ let passed = 0;
706
+ let total = 0;
391
707
 
392
708
  const speckit = detectSpecKit(projectDir);
393
709
 
394
710
  // If no Spec Kit detected, suggest it
395
711
  if (!speckit.detected) {
396
- results.total++;
397
- results.warnings.push(
398
- 'No Spec Kit artifacts detected. Consider `specify init` for spec-driven development (github.com/github/spec-kit)'
399
- );
400
- return results;
712
+ total++;
713
+ findings.push(mkFinding({
714
+ code: 'SPK001',
715
+ validator: 'specKit',
716
+ severity: 'warn',
717
+ message: 'No Spec Kit artifacts detected. Consider `specify init` for spec-driven development (github.com/github/spec-kit)',
718
+ location: null,
719
+ suggestion: { kind: 'review', text: 'Adopt spec-driven development by initializing Spec Kit', command: 'specify init' },
720
+ }));
721
+ return resultFromFindings(findings, { passed, total });
401
722
  }
402
723
 
403
724
  // ── Check 1: .specify/ directory exists ──
404
- results.total++;
725
+ total++;
405
726
  if (speckit.specifyDir) {
406
- results.passed++;
727
+ passed++;
407
728
  } else {
408
- results.warnings.push(
409
- 'Spec Kit artifacts found but .specify/ directory missing. Run `specify init` to create standard structure'
410
- );
729
+ findings.push(mkFinding({
730
+ code: 'SPK002',
731
+ validator: 'specKit',
732
+ severity: 'warn',
733
+ message: 'Spec Kit artifacts found but .specify/ directory missing. Run `specify init` to create standard structure',
734
+ location: '.specify',
735
+ suggestion: { kind: 'fix', text: 'Create the standard Spec Kit structure', command: 'specify init' },
736
+ }));
411
737
  }
412
738
 
413
739
  // ── Check 2: Validate each spec's quality ──
414
740
  for (const spec of speckit.specs) {
415
741
  // 2a: spec.md quality
416
742
  if (spec.hasSpec && spec.specPath) {
417
- results.total++;
743
+ total++;
744
+ const loc = relative(projectDir, spec.specPath);
418
745
  try {
419
746
  const issues = validateSpecQuality(spec.specPath);
420
747
  if (issues.length === 0) {
421
- results.passed++;
748
+ passed++;
422
749
  } else {
423
750
  for (const issue of issues) {
424
- results.warnings.push(`specs/${spec.name}/spec.md: ${issue}`);
751
+ findings.push(mkFinding({
752
+ code: 'SPK003',
753
+ validator: 'specKit',
754
+ severity: 'warn',
755
+ message: `specs/${spec.name}/spec.md: ${issue}`,
756
+ location: loc,
757
+ suggestion: { kind: 'fix', text: 'Bring the spec up to the spec-kit spec-template.md shape (sections, FR-/SC- IDs)' },
758
+ }));
425
759
  }
426
760
  }
427
761
  } catch {
428
- results.warnings.push(`specs/${spec.name}/spec.md: Could not read file`);
762
+ findings.push(mkFinding({
763
+ code: 'SPK006',
764
+ validator: 'specKit',
765
+ severity: 'warn',
766
+ message: `specs/${spec.name}/spec.md: Could not read file`,
767
+ location: loc,
768
+ suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
769
+ }));
429
770
  }
430
771
  }
431
772
 
432
773
  // 2b: plan.md quality
433
774
  if (spec.hasPlan && spec.planPath) {
434
- results.total++;
775
+ total++;
776
+ const loc = relative(projectDir, spec.planPath);
435
777
  try {
436
778
  const issues = validatePlanQuality(spec.planPath);
437
779
  if (issues.length === 0) {
438
- results.passed++;
780
+ passed++;
439
781
  } else {
440
782
  for (const issue of issues) {
441
- results.warnings.push(`specs/${spec.name}/plan.md: ${issue}`);
783
+ findings.push(mkFinding({
784
+ code: 'SPK004',
785
+ validator: 'specKit',
786
+ severity: 'warn',
787
+ message: `specs/${spec.name}/plan.md: ${issue}`,
788
+ location: loc,
789
+ suggestion: { kind: 'fix', text: 'Add the missing section per spec-kit plan-template.md' },
790
+ }));
442
791
  }
443
792
  }
444
793
  } catch {
445
- results.warnings.push(`specs/${spec.name}/plan.md: Could not read file`);
794
+ findings.push(mkFinding({
795
+ code: 'SPK006',
796
+ validator: 'specKit',
797
+ severity: 'warn',
798
+ message: `specs/${spec.name}/plan.md: Could not read file`,
799
+ location: loc,
800
+ suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
801
+ }));
446
802
  }
447
803
  }
448
804
 
449
805
  // 2c: tasks.md quality
450
806
  if (spec.hasTasks && spec.tasksPath) {
451
- results.total++;
807
+ total++;
808
+ const loc = relative(projectDir, spec.tasksPath);
452
809
  try {
453
810
  const issues = validateTasksQuality(spec.tasksPath);
454
811
  if (issues.length === 0) {
455
- results.passed++;
812
+ passed++;
456
813
  } else {
457
814
  for (const issue of issues) {
458
- results.warnings.push(`specs/${spec.name}/tasks.md: ${issue}`);
815
+ findings.push(mkFinding({
816
+ code: 'SPK005',
817
+ validator: 'specKit',
818
+ severity: 'warn',
819
+ message: `specs/${spec.name}/tasks.md: ${issue}`,
820
+ location: loc,
821
+ suggestion: { kind: 'fix', text: 'Add a phased breakdown with T-IDs per spec-kit tasks-template.md' },
822
+ }));
459
823
  }
460
824
  }
461
825
  } catch {
462
- results.warnings.push(`specs/${spec.name}/tasks.md: Could not read file`);
826
+ findings.push(mkFinding({
827
+ code: 'SPK006',
828
+ validator: 'specKit',
829
+ severity: 'warn',
830
+ message: `specs/${spec.name}/tasks.md: Could not read file`,
831
+ location: loc,
832
+ suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
833
+ }));
463
834
  }
464
835
  }
465
836
  }
466
837
 
838
+ // ── Check 2d: Phantom completions — tasks checked [x] with no implementation evidence ──
839
+ // Opt out with `"specKit": { "phantomCheck": false }` in .docguard.json.
840
+ // Each tasks.md with at least one checked task counts as one check.
841
+ if (config?.specKit?.phantomCheck !== false) {
842
+ const phantomResults = detectPhantomCompletions(projectDir, speckit.specs);
843
+ const flagged = [];
844
+ for (const r of phantomResults) {
845
+ if (r.checkedCount === 0) continue;
846
+ total++;
847
+ if (r.phantoms.length === 0) { passed++; continue; }
848
+ for (const ph of r.phantoms) flagged.push({ r, ph });
849
+ }
850
+ for (const { r, ph } of flagged.slice(0, MAX_PHANTOM_FINDINGS)) {
851
+ const text = ph.text.length > 80 ? ph.text.slice(0, 77) + '...' : ph.text;
852
+ const label = ph.id ? `${ph.id} marked [x]` : 'task marked [x]';
853
+ findings.push(mkFinding({
854
+ code: 'SPK008',
855
+ validator: 'specKit',
856
+ severity: 'warn',
857
+ confidence: 'low',
858
+ message: `specs/${r.spec.name}/tasks.md: ${label} with no implementation evidence — "${text}" (checked: ${ph.tiers.join(', ')})`,
859
+ location: `${r.relPath}:${ph.line}`,
860
+ suggestion: { kind: 'review', text: 'Uncheck the task or land the implementation it claims — a checked task with no artifact is memory corruption for agents' },
861
+ }));
862
+ }
863
+ if (flagged.length > MAX_PHANTOM_FINDINGS) {
864
+ findings.push(mkFinding({
865
+ code: 'SPK009',
866
+ validator: 'specKit',
867
+ severity: 'warn',
868
+ message: `...and ${flagged.length - MAX_PHANTOM_FINDINGS} more checked tasks with no implementation evidence`,
869
+ location: null,
870
+ suggestion: { kind: 'review', text: 'Fix or uncheck the tasks above and re-run guard to surface the rest — or set specKit.phantomCheck=false in .docguard.json to disable' },
871
+ }));
872
+ }
873
+ }
874
+
467
875
  // ── Check 3: Constitution → AGENTS.md mapping ──
468
876
  if (speckit.constitution) {
469
- results.total++;
877
+ total++;
470
878
  const agentsPath = resolve(projectDir, 'AGENTS.md');
471
879
  if (existsSync(agentsPath)) {
472
- results.passed++;
880
+ passed++;
473
881
  } else {
474
- results.warnings.push('constitution.md exists but no AGENTS.md found. Create one for AI agent rules');
882
+ findings.push(mkFinding({
883
+ code: 'SPK007',
884
+ validator: 'specKit',
885
+ severity: 'warn',
886
+ message: 'constitution.md exists but no AGENTS.md found. Create one for AI agent rules',
887
+ location: 'AGENTS.md',
888
+ suggestion: { kind: 'fix', text: 'Create an AGENTS.md that references the constitution', command: 'docguard init' },
889
+ }));
475
890
  }
476
891
  }
477
892
 
478
- return results;
893
+ return resultFromFindings(findings, { passed, total });
479
894
  }