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.
- package/README.md +45 -22
- package/cli/commands/agent.mjs +47 -1
- package/cli/commands/explain.mjs +16 -0
- package/cli/commands/feedback.mjs +147 -6
- package/cli/commands/fix.mjs +13 -11
- package/cli/commands/generate.mjs +52 -18
- package/cli/commands/guard.mjs +13 -2
- package/cli/commands/mcp.mjs +22 -2
- package/cli/commands/score.mjs +13 -1
- package/cli/commands/specs.mjs +21 -2
- package/cli/commands/sync.mjs +20 -7
- package/cli/commands/verify.mjs +65 -2
- package/cli/config.mjs +3 -0
- package/cli/docguard.mjs +48 -16
- package/cli/evidence/adapters.mjs +200 -0
- package/cli/evidence/evaluate.mjs +185 -0
- package/cli/evidence/manifest.mjs +194 -0
- package/cli/evidence/markdown.mjs +107 -0
- package/cli/feedback-fixture.mjs +188 -0
- package/cli/findings.mjs +31 -0
- package/cli/repository-root.mjs +159 -0
- package/cli/scanners/py-ast.mjs +39 -2
- package/cli/scanners/task-context.mjs +312 -0
- package/cli/shared-doc-roles.mjs +44 -1
- package/cli/shared-source.mjs +101 -28
- package/cli/validators/architecture.mjs +186 -13
- package/cli/validators/environment.mjs +14 -1
- package/cli/validators/evidence.mjs +52 -0
- package/cli/validators/security.mjs +5 -4
- package/cli/validators/todo-tracking.mjs +45 -2
- package/cli/writers/doc-generators.mjs +31 -17
- package/cli/writers/mechanical.mjs +44 -14
- package/cli/writers/sections.mjs +31 -3
- package/docs/ai-integration.md +31 -6
- package/docs/commands.md +43 -5
- package/docs/configuration.md +11 -3
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/commands/fix.md +4 -2
- package/extensions/spec-kit-docguard/commands/generate.md +6 -1
- package/extensions/spec-kit-docguard/commands/guard.md +3 -2
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
- package/package.json +3 -1
- package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
- package/schemas/docguard-agent-context-result.schema.json +95 -0
- package/schemas/docguard-benchmark.schema.json +84 -0
- package/schemas/docguard-config.schema.json +1 -0
- package/schemas/docguard-evidence.schema.json +169 -0
- package/schemas/docguard-feedback-fixture.schema.json +54 -0
- package/schemas/docguard-task-context.schema.json +144 -0
- package/templates/AGENTS.md.template +9 -4
- package/templates/ci/github-actions.yml +4 -4
- package/templates/commands/docguard.guard.md +5 -1
- package/templates/commands/docguard.review.md +6 -1
- package/templates/evidence-manifest.json +21 -0
- 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}
|
|
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}
|
|
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
|
-
|
|
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: '
|
|
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
|
-
['--
|
|
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 --
|
|
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
|
|
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
|
|
307
|
-
examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --
|
|
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: '
|
|
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 --
|
|
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
|
-
|
|
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
|
+
}
|