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
@@ -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
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Strict loader for evidence-scoped verification declarations.
3
+ * @implements docguard.evidence-scoped-verification#FR-001
4
+ * @implements docguard.evidence-scoped-verification#FR-002
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { createEvidenceReader } from '../scanners/semantic-claims.mjs';
10
+
11
+ export const EVIDENCE_MANIFEST_PATH = '.docguard-evidence.json';
12
+ export const EVIDENCE_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-evidence.schema.json';
13
+ export const EVIDENCE_SCHEMA_VERSION = 1;
14
+ export const MAX_DECLARATIONS = 128;
15
+ export const MAX_REPORT_INPUTS = 32;
16
+
17
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{2,127}$/;
18
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/;
19
+ const VALUE_PREDICATES = new Set(['equals', 'set-equals', 'count-equals']);
20
+ const REPORT_ADAPTERS = new Set(['oasdiff', 'buf']);
21
+
22
+ function issue(message, declarationId = null) {
23
+ return { code: 'invalid-manifest', message, declarationId };
24
+ }
25
+
26
+ function object(value) {
27
+ return value && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ function exactKeys(value, allowed, label, errors, id = null) {
31
+ if (!object(value)) {
32
+ errors.push(issue(`${label} must be an object.`, id));
33
+ return false;
34
+ }
35
+ const unknown = Object.keys(value).filter(key => !allowed.includes(key));
36
+ if (unknown.length) errors.push(issue(`${label} has unknown field(s): ${unknown.join(', ')}.`, id));
37
+ return unknown.length === 0;
38
+ }
39
+
40
+ export function isSafeEvidencePath(path) {
41
+ return typeof path === 'string' && path.length > 0 && path.length <= 512
42
+ && !path.startsWith('/') && !/[\\:\0]/.test(path)
43
+ && !path.split('/').some(part => part === '..' || part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part));
44
+ }
45
+
46
+ function validateTarget(target, errors, id) {
47
+ exactKeys(target, ['document', 'heading', 'statement'], `${id}.target`, errors, id);
48
+ if (!isSafeEvidencePath(target?.document) || !target.document.endsWith('.md')) {
49
+ errors.push(issue(`${id}.target.document must be a safe repository-relative Markdown path.`, id));
50
+ }
51
+ if (typeof target?.heading !== 'string' || !target.heading.trim() || target.heading.length > 200) {
52
+ errors.push(issue(`${id}.target.heading must contain 1-200 characters.`, id));
53
+ }
54
+ if (typeof target?.statement !== 'string' || !target.statement.trim() || target.statement.length > 1000) {
55
+ errors.push(issue(`${id}.target.statement must contain 1-1000 characters.`, id));
56
+ }
57
+ }
58
+
59
+ function validateInputs(inputs, errors, id) {
60
+ if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > MAX_REPORT_INPUTS) {
61
+ errors.push(issue(`${id}.source.inputs must contain 1-${MAX_REPORT_INPUTS} input snapshots.`, id));
62
+ return;
63
+ }
64
+ const seen = new Set();
65
+ for (const [index, input] of inputs.entries()) {
66
+ exactKeys(input, ['path', 'sha256'], `${id}.source.inputs[${index}]`, errors, id);
67
+ if (!isSafeEvidencePath(input?.path)) errors.push(issue(`${id}.source.inputs[${index}].path is unsafe.`, id));
68
+ if (!HASH_RE.test(input?.sha256 || '')) errors.push(issue(`${id}.source.inputs[${index}].sha256 must be a lowercase SHA-256 identity.`, id));
69
+ if (seen.has(input?.path)) errors.push(issue(`${id}.source.inputs repeats ${input.path}.`, id));
70
+ seen.add(input?.path);
71
+ }
72
+ }
73
+
74
+ function validateSource(source, errors, id) {
75
+ if (!object(source)) {
76
+ errors.push(issue(`${id}.source must be an object.`, id));
77
+ return;
78
+ }
79
+ if (source.adapter === 'json-pointer') {
80
+ exactKeys(source, ['adapter', 'path', 'pointer'], `${id}.source`, errors, id);
81
+ if (!isSafeEvidencePath(source.path)) errors.push(issue(`${id}.source.path is unsafe.`, id));
82
+ if (typeof source.pointer !== 'string' || source.pointer.length > 512 || (source.pointer && !source.pointer.startsWith('/'))) {
83
+ errors.push(issue(`${id}.source.pointer must be an RFC 6901 JSON Pointer.`, id));
84
+ }
85
+ return;
86
+ }
87
+ if (source.adapter === 'collection-count') {
88
+ exactKeys(source, ['adapter', 'glob', 'allowEmpty'], `${id}.source`, errors, id);
89
+ if (!isSafeEvidencePath(source.glob) || source.glob.length > 512) errors.push(issue(`${id}.source.glob is unsafe or unbounded.`, id));
90
+ if (typeof source.allowEmpty !== 'boolean') errors.push(issue(`${id}.source.allowEmpty must be boolean.`, id));
91
+ return;
92
+ }
93
+ if (REPORT_ADAPTERS.has(source.adapter)) {
94
+ exactKeys(source, ['adapter', 'adapterVersion', 'path', 'producerVersion', 'command', 'inputs'], `${id}.source`, errors, id);
95
+ if (!Number.isInteger(source.adapterVersion) || source.adapterVersion < 1 || source.adapterVersion > 100) {
96
+ errors.push(issue(`${id}.source.adapterVersion must be an integer from 1-100.`, id));
97
+ }
98
+ if (!isSafeEvidencePath(source.path)) errors.push(issue(`${id}.source.path is unsafe.`, id));
99
+ if (typeof source.producerVersion !== 'string' || !source.producerVersion.trim() || source.producerVersion.length > 80) {
100
+ errors.push(issue(`${id}.source.producerVersion must contain 1-80 characters.`, id));
101
+ }
102
+ if (typeof source.command !== 'string' || !source.command.trim() || source.command.length > 40) {
103
+ errors.push(issue(`${id}.source.command must contain 1-40 characters.`, id));
104
+ }
105
+ validateInputs(source.inputs, errors, id);
106
+ return;
107
+ }
108
+ errors.push(issue(`${id}.source.adapter is unsupported.`, id));
109
+ }
110
+
111
+ function validatePredicate(predicate, source, statement, errors, id) {
112
+ if (!object(predicate)) {
113
+ errors.push(issue(`${id}.predicate must be an object.`, id));
114
+ return;
115
+ }
116
+ if (predicate.kind === 'equals') {
117
+ exactKeys(predicate, ['kind', 'valueType'], `${id}.predicate`, errors, id);
118
+ if (!['string', 'number', 'boolean', 'null'].includes(predicate.valueType)) {
119
+ errors.push(issue(`${id}.predicate.valueType is unsupported.`, id));
120
+ }
121
+ } else if (predicate.kind === 'set-equals') {
122
+ exactKeys(predicate, ['kind', 'itemType', 'separator'], `${id}.predicate`, errors, id);
123
+ if (predicate.itemType !== 'string') errors.push(issue(`${id}.predicate.itemType must be string.`, id));
124
+ if (typeof predicate.separator !== 'string' || !predicate.separator || predicate.separator.length > 16) {
125
+ errors.push(issue(`${id}.predicate.separator must contain 1-16 characters.`, id));
126
+ }
127
+ } else if (predicate.kind === 'count-equals' || predicate.kind === 'no-findings') {
128
+ exactKeys(predicate, ['kind'], `${id}.predicate`, errors, id);
129
+ } else {
130
+ errors.push(issue(`${id}.predicate.kind is unsupported.`, id));
131
+ }
132
+
133
+ const slots = typeof statement === 'string' ? statement.split('{{value}}').length - 1 : 0;
134
+ if (VALUE_PREDICATES.has(predicate.kind) && slots !== 1) {
135
+ errors.push(issue(`${id}.target.statement must contain exactly one {{value}} slot for ${predicate.kind}.`, id));
136
+ }
137
+ if (predicate.kind === 'no-findings' && slots !== 0) {
138
+ errors.push(issue(`${id}.target.statement cannot contain {{value}} for no-findings.`, id));
139
+ }
140
+ if (source?.adapter === 'json-pointer' && !['equals', 'set-equals'].includes(predicate.kind)) {
141
+ errors.push(issue(`${id} combines json-pointer with an incompatible predicate.`, id));
142
+ }
143
+ if (source?.adapter === 'collection-count' && predicate.kind !== 'count-equals') {
144
+ errors.push(issue(`${id} must combine collection-count with count-equals.`, id));
145
+ }
146
+ if (REPORT_ADAPTERS.has(source?.adapter) && predicate.kind !== 'no-findings') {
147
+ errors.push(issue(`${id} must combine ${source.adapter} with no-findings.`, id));
148
+ }
149
+ }
150
+
151
+ function validateDeclaration(declaration, index, errors) {
152
+ const label = declaration?.id || `declarations[${index}]`;
153
+ exactKeys(declaration, ['id', 'applicability', 'target', 'source', 'predicate'], label, errors, label);
154
+ if (!ID_RE.test(declaration?.id || '')) errors.push(issue(`${label}.id is invalid.`, label));
155
+ exactKeys(declaration?.applicability, ['mode'], `${label}.applicability`, errors, label);
156
+ if (declaration?.applicability?.mode !== 'always') errors.push(issue(`${label}.applicability.mode is unsupported.`, label));
157
+ validateTarget(declaration?.target, errors, label);
158
+ validateSource(declaration?.source, errors, label);
159
+ validatePredicate(declaration?.predicate, declaration?.source, declaration?.target?.statement, errors, label);
160
+ }
161
+
162
+ export function validateEvidenceManifest(manifest) {
163
+ const errors = [];
164
+ exactKeys(manifest, ['$schema', 'schemaVersion', 'declarations'], 'manifest', errors);
165
+ if (manifest?.$schema !== EVIDENCE_SCHEMA_URL) errors.push(issue(`$schema must be ${EVIDENCE_SCHEMA_URL}.`));
166
+ if (manifest?.schemaVersion !== EVIDENCE_SCHEMA_VERSION) errors.push(issue(`schemaVersion must be ${EVIDENCE_SCHEMA_VERSION}.`));
167
+ if (!Array.isArray(manifest?.declarations) || manifest.declarations.length < 1 || manifest.declarations.length > MAX_DECLARATIONS) {
168
+ errors.push(issue(`declarations must contain 1-${MAX_DECLARATIONS} entries.`));
169
+ return errors;
170
+ }
171
+ const ids = new Set();
172
+ for (const [index, declaration] of manifest.declarations.entries()) {
173
+ validateDeclaration(declaration, index, errors);
174
+ if (ids.has(declaration?.id)) errors.push(issue(`Duplicate declaration ID ${declaration.id}.`, declaration.id));
175
+ ids.add(declaration?.id);
176
+ }
177
+ return errors;
178
+ }
179
+
180
+ export function loadEvidenceManifest(projectDir, read = createEvidenceReader(projectDir)) {
181
+ const path = resolve(projectDir, EVIDENCE_MANIFEST_PATH);
182
+ if (!existsSync(path)) return { exists: false, manifest: null, errors: [], evidence: null };
183
+ const snapshot = read(EVIDENCE_MANIFEST_PATH);
184
+ if (snapshot.content === null) {
185
+ return { exists: true, manifest: null, errors: [issue(`Cannot safely read ${EVIDENCE_MANIFEST_PATH}: ${snapshot.evidence.reason}.`)], evidence: snapshot.evidence };
186
+ }
187
+ let manifest;
188
+ try { manifest = JSON.parse(snapshot.content); }
189
+ catch (error) {
190
+ return { exists: true, manifest: null, errors: [issue(`${EVIDENCE_MANIFEST_PATH} is invalid JSON: ${error.message}`)], evidence: snapshot.evidence };
191
+ }
192
+ const errors = validateEvidenceManifest(manifest);
193
+ return { exists: true, manifest: errors.length ? null : manifest, errors, evidence: snapshot.evidence };
194
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Exact, heading-scoped Markdown statement selection.
3
+ * @implements docguard.evidence-scoped-verification#FR-006
4
+ */
5
+
6
+ function normalizeHorizontal(value) {
7
+ return String(value).replace(/[ \t]+/g, ' ').trim();
8
+ }
9
+
10
+ function escapeRegex(value) {
11
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/[ \t]+/g, '[ \\t]+');
12
+ }
13
+
14
+ function visibleLines(content) {
15
+ const lines = String(content).split(/\r?\n/);
16
+ let fence = null;
17
+ return lines.map((text, index) => {
18
+ const marker = text.match(/^\s*(`{3,}|~{3,})/);
19
+ if (marker) {
20
+ const char = marker[1][0];
21
+ if (!fence) fence = { char, length: marker[1].length };
22
+ else if (fence.char === char && marker[1].length >= fence.length) fence = null;
23
+ return { line: index + 1, text: '', hidden: true };
24
+ }
25
+ return { line: index + 1, text: fence ? '' : text, hidden: Boolean(fence) };
26
+ });
27
+ }
28
+
29
+ function headings(lines) {
30
+ const out = [];
31
+ for (let index = 0; index < lines.length; index++) {
32
+ const row = lines[index];
33
+ const match = row.text.match(/^\s{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/);
34
+ if (match) {
35
+ out.push({ level: match[1].length, text: normalizeHorizontal(match[2]), line: row.line, endLine: row.line });
36
+ continue;
37
+ }
38
+ const underline = lines[index + 1]?.text.match(/^\s{0,3}(=+|-+)[ \t]*$/);
39
+ if (!row.hidden && normalizeHorizontal(row.text) && underline) {
40
+ out.push({ level: underline[1][0] === '=' ? 1 : 2, text: normalizeHorizontal(row.text), line: row.line, endLine: lines[index + 1].line });
41
+ index++;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export function selectMarkdownStatement(content, target, predicateKind) {
48
+ const lines = visibleLines(content);
49
+ const foundHeadings = headings(lines).filter(item => item.text === normalizeHorizontal(target.heading));
50
+ if (foundHeadings.length === 0) return { status: 'inconclusive', reasonCode: 'heading-missing', message: `Heading “${target.heading}” was not found.` };
51
+ if (foundHeadings.length > 1) return { status: 'inconclusive', reasonCode: 'heading-ambiguous', message: `Heading “${target.heading}” is not unique.` };
52
+ const heading = foundHeadings[0];
53
+ const allHeadings = headings(lines);
54
+ const next = allHeadings.find(item => item.line > heading.line && item.level <= heading.level);
55
+ const section = lines.filter(row => row.line > heading.endLine && (!next || row.line < next.line));
56
+ const valueMode = predicateKind !== 'no-findings';
57
+ const pieces = target.statement.split('{{value}}');
58
+ const pattern = valueMode
59
+ ? `${escapeRegex(pieces[0])}(.+?)${escapeRegex(pieces[1])}`
60
+ : escapeRegex(target.statement);
61
+ const matcher = new RegExp(pattern, 'g');
62
+ const matches = [];
63
+ for (const row of section) {
64
+ const normalized = normalizeHorizontal(row.text);
65
+ if (!normalized) continue;
66
+ matcher.lastIndex = 0;
67
+ let match;
68
+ while ((match = matcher.exec(normalized))) {
69
+ matches.push({ line: row.line, statement: normalizeHorizontal(match[0]), value: valueMode ? normalizeHorizontal(match[1]) : null });
70
+ if (!match[0]) matcher.lastIndex++;
71
+ }
72
+ }
73
+ if (matches.length === 0) return { status: 'inconclusive', reasonCode: 'statement-missing', message: 'The declared statement was not found in the selected heading.' };
74
+ if (matches.length > 1) return { status: 'inconclusive', reasonCode: 'statement-ambiguous', message: 'The declared statement is not unique in the selected heading.' };
75
+ return { status: 'ok', reasonCode: 'statement-selected', message: 'Markdown statement selected.', ...matches[0] };
76
+ }
77
+
78
+ export function parseDocumentValue(raw, predicate) {
79
+ if (predicate.kind === 'count-equals') {
80
+ if (!/^(?:0|[1-9][0-9]*)$/.test(raw)) return { ok: false, reasonCode: 'invalid-count', message: 'Document value is not a non-negative base-ten integer.' };
81
+ const value = Number(raw);
82
+ if (!Number.isSafeInteger(value)) return { ok: false, reasonCode: 'unsafe-count', message: 'Document count exceeds the safe integer range.' };
83
+ return { ok: true, value };
84
+ }
85
+ if (predicate.kind === 'set-equals') {
86
+ const values = raw.split(predicate.separator).map(item => item.trim());
87
+ if (values.some(item => !item)) return { ok: false, reasonCode: 'empty-set-item', message: 'Document set contains an empty item.' };
88
+ if (new Set(values).size !== values.length) return { ok: false, reasonCode: 'duplicate-set-item', message: 'Document set contains duplicate items.' };
89
+ return { ok: true, value: values };
90
+ }
91
+ if (predicate.valueType === 'string') return { ok: true, value: raw };
92
+ if (predicate.valueType === 'number') {
93
+ if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/.test(raw)) return { ok: false, reasonCode: 'invalid-number', message: 'Document value is not a canonical JSON number.' };
94
+ const value = Number(raw);
95
+ if (!Number.isFinite(value)) return { ok: false, reasonCode: 'non-finite-number', message: 'Document number is not finite.' };
96
+ return { ok: true, value };
97
+ }
98
+ if (predicate.valueType === 'boolean') {
99
+ if (raw !== 'true' && raw !== 'false') return { ok: false, reasonCode: 'invalid-boolean', message: 'Document value must be true or false.' };
100
+ return { ok: true, value: raw === 'true' };
101
+ }
102
+ if (predicate.valueType === 'null') {
103
+ if (raw !== 'null') return { ok: false, reasonCode: 'invalid-null', message: 'Document value must be null.' };
104
+ return { ok: true, value: null };
105
+ }
106
+ return { ok: false, reasonCode: 'unsupported-value-type', message: 'Predicate value type is unsupported.' };
107
+ }
package/cli/findings.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  /**
2
+ * @implements docguard.evidence-scoped-verification#FR-010
2
3
  * Findings — the structured, LLM-addressable result unit (v0.27).
3
4
  *
4
5
  * Background (LLM field report #3): DocGuard's whole job is to tell an agent
@@ -686,6 +687,36 @@ export const CODES = {
686
687
  help: 'An API doc unit is vague/generic or barely exceeds the signature it documents (deterministic Lazy detector F1 0.95). Document parameters, return, and errors concretely.',
687
688
  suppress: '<!-- docguard:quality api-smell off — your reason -->',
688
689
  },
690
+ EVD001: {
691
+ validator: 'evidence',
692
+ title: 'Evidence manifest is invalid',
693
+ help: 'The strict `.docguard-evidence.json` contract is malformed, unsafe, duplicated, or combines an adapter with an incompatible predicate. Compare it with `templates/evidence-manifest.json`, repair the named field, and rerun `docguard verify --evidence`.',
694
+ suppress: null,
695
+ },
696
+ EVD002: {
697
+ validator: 'evidence',
698
+ title: 'Declared evidence contradicts documentation',
699
+ help: 'A unique Markdown statement and its current declared source disagree under an exact predicate. Review approved intent before changing either side; implementation may have regressed from the document.',
700
+ suppress: null,
701
+ },
702
+ EVD003: {
703
+ validator: 'evidence',
704
+ title: 'Saved evidence is stale',
705
+ help: 'A saved oasdiff or Buf report names an input whose current SHA-256 differs from the declared snapshot. Regenerate the upstream report with the recorded command and producer version, update every input digest, then review the dependent statement.',
706
+ suppress: null,
707
+ },
708
+ EVD004: {
709
+ validator: 'evidence',
710
+ title: 'Evidence verification is inconclusive',
711
+ help: 'DocGuard could not safely and uniquely read the declaration, document statement, source, or report. Restore the evidence or narrow the target; missing evidence never becomes a pass.',
712
+ suppress: null,
713
+ },
714
+ EVD005: {
715
+ validator: 'evidence',
716
+ title: 'Evidence format is unsupported',
717
+ help: 'The adapter version, command, source type, report shape, or predicate is outside the implemented contract. Keep the finding visible and contribute a synthetic failing fixture plus a neighboring valid control before expanding support.',
718
+ suppress: null,
719
+ },
689
720
  };
690
721
 
691
722
  /**
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure policy for privileged release pull-request evaluation.
3
+ *
4
+ * @implements docguard.tokenless-scheduled-releases#FR-005
5
+ * @implements docguard.tokenless-scheduled-releases#FR-006
6
+ * @implements docguard.tokenless-scheduled-releases#FR-007
7
+ * @implements docguard.tokenless-scheduled-releases#FR-008
8
+ * @implements docguard.tokenless-scheduled-releases#FR-011
9
+ */
10
+
11
+ export const REQUIRED_RELEASE_JOBS = Object.freeze([
12
+ 'test (18)',
13
+ 'test (20)',
14
+ 'test (22)',
15
+ 'test (24)',
16
+ ]);
17
+
18
+ export const RELEASE_PATH_ALLOWLIST = /^(package(-lock)?\.json|pyproject\.toml|server\.json|CHANGELOG\.md|templates\/ci\/github-actions\.yml|extensions\/spec-kit-docguard\/(extension\.yml|templates\/github-workflows\/(docguard-guard|docguard-autofix)\.yml|skills\/docguard-(fix|guard|review|score|sync)\/SKILL\.md)|\.agent\/skills\/docguard-(fix|guard|review|score|sync)\/SKILL\.md)$/;
19
+
20
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
21
+
22
+ function parseVersion(value) {
23
+ const match = VERSION_PATTERN.exec(value || '');
24
+ return match ? match.slice(1).map(Number) : null;
25
+ }
26
+
27
+ function nextAllowed(base, candidate) {
28
+ if (!base || !candidate || candidate[0] !== base[0]) return false;
29
+ const nextPatch = candidate[1] === base[1] && candidate[2] === base[2] + 1;
30
+ const nextMinor = candidate[1] === base[1] + 1 && candidate[2] === 0;
31
+ return nextPatch || nextMinor;
32
+ }
33
+
34
+ function parseJson(text, label, errors) {
35
+ try {
36
+ return JSON.parse(text);
37
+ } catch {
38
+ errors.push(`${label}: invalid JSON`);
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function extractVersions(files, errors) {
44
+ const pkg = parseJson(files.packageJson, 'package.json', errors);
45
+ const lock = parseJson(files.packageLock, 'package-lock.json', errors);
46
+ const server = parseJson(files.server, 'server.json', errors);
47
+ const python = /^version\s*=\s*["']([^"']+)["']/m.exec(files.pyproject || '')?.[1];
48
+ const extension = /^ version:\s*["']([^"']+)["']/m.exec(files.extension || '')?.[1];
49
+ return {
50
+ package: pkg.version,
51
+ lock: lock.version,
52
+ lockPackage: lock.packages?.['']?.version,
53
+ python,
54
+ server: server.version,
55
+ extension,
56
+ };
57
+ }
58
+
59
+ export function evaluateReleaseCandidate(input) {
60
+ const errors = [];
61
+ const branchMatch = /^release\/v(\d+\.\d+\.\d+)$/.exec(input.headRef || '');
62
+ const titleMatch = /^release: v(\d+\.\d+\.\d+) — automated weekly batch$/.exec(input.title || '');
63
+ const branchVersion = branchMatch?.[1];
64
+ const titleVersion = titleMatch?.[1];
65
+
66
+ if (input.headRepo !== input.repository) errors.push('head repository: mismatch');
67
+ if (input.baseRef !== input.defaultBranch) errors.push('base branch: mismatch');
68
+ if (input.author !== 'github-actions[bot]') errors.push('author: must be github-actions[bot]');
69
+ if (!branchVersion) errors.push('head branch: invalid release branch');
70
+ if (!titleVersion) errors.push('title: invalid automated release title');
71
+
72
+ const unexpectedPaths = (input.paths || []).filter(path => !RELEASE_PATH_ALLOWLIST.test(path));
73
+ if (unexpectedPaths.length) errors.push(`paths: unexpected ${unexpectedPaths.join(', ')}`);
74
+ if (!(input.paths || []).includes('package.json')) errors.push('paths: package.json is required');
75
+ if (!(input.paths || []).includes('CHANGELOG.md')) errors.push('paths: CHANGELOG.md is required');
76
+
77
+ const versions = extractVersions(input.files || {}, errors);
78
+ const candidateVersion = versions.package;
79
+ for (const [surface, value] of Object.entries(versions)) {
80
+ if (!value) errors.push(`${surface}: missing version`);
81
+ else if (candidateVersion && value !== candidateVersion) errors.push(`${surface}: version mismatch`);
82
+ }
83
+ if (branchVersion && candidateVersion && branchVersion !== candidateVersion) errors.push('branch: version mismatch');
84
+ if (titleVersion && candidateVersion && titleVersion !== candidateVersion) errors.push('title: version mismatch');
85
+ if (!nextAllowed(parseVersion(input.baseVersion), parseVersion(candidateVersion))) {
86
+ errors.push('version: must be the next patch or next minor');
87
+ }
88
+ if (input.tagExists) errors.push('tag: release already exists');
89
+
90
+ return { ok: errors.length === 0, version: candidateVersion || branchVersion || null, errors };
91
+ }
92
+
93
+ export function evaluateReleaseJobs(jobs) {
94
+ const errors = [];
95
+ for (const name of REQUIRED_RELEASE_JOBS) {
96
+ const matches = (jobs || []).filter(job => job.name === name);
97
+ if (matches.length !== 1) {
98
+ errors.push(`${name}: expected one job, found ${matches.length}`);
99
+ continue;
100
+ }
101
+ const [job] = matches;
102
+ if (job.status !== 'completed' || job.conclusion !== 'success') {
103
+ errors.push(`${name}: ${job.status || 'unknown'}/${job.conclusion || 'unknown'}`);
104
+ }
105
+ }
106
+ return { ok: errors.length === 0, errors };
107
+ }