docguard-cli 0.38.0 → 0.39.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 CHANGED
@@ -294,10 +294,10 @@ DocGuard ships **23 commands** (the "Daily 5" + 18 situational tools, including
294
294
  | `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
295
295
  | `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
296
296
  | `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
297
- | `feedback` | Report likely false positives back to DocGuard local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
297
+ | `feedback` | Review any finding or a synthetic false-positive/false-negative/unsupported fixture; verify its opposite control, reduce it deterministically, search open and closed duplicates, and optionally emit a test-only contribution. Nothing is submitted automatically. |
298
298
  | `retire` | Find completed or superseded planning material (`--plan`/`--check`; `--fail-on-warning` gates advisory candidates) and explicitly remove clean tracked documentation from active AI context. `.docguard-archive.json` records recovery metadata and retired requirement identities, and `--retention-ref` proves the source revision remains reachable. This is separate from the Spec Kit Archive extension, which consolidates feature documents. |
299
299
  | `reconcile` | Build a read-only code↔spec review graph since a Git ref. Classifies mechanical facts, approved intent, decisions, unrelated changes, and unsupported evidence; `--write` applies only mechanical generated-section refreshes. |
300
- | `specs` | Maintain the versioned spec registry, preflight new specs, and apply evidence-gated completion transactions with bounded outcomes and active-context regeneration. |
300
+ | `specs` | Maintain the versioned spec registry, preflight new specs, and apply evidence-gated completion transactions with bounded outcomes and active-context regeneration. Verified living specs can record later reviewed maintenance without reopening or duplicating the specification. |
301
301
  | `specs --check` / `specs --write` | Validate or refresh `.docguard-specs.json`, the byte-stable index of immutable spec IDs, reviewed lifecycle/lineage/scope, artifact digests, task state, explicitly scoped test evidence, and archive tombstones. Refreshes preserve the reviewed block. |
302
302
  | `specs preflight [--path <spec>]` | Before specification, print current spec lifecycle and evidence. Before planning, check the generated draft for structural blockers and report semantic overlap as review-only evidence. |
303
303
  | `mcp` | MCP server — exposes guard/score/explain/verify/report/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
@@ -12,15 +12,23 @@
12
12
  *
13
13
  * Nothing is transmitted automatically. This command never scaffolds skills
14
14
  * or edits source files. Zero npm dependencies — pure Node.js built-ins.
15
+ * @implements docguard.precision-evidence-loop#FR-012
16
+ * @implements docguard.precision-evidence-loop#FR-015
17
+ * @implements docguard.precision-evidence-loop#FR-016
15
18
  */
16
19
 
17
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
18
- import { resolve, dirname } from 'node:path';
20
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { resolve, dirname, join, relative, sep } from 'node:path';
22
+ import { tmpdir } from 'node:os';
19
23
  import { fileURLToPath } from 'node:url';
20
24
  import { c } from '../shared.mjs';
21
25
  import { CODES } from '../findings.mjs';
22
26
  import { safeWrite } from '../writers/generate-io.mjs';
23
27
  import { runGuardInternal } from './guard.mjs';
28
+ import {
29
+ buildTestOnlyContribution, feedbackFindingIdentity, feedbackSearchUrls, parseFeedbackFixture,
30
+ reduceFixtureDeterministically,
31
+ } from '../feedback-fixture.mjs';
24
32
 
25
33
  const _PKG = JSON.parse(
26
34
  readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8')
@@ -39,16 +47,20 @@ function shortId(str) {
39
47
  }
40
48
 
41
49
  /** Shared output deliberately excludes every source-derived string. */
42
- export function buildIssueUrl(finding) {
50
+ export function buildIssueUrl(finding, context = null) {
43
51
  const code = Object.hasOwn(CODES, finding.code || '') ? finding.code : 'FINDING';
44
52
  const validator = CODES[code]?.validator || 'unknown';
45
- const title = `[feedback] ${code} (${validator}): detection feedback`;
53
+ const classification = context?.classification || 'false_positive';
54
+ const title = `[feedback] ${code} (${validator}): ${classification.replaceAll('_', ' ')}`;
46
55
  const confidence = ['high', 'medium', 'low'].includes(finding.confidence) ? finding.confidence : 'unknown';
47
56
  const body = [
48
57
  `DocGuard v${CLI_VERSION}`,
49
58
  `- Code: ${code}`,
50
59
  `- Validator: ${validator}`,
51
60
  `- Confidence: ${confidence}`,
61
+ `- Classification: ${classification}`,
62
+ ...(context?.parserTier ? [`- Parser tier: ${context.parserTier}`] : []),
63
+ ...(context?.duplicateIdentity ? [`- Duplicate identity: ${context.duplicateIdentity}`] : []),
52
64
  '',
53
65
  'Expected behavior:',
54
66
  'Actual behavior:',
@@ -60,12 +72,139 @@ export function buildIssueUrl(finding) {
60
72
  'Generated by docguard feedback. No project paths, messages, source code, or secret values are included.',
61
73
  ].join('\n');
62
74
  const url = `${ISSUES_BASE}/new?labels=docguard-feedback&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
63
- const query = `repo:raccioly/docguard ${code}`;
75
+ const query = context?.duplicateIdentity ? `repo:raccioly/docguard "${context.duplicateIdentity}"` : `repo:raccioly/docguard ${code}`;
64
76
  const searchUrl = `https://github.com/search?q=${encodeURIComponent(query)}&type=issues`;
65
77
  return { url: url.length <= URL_CAP ? url : `${ISSUES_BASE}/new`, title, searchUrl };
66
78
  }
