docguard-cli 0.39.0 → 0.40.1

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 (57) hide show
  1. package/README.md +43 -20
  2. package/cli/commands/agent.mjs +47 -1
  3. package/cli/commands/explain.mjs +16 -0
  4. package/cli/commands/fix.mjs +13 -11
  5. package/cli/commands/generate.mjs +52 -18
  6. package/cli/commands/guard.mjs +13 -2
  7. package/cli/commands/mcp.mjs +22 -2
  8. package/cli/commands/score.mjs +13 -1
  9. package/cli/commands/sync.mjs +20 -7
  10. package/cli/commands/verify.mjs +65 -2
  11. package/cli/config.mjs +3 -0
  12. package/cli/docguard.mjs +33 -12
  13. package/cli/evidence/adapters.mjs +200 -0
  14. package/cli/evidence/evaluate.mjs +185 -0
  15. package/cli/evidence/manifest.mjs +194 -0
  16. package/cli/evidence/markdown.mjs +107 -0
  17. package/cli/findings.mjs +31 -0
  18. package/cli/release-pr-policy.mjs +107 -0
  19. package/cli/repository-root.mjs +159 -0
  20. package/cli/scanners/py-ast.mjs +39 -2
  21. package/cli/scanners/task-context.mjs +312 -0
  22. package/cli/shared-doc-roles.mjs +44 -1
  23. package/cli/shared-source.mjs +101 -28
  24. package/cli/validators/architecture.mjs +186 -13
  25. package/cli/validators/environment.mjs +14 -1
  26. package/cli/validators/evidence.mjs +52 -0
  27. package/cli/validators/todo-tracking.mjs +45 -2
  28. package/cli/writers/doc-generators.mjs +31 -17
  29. package/cli/writers/mechanical.mjs +44 -14
  30. package/cli/writers/sections.mjs +31 -3
  31. package/docs/ai-integration.md +31 -6
  32. package/docs/commands.md +43 -5
  33. package/docs/configuration.md +11 -3
  34. package/docs/quickstart.md +1 -1
  35. package/extensions/spec-kit-docguard/commands/fix.md +4 -2
  36. package/extensions/spec-kit-docguard/commands/generate.md +6 -1
  37. package/extensions/spec-kit-docguard/commands/guard.md +3 -2
  38. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  39. package/extensions/spec-kit-docguard/extension.yml +1 -1
  40. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
  41. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
  42. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
  43. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
  44. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
  45. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
  46. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
  47. package/package.json +2 -1
  48. package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
  49. package/schemas/docguard-agent-context-result.schema.json +95 -0
  50. package/schemas/docguard-config.schema.json +1 -0
  51. package/schemas/docguard-evidence.schema.json +169 -0
  52. package/schemas/docguard-task-context.schema.json +144 -0
  53. package/templates/AGENTS.md.template +9 -4
  54. package/templates/ci/github-actions.yml +4 -4
  55. package/templates/commands/docguard.guard.md +5 -1
  56. package/templates/commands/docguard.review.md +6 -1
  57. package/templates/evidence-manifest.json +21 -0
@@ -1,5 +1,6 @@
1
1
  import { applyDocRoles, remapDocPath, resolveDocRole } from '../shared-doc-roles.mjs';
2
2
  /**
3
+ * @implements docguard.evidence-scoped-verification#FR-012
3
4
  * Score Command — Calculate CDD maturity score (0-100)
4
5
  * Shows category breakdown with weighted scoring.
5
6
  */
@@ -14,6 +15,7 @@ import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
14
15
  import { assessAgentReadability } from '../scanners/agent-readability.mjs';
15
16
  import { loadHistory, sparkline } from '../writers/history.mjs';
16
17
  import { listCanonicalDocs } from '../shared-ignore.mjs';
18
+ import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
17
19
 
18
20
  /**
19
21
  * Detect whether the project configures a test runner (the "Check 3" of the
@@ -415,11 +417,21 @@ export function runScoreInternal(projectDir, config) {
415
417
  /** Evidence boundary shared by human, CI, report, and MCP score consumers. */
416
418
  export function buildScoreAssurance(projectDir, config) {
417
419
  let unverifiedClaims = null;
418
- try { unverifiedClaims = extractSemanticClaims(projectDir, config).length; } catch { /* unknown, never zero on failure */ }
420
+ let declaredEvidence = null;
421
+ try {
422
+ const claims = extractSemanticClaims(projectDir, config);
423
+ const evaluated = evaluateEvidence(projectDir, config);
424
+ unverifiedClaims = coverSemanticClaims(claims, evaluated).unverified;
425
+ } catch { /* unknown, never zero on failure */ }
426
+ try {
427
+ const evaluated = evaluateEvidence(projectDir, config);
428
+ declaredEvidence = { configured: evaluated.exists, status: evaluated.status, summary: evaluated.summary };
429
+ } catch { /* unknown, never clean on failure */ }
419
430
  return {
420
431
  status: 'unverified',
421
432
  factualAccuracy: null,
422
433
  unverifiedClaims,
434
+ declaredEvidence,
423
435
  limitation: 'Structural maturity is not factual accuracy. Claim discovery is heuristic; uncaptured prose remains unverified.',
424
436
  };
425
437
  }
