docguard-cli 0.38.0 → 0.40.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 (63) hide show
  1. package/README.md +45 -22
  2. package/cli/commands/agent.mjs +47 -1
  3. package/cli/commands/explain.mjs +16 -0
  4. package/cli/commands/feedback.mjs +147 -6
  5. package/cli/commands/fix.mjs +13 -11
  6. package/cli/commands/generate.mjs +52 -18
  7. package/cli/commands/guard.mjs +13 -2
  8. package/cli/commands/mcp.mjs +22 -2
  9. package/cli/commands/score.mjs +13 -1
  10. package/cli/commands/specs.mjs +21 -2
  11. package/cli/commands/sync.mjs +20 -7
  12. package/cli/commands/verify.mjs +65 -2
  13. package/cli/config.mjs +3 -0
  14. package/cli/docguard.mjs +48 -16
  15. package/cli/evidence/adapters.mjs +200 -0
  16. package/cli/evidence/evaluate.mjs +185 -0
  17. package/cli/evidence/manifest.mjs +194 -0
  18. package/cli/evidence/markdown.mjs +107 -0
  19. package/cli/feedback-fixture.mjs +188 -0
  20. package/cli/findings.mjs +31 -0
  21. package/cli/repository-root.mjs +159 -0
  22. package/cli/scanners/py-ast.mjs +39 -2
  23. package/cli/scanners/task-context.mjs +312 -0
  24. package/cli/shared-doc-roles.mjs +44 -1
  25. package/cli/shared-source.mjs +101 -28
  26. package/cli/validators/architecture.mjs +186 -13
  27. package/cli/validators/environment.mjs +14 -1
  28. package/cli/validators/evidence.mjs +52 -0
  29. package/cli/validators/security.mjs +5 -4
  30. package/cli/validators/todo-tracking.mjs +45 -2
  31. package/cli/writers/doc-generators.mjs +31 -17
  32. package/cli/writers/mechanical.mjs +44 -14
  33. package/cli/writers/sections.mjs +31 -3
  34. package/docs/ai-integration.md +31 -6
  35. package/docs/commands.md +43 -5
  36. package/docs/configuration.md +11 -3
  37. package/docs/quickstart.md +1 -1
  38. package/extensions/spec-kit-docguard/commands/fix.md +4 -2
  39. package/extensions/spec-kit-docguard/commands/generate.md +6 -1
  40. package/extensions/spec-kit-docguard/commands/guard.md +3 -2
  41. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  42. package/extensions/spec-kit-docguard/extension.yml +1 -1
  43. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
  44. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
  45. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
  46. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
  49. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
  50. package/package.json +3 -1
  51. package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
  52. package/schemas/docguard-agent-context-result.schema.json +95 -0
  53. package/schemas/docguard-benchmark.schema.json +84 -0
  54. package/schemas/docguard-config.schema.json +1 -0
  55. package/schemas/docguard-evidence.schema.json +169 -0
  56. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  57. package/schemas/docguard-task-context.schema.json +144 -0
  58. package/templates/AGENTS.md.template +9 -4
  59. package/templates/ci/github-actions.yml +4 -4
  60. package/templates/commands/docguard.guard.md +5 -1
  61. package/templates/commands/docguard.review.md +6 -1
  62. package/templates/evidence-manifest.json +21 -0
  63. package/templates/feedback-fixture.json +18 -0
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).',
@@ -301,20 +304,21 @@ const COMMAND_HELP = {
301
304
  examples: ['docguard memory', 'docguard memory --diff'],
302
305
  },