67
79
 
80
+ const FEEDBACK_CLASSES = new Set(['false_positive', 'false_negative', 'unsupported_syntax', 'ambiguous', 'policy_disagreement']);
81
+
82
+ function projectFile(projectDir, value, label) {
83
+ if (typeof value !== 'string' || !value || value.includes('\\')) throw new Error(`${label} must be a project-relative path.`);
84
+ const root = resolve(projectDir);
85
+ const path = resolve(root, value);
86
+ const rel = relative(root, path);
87
+ if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.split(sep).some(part => ['.git', '.local'].includes(part.toLowerCase()))) {
88
+ throw new Error(`${label} must remain in the project and outside protected directories.`);
89
+ }
90
+ return path;
91
+ }
92
+
93
+ function evaluateFileSet(manifest, fileSet) {
94
+ const root = mkdtempSync(join(tmpdir(), 'docguard-feedback-fixture-'));
95
+ try {
96
+ for (const file of fileSet.files) {
97
+ const target = resolve(root, file.path);
98
+ mkdirSync(dirname(target), { recursive: true });
99
+ writeFileSync(target, file.content, 'utf8');
100
+ }
101
+ const report = runGuardInternal(root, manifest.config);
102
+ const validator = (report.validators || []).find(item => item.key === manifest.detector.validator);
103
+ return {
104
+ identities: (report.findings || []).filter(item => item.code === manifest.detector.code).map(feedbackFindingIdentity).sort(),
105
+ applicability: validator?.applicability?.status || 'unknown',
106
+ };
107
+ } finally {
108
+ rmSync(root, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ function predicateMatches(manifest, result) {
113
+ if (manifest.interestingness.predicate === 'finding_present') return result.identities.includes(manifest.expectedIdentity);
114
+ if (manifest.interestingness.predicate === 'finding_absent') return !result.identities.includes(manifest.expectedIdentity);
115
+ return result.applicability === 'unsupported';
116
+ }
117
+
118
+ function oppositeMatches(manifest, result) {
119
+ if (manifest.interestingness.predicate === 'finding_present') return result.identities.includes(manifest.expectedIdentity);
120
+ if (manifest.interestingness.predicate === 'finding_absent') return !result.identities.includes(manifest.expectedIdentity);
121
+ return result.applicability === 'checked';
122
+ }
123
+
124
+ function runFixtureFeedback(projectDir, flags) {
125
+ let manifest;
126
+ try {
127
+ const manifestPath = projectFile(projectDir, flags.fixtureManifest, '--fixture-manifest');
128
+ if (!existsSync(manifestPath) || lstatSync(manifestPath).isSymbolicLink()) throw new Error('Fixture manifest must be a regular non-symlink file.');
129
+ const text = readFileSync(manifestPath, 'utf8');
130
+ if (Buffer.byteLength(text) > 262_144) throw new Error('Fixture manifest exceeds 256 KiB.');
131
+ manifest = parseFeedbackFixture(JSON.parse(text));
132
+ if (flags.classification && flags.classification !== manifest.classification) throw new Error('--classification disagrees with the fixture manifest.');
133
+ } catch (error) {
134
+ console.log(JSON.stringify({ status: 'ERROR', error: error.message }, null, 2));
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+
139
+ const initial = evaluateFileSet(manifest, manifest.fixture);
140
+ const control = evaluateFileSet(manifest, manifest.oppositeControl);
141
+ const reproduced = predicateMatches(manifest, initial);
142
+ const controlConfirmed = oppositeMatches(manifest, control);
143
+ if (!reproduced || !controlConfirmed) {
144
+ console.log(JSON.stringify({
145
+ status: 'NOT_REPRODUCED', classification: manifest.classification,
146
+ reproductionConfirmed: reproduced, controlConfirmed,
147
+ message: 'The explicit predicate and opposite control must both reproduce before reduction or contribution.',
148
+ }, null, 2));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+
153
+ let reduction = null;
154
+ if (flags.reduce) {
155
+ reduction = reduceFixtureDeterministically(manifest, candidate => predicateMatches(candidate, evaluateFileSet(candidate, candidate.fixture)));
156
+ manifest = reduction.manifest;
157
+ }
158
+ const searches = feedbackSearchUrls(manifest, ISSUES_BASE);
159
+ const issue = buildIssueUrl({ code: manifest.detector.code, confidence: 'unknown' }, {
160
+ classification: manifest.classification, parserTier: manifest.parserTier, duplicateIdentity: searches.identity,
161
+ });
162
+ const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
163
+ let record = null;
164
+ let contribution = null;
165
+ let contributionPreview = null;
166
+ try {
167
+ if (!flags.preview) {
168
+ record = resolve(feedbackDir, `${searches.identity}.fixture.json`);
169
+ safeWrite(record, `${JSON.stringify(manifest, null, 2)}\n`);
170
+ }
171
+ if (flags.contribution) {
172
+ if (!/^tests\/[^/]+\.test\.mjs$/.test(flags.contribution)) {
173
+ throw new Error('--contribution must be a direct tests/<name>.test.mjs path.');
174
+ }
175
+ contributionPreview = buildTestOnlyContribution(manifest);
176
+ if (!flags.preview) {
177
+ contribution = projectFile(projectDir, flags.contribution, '--contribution');
178
+ safeWrite(contribution, contributionPreview);
179
+ }
180
+ }
181
+ } catch (error) {
182
+ console.log(JSON.stringify({ status: 'ERROR', error: error.message }, null, 2));
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ const output = {
187
+ status: 'READY', preview: Boolean(flags.preview), classification: manifest.classification,
188
+ reproductionConfirmed: true, controlConfirmed: true, reduction: reduction ? { status: reduction.status, attempts: reduction.attempts } : null,
189
+ duplicateIdentity: searches.identity, searchUrls: searches, issueUrl: issue.url,
190
+ record: record ? relative(projectDir, record).split(sep).join('/') : null,
191
+ contribution: contribution ? relative(projectDir, contribution).split(sep).join('/') : null,
192
+ contributionPreview: flags.preview && flags.contribution ? contributionPreview : undefined,
193
+ };
194
+ console.log(JSON.stringify(output, null, 2));
195
+ }
196
+
68
197
  export function runFeedback(projectDir, config, flags) {
198
+ if (flags.fixtureManifest) return runFixtureFeedback(projectDir, flags);
199
+ const classification = flags.classification || 'false_positive';
200
+ if (!FEEDBACK_CLASSES.has(classification) || classification === 'false_negative' || classification === 'unsupported_syntax') {
201
+ const error = classification === 'false_negative' || classification === 'unsupported_syntax'
202
+ ? 'This classification requires --fixture-manifest with an explicit expected identity and opposite control.'
203
+ : 'Unknown feedback classification.';
204
+ if (flags.format === 'json') console.log(JSON.stringify({ error, reportable: [] })); else console.error(error);
205
+ process.exitCode = 1;
206
+ return;
207
+ }
69
208
  const data = runGuardInternal(projectDir, config);
70
209
  const selectedCode = typeof flags.code === 'string' ? flags.code.toUpperCase() : null;
71
210
  if (flags.code !== undefined && (!selectedCode || !Object.hasOwn(CODES, selectedCode))) {
@@ -93,7 +232,7 @@ export function runFeedback(projectDir, config, flags) {
93
232
  const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
94
233
  const items = reportable.map((f) => {
95
234
  const id = shortId(`${f.code}|${f.location || f.message}`);
96
- const { url, title, searchUrl } = buildIssueUrl(f);
235
+ const { url, title, searchUrl } = buildIssueUrl(f, { classification });
97
236
  const fileName = `${(f.code || 'finding').toLowerCase()}-${id}.json`;
98
237
  const filePath = resolve(feedbackDir, fileName);
99
238
  return { finding: f, id, url, title, searchUrl, fileName, filePath, saved: false, error: null };
@@ -105,6 +244,7 @@ export function runFeedback(projectDir, config, flags) {
105
244
  if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
106
245
  safeWrite(it.filePath, JSON.stringify({
107
246
  capturedBy: `docguard feedback (v${CLI_VERSION})`,
247
+ classification,
108
248
  finding: it.finding,
109
249
  issueUrl: it.url,
110
250
  searchUrl: it.searchUrl,
@@ -123,6 +263,7 @@ export function runFeedback(projectDir, config, flags) {
123
263
  console.log(JSON.stringify({
124
264
  version: CLI_VERSION,
125
265
  preview: Boolean(flags.preview),
266
+ classification,
126
267
  reportable: items.map(it => ({
127
268
  code: it.finding.code,
128
269
  location: it.finding.location,
@@ -56,6 +56,13 @@ function archiveReadiness(spec, targetVerified = false) {
56
56
  return { status: 'READY', reason: 'Run the reviewed spec retirement flow after the verified state is committed and retained.' };
57
57
  }
58
58
 
59
+ function completionTransition(spec) {
60
+ if (spec?.reviewed.lifecycle.delivery === 'verified') return 'verified→verified';
61
+ return spec?.reviewed.lifecycle.delivery === 'in_progress'
62
+ ? 'in_progress→implemented→verified'
63
+ : 'implemented→verified';
64
+ }
65
+
59
66
  export function planSpecCompletion(projectDir, config, flags, options = {}) {
60
67
  const projection = projectSpecRegistry(projectDir, config);
61
68
  const loaded = readSpecRegistry(projectDir);
@@ -70,8 +77,12 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
70
77
 
71
78
  let reconcile = null;
72
79
  if (spec) {
80
+ const maintenance = spec.reviewed.lifecycle.delivery === 'verified'
81
+ && spec.reviewed.lifecycle.persistenceModel === 'living';
73
82
  if (spec.reviewed.lifecycle.approval !== 'approved') blockers.push({ code: 'SPC002', message: 'Only an approved spec can become verified.' });
74
- if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery)) blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress or implemented, found ${spec.reviewed.lifecycle.delivery}.` });
83
+ if (!['in_progress', 'implemented'].includes(spec.reviewed.lifecycle.delivery) && !maintenance) {
84
+ blockers.push({ code: 'SPC002', message: `Expected delivery=in_progress, implemented, or verified with persistenceModel=living; found ${spec.reviewed.lifecycle.delivery}/${spec.reviewed.lifecycle.persistenceModel || 'unset'}.` });
85
+ }
75
86
  const tasks = spec.observed.taskCompletion;
76
87
  if (!tasks.total || tasks.checked !== tasks.total) blockers.push({ code: 'SPC003', message: `All tasks must be checked (${tasks.checked}/${tasks.total}).` });
77
88
  if (spec.observed.implementationEvidence.length === 0) blockers.push({ code: 'SPC004', message: 'At least one qualified source implementation annotation is required.' });
@@ -92,6 +103,14 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
92
103
  if (reconcile.status === 'UNSUPPORTED' || reconcile.status === 'BLOCKED') blockers.push({ code: 'SPC006', message: 'Reconciliation coverage is unsupported or blocked.' });
93
104
  const unresolved = reconcile.classifications.filter(item => item.disposition === 'unsupported_or_ambiguous');
94
105
  if (unresolved.length) blockers.push({ code: 'SPC006', message: `Unresolved changed files: ${unresolved.map(item => item.path).join(', ')}.` });
106
+ if (maintenance) {
107
+ const reviewable = reconcile.classifications.filter(item =>
108
+ item.specs.includes(spec.specId)
109
+ && ['source', 'test', 'canonical_doc', 'decision'].includes(item.kind));
110
+ if (revision === spec.reviewed.reconciliation.lastReviewedRevision || reviewable.length === 0) {
111
+ blockers.push({ code: 'SPC006', message: 'Living-spec maintenance requires a new linked source, test, canonical-document, or decision change since the last reviewed revision.' });
112
+ }
113
+ }
95
114
  }
96
115
  }
97
116
  const guard = options.guardResult || runGuardInternal(projectDir, config);
@@ -100,7 +119,7 @@ export function planSpecCompletion(projectDir, config, flags, options = {}) {
100
119
  status: blockers.length ? 'BLOCKED' : 'READY',
101
120
  specId: flags.id || null,
102
121
  revision,
103
- transition: spec?.reviewed.lifecycle.delivery === 'in_progress' ? 'in_progress→implemented→verified' : 'implemented→verified',
122
+ transition: completionTransition(spec),
104
123
  blockers,
105
124
  reconciliation: reconcile,
106
125
  evidence: spec ? [...new Set([
package/cli/docguard.mjs CHANGED
@@ -301,10 +301,10 @@ const COMMAND_HELP = {
301
301
  examples: ['docguard memory', 'docguard memory --diff'],
302
302
  },
303
303
  feedback: {
304
- summary: 'Review detection feedback locally. Select any finding with --code or --all, check duplicates, and prepare a metadata-only issue URL. Nothing is submitted automatically.',
305
- usage: 'docguard feedback [--code <CODE> | --all] [--preview] [--format json]',
306
- flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--preview', 'Skip local feedback-record writes'], ['--format json', 'Machine-readable selected findings and issue/search URLs']],
307
- examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --all --format json'],
304
+ summary: 'Review detection feedback locally, or validate and reduce a synthetic fixture manifest. Duplicate searches cover open and closed work; nothing is submitted automatically.',
305
+ usage: 'docguard feedback [--code <CODE> | --all] [--classification <class>] [--fixture-manifest <path> [--reduce] [--contribution <path>]] [--preview] [--format json]',
306
+ 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']],
307
+ examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --fixture-manifest feedback.json --reduce --preview --format json'],
308
308
  },
309
309
  verify: {
310
310
  summary: 'Extract the semantic claims in your canonical docs — documented numbers, limits, and enums (retention days, rate limits, GSI/role counts, status enums) — as a verification task list the agent checks against the code. This is the highest-value bug class (a doc value that drifted from code) and the one regex/AST cannot judge. DocGuard finds the claims; the LLM confirms them.',
@@ -626,6 +626,17 @@ async function main() {
626
626
  flags.all = true;
627
627
  } else if (args[i] === '--preview') {
628
628
  flags.preview = true;
629
+ } else if (args[i] === '--classification' && args[i + 1]) {
630
+ flags.classification = args[i + 1].replaceAll('-', '_');
631
+ i++;
632
+ } else if (args[i] === '--fixture-manifest' && args[i + 1]) {
633
+ flags.fixtureManifest = args[i + 1];
634
+ i++;
635
+ } else if (args[i] === '--reduce') {
636
+ flags.reduce = true;
637
+ } else if (args[i] === '--contribution' && args[i + 1]) {
638
+ flags.contribution = args[i + 1];
639
+ i++;
629
640
  } else if (args[i] === '--signals') {
630
641
  flags.signals = true;
631
642
  } else if (args[i] === '--debate') {
@@ -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
+ }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Respects config.securityIgnore (glob patterns) and config.ignore (global).
5
5
  * Uses shared-ignore.mjs for consistent filtering (Constitution IV, v1.1.0).
6
+ * @implements docguard.precision-evidence-loop#FR-003
6
7
  */
7
8
 
8
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
@@ -35,10 +36,10 @@ const IGNORE_DIRS = new Set([
35
36
 
36
37
  // Patterns that might indicate hardcoded secrets
37
38
  const SECRET_PATTERNS = [
38
- { pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]/gi, label: 'hardcoded password' },
39
- { pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded API key' },
40
- { pattern: /(?:secret[_-]?key|secretkey)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded secret key' },
41
- { pattern: /(?:access[_-]?token|accesstoken)\s*[:=]\s*['"][^'"]{16,}['"]/gi, label: 'hardcoded access token' },
39
+ { pattern: /(?:password|passwd|pwd)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{8,}['"]/gi, label: 'hardcoded password' },
40
+ { pattern: /(?:api[_-]?key|apikey)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded API key' },
41
+ { pattern: /(?:secret[_-]?key|secretkey)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded secret key' },
42
+ { pattern: /(?:access[_-]?token|accesstoken)\??\s*(?:=\s*|:\s*(?:[^'";=\n]+?=\s*)?)['"][^'"]{16,}['"]/gi, label: 'hardcoded access token' },
42
43
  { pattern: /AKIA[0-9A-Z]{16}/g, label: 'AWS Access Key ID' },
43
44
  { pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/g, label: 'API secret key (Stripe/OpenAI pattern)' },
44
45
  ];
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.38.0"
6
+ version: "0.39.0"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 5 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.38.0
9
+ version: 0.39.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.38.0 -->
12
+ <!-- docguard:version: 0.39.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.38.0
10
+ version: 0.39.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.38.0 -->
13
+ <!-- docguard:version: 0.39.0 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.38.0
9
+ version: 0.39.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.38.0 -->
12
+ <!-- docguard:version: 0.39.0 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.38.0
9
+ version: 0.39.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.38.0 -->
12
+ <!-- docguard:version: 0.39.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.38.0
7
+ version: 0.39.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.38.0 -->
10
+ <!-- docguard:version: 0.39.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
 
37
37
  - name: Install DocGuard
38
- run: npm install --global --ignore-scripts docguard-cli@0.38.0
38
+ run: npm install --global --ignore-scripts docguard-cli@0.39.0
39
39
 
40
40
  - name: Run DocGuard
41
41
  shell: bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "guard": "node cli/docguard.mjs guard",
13
13
  "init": "node cli/docguard.mjs init",
14
14
  "score": "node cli/docguard.mjs score",
15
+ "benchmark": "node benchmarks/run.mjs",
15
16
  "diff": "node cli/docguard.mjs diff",
16
17
  "generate": "node cli/docguard.mjs generate",
17
18
  "hooks": "node cli/docguard.mjs hooks",
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raccioly.github.io/docguard/schemas/docguard-benchmark.schema.json",
4
+ "title": "DocGuard benchmark corpus",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["$schema", "schemaVersion", "cases"],
8
+ "properties": {
9
+ "$schema": { "const": "https://raccioly.github.io/docguard/schemas/docguard-benchmark.schema.json" },
10
+ "schemaVersion": { "const": 1 },
11
+ "cases": {
12
+ "type": "array",
13
+ "minItems": 1,
14
+ "items": { "$ref": "#/$defs/case" }
15
+ }
16
+ },
17
+ "$defs": {
18
+ "identity": { "type": "string", "pattern": "^[A-Z]{3}[0-9]{3}@[^/\\\\][^\\\\]*$" },
19
+ "source": {
20
+ "oneOf": [
21
+ {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": ["kind", "path"],
25
+ "properties": {
26
+ "kind": { "const": "fixture" },
27
+ "path": { "type": "string", "pattern": "^fixtures/" }
28
+ }
29
+ },
30
+ {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["kind", "url", "revision", "license"],
34
+ "properties": {
35
+ "kind": { "const": "git" },
36
+ "url": { "type": "string", "pattern": "^https://[^@]+\\.git$" },
37
+ "revision": { "type": "string", "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" },
38
+ "license": { "type": "string", "minLength": 1, "maxLength": 128 }
39
+ }
40
+ }
41
+ ]
42
+ },
43
+ "mutation": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": ["path", "find", "replace", "expectedOccurrences"],
47
+ "properties": {
48
+ "path": { "type": "string", "minLength": 1 },
49
+ "find": { "type": "string", "minLength": 1, "maxLength": 20000 },
50
+ "replace": { "type": "string", "maxLength": 20000 },
51
+ "expectedOccurrences": { "type": "integer", "minimum": 1, "maximum": 20 }
52
+ }
53
+ },
54
+ "case": {
55
+ "type": "object",
56
+ "additionalProperties": false,
57
+ "required": ["id", "split", "repositoryGroup", "causalFamily", "parserTier", "classification", "source", "scope", "expected", "forbidden", "repairOutcome"],
58
+ "properties": {
59
+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,127}$" },
60
+ "split": { "enum": ["development", "evaluation"] },
61
+ "repositoryGroup": { "type": "string", "minLength": 1, "maxLength": 128 },
62
+ "causalFamily": { "type": "string", "minLength": 1, "maxLength": 128 },
63
+ "parserTier": { "enum": ["js-ast", "py-ast", "regex-fallback", "fallback-language", "mixed", "not-applicable"] },
64
+ "classification": { "enum": ["defect", "clean_control", "ambiguous", "unsupported_syntax", "policy_disagreement"] },
65
+ "repairOutcome": { "enum": ["accepted", "rejected", "not_evaluated"] },
66
+ "source": { "$ref": "#/$defs/source" },
67
+ "scope": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": ["validatorKey", "codes"],
71
+ "properties": {
72
+ "validatorKey": { "type": "string", "minLength": 1, "maxLength": 64 },
73
+ "codes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Z]{3}[0-9]{3}$" } }
74
+ }
75
+ },
76
+ "config": { "type": "object" },
77
+ "mutations": { "type": "array", "items": { "$ref": "#/$defs/mutation" } },
78
+ "expected": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/identity" } },
79
+ "forbidden": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/identity" } },
80
+ "oppositeControl": { "type": ["string", "null"], "pattern": "^[a-z0-9][a-z0-9._-]{2,127}$" }
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raccioly.github.io/docguard/schemas/docguard-feedback-fixture.schema.json",
4
+ "title": "DocGuard synthetic feedback fixture",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["$schema", "schemaVersion", "classification", "detector", "parserTier", "config", "expectedIdentity", "interestingness", "fixture", "oppositeControl", "provenance"],
8
+ "properties": {
9
+ "$schema": { "const": "https://raccioly.github.io/docguard/schemas/docguard-feedback-fixture.schema.json" },
10
+ "schemaVersion": { "const": 1 },
11
+ "classification": { "enum": ["false_positive", "false_negative", "unsupported_syntax", "ambiguous", "policy_disagreement"] },
12
+ "detector": {
13
+ "type": "object", "additionalProperties": false, "required": ["code", "validator"],
14
+ "properties": { "code": { "type": "string", "pattern": "^[A-Z]{3}[0-9]{3}$" }, "validator": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]{1,63}$" } }
15
+ },
16
+ "parserTier": { "enum": ["js-ast", "py-ast", "regex-fallback", "fallback-language", "mixed", "not-applicable"] },
17
+ "config": { "type": "object" },
18
+ "expectedIdentity": { "type": "string", "pattern": "^[A-Z]{3}[0-9]{3}@.+$" },
19
+ "interestingness": {
20
+ "type": "object", "additionalProperties": false, "required": ["predicate"],
21
+ "properties": { "predicate": { "enum": ["finding_present", "finding_absent", "validator_unsupported"] } }
22
+ },
23
+ "fixture": { "$ref": "#/$defs/fileSet" },
24
+ "oppositeControl": { "$ref": "#/$defs/fileSet" },
25
+ "provenance": {
26
+ "type": "object", "additionalProperties": false, "required": ["synthetic", "redactionAttested"],
27
+ "properties": { "synthetic": { "const": true }, "redactionAttested": { "const": true } }
28
+ },
29
+ "contribution": {
30
+ "type": "object", "additionalProperties": false, "required": ["testOnly", "scopeDocumented", "benchmarkDelta"],
31
+ "properties": {
32
+ "testOnly": { "const": true }, "scopeDocumented": { "const": true },
33
+ "benchmarkDelta": {
34
+ "type": "object", "additionalProperties": false,
35
+ "required": ["falsePositives", "falseNegatives", "unsupportedCases", "abstainedSupportedCases"],
36
+ "properties": {
37
+ "falsePositives": { "type": "integer", "minimum": 0 }, "falseNegatives": { "type": "integer", "minimum": 0 },
38
+ "unsupportedCases": { "type": "integer", "minimum": 0 }, "abstainedSupportedCases": { "type": "integer", "minimum": 0 }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ },
44
+ "$defs": {
45
+ "fileSet": {
46
+ "type": "object", "additionalProperties": false, "required": ["files"],
47
+ "properties": { "files": { "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/file" } } }
48
+ },
49
+ "file": {
50
+ "type": "object", "additionalProperties": false, "required": ["path", "content"],
51
+ "properties": { "path": { "type": "string", "minLength": 1 }, "content": { "type": "string", "maxLength": 131072 } }
52
+ }
53
+ }
54
+ }
@@ -31,7 +31,7 @@ jobs:
31
31
  node-version: '20'
32
32
 
33
33
  - name: Install DocGuard
34
- run: npm install --global --ignore-scripts docguard-cli@0.38.0
34
+ run: npm install --global --ignore-scripts docguard-cli@0.39.0
35
35
 
36
36
  - name: Run DocGuard
37
37
  shell: bash
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://raccioly.github.io/docguard/schemas/docguard-feedback-fixture.schema.json",
3
+ "schemaVersion": 1,
4
+ "classification": "false_positive",
5
+ "detector": { "code": "SEC001", "validator": "security" },
6
+ "parserTier": "js-ast",
7
+ "config": { "profile": "starter", "diskCache": false, "validators": { "security": true } },
8
+ "expectedIdentity": "SEC001@src/case.js",
9
+ "interestingness": { "predicate": "finding_present" },
10
+ "fixture": { "files": [{ "path": "src/case.js", "content": "const password = \"validation message with several words\";\n" }] },
11
+ "oppositeControl": { "files": [{ "path": "src/case.js", "content": "const password = \"real-looking-password\";\n" }] },
12
+ "provenance": { "synthetic": true, "redactionAttested": true },
13
+ "contribution": {
14
+ "testOnly": true,
15
+ "scopeDocumented": true,
16
+ "benchmarkDelta": { "falsePositives": 0, "falseNegatives": 0, "unsupportedCases": 0, "abstainedSupportedCases": 0 }
17
+ }
18
+ }