@@ -1,4 +1,4 @@
1
- import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
1
+ import { isMappedDocPath } from '../shared-doc-roles.mjs';
2
2
  /**
3
3
  * Sync Command — keep the documentation memory ALWAYS UP TO DATE.
4
4
  *
@@ -15,12 +15,13 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
15
15
  * @implements docguard.document-lifecycle#FR-010
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { resolve } from 'node:path';
20
20
  import { execFileSync } from 'node:child_process';
21
21
  import { c } from '../shared.mjs';
22
22
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
23
- import { getSection, replaceSection } from '../writers/sections.mjs';
23
+ import { assertOwnedCodeSection, getSection, inspectSections, replaceSection } from '../writers/sections.mjs';
24
+ import { safeWrite } from '../writers/generate-io.mjs';
24
25
  import { hasGeneratedMarker } from '../writers/api-reference.mjs';
25
26
  import { runSyncTests } from './sync-tests.mjs';
26
27
  import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
@@ -48,7 +49,6 @@ function gitChangedFiles(projectDir, since) {
48
49
  */
49
50
 
50
51
  export function runSync(projectDir, config, flags) {
51
- if (flags.write) assertDefaultDocWrites(config);
52
52
  // v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
53
53
  // Source-to-Test Map from disk (ghost-source removal + new co-located pairs) —
54
54
  // a distinct path from the generated code-truth section refresh below.
@@ -62,6 +62,7 @@ export function runSync(projectDir, config, flags) {
62
62
  const updates = []; // { doc, section, status }
63
63
  const reviews = []; // { doc, section, reason }
64
64
  const skipped = []; // { doc, reason }
65
+ const pendingWrites = [];
65
66
 
66
67
  for (const doc of plan.docs) {
67
68
  const full = resolve(projectDir, doc.path);
@@ -70,7 +71,11 @@ export function runSync(projectDir, config, flags) {
70
71
  continue;
71
72
  }
72
73
  let content = readFileSync(full, 'utf-8');
73
- if (!hasGeneratedMarker(content) && !flags.force) {
74
+ const mapped = isMappedDocPath(config, doc.path);
75
+ if (apply && mapped && inspectSections(content).issues.length) {
76
+ throw new Error(`${doc.path}: malformed or duplicate docguard:section markers; no write was applied.`);
77
+ }
78
+ if (!hasGeneratedMarker(content) && !flags.force && !mapped) {
74
79
  skipped.push({ doc: doc.path, reason: 'not marked docguard:generated (use --force to sync anyway)' });
75
80
  continue;
76
81
  }
@@ -98,7 +103,11 @@ export function runSync(projectDir, config, flags) {
98
103
  }
99
104
  codeSectionChanged = true;
100
105
  updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
101
- if (apply) { content = replaceSection(content, sec.id, sec.body).content; docChanged = true; }
106
+ if (apply) {
107
+ if (mapped) assertOwnedCodeSection(content, sec.id, doc.path);
108
+ content = replaceSection(content, sec.id, sec.body).content;
109
+ docChanged = true;
110
+ }
102
111
  }
103
112
 
104
113
  // If code changed, the prose around it may need an agent's eyes.
@@ -110,9 +119,13 @@ export function runSync(projectDir, config, flags) {
110
119
  }
111
120
  }
112
121
 
113
- if (apply && docChanged) writeFileSync(full, content, 'utf-8');
122
+ if (apply && docChanged) pendingWrites.push({ full, content });
114
123
  }
115
124
 
125
+ // Authorization for every mapped target completed above. Only now expose
126
+ // writes, preserving backup behavior for both default and mapped layouts.
127
+ for (const pending of pendingWrites) safeWrite(pending.full, pending.content);
128
+
116
129
  const result = {
117
130
  project: config.projectName,
118
131
  since: flags.since || null,
@@ -20,6 +20,7 @@
20
20
  * text is the human summary.
21
21
  *
22
22
  * docguard verify [--semantic | --instructions] [--format json]
23
+ * @implements docguard.evidence-scoped-verification#FR-009
23
24
  */
24
25
 
25
26
  import { basename } from 'node:path';
@@ -29,6 +30,7 @@ import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/sem
29
30
  import { auditInstructions } from '../scanners/instruction-audit.mjs';
30
31
  import { isGitRepo, getDiffText } from '../shared-git.mjs';
31
32
  import { parseUnifiedDiff, activityLabeledDiff } from '../shared-diff.mjs';
33
+ import { coverSemanticClaims, evaluateEvidence } from '../evidence/evaluate.mjs';
32
34
 
33
35
  const CHANGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
34
36
 
@@ -68,14 +70,30 @@ function taskTouchesChange(task, changedSet, changedBasenames) {
68
70
  }
69
71
 
70
72
  export function runVerify(projectDir, config, flags) {
73
+ const selectedModes = ['semantic', 'instructions', 'evidence'].filter(mode => flags[mode]);
74
+ if (selectedModes.length > 1) {
75
+ const message = `Choose exactly one verify mode; --${selectedModes.join(', --')} cannot be combined.`;
76
+ if (flags.format === 'json') {
77
+ console.log(JSON.stringify({ command: 'verify', status: 'error', error: { code: 'VERIFY_MODE_CONFLICT', message } }, null, 2));
78
+ } else {
79
+ console.error(`${c.red}Error: ${message}${c.reset}`);
80
+ }
81
+ process.exitCode = 1;
82
+ return;
83
+ }
71
84
  if (flags.instructions) {
72
85
  runInstructionAudit(projectDir, config, flags);
73
86
  return;
74
87
  }
88
+ if (flags.evidence) {
89
+ runEvidenceVerification(projectDir, config, flags);
90
+ return;
91
+ }
75
92
 
76
93
  const isJson = flags.format === 'json';
77
94
  const claims = extractSemanticClaims(projectDir, config);
78
- const tasks = buildSemanticVerifyTasks(claims);
95
+ const evidenceCoverage = coverSemanticClaims(claims, evaluateEvidence(projectDir, config));
96
+ const tasks = buildSemanticVerifyTasks(evidenceCoverage.remaining);
79
97
 
80
98
  // Change-aware staging (feat 6): if --since given, attach the structured diff
81
99
  // and flag which claims are about just-changed code (verify those first).
@@ -92,6 +110,9 @@ export function runVerify(projectDir, config, flags) {
92
110
  console.log(JSON.stringify({
93
111
  command: 'verify --semantic',
94
112
  project: config.projectName,
113
+ discoveredClaimCount: evidenceCoverage.total,
114
+ verifiedWithinScope: evidenceCoverage.verifiedWithinScope,
115
+ coveredClaimIds: evidenceCoverage.covered,
95
116
  claimCount: tasks.length,
96
117
  // How to act on this: each task is a claim to confirm against the code.
97
118
  howToVerify: changeContext
@@ -107,7 +128,10 @@ export function runVerify(projectDir, config, flags) {
107
128
  console.log(`${c.dim} ${config.projectName} · documented numbers / limits / enums to check against code${c.reset}\n`);
108
129
 
109
130
  if (tasks.length === 0) {
110
- console.log(` ${c.green}✅ No semantic claims found in the canonical docs.${c.reset}`);
131
+ const message = evidenceCoverage.total > 0
132
+ ? `${evidenceCoverage.verifiedWithinScope} discovered claim(s) are already covered by unique current evidence declarations.`
133
+ : 'No semantic claims found in the canonical docs.';
134
+ console.log(` ${c.green}✅ ${message}${c.reset}`);
111
135
  console.log(` ${c.dim}(Looks for numbers with units — days/ms/req-s/GSIs/roles/… — and status/enum lists.)${c.reset}\n`);
112
136
  return;
113
137
  }
@@ -120,6 +144,9 @@ export function runVerify(projectDir, config, flags) {
120
144
  }
121
145
 
122
146
  console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
147
+ if (evidenceCoverage.verifiedWithinScope > 0) {
148
+ console.log(` ${c.green}✓ ${evidenceCoverage.verifiedWithinScope} additional discovered claim(s) have unique verified-within-scope declarations.${c.reset}\n`);
149
+ }
123
150
  if (changeContext) {
124
151
  const nChanged = tasks.filter(t => t.aboutChangedCode).length;
125
152
  console.log(` ${c.cyan}⚡ ${nChanged} claim(s) are about code changed since ${flags.since}${c.reset} ${c.dim}— verify these first (structured diff in --format json).${c.reset}\n`);
@@ -140,6 +167,42 @@ export function runVerify(projectDir, config, flags) {
140
167
  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`);
141
168
  }
142
169
 
170
+ function runEvidenceVerification(projectDir, config, flags) {
171
+ const evaluation = evaluateEvidence(projectDir, config);
172
+ if (flags.format === 'json') {
173
+ console.log(JSON.stringify(evaluation, null, 2));
174
+ return;
175
+ }
176
+ console.log(`${c.bold}🔬 DocGuard Verify — declared evidence${c.reset}`);
177
+ console.log(`${c.dim} ${config.projectName} · exact statement-to-source checks${c.reset}\n`);
178
+ if (!evaluation.exists) {
179
+ console.log(` ${c.dim}No .docguard-evidence.json manifest is configured.${c.reset}`);
180
+ console.log(` ${c.dim}Start from templates/evidence-manifest.json; heuristic discovery remains available with docguard verify --semantic.${c.reset}\n`);
181
+ return;
182
+ }
183
+ if (evaluation.errors.length) {
184
+ console.log(` ${c.red}Invalid evidence manifest:${c.reset}`);
185
+ for (const error of evaluation.errors) console.log(` ${c.red}✗${c.reset} ${error.message}`);
186
+ console.log('');
187
+ return;
188
+ }
189
+ const symbols = {
190
+ 'verified-within-scope': `${c.green}✓${c.reset}`,
191
+ contradicted: `${c.red}✗${c.reset}`,
192
+ stale: `${c.yellow}↻${c.reset}`,
193
+ inconclusive: `${c.yellow}?${c.reset}`,
194
+ unsupported: `${c.yellow}◇${c.reset}`,
195
+ };
196
+ for (const state of ['contradicted', 'stale', 'inconclusive', 'unsupported', 'verified-within-scope']) {
197
+ const results = evaluation.results.filter(result => result.state === state);
198
+ if (!results.length) continue;
199
+ console.log(` ${c.bold}${state}${c.reset} (${results.length})`);
200
+ for (const result of results) console.log(` ${symbols[state]} ${result.declarationId} · ${result.location} · ${result.message}`);
201
+ console.log('');
202
+ }
203
+ console.log(` ${c.dim}${evaluation.scopeLimitation}${c.reset}\n`);
204
+ }
205
+
143
206
  // ── verify --instructions: agent-instruction drift/conflict audit ───────────
144
207
 
145
208
  function runInstructionAudit(projectDir, config, flags) {
package/cli/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { hasWorkerConfig } from './shared-source.mjs';
2
2
  import { applyDocRoles } from './shared-doc-roles.mjs';
3
3
  /**
4
+ * @implements docguard.evidence-scoped-verification#FR-010
4
5
  * DocGuard — configuration loading.
5
6
  *
6
7
  * Extracted from docguard.mjs (v0.23.0) to break the demo.mjs → docguard.mjs
@@ -81,6 +82,7 @@ export function loadConfig(projectDir) {
81
82
  freshness: true,
82
83
  documentLifecycle: true,
83
84
  specRegistry: true,
85
+ evidence: true,
84
86
  // v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
85
87
  // heuristic (field cases require ongoing precision checks), and quiet when
86
88
  // not applicable (no diff / no API-reference doc). api-doc-smells is
@@ -225,6 +227,7 @@ const _KNOWN_VALIDATORS = [
225
227
  'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
226
228
  'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
227
229
  'canonicalSync', 'surfaceSync', 'metricsConsistency',
230
+ 'evidence',
228
231
  ];
229
232
 
230
233
  function _kebabToCamel(k) {
package/cli/docguard.mjs CHANGED
@@ -57,6 +57,7 @@ import { runArchive } from './commands/retire.mjs';
57
57
  import { runSpecs } from './commands/specs.mjs';
58
58
  import { runReconcile } from './commands/reconcile.mjs';
59
59
  import { ensureSkills } from './ensure-skills.mjs';
60
+ import { detectRepositoryRootGuidance, renderRepositoryRootGuidance } from './repository-root.mjs';
60
61
 
61
62
  // ── Shared constants (imported to break circular dependencies) ──────────
62
63
  import { c, PROFILES } from './shared.mjs';
@@ -94,9 +95,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
94
95
  ${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
95
96
  ${c.green}fix${c.reset} Generate AI fix instructions for specific docs
96
97
  ${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
97
- ${c.green}agent${c.reset} One-shot agent task graph ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
98
+ ${c.green}agent${c.reset} Agent task graph or bounded task context (${c.cyan}--task <text>${c.reset}, ${c.cyan}--format json${c.reset})
98
99
  ${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
99
- ${c.green}verify${c.reset} Extract documented numbers/limits/enums for an agent to check vs code (${c.cyan}--semantic${c.reset})
100
+ ${c.green}verify${c.reset} Check declared local evidence or extract claims for review (${c.cyan}--evidence${c.reset}, ${c.cyan}--semantic${c.reset})
100
101
  ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
101
102
  ${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/report/diagnose as agent tools
102
103
  ${c.green}report${c.reset} Compliance-evidence bundle — guard + score + ALCOA+ + integrity hash (${c.cyan}--format json${c.reset}, ${c.cyan}--out <file>${c.reset})
@@ -125,7 +126,7 @@ ${c.bold}Deprecation aliases${c.reset} ${c.dim}— supported until v1.0 with a y
125
126
  ${c.dim}Run the legacy form to see its replacement.${c.reset}
126
127
 
127
128
  ${c.bold}Options:${c.reset}
128
- --dir <path> Project directory (default: current directory)
129
+ --dir <path> Project directory (default: current directory; explicit paths suppress ancestor-root guidance)
129
130
  --verbose Show detailed output
130
131
  --format json Output results as JSON (for CI)
131
132
  --fix Auto-create missing files from templates
@@ -138,7 +139,8 @@ ${c.bold}Options:${c.reset}
138
139
  --fail-on-warning Fail CI on warnings (used with ci command)
139
140
  --auto Auto-fix what's possible (used with fix command)
140
141
  --write Apply a command's explicit deterministic write path. For fix,
141
- only edits docguard:generated docs unless --force; specs
142
+ mapped human docs require unique source=code ownership and
143
+ --force cannot grant ownership; specs
142
144
  refreshes observed registry evidence; retire requires --path.
143
145
  --plan AI-powered Generate (generate command): scan any project
144
146
  (JS/Python/Rust/Go/Java/…), emit the agent task manifest +
@@ -213,13 +215,14 @@ const COMMAND_HELP = {
213
215
  examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
214
216
  },
215
217
  agent: {
216
- summary: 'One-shot agent task graph: ordered, dependency-aware, with pre-filled code-truth + per-task verify.',
217
- usage: 'docguard agent [--profile <name>] [--format json]',
218
+ summary: 'Agent task graph, or a bounded evidence packet for one explicit task.',
219
+ usage: 'docguard agent [--task <text>] [--profile <name>] [--format json]',
218
220
  flags: [
219
- ['--format json', 'Machine-readable task graph (the agent-executable artifact)'],
221
+ ['--task <text>', 'Select current task-linked requirements and evidence; abstain when relevance is weak'],
222
+ ['--format json', 'Machine-readable task graph or deterministic task packet'],
220
223
  ['--profile <name>', 'Preview a profile (cli/library/standard/…) without running init first'],
221
224
  ],
222
- examples: ['docguard agent', 'docguard agent --format json', 'docguard agent --profile cli --format json'],
225
+ examples: ['docguard agent', 'docguard agent --task "Fix SEC001 in src/config.mjs"', 'docguard agent --task "Implement acme.feature#FR-001" --format json'],
223
226
  },
224
227
  guard: {
225
228
  summary: 'Validate code against canonical docs (all validators).',
@@ -307,14 +310,15 @@ const COMMAND_HELP = {
307
310
  examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --fixture-manifest feedback.json --reduce --preview --format json'],
308
311
  },
309
312
  verify: {
310
- 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.',
311
- usage: 'docguard verify [--semantic|--instructions] [--format json]',
313
+ summary: 'Verify exact declared evidence, extract heuristic semantic claims, or audit agent instructions.',
314
+ usage: 'docguard verify [--evidence|--semantic|--instructions] [--format json]',
312
315
  flags: [
316
+ ['--evidence', 'Evaluate `.docguard-evidence.json` using local bounded JSON, collection, oasdiff, and Buf evidence'],
313
317
  ['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
314
318
  ['--instructions', 'Audit AGENTS.md/CLAUDE.md for duplicate, contradictory, and stale-pointer rules (deterministic findings + agent conflict tasks)'],
315
319
  ['--format json', 'Machine-readable task list (the agent-executable artifact)'],
316
320
  ],
317
- examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
321
+ examples: ['docguard verify --evidence', 'docguard verify --evidence --format json', 'docguard verify --semantic --format json'],
318
322
  },
319
323
  retire: {
320
324
  summary: 'Remove reviewed docs from active AI context while preserving recovery from a retained Git ref.',
@@ -391,6 +395,7 @@ async function main() {
391
395
  // Parse flags
392
396
  const flags = {
393
397
  dir: '.',
398
+ dirExplicit: false,
394
399
  verbose: false,
395
400
  format: 'text',
396
401
  fix: false,
@@ -401,6 +406,7 @@ async function main() {
401
406
  for (let i = 1; i < args.length; i++) {
402
407
  if (args[i] === '--dir' && args[i + 1]) {
403
408
  flags.dir = args[i + 1];
409
+ flags.dirExplicit = true;
404
410
  i++;
405
411
  } else if (args[i] === '--verbose') {
406
412
  flags.verbose = true;
@@ -438,6 +444,8 @@ async function main() {
438
444
  // v0.28 (field report #5): `docguard verify --semantic` extracts
439
445
  // documented numbers/enums/limits for the agent to check against code.
440
446
  flags.semantic = true;
447
+ } else if (args[i] === '--evidence') {
448
+ flags.evidence = true;
441
449
  } else if (args[i] === '--instructions') {
442
450
  // v0.30: `docguard verify --instructions` audits AGENTS.md/CLAUDE.md for
443
451
  // duplicate/contradictory/stale rules (MemoryLint-inspired).
@@ -544,6 +552,8 @@ async function main() {
544
552
  // v0.21: `docguard demo --keep` doesn't delete the temp fixture after
545
553
  // running (useful for poking around what DocGuard set up).
546
554
  flags.keep = true;
555
+ } else if (args[i] === '--task') {
556
+ flags.task = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
547
557
  } else if (!args[i].startsWith('--') && i > 0) {
548
558
  // Positional args go into flags.args for commands that take them (e.g.
549
559
  // `docguard trace --reverse <path>`). Skip the command itself (i === 0).
@@ -696,8 +706,18 @@ async function main() {
696
706
 
697
707
  if (!headless) printBanner();
698
708
 
709
+ const rootGuidance = detectRepositoryRootGuidance(projectDir, {
710
+ explicitDir: flags.dirExplicit,
711
+ argv: args,
712
+ });
713
+ if (rootGuidance) {
714
+ process.stderr.write(renderRepositoryRootGuidance(rootGuidance, { machine: jsonMode }) + '\n');
715
+ }
699
716
  const config = loadConfig(projectDir);
700
- if (['init', 'setup', 'generate'].includes(command) && !(command === 'generate' && flags.plan && !flags.write) || ['sync', 'fix'].includes(command) && flags.write || command === 'diagnose' && flags.auto) assertDefaultDocWrites(config);
717
+ // Init and diagnose --auto can scaffold several unrelated files and retain
718
+ // their legacy default-layout contract. Generate, sync, and fix perform their
719
+ // own target/section authorization so mapped layouts can use bounded writers.
720
+ if (['init', 'setup'].includes(command) || command === 'diagnose' && flags.auto) assertDefaultDocWrites(config);
701
721
 
702
722
  // `--no-baseline` disables the committed adoption baseline for this run —
703
723
  // threaded through config so guard, ci, report, and mcp all honor it the
@@ -839,6 +859,7 @@ async function main() {
839
859
  case 'agent':
840
860
  // v0.26 (field report §2): one-shot, dependency-ordered task graph with
841
861
  // pre-filled code-truth + per-task verify. Read-only; JSON by default.
862
+ // @implements docguard.task-specific-agent-context#FR-010
842
863
  runAgent(projectDir, config, flags);
843
864
  break;
844
865
  case 'hooks':
@@ -0,0 +1,200 @@
1
+ /**
2
+ * File-only source adapters for evidence-scoped verification.
3
+ * @implements docguard.evidence-scoped-verification#FR-002
4
+ * @implements docguard.evidence-scoped-verification#FR-003
5
+ * @implements docguard.evidence-scoped-verification#FR-004
6
+ * @implements docguard.evidence-scoped-verification#FR-005
7
+ */
8
+
9
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
10
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
11
+ import { buildIgnoreFilter, compileGlob, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
12
+
13
+ const MAX_COLLECTION_FILES = 20_000;
14
+ const MAX_REPORT_FINDINGS = 10_000;
15
+ const OASDIFF_LEVELS = new Set(['ERR', 'WARN', 'INFO', 'NONE']);
16
+
17
+ const answer = (status, reasonCode, message, extra = {}) => ({ ...extra, status, reasonCode, message });
18
+
19
+ export function resolveJsonPointer(value, pointer) {
20
+ if (pointer === '') return { found: true, value };
21
+ if (typeof pointer !== 'string' || !pointer.startsWith('/')) return { found: false, reason: 'invalid-json-pointer' };
22
+ let current = value;
23
+ for (const raw of pointer.slice(1).split('/')) {
24
+ if (/~(?:[^01]|$)/.test(raw)) return { found: false, reason: 'invalid-json-pointer-escape' };
25
+ const token = raw.replace(/~1/g, '/').replace(/~0/g, '~');
26
+ if (Array.isArray(current)) {
27
+ if (!/^(?:0|[1-9][0-9]*)$/.test(token)) return { found: false, reason: 'invalid-array-index' };
28
+ const index = Number(token);
29
+ if (!Number.isSafeInteger(index) || index >= current.length) return { found: false, reason: 'unresolved-json-pointer' };
30
+ current = current[index];
31
+ } else if (current && typeof current === 'object' && Object.hasOwn(current, token)) {
32
+ current = current[token];
33
+ } else {
34
+ return { found: false, reason: 'unresolved-json-pointer' };
35
+ }
36
+ }
37
+ return { found: true, value: current };
38
+ }
39
+
40
+ function lexicalBase(pattern) {
41
+ const parts = [];
42
+ for (const part of pattern.split('/')) {
43
+ if (/[*?{]/.test(part)) break;
44
+ parts.push(part);
45
+ }
46
+ return parts.join('/') || '.';
47
+ }
48
+
49
+ /** Count regular, non-symlink files with a bounded walk and the shared ignore contract. */
50
+ export function countEvidenceCollection(projectDir, pattern, config = {}) {
51
+ if (typeof pattern !== 'string' || pattern.startsWith('/') || /[\\:\0]/.test(pattern)
52
+ || pattern.split('/').some(part => part === '..' || part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part))) {
53
+ return answer('inconclusive', 'unsafe-collection-base', 'Collection pattern is unsafe.');
54
+ }
55
+ let root;
56
+ try { root = realpathSync(projectDir); } catch { return answer('inconclusive', 'repository-unavailable', 'Repository root is unavailable.'); }
57
+ let matcher;
58
+ try { matcher = compileGlob(pattern); } catch { return answer('unsupported', 'unsupported-glob', 'Collection glob cannot be compiled.'); }
59
+ const ignored = buildIgnoreFilter(config.ignore || []);
60
+ const lexical = lexicalBase(pattern);
61
+ let base = root;
62
+ try {
63
+ for (const part of lexical.split('/').filter(item => item && item !== '.')) {
64
+ base = resolve(base, part);
65
+ if (lstatSync(base).isSymbolicLink()) return answer('inconclusive', 'symlink', 'Collection base traverses a symlink.');
66
+ }
67
+ } catch (error) {
68
+ if (error?.code === 'ENOENT') return answer('ok', 'collection-read', 'Collection evaluated.', { value: 0, inputHashes: [] });
69
+ return answer('inconclusive', 'collection-unavailable', 'Collection base is unavailable.');
70
+ }
71
+ const relBase = relative(root, base);
72
+ if (isAbsolute(relBase) || relBase === '..' || relBase.startsWith(`..${sep}`)) {
73
+ return answer('inconclusive', 'unsafe-collection-base', 'Collection base leaves the repository.');
74
+ }
75
+ let baseStat;
76
+ try { baseStat = lstatSync(base); } catch (error) {
77
+ if (error?.code === 'ENOENT') return answer('ok', 'collection-read', 'Collection evaluated.', { value: 0, inputHashes: [] });
78
+ return answer('inconclusive', 'collection-unavailable', 'Collection base is unavailable.');
79
+ }
80
+ if (baseStat.isSymbolicLink()) return answer('inconclusive', 'symlink', 'Collection base is a symlink.');
81
+ let visited = 0;
82
+ let matched = 0;
83
+ const walk = path => {
84
+ let entries;
85
+ try { entries = readdirSync(path, { withFileTypes: true }); }
86
+ catch { throw new Error('collection-unreadable'); }
87
+ for (const entry of entries) {
88
+ if (++visited > MAX_COLLECTION_FILES) throw new Error('collection-budget');
89
+ if (DEFAULT_IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
90
+ const full = resolve(path, entry.name);
91
+ const rel = relPosix(root, full);
92
+ if (ignored(rel)) continue;
93
+ let stat;
94
+ try { stat = lstatSync(full); } catch { throw new Error('collection-unreadable'); }
95
+ if (stat.isSymbolicLink()) continue;
96
+ if (stat.isDirectory()) walk(full);
97
+ else if (stat.isFile() && matcher.test(rel)) matched++;
98
+ }
99
+ };
100
+ try {
101
+ if (baseStat.isFile()) matched = matcher.test(relPosix(root, base)) ? 1 : 0;
102
+ else if (baseStat.isDirectory()) walk(base);
103
+ else return answer('inconclusive', 'collection-not-file-or-directory', 'Collection base is not a regular file or directory.');
104
+ } catch (error) {
105
+ return answer('inconclusive', error.message, error.message === 'collection-budget'
106
+ ? `Collection exceeded the ${MAX_COLLECTION_FILES}-entry budget.`
107
+ : 'Collection could not be read completely.');
108
+ }
109
+ return answer('ok', 'collection-read', 'Collection evaluated.', { value: matched, inputHashes: [] });
110
+ }
111
+
112
+ function currentInputs(source, read) {
113
+ const hashes = [];
114
+ for (const input of source.inputs) {
115
+ const snapshot = read(input.path);
116
+ if (snapshot.content === null) {
117
+ return answer('inconclusive', `input-${snapshot.evidence.reason}`, `Cannot safely read declared input ${input.path}.`, { inputHashes: hashes });
118
+ }
119
+ hashes.push({ path: input.path, declared: input.sha256, current: snapshot.evidence.hash });
120
+ }
121
+ const stale = hashes.filter(input => input.declared !== input.current);
122
+ if (stale.length) return answer('stale', 'input-digest-mismatch', `${stale.length} declared input digest(s) no longer match.`, { inputHashes: hashes });
123
+ return answer('ok', 'inputs-current', 'Declared report inputs are current.', { inputHashes: hashes });
124
+ }
125
+
126
+ function readReport(source, read) {
127
+ if (source.adapterVersion !== 1) return answer('unsupported', 'adapter-version', `Adapter version ${source.adapterVersion} is unsupported.`);
128
+ const inputs = currentInputs(source, read);
129
+ if (inputs.status !== 'ok') return inputs;
130
+ const snapshot = read(source.path);
131
+ if (snapshot.content === null) {
132
+ return answer('inconclusive', `report-${snapshot.evidence.reason}`, `Cannot safely read saved ${source.adapter} report ${source.path}.`, { inputHashes: inputs.inputHashes });
133
+ }
134
+ return answer('ok', 'report-read', 'Saved report is current and readable.', {
135
+ content: snapshot.content,
136
+ sourceEvidence: snapshot.evidence,
137
+ inputHashes: inputs.inputHashes,
138
+ });
139
+ }
140
+
141
+ function oasdiffReport(source, read) {
142
+ if (!['breaking', 'changelog'].includes(source.command)) return answer('unsupported', 'oasdiff-command', `oasdiff command ${source.command} is unsupported.`);
143
+ const report = readReport(source, read);
144
+ if (report.status !== 'ok') return report;
145
+ let parsed;
146
+ try { parsed = JSON.parse(report.content); }
147
+ catch { return answer('inconclusive', 'malformed-oasdiff-json', 'Saved oasdiff report is not valid JSON.', report); }
148
+ if (!Array.isArray(parsed) || parsed.length > MAX_REPORT_FINDINGS) {
149
+ return answer('unsupported', 'unsupported-oasdiff-shape', 'Saved oasdiff report must be a bounded JSON array.', report);
150
+ }
151
+ const valid = parsed.every(change => change && typeof change === 'object' && !Array.isArray(change)
152
+ && typeof change.id === 'string' && change.id.length > 0
153
+ && (typeof change.level === 'number' || OASDIFF_LEVELS.has(change.level)));
154
+ if (!valid) return answer('unsupported', 'unsupported-oasdiff-change', 'Saved oasdiff report contains an unknown change shape.', report);
155
+ return answer('ok', 'oasdiff-report', 'Saved oasdiff report evaluated.', { ...report, value: parsed.length });
156
+ }
157
+
158
+ function bufReport(source, read) {
159
+ if (source.command !== 'breaking') return answer('unsupported', 'buf-command', `Buf command ${source.command} is unsupported.`);
160
+ const report = readReport(source, read);
161
+ if (report.status !== 'ok') return report;
162
+ const lines = report.content.split(/\r?\n/).filter(line => line.trim());
163
+ if (lines.length > MAX_REPORT_FINDINGS) return answer('unsupported', 'buf-finding-budget', `Saved Buf report exceeds ${MAX_REPORT_FINDINGS} findings.`, report);
164
+ for (const line of lines) {
165
+ let finding;
166
+ try { finding = JSON.parse(line); }
167
+ catch { return answer('inconclusive', 'malformed-buf-jsonl', 'Saved Buf report contains malformed JSON Lines.', report); }
168
+ if (!finding || typeof finding !== 'object' || Array.isArray(finding)
169
+ || typeof finding.path !== 'string' || !finding.path
170
+ || typeof finding.type !== 'string' || !finding.type
171
+ || typeof finding.message !== 'string' || !finding.message) {
172
+ return answer('unsupported', 'unsupported-buf-finding', 'Saved Buf report contains an unknown violation shape.', report);
173
+ }
174
+ }
175
+ return answer('ok', 'buf-report', 'Saved Buf report evaluated.', { ...report, value: lines.length });
176
+ }
177
+
178
+ export function readEvidenceSource(projectDir, declaration, read, config = {}) {
179
+ const source = declaration.source;
180
+ if (source.adapter === 'json-pointer') {
181
+ const snapshot = read(source.path);
182
+ if (snapshot.content === null) return answer('inconclusive', `source-${snapshot.evidence.reason}`, `Cannot safely read JSON source ${source.path}.`);
183
+ let parsed;
184
+ try { parsed = JSON.parse(snapshot.content); }
185
+ catch { return answer('inconclusive', 'malformed-source-json', `JSON source ${source.path} is malformed.`, { sourceEvidence: snapshot.evidence, inputHashes: [] }); }
186
+ const selected = resolveJsonPointer(parsed, source.pointer);
187
+ if (!selected.found) return answer('inconclusive', selected.reason, `JSON Pointer ${source.pointer || '<root>'} did not resolve.`, { sourceEvidence: snapshot.evidence, inputHashes: [] });
188
+ return answer('ok', 'json-pointer-resolved', 'JSON Pointer resolved.', { value: selected.value, sourceEvidence: snapshot.evidence, inputHashes: [] });
189
+ }
190
+ if (source.adapter === 'collection-count') {
191
+ const result = countEvidenceCollection(projectDir, source.glob, config);
192
+ if (result.status === 'ok' && result.value === 0 && !source.allowEmpty) {
193
+ return answer('inconclusive', 'empty-collection-not-allowed', 'Collection matched no files and allowEmpty is false.', result);
194
+ }
195
+ return result;
196
+ }
197
+ if (source.adapter === 'oasdiff') return oasdiffReport(source, read);
198
+ if (source.adapter === 'buf') return bufReport(source, read);
199
+ return answer('unsupported', 'unsupported-adapter', `Adapter ${source.adapter} is unsupported.`);
200
+ }