303
306
  feedback: {
304
- summary: 'Review detection feedback locally. Select any finding with --code or --all, check duplicates, and prepare a metadata-only issue URL. Nothing is submitted automatically.',
305
- usage: 'docguard feedback [--code <CODE> | --all] [--preview] [--format json]',
306
- flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--preview', 'Skip local feedback-record writes'], ['--format json', 'Machine-readable selected findings and issue/search URLs']],
307
- examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --all --format json'],
307
+ summary: 'Review detection feedback locally, or validate and reduce a synthetic fixture manifest. Duplicate searches cover open and closed work; nothing is submitted automatically.',
308
+ usage: 'docguard feedback [--code <CODE> | --all] [--classification <class>] [--fixture-manifest <path> [--reduce] [--contribution <path>]] [--preview] [--format json]',
309
+ flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--classification <class>', 'false_positive, false_negative, unsupported_syntax, ambiguous, or policy_disagreement'], ['--fixture-manifest <path>', 'Validate a reviewed synthetic fixture and opposite control'], ['--reduce', 'Deterministically reduce a reproducing fixture'], ['--contribution <path>', 'Write a test-only contribution when required evidence is present'], ['--preview', 'Skip local writes and return contribution text inline'], ['--format json', 'Machine-readable evidence and issue/search URLs']],
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).
@@ -626,6 +636,17 @@ async function main() {
626
636
  flags.all = true;
627
637
  } else if (args[i] === '--preview') {
628
638
  flags.preview = true;
639
+ } else if (args[i] === '--classification' && args[i + 1]) {
640
+ flags.classification = args[i + 1].replaceAll('-', '_');
641
+ i++;
642
+ } else if (args[i] === '--fixture-manifest' && args[i + 1]) {
643
+ flags.fixtureManifest = args[i + 1];
644
+ i++;
645
+ } else if (args[i] === '--reduce') {
646
+ flags.reduce = true;
647
+ } else if (args[i] === '--contribution' && args[i + 1]) {
648
+ flags.contribution = args[i + 1];
649
+ i++;
629
650
  } else if (args[i] === '--signals') {
630
651
  flags.signals = true;
631
652
  } else if (args[i] === '--debate') {
@@ -685,8 +706,18 @@ async function main() {
685
706
 
686
707
  if (!headless) printBanner();
687
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
+ }
688
716
  const config = loadConfig(projectDir);
689
- 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);
690
721
 
691
722
  // `--no-baseline` disables the committed adoption baseline for this run —
692
723
  // threaded through config so guard, ci, report, and mcp all honor it the
