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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict, privacy-bounded feedback fixtures and deterministic reduction.
|
|
3
|
+
* @implements docguard.precision-evidence-loop#FR-011
|
|
4
|
+
* @implements docguard.precision-evidence-loop#FR-013
|
|
5
|
+
* @implements docguard.precision-evidence-loop#FR-014
|
|
6
|
+
* @implements docguard.precision-evidence-loop#FR-015
|
|
7
|
+
* @implements docguard.precision-evidence-loop#FR-016
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { extname, isAbsolute } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const FEEDBACK_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-feedback-fixture.schema.json';
|
|
14
|
+
const CLASSIFICATIONS = new Set(['false_positive', 'false_negative', 'unsupported_syntax', 'ambiguous', 'policy_disagreement']);
|
|
15
|
+
const PARSER_TIERS = new Set(['js-ast', 'py-ast', 'regex-fallback', 'fallback-language', 'mixed', 'not-applicable']);
|
|
16
|
+
const PREDICATES = new Set(['finding_present', 'finding_absent', 'validator_unsupported']);
|
|
17
|
+
const ROOT_KEYS = new Set(['$schema', 'schemaVersion', 'classification', 'detector', 'parserTier', 'config', 'expectedIdentity', 'interestingness', 'fixture', 'oppositeControl', 'provenance', 'contribution']);
|
|
18
|
+
|
|
19
|
+
function object(value, label) {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exactKeys(value, keys, label) {
|
|
25
|
+
const unknown = Object.keys(value).filter(key => !keys.has(key));
|
|
26
|
+
if (unknown.length) throw new Error(`${label} has unknown field(s): ${unknown.join(', ')}.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safePath(value, label) {
|
|
30
|
+
if (typeof value !== 'string' || !value || value.includes('\\') || isAbsolute(value)) throw new Error(`${label} must be a POSIX relative path.`);
|
|
31
|
+
const parts = value.split('/');
|
|
32
|
+
if (parts.some(part => !part || part === '.' || part === '..' || ['.git', '.local'].includes(part.toLowerCase()))) {
|
|
33
|
+
throw new Error(`${label} enters a protected or escaping path.`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateConfig(value, label = 'config', depth = 0) {
|
|
39
|
+
if (depth > 6) throw new Error(`${label} is too deeply nested.`);
|
|
40
|
+
if (value === null || typeof value === 'boolean' || Number.isFinite(value)) return;
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
if (value.length > 10_000 || value.includes('\0') || isAbsolute(value) || /(?:^|[/\\])\.\.(?:[/\\]|$)/.test(value) || value.includes('.local') || value.includes('.git')) {
|
|
43
|
+
throw new Error(`${label} contains an unsafe value.`);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
if (value.length > 100) throw new Error(`${label} is too large.`);
|
|
49
|
+
value.forEach((item, index) => validateConfig(item, `${label}[${index}]`, depth + 1));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
object(value, label);
|
|
53
|
+
const keys = Object.keys(value);
|
|
54
|
+
if (keys.length > 100 || keys.some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) throw new Error(`${label} has unsafe keys.`);
|
|
55
|
+
for (const key of keys) validateConfig(value[key], `${label}.${key}`, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseFiles(value, label) {
|
|
59
|
+
const holder = object(value, label);
|
|
60
|
+
exactKeys(holder, new Set(['files']), label);
|
|
61
|
+
if (!Array.isArray(holder.files) || holder.files.length < 1 || holder.files.length > 16) throw new Error(`${label}.files requires 1-16 files.`);
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const files = holder.files.map((entry, index) => {
|
|
65
|
+
object(entry, `${label}.files[${index}]`);
|
|
66
|
+
exactKeys(entry, new Set(['path', 'content']), `${label}.files[${index}]`);
|
|
67
|
+
const path = safePath(entry.path, `${label}.files[${index}].path`);
|
|
68
|
+
if (seen.has(path)) throw new Error(`${label}.files contains duplicate path ${path}.`);
|
|
69
|
+
seen.add(path);
|
|
70
|
+
if (typeof entry.content !== 'string' || entry.content.includes('\0')) throw new Error(`${label}.files[${index}].content must be text.`);
|
|
71
|
+
bytes += Buffer.byteLength(entry.content);
|
|
72
|
+
return { path, content: entry.content };
|
|
73
|
+
});
|
|
74
|
+
if (bytes > 131_072) throw new Error(`${label} exceeds 128 KiB.`);
|
|
75
|
+
return { files };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseContribution(value) {
|
|
79
|
+
if (value === undefined) return null;
|
|
80
|
+
const contribution = object(value, 'contribution');
|
|
81
|
+
exactKeys(contribution, new Set(['testOnly', 'scopeDocumented', 'benchmarkDelta']), 'contribution');
|
|
82
|
+
const delta = object(contribution.benchmarkDelta, 'contribution.benchmarkDelta');
|
|
83
|
+
exactKeys(delta, new Set(['falsePositives', 'falseNegatives', 'unsupportedCases', 'abstainedSupportedCases']), 'contribution.benchmarkDelta');
|
|
84
|
+
for (const [key, count] of Object.entries(delta)) {
|
|
85
|
+
if (!Number.isInteger(count) || count < 0) throw new Error(`contribution.benchmarkDelta.${key} must be a non-negative integer.`);
|
|
86
|
+
}
|
|
87
|
+
return { testOnly: contribution.testOnly === true, scopeDocumented: contribution.scopeDocumented === true, benchmarkDelta: delta };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseFeedbackFixture(value) {
|
|
91
|
+
const root = object(value, 'feedback fixture');
|
|
92
|
+
exactKeys(root, ROOT_KEYS, 'feedback fixture');
|
|
93
|
+
if (root.$schema !== FEEDBACK_SCHEMA_URL || root.schemaVersion !== 1) throw new Error('Feedback fixture uses an unsupported schema contract.');
|
|
94
|
+
if (!CLASSIFICATIONS.has(root.classification)) throw new Error('feedback fixture classification is invalid.');
|
|
95
|
+
if (!PARSER_TIERS.has(root.parserTier)) throw new Error('feedback fixture parserTier is invalid.');
|
|
96
|
+
const detector = object(root.detector, 'detector');
|
|
97
|
+
exactKeys(detector, new Set(['code', 'validator']), 'detector');
|
|
98
|
+
if (!/^[A-Z]{3}\d{3}$/.test(detector.code) || !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(detector.validator)) throw new Error('detector code or validator is invalid.');
|
|
99
|
+
const match = String(root.expectedIdentity || '').match(/^([A-Z]{3}\d{3})@(.+)$/);
|
|
100
|
+
if (!match || match[1] !== detector.code) throw new Error('expectedIdentity must use the detector code.');
|
|
101
|
+
const expectedPath = safePath(match[2], 'expectedIdentity path');
|
|
102
|
+
const interestingness = object(root.interestingness, 'interestingness');
|
|
103
|
+
exactKeys(interestingness, new Set(['predicate']), 'interestingness');
|
|
104
|
+
if (!PREDICATES.has(interestingness.predicate)) throw new Error('interestingness.predicate is invalid.');
|
|
105
|
+
const fixture = parseFiles(root.fixture, 'fixture');
|
|
106
|
+
const oppositeControl = parseFiles(root.oppositeControl, 'oppositeControl');
|
|
107
|
+
const fixturePaths = fixture.files.map(file => file.path).sort();
|
|
108
|
+
const controlPaths = oppositeControl.files.map(file => file.path).sort();
|
|
109
|
+
if (JSON.stringify(fixturePaths) !== JSON.stringify(controlPaths) || !fixturePaths.includes(expectedPath)) {
|
|
110
|
+
throw new Error('fixture and oppositeControl must share paths and include the expectedIdentity path.');
|
|
111
|
+
}
|
|
112
|
+
const provenance = object(root.provenance, 'provenance');
|
|
113
|
+
exactKeys(provenance, new Set(['synthetic', 'redactionAttested']), 'provenance');
|
|
114
|
+
if (provenance.synthetic !== true || provenance.redactionAttested !== true) throw new Error('Synthetic provenance and reviewed redaction must both be attested.');
|
|
115
|
+
validateConfig(root.config);
|
|
116
|
+
return {
|
|
117
|
+
$schema: FEEDBACK_SCHEMA_URL, schemaVersion: 1, classification: root.classification,
|
|
118
|
+
detector: { code: detector.code, validator: detector.validator }, parserTier: root.parserTier,
|
|
119
|
+
config: root.config, expectedIdentity: `${detector.code}@${expectedPath}`,
|
|
120
|
+
interestingness: { predicate: interestingness.predicate }, fixture, oppositeControl,
|
|
121
|
+
provenance: { synthetic: true, redactionAttested: true }, contribution: parseContribution(root.contribution),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function shape(files) {
|
|
126
|
+
return files.map(file => {
|
|
127
|
+
const normalized = file.content
|
|
128
|
+
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, ' STRING ')
|
|
129
|
+
.replace(/\b\d+(?:\.\d+)?\b/g, ' NUMBER ')
|
|
130
|
+
.replace(/\b[A-Za-z_$][\w$]*\b/g, ' ID ')
|
|
131
|
+
.replace(/\s+/g, ' ').trim();
|
|
132
|
+
return `${extname(file.path).toLowerCase()}:${normalized}`;
|
|
133
|
+
}).sort().join('|');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function feedbackDuplicateIdentity(manifest) {
|
|
137
|
+
const input = [manifest.detector.code, manifest.classification, manifest.parserTier, shape(manifest.fixture.files)].join('|');
|
|
138
|
+
return `dgf-${createHash('sha256').update(input).digest('hex').slice(0, 16)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function feedbackFindingIdentity(finding) {
|
|
142
|
+
const raw = typeof finding.location === 'string' ? finding.location : finding.location?.file || '<project>';
|
|
143
|
+
return `${finding.code}@${raw.replace(/:\d+(?::\d+)?$/, '').replaceAll('\\', '/')}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function feedbackSearchUrls(manifest, issuesBase = 'https://github.com/raccioly/docguard/issues') {
|
|
147
|
+
const repository = issuesBase.replace(/^https:\/\/github\.com\//, '').replace(/\/issues.*$/, '');
|
|
148
|
+
const identity = feedbackDuplicateIdentity(manifest);
|
|
149
|
+
const build = state => `https://github.com/search?q=${encodeURIComponent(`repo:${repository} "${identity}" state:${state}`)}&type=issues`;
|
|
150
|
+
return { identity, all: build('open').replace(encodeURIComponent(' state:open'), ''), open: build('open'), closed: build('closed') };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function reduceFixtureDeterministically(manifest, interesting, maxAttempts = 100) {
|
|
154
|
+
let current = structuredClone(manifest);
|
|
155
|
+
let attempts = 0;
|
|
156
|
+
if (!interesting(current)) return { status: 'NOT_REPRODUCED', attempts, manifest: current };
|
|
157
|
+
for (let fileIndex = 0; fileIndex < current.fixture.files.length && attempts < maxAttempts; fileIndex++) {
|
|
158
|
+
let changed = true;
|
|
159
|
+
while (changed && attempts < maxAttempts) {
|
|
160
|
+
changed = false;
|
|
161
|
+
const lines = current.fixture.files[fileIndex].content.split('\n');
|
|
162
|
+
if (lines.length <= 1) break;
|
|
163
|
+
for (let line = 0; line < lines.length && attempts < maxAttempts; line++) {
|
|
164
|
+
const candidate = structuredClone(current);
|
|
165
|
+
candidate.fixture.files[fileIndex].content = lines.filter((_, index) => index !== line).join('\n');
|
|
166
|
+
attempts++;
|
|
167
|
+
if (interesting(candidate)) { current = candidate; changed = true; break; }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { status: attempts >= maxAttempts ? 'REDUCED_LIMIT' : 'REDUCED', attempts, manifest: current };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function assertContributionReady(manifest) {
|
|
175
|
+
if (!['false_positive', 'false_negative', 'unsupported_syntax'].includes(manifest.classification)) {
|
|
176
|
+
throw new Error('Ambiguous and policy-disagreement fixtures require adjudication before a test contribution.');
|
|
177
|
+
}
|
|
178
|
+
if (!manifest.contribution?.testOnly || !manifest.contribution.scopeDocumented) {
|
|
179
|
+
throw new Error('Contribution requires testOnly and scopeDocumented attestations plus benchmarkDelta.');
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildTestOnlyContribution(manifest) {
|
|
185
|
+
assertContributionReady(manifest);
|
|
186
|
+
const encoded = JSON.stringify(manifest);
|
|
187
|
+
return `import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { runGuardInternal } from '../cli/commands/guard.mjs';\nimport { feedbackFindingIdentity } from '../cli/feedback-fixture.mjs';\n\nconst manifest = ${encoded};\nfunction run(files) {\n const root = mkdtempSync(join(tmpdir(), 'docguard-contribution-'));\n try {\n for (const file of files) { const target = join(root, file.path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, file.content); }\n return runGuardInternal(root, manifest.config);\n } finally { rmSync(root, { recursive: true, force: true }); }\n}\n\ntest('${manifest.detector.code} ${manifest.classification} synthetic reproduction (${feedbackDuplicateIdentity(manifest)})', () => {\n const fixture = run(manifest.fixture.files);\n const control = run(manifest.oppositeControl.files);\n const fixtureHas = fixture.findings.some(finding => feedbackFindingIdentity(finding) === manifest.expectedIdentity);\n const controlHas = control.findings.some(finding => feedbackFindingIdentity(finding) === manifest.expectedIdentity);\n const fixtureApplicability = fixture.validators.find(item => item.key === manifest.detector.validator)?.applicability?.status;\n const controlApplicability = control.validators.find(item => item.key === manifest.detector.validator)?.applicability?.status;\n ${manifest.classification === 'false_positive' ? 'assert.equal(fixtureHas, false); assert.equal(controlHas, true);' : manifest.classification === 'false_negative' ? 'assert.equal(fixtureHas, true); assert.equal(controlHas, false);' : `assert.notEqual(fixtureApplicability, 'unsupported'); assert.equal(controlApplicability, 'checked'); assert.equal(controlHas, false);`}\n});\n`;
|
|
188
|
+
}
|
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
|
/**
|