docguard-cli 0.29.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.
package/README.md CHANGED
@@ -132,6 +132,19 @@ docguard diagnose
132
132
 
133
133
  > **Note:** The Python package is a thin wrapper that delegates to `npx`. Node.js 18+ is required on the system.
134
134
 
135
+ ### More ways to integrate
136
+
137
+ - **pre-commit** — changed-only guard on every commit:
138
+ ```yaml
139
+ repos:
140
+ - repo: https://github.com/raccioly/docguard
141
+ rev: v0.29.0
142
+ hooks: [{ id: docguard-guard }] # docguard-guard-full for pre-push
143
+ ```
144
+ - **MCP** (Claude, Cursor, any MCP client) — `claude mcp add docguard -- npx -y docguard-cli mcp`; 5 read-only tools (guard, score, explain, verify-claims, diagnose). Registry manifest ships in-repo (`server.json`, Smithery-ready).
145
+ - **GitLab CI** — component staged at [`templates/ci/gitlab-component.yml`](templates/ci/gitlab-component.yml) (guard/score/ci job with a SARIF artifact).
146
+ - **Homebrew** — `brew install raccioly/tap/docguard` (formula in [`packaging/homebrew/`](packaging/homebrew/)).
147
+
135
148
  ### Core Workflow
136
149
 
137
150
  ```bash
@@ -253,10 +266,11 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
253
266
  | `fix` | Generate AI fix instructions for specific docs (`--doc <name> --format prompt`) |
254
267
  | `fix --write` | Apply deterministic fixes (no AI — version bumps, counts, anchors, sections) |
255
268
  | `fix --history` | Audit log of every mechanical fix applied (from `.docguard/fixed.json`) |
256
- | `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
269
+ | `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) — includes auto-generated Mermaid ER diagrams from your detected schemas (Prisma/Drizzle/TypeORM/Sequelize/Django/Rails) in DATA-MODEL.md |
257
270
  | `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
258
271
  | `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
259
272
  | `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