@@ -828,6 +859,7 @@ async function main() {
828
859
  case 'agent':
829
860
  // v0.26 (field report §2): one-shot, dependency-ordered task graph with
830
861
  // pre-filled code-truth + per-task verify. Read-only; JSON by default.
862
+ // @implements docguard.task-specific-agent-context#FR-010
831
863
  runAgent(projectDir, config, flags);
832
864
  break;
833
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
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Five-state evidence evaluation and stable identities.
3
+ * @implements docguard.evidence-scoped-verification#FR-007
4
+ * @implements docguard.evidence-scoped-verification#FR-008
5
+ * @implements docguard.evidence-scoped-verification#FR-010
6
+ * @implements docguard.evidence-scoped-verification#FR-011
7
+ * @implements docguard.evidence-scoped-verification#FR-012
8
+ */
9
+
10
+ import { createEvidenceReader, contentHash } from '../scanners/semantic-claims.mjs';
11
+ import { loadEvidenceManifest } from './manifest.mjs';
12
+ import { readEvidenceSource } from './adapters.mjs';
13
+ import { parseDocumentValue, selectMarkdownStatement } from './markdown.mjs';
14
+
15
+ export const EVIDENCE_SCOPE_LIMITATION = 'Verification applies only to the declared Markdown statement, source adapter, predicate, producer metadata, and captured local inputs. It does not establish whole-document, runtime, deployment, or compliance accuracy.';
16
+ export const EVIDENCE_STATES = ['verified-within-scope', 'contradicted', 'stale', 'inconclusive', 'unsupported'];
17
+
18
+ function canonical(value) {
19
+ if (Array.isArray(value)) return value.map(canonical);
20
+ if (value && typeof value === 'object') {
21
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
22
+ }
23
+ return value;
24
+ }
25
+
26
+ function identity(prefix, value) {
27
+ return `${prefix}.${contentHash(JSON.stringify(canonical(value))).slice(7)}`;
28
+ }
29
+
30
+ function resultState(declaration, selection, source, state, reasonCode, message, extra = {}) {
31
+ const selectedStatement = selection?.statement || declaration.target.statement;
32
+ const claimId = identity('claim', {
33
+ declaration,
34
+ selectedStatement,
35
+ sourceValue: source?.value,
36
+ sourceEvidence: source?.sourceEvidence || null,
37
+ inputHashes: source?.inputHashes || [],
38
+ });
39
+ const evidencePayload = {
40
+ claimId,
41
+ sourceEvidence: source?.sourceEvidence || null,
42
+ inputHashes: source?.inputHashes || [],
43
+ sourceValue: source?.value,
44
+ };
45
+ return {
46
+ declarationId: declaration.id,
47
+ state,
48
+ claimId,
49
+ evidenceId: identity('evidence', evidencePayload),
50
+ document: declaration.target.document,
51
+ location: selection?.line ? `${declaration.target.document}:${selection.line}` : declaration.target.document,
52
+ heading: declaration.target.heading,
53
+ statement: selectedStatement,
54
+ adapter: declaration.source.adapter,
55
+ predicate: declaration.predicate.kind,
56
+ inputHashes: source?.inputHashes || [],
57
+ evidenceHash: source?.sourceEvidence?.hash || identity('snapshot', evidencePayload),
58
+ reasonCode,
59
+ message,
60
+ scopeLimitation: EVIDENCE_SCOPE_LIMITATION,
61
+ ...extra,
62
+ };
63
+ }
64
+
65
+ function compare(declaration, selection, source) {
66
+ const predicate = declaration.predicate;
67
+ if (predicate.kind === 'no-findings') {
68
+ return source.value === 0
69
+ ? { match: true, documentValue: null, sourceValue: 0 }
70
+ : { match: false, documentValue: null, sourceValue: source.value };
71
+ }
72
+ const document = parseDocumentValue(selection.value, predicate);
73
+ if (!document.ok) return { unsupported: false, inconclusive: true, ...document };
74
+ if (predicate.kind === 'equals') {
75
+ const expectedType = predicate.valueType;
76
+ const actualType = source.value === null ? 'null' : typeof source.value;
77
+ if (actualType !== expectedType || (actualType === 'number' && !Number.isFinite(source.value))) {
78
+ return { unsupported: true, reasonCode: 'source-type-mismatch', message: `JSON source type ${actualType} does not match declared ${expectedType}.` };
79
+ }
80
+ return { match: Object.is(document.value, source.value), documentValue: document.value, sourceValue: source.value };
81
+ }
82
+ if (predicate.kind === 'set-equals') {
83
+ if (!Array.isArray(source.value) || source.value.some(item => typeof item !== 'string')) {
84
+ return { unsupported: true, reasonCode: 'source-set-shape', message: 'JSON source must be an array of strings for set-equals.' };
85
+ }
86
+ if (new Set(source.value).size !== source.value.length) {
87
+ return { unsupported: true, reasonCode: 'duplicate-source-set-item', message: 'JSON source set contains duplicate items.' };
88
+ }
89
+ const left = [...document.value].sort();
90
+ const right = [...source.value].sort();
91
+ return { match: left.length === right.length && left.every((item, index) => item === right[index]), documentValue: document.value, sourceValue: source.value };
92
+ }
93
+ if (predicate.kind === 'count-equals') {
94
+ if (!Number.isSafeInteger(source.value) || source.value < 0) {
95
+ return { unsupported: true, reasonCode: 'source-count-shape', message: 'Collection source did not produce a non-negative safe integer.' };
96
+ }
97
+ return { match: document.value === source.value, documentValue: document.value, sourceValue: source.value };
98
+ }
99
+ return { unsupported: true, reasonCode: 'unsupported-predicate', message: `Predicate ${predicate.kind} is unsupported.` };
100
+ }
101
+
102
+ function evaluateDeclaration(projectDir, config, declaration, read) {
103
+ const document = read(declaration.target.document);
104
+ if (document.content === null) {
105
+ return resultState(declaration, null, document, 'inconclusive', `document-${document.evidence.reason}`, `Cannot safely read ${declaration.target.document}.`);
106
+ }
107
+ const selection = selectMarkdownStatement(document.content, declaration.target, declaration.predicate.kind);
108
+ if (selection.status !== 'ok') {
109
+ return resultState(declaration, selection, document, 'inconclusive', selection.reasonCode, selection.message);
110
+ }
111
+ const source = readEvidenceSource(projectDir, declaration, read, config);
112
+ if (source.status !== 'ok') {
113
+ return resultState(declaration, selection, source, source.status, source.reasonCode, source.message);
114
+ }
115
+ const compared = compare(declaration, selection, source);
116
+ if (compared.unsupported) return resultState(declaration, selection, source, 'unsupported', compared.reasonCode, compared.message);
117
+ if (compared.inconclusive) return resultState(declaration, selection, source, 'inconclusive', compared.reasonCode, compared.message);
118
+ const state = compared.match ? 'verified-within-scope' : 'contradicted';
119
+ const message = compared.match
120
+ ? 'The selected statement matches its current declared evidence.'
121
+ : 'The selected statement contradicts its current declared evidence.';
122
+ return resultState(declaration, selection, source, state, compared.match ? 'predicate-satisfied' : 'predicate-mismatch', message, {
123
+ documentValue: compared.documentValue,
124
+ });
125
+ }
126
+
127
+ export function evaluateEvidence(projectDir, config = {}) {
128
+ const read = createEvidenceReader(projectDir);
129
+ const loaded = loadEvidenceManifest(projectDir, read);
130
+ if (!loaded.exists) {
131
+ return {
132
+ command: 'verify --evidence', exists: false, manifest: '.docguard-evidence.json',
133
+ status: 'not-configured', errors: [], results: [], summary: Object.fromEntries(EVIDENCE_STATES.map(state => [state, 0])),
134
+ scopeLimitation: EVIDENCE_SCOPE_LIMITATION,
135
+ };
136
+ }
137
+ if (loaded.errors.length) {
138
+ return {
139
+ command: 'verify --evidence', exists: true, manifest: '.docguard-evidence.json',
140
+ status: 'invalid', manifestEvidence: loaded.evidence, errors: loaded.errors, results: [],
141
+ summary: Object.fromEntries(EVIDENCE_STATES.map(state => [state, 0])), scopeLimitation: EVIDENCE_SCOPE_LIMITATION,
142
+ };
143
+ }
144
+ const results = loaded.manifest.declarations.map(declaration => evaluateDeclaration(projectDir, config, declaration, read));
145
+ const summary = Object.fromEntries(EVIDENCE_STATES.map(state => [state, results.filter(result => result.state === state).length]));
146
+ const status = summary.contradicted > 0 ? 'contradicted'
147
+ : summary.stale + summary.inconclusive + summary.unsupported > 0 ? 'attention-required'
148
+ : 'verified-within-scope';
149
+ return {
150
+ command: 'verify --evidence', exists: true, manifest: '.docguard-evidence.json', status,
151
+ manifestEvidence: loaded.evidence, errors: [], results, summary, scopeLimitation: EVIDENCE_SCOPE_LIMITATION,
152
+ };
153
+ }
154
+
155
+ /** Reduce heuristic work only when one verified declaration maps to one exact extracted claim. */
156
+ export function coverSemanticClaims(claims, evaluation) {
157
+ const verified = evaluation?.results?.filter(result => result.state === 'verified-within-scope') || [];
158
+ const candidates = new Map();
159
+ for (const claim of claims) {
160
+ const matches = verified.filter(result => {
161
+ const line = Number(String(result.location).match(/:(\d+)$/)?.[1]);
162
+ if (result.document !== claim.doc || line !== claim.line || !String(claim.text).includes(result.statement)) return false;
163
+ if (Array.isArray(result.documentValue)) return claim.kind === 'enum' && result.documentValue.join('/') === claim.value;
164
+ if (result.documentValue === null) return false;
165
+ return String(result.documentValue) === String(claim.value);
166
+ });
167
+ candidates.set(claim.stableId, matches);
168
+ }
169
+ const resultUse = new Map();
170
+ for (const matches of candidates.values()) {
171
+ for (const result of matches) resultUse.set(result.claimId, (resultUse.get(result.claimId) || 0) + 1);
172
+ }
173
+ const covered = claims.filter(claim => {
174
+ const matches = candidates.get(claim.stableId) || [];
175
+ return matches.length === 1 && resultUse.get(matches[0].claimId) === 1;
176
+ });
177
+ const coveredIds = new Set(covered.map(claim => claim.stableId));
178
+ return {
179
+ total: claims.length,
180
+ verifiedWithinScope: covered.length,
181
+ unverified: claims.length - covered.length,
182
+ covered: covered.map(claim => claim.stableId),
183
+ remaining: claims.filter(claim => !coveredIds.has(claim.stableId)),
184
+ };
185
+ }