273
+ | `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
260
274
  | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
261
275
  | `mcp` | MCP server over stdio — exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Setup: `claude mcp add docguard -- npx docguard-cli mcp` |
262
276
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
@@ -264,6 +278,7 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
264
278
  | `memory --pack` | Write `.docguard/context-pack.md` — compact, code-truth-stamped session-start context for AI agents |
265
279
  | `score --diff` | Drill into which checks pulled each category down |
266
280
  | `trace` / `trace --reverse <file>` | Requirements traceability — forward AND reverse |
281
+ | `trace --features` | Per-feature spec-adherence scores (requirement coverage, task completion, task evidence, artifacts) — worst-first with fix hints |
267
282
  | `upgrade [--apply] [--pr]` | Check + migrate `.docguard.json` schema; `--pr` opens a PR |
268
283
  | `watch` | Live mode: re-run guard on file changes |
269
284
 
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
- import { resolve, join, extname, basename, relative } from 'node:path';
10
+ import { resolve, join, extname, basename, relative, dirname } from 'node:path';
11
11
  import { c } from '../shared.mjs';
12
+ import { detectSpecKit } from '../scanners/speckit.mjs';
12
13
 
13
14
  const IGNORE_DIRS = new Set([
14
15
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -134,6 +135,11 @@ export function runTrace(projectDir, config, flags) {
134
135
  return runTraceReverse(projectDir, config, flags);
135
136
  }
136
137
 
138
+ // Per-feature spec-kit adherence scoring when --features is set.
139
+ if (flags.features) {
140
+ return runTraceFeatures(projectDir, config, flags);
141
+ }
142
+
137
143
  // v0.16-P1: same headless-mode pattern as guard/score. Reported by Python
138
144
  // user — trace --format json was leaking ANSI escapes before the body.
139
145
  const isJson = flags.format === 'json';
@@ -366,6 +372,363 @@ function scanDir(rootDir, dir, files) {
366
372
  }
367
373
  }
368
374
 
375
+ // ── Per-feature spec adherence (trace --features) ───────────────────────────
376
+ //
377
+ // Scores each detected spec-kit feature's implementation adherence
378
+ // individually (inspired by spec-kit-retrospective), instead of only the
379
+ // repo-wide scores that `docguard score` produces. Deterministic signals only —
380
+ // no LLM judgment:
381
+ //
382
+ // reqCoverage 40% FR-/SC- IDs in spec.md referenced by any test file
383
+ // taskCompletion 25% checked/total `- [x]` tasks in tasks.md
384
+ // taskEvidence 20% checked tasks whose line names an existing file
385
+ // artifactCompleteness 15% spec.md (40%) + plan.md (30%) + tasks.md (30%)
386
+ //
387
+ // A signal that cannot be measured (no tasks.md, no requirement IDs, no
388
+ // checked task names a parseable path) is NEUTRAL: excluded from the weighted
389
+ // sum and its weight redistributed across the measurable signals. This mirrors
390
+ // the traceability validator's "no requirement IDs → silently pass" stance —
391
+ // absence of a convention is not evidence of low adherence (missing artifacts
392
+ // are already priced in by artifactCompleteness).
393
+
394
+ const FEATURE_SIGNAL_WEIGHTS = {
395
+ reqCoverage: 0.40,
396
+ taskCompletion: 0.25,
397
+ taskEvidence: 0.20,
398
+ artifactCompleteness: 0.15,
399
+ };
400
+
401
+ // Grade bands mirrored from cli/scanners/agent-readability.mjs GRADES.
402
+ // Display-only — never feeds the gating CDD grade that CI thresholds read.
403
+ const FEATURE_GRADES = [[90, 'A'], [75, 'B'], [60, 'C'], [40, 'D']];
404
+
405
+ function featureGrade(score) {
406
+ for (const [min, g] of FEATURE_GRADES) if (score >= min) return g;
407
+ return 'F';
408
+ }
409
+
410
+ // Spec-kit requirement IDs scored per feature. Subset of the traceability
411
+ // validator's DEFAULT_REQ_PATTERNS (cli/validators/traceability.mjs) — the two
412
+ // ID families spec-kit's spec-template.md mandates.
413
+ const FEATURE_REQ_RE = /\b(?:FR|SC)-\d{2,4}\b/g;
414
+
415
+ // Path-token heuristic mirrored from cli/scanners/semantic-claims.mjs
416
+ // CITED_CODE_RE: a backticked or bare path-like token with a code extension.
417
+ const TASK_PATH_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?/g;
418
+
419
+ // 20-char bar mirrored from cli/commands/score.mjs renderBar (not exported
420
+ // there; score.mjs is display-conventions-only for this feature).
421
+ function featureBar(score) {
422
+ const filled = Math.round(score / 5);
423
+ const empty = 20 - filled;
424
+ const color = score >= 80 ? c.green : score >= 60 ? c.yellow : c.red;
425
+ return `${color}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
426
+ }
427
+
428
+ /**
429
+ * Collect every FR-/SC- ID referenced anywhere in a test file, once for the
430
+ * whole project. Test-file discovery mirrors the traceability validator's
431
+ * scanTestFilesForReferences(): TEST_PATTERNS ∪ __tests__/ ∪ tests?/ dirs, and
432
+ * any occurrence of the ID in file content counts (not just @req lines).
433
+ */
434
+ function collectTestReferencedIds(projectDir) {
435
+ const projectFiles = [];
436
+ scanDir(projectDir, projectDir, projectFiles);
437
+ const testFiles = projectFiles.filter(f =>
438
+ TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
439
+ );
440
+
441
+ const ids = new Set();
442
+ for (const rel of testFiles) {
443
+ let content;
444
+ try { content = readFileSync(resolve(projectDir, rel), 'utf-8'); } catch { continue; }
445
+ FEATURE_REQ_RE.lastIndex = 0;
446
+ let m;
447
+ while ((m = FEATURE_REQ_RE.exec(content)) !== null) ids.add(m[0]);
448
+ }
449
+ return ids;
450
+ }
451
+
452
+ /**
453
+ * Compute the four adherence signals for one detected spec-kit feature.
454
+ * Each signal: { applicable, value (0..1 | null), ...n/m detail fields }.
455
+ */
456
+ function computeFeatureSignals(projectDir, feature, testRefIds) {
457
+ // ── artifactCompleteness — always measurable ──
458
+ const artifactValue = (feature.hasSpec ? 0.4 : 0)
459
+ + (feature.hasPlan ? 0.3 : 0)
460
+ + (feature.hasTasks ? 0.3 : 0);
461
+
462
+ // ── taskCompletion + taskEvidence — parse tasks.md checklist lines ──
463
+ let totalTasks = 0, checkedTasks = 0, evidenced = 0, considered = 0;
464
+ if (feature.hasTasks && feature.tasksPath) {
465
+ let content = null;
466
+ try { content = readFileSync(feature.tasksPath, 'utf-8'); } catch { /* unreadable → no tasks */ }
467
+ if (content !== null) {
468
+ for (const line of content.split('\n')) {
469
+ const box = /^\s*[-*]\s*\[([ xX])\]/.exec(line);
470
+ if (!box) continue;
471
+ totalTasks++;
472
+ if (box[1] === ' ') continue;
473
+ checkedTasks++;
474
+ // Evidence: any named path on the line that exists in the project.
475
+ // Checked tasks with no parseable path are neutral (skip denominator).
476
+ TASK_PATH_RE.lastIndex = 0;
477
+ let tok, sawToken = false, exists = false;
478
+ while ((tok = TASK_PATH_RE.exec(line)) !== null) {
479
+ sawToken = true;
480
+ if (existsSync(resolve(projectDir, tok[1].replace(/^\.\//, '')))) { exists = true; break; }
481
+ }
482
+ if (sawToken) { considered++; if (exists) evidenced++; }
483
+ }
484
+ }
485
+ }
486
+
487
+ // ── reqCoverage — spec.md IDs that appear in ANY test file ──
488
+ const specIds = [];
489
+ if (feature.hasSpec && feature.specPath) {
490
+ try {
491
+ const spec = readFileSync(feature.specPath, 'utf-8');
492
+ const seen = new Set();
493
+ FEATURE_REQ_RE.lastIndex = 0;
494
+ let m;
495
+ while ((m = FEATURE_REQ_RE.exec(spec)) !== null) {
496
+ if (!seen.has(m[0])) { seen.add(m[0]); specIds.push(m[0]); }
497
+ }
498
+ } catch { /* unreadable spec → no IDs */ }
499
+ }
500
+ const covered = specIds.filter(id => testRefIds.has(id));
501
+ const uncovered = specIds.filter(id => !testRefIds.has(id));
502
+
503
+ return {
504
+ reqCoverage: {
505
+ applicable: specIds.length > 0,
506
+ value: specIds.length > 0 ? covered.length / specIds.length : null,
507
+ covered: covered.length,
508
+ total: specIds.length,
509
+ uncovered,
510
+ },
511
+ taskCompletion: {
512
+ applicable: totalTasks > 0,
513
+ value: totalTasks > 0 ? checkedTasks / totalTasks : null,
514
+ checked: checkedTasks,
515
+ total: totalTasks,
516
+ },
517
+ taskEvidence: {
518
+ applicable: considered > 0,
519
+ value: considered > 0 ? evidenced / considered : null,
520
+ evidenced,
521
+ considered,
522
+ },
523
+ artifactCompleteness: {
524
+ applicable: true,
525
+ value: artifactValue,
526
+ spec: feature.hasSpec,
527
+ plan: feature.hasPlan,
528
+ tasks: feature.hasTasks,
529
+ },
530
+ };
531
+ }
532
+
533
+ /** Weighted 0–100 score over the applicable signals (weights renormalized). */
534
+ function scoreFromSignals(signals) {
535
+ let weighted = 0, weightTotal = 0;
536
+ for (const [key, weight] of Object.entries(FEATURE_SIGNAL_WEIGHTS)) {
537
+ const s = signals[key];
538
+ if (!s.applicable) continue;
539
+ weighted += weight * s.value;
540
+ weightTotal += weight;
541
+ }
542
+ return weightTotal > 0 ? Math.round((weighted / weightTotal) * 100) : 0;
543
+ }
544
+
545
+ /**
546
+ * The lowest-valued applicable signal. Iteration order is descending weight,
547
+ * and replacement is strict-less-than, so ties resolve to the highest-impact
548
+ * signal — the one worth fixing first.
549
+ */
550
+ function weakestSignal(signals) {
551
+ let worstKey = null;
552
+ for (const key of Object.keys(FEATURE_SIGNAL_WEIGHTS)) {
553
+ const s = signals[key];
554
+ if (!s.applicable) continue;
555
+ if (worstKey === null || s.value < signals[worstKey].value) worstKey = key;
556
+ }
557
+ return worstKey;
558
+ }
559
+
560
+ function fixHintFor(key, s, feature) {
561
+ switch (key) {
562
+ case 'reqCoverage':
563
+ return `Cover the untested spec IDs (e.g. ${s.uncovered[0]}) — reference them from tests via @req annotations`;
564
+ case 'taskCompletion':
565
+ return `Complete (or prune) the ${s.total - s.checked} unchecked task(s) in tasks.md`;
566
+ case 'taskEvidence':
567
+ return `${s.considered - s.evidenced} checked task(s) name files that don't exist — fix stale paths or uncheck them`;
568
+ case 'artifactCompleteness': {
569
+ const missing = [
570
+ feature.hasSpec ? null : 'spec.md',
571
+ feature.hasPlan ? null : 'plan.md',
572
+ feature.hasTasks ? null : 'tasks.md',
573
+ ].filter(Boolean);
574
+ return `Add ${missing.join(', ')} to complete the artifact set`;
575
+ }
576
+ default:
577
+ return null;
578
+ }
579
+ }
580
+
581
+ /**
582
+ * `docguard trace --features` — per-feature spec-kit adherence report.
583
+ * Reuses detectSpecKit() for feature discovery (no re-implementation).
584
+ */
585
+ export function runTraceFeatures(projectDir, config, flags) {
586
+ const isJson = flags.format === 'json';
587
+ if (!isJson) {
588
+ console.log(`${c.bold}🎯 DocGuard Trace (features) — ${config.projectName}${c.reset}`);
589
+ console.log(`${c.dim} Scoring per-feature spec adherence (spec-kit)...${c.reset}\n`);
590
+ }
591
+
592
+ const speckit = detectSpecKit(projectDir);
593
+ if (!speckit.detected || speckit.specs.length === 0) {
594
+ // Same empty-state contract as trace --reverse: JSON stays parseable with
595
+ // an `error` field; text gets an actionable pointer.
596
+ if (isJson) {
597
+ console.log(JSON.stringify({
598
+ features: [],
599
+ summary: { features: 0, avgScore: null, worst: null },
600
+ error: 'no spec-kit features detected',
601
+ timestamp: new Date().toISOString(),
602
+ }, null, 2));
603
+ } else {
604
+ console.log(` ${c.yellow}No spec-kit features detected.${c.reset}`);
605
+ console.log(` ${c.dim}Feature scoring needs .specify/specs/** or specs/** (spec.md/plan.md/tasks.md). Run \`specify init\` to start.${c.reset}`);
606
+ }
607
+ return;
608
+ }
609
+
610
+ const testRefIds = collectTestReferencedIds(projectDir);
611
+
612
+ const features = speckit.specs.map(f => {
613
+ const signals = computeFeatureSignals(projectDir, f, testRefIds);
614
+ const score = scoreFromSignals(signals);
615
+ const weakest = weakestSignal(signals);
616
+ const needsFix = weakest !== null && signals[weakest].value < 1;
617
+ return {
618
+ name: f.name,
619
+ dir: relative(projectDir, dirname(f.specPath || f.planPath || f.tasksPath)),
620
+ score,
621
+ grade: featureGrade(score),
622
+ signals,
623
+ weakest,
624
+ fixHint: needsFix ? fixHintFor(weakest, signals[weakest], f) : null,
625
+ };
626
+ });
627
+
628
+ // Worst-first — act on the weakest feature. Name tie-break for determinism.
629
+ features.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
630
+
631
+ const avgScore = Math.round(features.reduce((sum, f) => sum + f.score, 0) / features.length);
632
+ const summary = {
633
+ features: features.length,
634
+ avgScore,
635
+ worst: { name: features[0].name, score: features[0].score },
636
+ };
637
+
638
+ if (isJson) {
639
+ outputFeaturesJSON(features, summary);
640
+ } else {
641
+ outputFeaturesText(features, summary);
642
+ }
643
+ }
644
+
645
+ function pctOrNull(signal) {
646
+ return signal.applicable ? Math.round(signal.value * 100) : null;
647
+ }
648
+
649
+ function outputFeaturesJSON(features, summary) {
650
+ console.log(JSON.stringify({
651
+ features: features.map(f => ({
652
+ name: f.name,
653
+ dir: f.dir,
654
+ score: f.score,
655
+ grade: f.grade,
656
+ signals: {
657
+ reqCoverage: {
658
+ pct: pctOrNull(f.signals.reqCoverage),
659
+ covered: f.signals.reqCoverage.covered,
660
+ total: f.signals.reqCoverage.total,
661
+ uncovered: f.signals.reqCoverage.uncovered,
662
+ },
663
+ taskCompletion: {
664
+ pct: pctOrNull(f.signals.taskCompletion),
665
+ checked: f.signals.taskCompletion.checked,
666
+ total: f.signals.taskCompletion.total,
667
+ },
668
+ taskEvidence: {
669
+ pct: pctOrNull(f.signals.taskEvidence),
670
+ evidenced: f.signals.taskEvidence.evidenced,
671
+ considered: f.signals.taskEvidence.considered,
672
+ },
673
+ artifactCompleteness: {
674
+ pct: pctOrNull(f.signals.artifactCompleteness),
675
+ spec: f.signals.artifactCompleteness.spec,
676
+ plan: f.signals.artifactCompleteness.plan,
677
+ tasks: f.signals.artifactCompleteness.tasks,
678
+ },
679
+ },
680
+ weakest: f.weakest,
681
+ fixHint: f.fixHint,
682
+ })),
683
+ summary,
684
+ timestamp: new Date().toISOString(),
685
+ }, null, 2));
686
+ }
687
+
688
+ function outputFeaturesText(features, summary) {
689
+ console.log(` ${c.bold}Feature Adherence${c.reset} ${c.dim}(worst first)${c.reset}\n`);
690
+
691
+ for (const f of features) {
692
+ const gradeColor = f.score >= 80 ? c.green : f.score >= 60 ? c.yellow : c.red;
693
+ console.log(` 📦 ${c.bold}${f.name}${c.reset} — ${gradeColor}${f.score}/100 (${f.grade})${c.reset} ${featureBar(f.score)}`);
694
+ console.log(` ${c.dim}${f.dir}${c.reset}`);
695
+
696
+ const sig = f.signals;
697
+ const line = (label, weightPct, s, detail) => {
698
+ const pct = s.applicable ? `${Math.round(s.value * 100)}%`.padEnd(4) : 'n/a ';
699
+ const color = !s.applicable ? c.dim : s.value >= 0.8 ? c.green : s.value >= 0.5 ? c.yellow : c.red;
700
+ console.log(` ${color}${pct}${c.reset} ${label.padEnd(22)} ${c.dim}${detail} · weight ${weightPct}%${c.reset}`);
701
+ };
702
+
703
+ line('Requirement coverage', 40, sig.reqCoverage,
704
+ sig.reqCoverage.applicable
705
+ ? `${sig.reqCoverage.covered}/${sig.reqCoverage.total} spec IDs referenced by tests`
706
+ : 'no FR-/SC- IDs in spec.md');
707
+ line('Task completion', 25, sig.taskCompletion,
708
+ sig.taskCompletion.applicable
709
+ ? `${sig.taskCompletion.checked}/${sig.taskCompletion.total} tasks checked`
710
+ : 'no tasks.md checklist');
711
+ line('Task evidence', 20, sig.taskEvidence,
712
+ sig.taskEvidence.applicable
713
+ ? `${sig.taskEvidence.evidenced}/${sig.taskEvidence.considered} checked tasks name existing files`
714
+ : 'no checked task names a file path');
715
+ line('Artifacts', 15, sig.artifactCompleteness,
716
+ `${[sig.artifactCompleteness.spec, sig.artifactCompleteness.plan, sig.artifactCompleteness.tasks].filter(Boolean).length}/3 ` +
717
+ `(spec ${sig.artifactCompleteness.spec ? '✓' : '✗'} · plan ${sig.artifactCompleteness.plan ? '✓' : '✗'} · tasks ${sig.artifactCompleteness.tasks ? '✓' : '✗'})`);
718
+
719
+ if (f.fixHint) {
720
+ console.log(` ${c.yellow}⚠ Fix first:${c.reset} ${f.fixHint}`);
721
+ } else {
722
+ console.log(` ${c.green}✓ No weak signal — all applicable signals at 100%${c.reset}`);
723
+ }
724
+ console.log('');
725
+ }
726
+
727
+ console.log(` ${c.bold}─────────────────────────────────────${c.reset}`);
728
+ console.log(` ${summary.features} feature(s) · avg ${summary.avgScore}/100 · worst: ${c.red}${summary.worst.name} (${summary.worst.score}/100)${c.reset}`);
729
+ console.log(`\n ${c.dim}Signals are deterministic (checklist, ID-to-test references, file existence) — adherence of intent, not correctness.${c.reset}\n`);
730
+ }
731
+
369
732
  function findRelatedTests(projectFiles, sourcePatterns) {
370
733
  // Find test files that might cover the source patterns
371
734
  const testFiles = projectFiles.filter(f => TEST_PATTERNS.some(p => p.test(f)));
@@ -1,22 +1,38 @@
1
1
  /**
2
- * Verify Command — `docguard verify --semantic` (LLM field report #5).
2
+ * Verify Command — `docguard verify` (LLM field reports #5, #11).
3
3
  *
4
- * Surfaces the semantic claims in the canonical docs (documented numbers, limits,
5
- * and enums) as a structured verification task list for the agent to check
6
- * against the code. DocGuard does the deterministic discovery; the LLM does the
7
- * judgment the same division of labour as `docguard agent`.
4
+ * Two modes, same division of labour (DocGuard does the deterministic
5
+ * discovery; the LLM does the judgment like `docguard agent`):
6
+ *
7
+ * --semantic (default) Surface the semantic claims in the canonical docs
8
+ * (documented numbers, limits, enums) as a verification
9
+ * task list for the agent to check against the code.
10
+ *
11
+ * --instructions Audit the agent instruction files themselves
12
+ * (AGENTS.md, CLAUDE.md) for drift: duplicate rules,
13
+ * direct never/always contradictions, stale file
14
+ * pointers, and unknown docguard commands are found
15
+ * deterministically; topically-clustered rule pairs
16
+ * become agent tasks ("do these contradict in
17
+ * practice?"). Inspired by spec-kit's MemoryLint.
8
18
  *
9
19
  * Read-only. JSON is the machine artifact (the agent-executable task list);
10
20
  * text is the human summary.
11
21
  *
12
- * docguard verify [--semantic] [--format json]
22
+ * docguard verify [--semantic | --instructions] [--format json]
13
23
  */
14
24
 
15
25
  import { c } from '../shared.mjs';
16
26
  import { detectAgentMode } from '../ensure-skills.mjs';
17
27
  import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
28
+ import { auditInstructions } from '../scanners/instruction-audit.mjs';
18
29
 
19
30
  export function runVerify(projectDir, config, flags) {
31
+ if (flags.instructions) {
32
+ runInstructionAudit(projectDir, config, flags);
33
+ return;
34
+ }
35
+
20
36
  const isJson = flags.format === 'json';
21
37
  const claims = extractSemanticClaims(projectDir, config);
22
38
  const tasks = buildSemanticVerifyTasks(claims);
@@ -65,3 +81,74 @@ export function runVerify(projectDir, config, flags) {
65
81
  console.log(` ${c.dim}This is the highest-value bug class and DocGuard can't judge it — an agent must.${c.reset}`);
66
82
  console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
67
83
  }
84
+
85
+ // ── verify --instructions: agent-instruction drift/conflict audit ───────────
86
+
87
+ function runInstructionAudit(projectDir, config, flags) {
88
+ const isJson = flags.format === 'json';
89
+ const { rules, deterministic, tasks } = auditInstructions(projectDir, config);
90
+ const { duplicates, negations, stalePointers, staleCommands } = deterministic;
91
+ const findingCount = duplicates.length + negations.length + stalePointers.length + staleCommands.length;
92
+
93
+ if (isJson) {
94
+ console.log(JSON.stringify({
95
+ command: 'verify --instructions',
96
+ project: config.projectName,
97
+ ruleCount: rules.length,
98
+ findingCount,
99
+ findings: deterministic,
100
+ taskCount: tasks.length,
101
+ // How to act on this: findings are proven; tasks need judgment.
102
+ howToVerify: 'The findings are deterministic — fix them directly (delete the duplicate copy, resolve the negation in favour of one rule, repoint or remove stale paths/commands). For each task, read both rules in context and judge whether they contradict in practice; if so, report which should win, why, and which file to edit. DocGuard cannot judge the tasks — they require understanding intent.',
103
+ tasks,
104
+ }, null, 2));
105
+ return;
106
+ }
107
+
108
+ console.log(`${c.bold}🔬 DocGuard Verify — instruction audit${c.reset}`);
109
+ console.log(`${c.dim} ${config.projectName} · duplicate / contradictory / stale rules in AGENTS.md + CLAUDE.md${c.reset}\n`);
110
+
111
+ if (rules.length === 0) {
112
+ console.log(` ${c.green}✅ No instruction rules found (no AGENTS.md/CLAUDE.md, or nothing imperative in them).${c.reset}\n`);
113
+ return;
114
+ }
115
+
116
+ console.log(` ${c.dim}${rules.length} rule(s) extracted from ${[...new Set(rules.map(r => r.file))].join(' + ')}${c.reset}\n`);
117
+
118
+ if (findingCount === 0) {
119
+ console.log(` ${c.green}✅ No duplicate, directly-contradictory, or stale rules found.${c.reset}\n`);
120
+ } else {
121
+ console.log(` ${c.yellow}${findingCount} deterministic finding(s):${c.reset}\n`);
122
+ for (const d of duplicates) {
123
+ const where = d.rules.map(r => `${r.file}:${r.line}`).join(` ${c.dim}≡${c.reset} `);
124
+ console.log(` ${c.yellow}⚠${c.reset} duplicate rule — ${where}: ${c.dim}"${d.rules[0].text}"${c.reset}`);
125
+ }
126
+ for (const n of negations) {
127
+ console.log(` ${c.yellow}⚠${c.reset} negation conflict — ${n.a.file}:${n.a.line} ${c.dim}⇄${c.reset} ${n.b.file}:${n.b.line}: ${c.dim}"${n.a.text}" vs "${n.b.text}"${c.reset}`);
128
+ }
129
+ for (const s of stalePointers) {
130
+ console.log(` ${c.yellow}⚠${c.reset} stale pointer — ${s.file}:${s.line}: ${c.cyan}${s.path}${c.reset} does not exist`);
131
+ }
132
+ for (const s of staleCommands) {
133
+ console.log(` ${c.yellow}⚠${c.reset} stale command — ${s.file}:${s.line}: ${c.cyan}docguard ${s.command}${c.reset} is not a docguard command`);
134
+ }
135
+ console.log('');
136
+ }
137
+
138
+ if (tasks.length > 0) {
139
+ console.log(` ${c.yellow}${tasks.length} rule pair(s) for the agent to judge:${c.reset}\n`);
140
+ for (const t of tasks) {
141
+ console.log(` ${c.bold}${t.a.file}:${t.a.line} ↔ ${t.b.file}:${t.b.line}${c.reset} ${c.dim}(shared: ${t.sharedTerms.join(', ')})${c.reset}`);
142
+ console.log(` ${c.yellow}A${c.reset} ${c.dim}${t.a.section ? `[${t.a.section}] ` : ''}${c.reset}"${t.a.text}"`);
143
+ console.log(` ${c.yellow}B${c.reset} ${c.dim}${t.b.section ? `[${t.b.section}] ` : ''}${c.reset}"${t.b.text}"`);
144
+ console.log('');
145
+ }
146
+
147
+ const mode = detectAgentMode(projectDir);
148
+ const cmd = mode === 'llm' ? '/docguard.verify' : 'docguard verify --instructions --format json';
149
+ console.log(` ${c.dim}Whether clustered rules contradict in practice is judgment DocGuard can't make — an agent must.${c.reset}`);
150
+ console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then judge each pair and report which rule should win.${c.reset}\n`);
151
+ } else if (findingCount === 0) {
152
+ console.log(` ${c.dim}(Looks for duplicate/negated rules, dead file pointers, unknown docguard commands, and topically-clustered rule pairs.)${c.reset}\n`);
153
+ }
154
+ }
package/cli/docguard.mjs CHANGED
@@ -93,7 +93,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
93
93
  ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
94
94
  ${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/diagnose as agent tools
95
95
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
96
- ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
96
+ ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
97
97
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
98
98
  ${c.green}watch${c.reset} Live mode: re-run guard on file changes
99
99
 
@@ -258,7 +258,7 @@ const COMMAND_HELP = {
258
258
  },
259
259
  trace: {
260
260
  summary: 'Requirements traceability matrix.',
261
- usage: 'docguard trace [--reverse]',
261
+ usage: 'docguard trace [--reverse] [--features]',
262
262
  flags: [['--reverse', 'Code→doc map instead of doc→code']],
263
263
  examples: ['docguard trace', 'docguard trace --reverse'],
264
264
  },
@@ -294,9 +294,10 @@ const COMMAND_HELP = {
294
294
  },
295
295
  verify: {
296
296
  summary: 'Extract the semantic claims in your canonical docs — documented numbers, limits, and enums (retention days, rate limits, GSI/role counts, status enums) — as a verification task list the agent checks against the code. This is the highest-value bug class (a doc value that drifted from code) and the one regex/AST cannot judge. DocGuard finds the claims; the LLM confirms them.',
297
- usage: 'docguard verify [--semantic] [--format json]',
297
+ usage: 'docguard verify [--semantic|--instructions] [--format json]',
298
298
  flags: [
299
299
  ['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
300
+ ['--instructions', 'Audit AGENTS.md/CLAUDE.md for duplicate, contradictory, and stale-pointer rules (deterministic findings + agent conflict tasks)'],
300
301
  ['--format json', 'Machine-readable task list (the agent-executable artifact)'],
301
302
  ],
302
303
  examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
@@ -377,6 +378,10 @@ async function main() {
377
378
  // v0.28 (field report #5): `docguard verify --semantic` extracts
378
379
  // documented numbers/enums/limits for the agent to check against code.
379
380
  flags.semantic = true;
381
+ } else if (args[i] === '--instructions') {
382
+ // v0.30: `docguard verify --instructions` audits AGENTS.md/CLAUDE.md for
383
+ // duplicate/contradictory/stale rules (MemoryLint-inspired).
384
+ flags.instructions = true;
380
385
  } else if (args[i] === '--full') {
381
386
  // v0.29: `docguard llms --full` emits llms-full.txt (inline doc bodies,
382
387
  // the Mintlify-popularized companion to the llms.txt index).
@@ -409,6 +414,9 @@ async function main() {
409
414
  flags.changedOnly = true;
410
415
  } else if (args[i] === '--reverse') {
411
416
  flags.reverse = true;
417
+ } else if (args[i] === '--features') {
418
+ // v0.30: `docguard trace --features` — per-feature spec-adherence report.
419
+ flags.features = true;
412
420
  } else if (args[i] === '--history') {
413
421
  flags.history = true;
414
422
  } else if (args[i] === '--force-redo') {
package/cli/findings.mjs CHANGED
@@ -474,6 +474,18 @@ export const CODES = {
474
474
  help: 'constitution.md exists but there is no AGENTS.md. AI agents look to AGENTS.md for project rules — create one (e.g. via `docguard init`) and reference the constitution from it.',
475
475
  suppress: null,
476
476
  },
477
+ SPK008: {
478
+ validator: 'specKit',
479
+ title: 'Phantom completion — checked task with no implementation evidence',
480
+ help: 'A tasks.md task marked [x] names a deliverable path that does not exist, and no evidence tier confirms the work landed: no matching basename anywhere in the repo (moved file), no named code symbol in source, no plan.md/spec.md tie to an existing artifact, no task-ID annotation in source, and no task-ID in the git log. A checked task with no artifact corrupts agent memory — later sessions trust the checkbox and skip the work. Uncheck the task or land the implementation. Flagged low-confidence — report a false positive if the deliverable was renamed beyond recognition. Opt out with `"specKit": { "phantomCheck": false }` in .docguard.json.',
481
+ suppress: null,
482
+ },
483
+ SPK009: {
484
+ validator: 'specKit',
485
+ title: 'Additional phantom completions elided',
486
+ help: 'Guard reports at most 10 phantom-completion findings (SPK008) per run to avoid noise; this line counts the remainder. Fix or uncheck the reported tasks and re-run guard to surface more, or set `"specKit": { "phantomCheck": false }` in .docguard.json to disable the check.',
487
+ suppress: null,
488
+ },
477
489
  XRF001: {
478
490
  validator: 'crossReference',
479
491
  title: 'Broken doc link',