docguard-cli 0.37.1 → 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 +7 -5
- package/cli/commands/feedback.mjs +147 -6
- package/cli/commands/reconcile.mjs +42 -0
- package/cli/commands/retire.mjs +11 -15
- package/cli/commands/specs.mjs +198 -3
- package/cli/commands/sync.mjs +16 -44
- package/cli/docguard.mjs +51 -6
- package/cli/feedback-fixture.mjs +188 -0
- package/cli/scanners/document-lifecycle.mjs +19 -43
- package/cli/scanners/lifecycle-context.mjs +50 -0
- package/cli/scanners/reconciliation.mjs +141 -0
- package/cli/scanners/requirement-evidence.mjs +41 -8
- package/cli/scanners/retirement-manifest.mjs +52 -0
- package/cli/scanners/spec-registry.mjs +36 -9
- package/cli/shared-sync-scope.mjs +35 -0
- package/cli/validators/security.mjs +5 -4
- package/cli/validators/traceability.mjs +2 -1
- package/cli/writers/file-transaction.mjs +85 -0
- package/cli/writers/spec-outcomes.mjs +27 -0
- package/extensions/spec-kit-docguard/README.md +6 -2
- package/extensions/spec-kit-docguard/commands/complete.md +38 -0
- package/extensions/spec-kit-docguard/extension.yml +20 -4
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-benchmark.schema.json +84 -0
- package/schemas/docguard-feedback-fixture.schema.json +54 -0
- package/schemas/docguard-specs.schema.json +19 -2
- package/templates/ci/github-actions.yml +1 -1
- package/templates/feedback-fixture.json +18 -0
package/cli/commands/sync.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
|
|
|
12
12
|
*
|
|
13
13
|
* Default is a DRY RUN (preview); `--write` applies. `--since <ref>` adds the
|
|
14
14
|
* git diff as context. Only edits docguard:generated docs unless `--force`.
|
|
15
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -22,6 +23,7 @@ import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
|
22
23
|
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
23
24
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
24
25
|
import { runSyncTests } from './sync-tests.mjs';
|
|
26
|
+
import { sectionTouchedByChanges } from '../shared-sync-scope.mjs';
|
|
25
27
|
|
|
26
28
|
function gitChangedFiles(projectDir, since) {
|
|
27
29
|
const run = (args) => {
|
|
@@ -44,39 +46,6 @@ function gitChangedFiles(projectDir, since) {
|
|
|
44
46
|
* The predicates are matched against project-relative POSIX paths (the form
|
|
45
47
|
* `git diff --name-only` returns).
|
|
46
48
|
*/
|
|
47
|
-
const SECTION_FILE_MATCHERS = {
|
|
48
|
-
'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
|
|
49
|
-
'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
|
|
50
|
-
'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
|
|
51
|
-
|| /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
|
|
52
|
-
'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
53
|
-
|| /\.prisma$/.test(p),
|
|
54
|
-
'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p)
|
|
55
|
-
|| /\.prisma$/.test(p),
|
|
56
|
-
'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p)
|
|
57
|
-
|| /\.(tsx|jsx)$/.test(p),
|
|
58
|
-
'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
|
|
59
|
-
'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
|
|
60
|
-
'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
61
|
-
'features': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
62
|
-
'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
|
|
63
|
-
|| /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p), // any code may use env
|
|
64
|
-
'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Decide whether a given code-truth section should be re-synced based on the
|
|
69
|
-
* set of changed files. Returns true when:
|
|
70
|
-
* - changedFiles is null/empty (no scope info → sync everything), OR
|
|
71
|
-
* - any changed file matches the section's known source patterns, OR
|
|
72
|
-
* - the section has no matcher registered (unknown → conservative: sync)
|
|
73
|
-
*/
|
|
74
|
-
function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
75
|
-
if (!changedFiles || changedFiles.length === 0) return true;
|
|
76
|
-
const matcher = SECTION_FILE_MATCHERS[sectionId];
|
|
77
|
-
if (!matcher) return true; // unknown section → don't accidentally skip it
|
|
78
|
-
return changedFiles.some(matcher);
|
|
79
|
-
}
|
|
80
49
|
|
|
81
50
|
export function runSync(projectDir, config, flags) {
|
|
82
51
|
if (flags.write) assertDefaultDocWrites(config);
|
|
@@ -144,18 +113,20 @@ export function runSync(projectDir, config, flags) {
|
|
|
144
113
|
if (apply && docChanged) writeFileSync(full, content, 'utf-8');
|
|
145
114
|
}
|
|
146
115
|
|
|
116
|
+
const result = {
|
|
117
|
+
project: config.projectName,
|
|
118
|
+
since: flags.since || null,
|
|
119
|
+
changedFiles: changed,
|
|
120
|
+
applied: apply,
|
|
121
|
+
updates,
|
|
122
|
+
reviews,
|
|
123
|
+
skipped,
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
if (flags.silent) return result;
|
|
147
127
|
if (isJson) {
|
|
148
|
-
console.log(JSON.stringify(
|
|
149
|
-
|
|
150
|
-
since: flags.since || null,
|
|
151
|
-
changedFiles: changed,
|
|
152
|
-
applied: apply,
|
|
153
|
-
updates,
|
|
154
|
-
reviews,
|
|
155
|
-
skipped,
|
|
156
|
-
timestamp: new Date().toISOString(),
|
|
157
|
-
}, null, 2));
|
|
158
|
-
return;
|
|
128
|
+
console.log(JSON.stringify(result, null, 2));
|
|
129
|
+
return result;
|
|
159
130
|
}
|
|
160
131
|
|
|
161
132
|
console.log(`${c.bold}🔄 DocGuard Sync — ${config.projectName}${c.reset}`);
|
|
@@ -184,4 +155,5 @@ export function runSync(projectDir, config, flags) {
|
|
|
184
155
|
for (const s of skipped) console.log(` ${c.dim}- ${s.doc}: ${s.reason}${c.reset}`);
|
|
185
156
|
console.log('');
|
|
186
157
|
}
|
|
158
|
+
return result;
|
|
187
159
|
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { assertDefaultDocWrites } from './shared-doc-roles.mjs';
|
|
|
13
13
|
* npx docguard-cli --help — Show help
|
|
14
14
|
*
|
|
15
15
|
* @see https://github.com/raccioly/docguard
|
|
16
|
+
* @implements docguard.document-lifecycle#FR-012
|
|
17
|
+
* @implements docguard.document-lifecycle#FR-020
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -53,6 +55,7 @@ import { runAgent } from './commands/agent.mjs';
|
|
|
53
55
|
import { runMcp } from './commands/mcp.mjs';
|
|
54
56
|
import { runArchive } from './commands/retire.mjs';
|
|
55
57
|
import { runSpecs } from './commands/specs.mjs';
|
|
58
|
+
import { runReconcile } from './commands/reconcile.mjs';
|
|
56
59
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
57
60
|
|
|
58
61
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
@@ -101,6 +104,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
101
104
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
102
105
|
${c.green}retire${c.reset} Remove reviewed docs from active AI context (${c.cyan}--plan${c.reset}; explicit ${c.cyan}--write --path${c.reset})
|
|
103
106
|
${c.green}specs${c.reset} Track spec lifecycle and evidence (${c.cyan}--check|--write${c.reset}; ${c.cyan}preflight --path <spec>${c.reset})
|
|
107
|
+
${c.green}reconcile${c.reset} Classify code/spec changes since a Git ref before changing intent
|
|
104
108
|
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
|
|
105
109
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
106
110
|
${c.green}watch${c.reset} Live mode: re-run guard on file changes
|
|
@@ -297,10 +301,10 @@ const COMMAND_HELP = {
|
|
|
297
301
|
examples: ['docguard memory', 'docguard memory --diff'],
|
|
298
302
|
},
|
|
299
303
|
feedback: {
|
|
300
|
-
summary: 'Review detection feedback locally
|
|
301
|
-
usage: 'docguard feedback [--code <CODE> | --all] [--preview] [--format json]',
|
|
302
|
-
flags: [['--code <CODE>', 'Select a finding regardless of confidence'], ['--all', 'Select every active finding'], ['--preview', 'Skip local
|
|
303
|
-
examples: ['docguard feedback', 'docguard feedback --code TRC005 --preview', 'docguard feedback --
|
|
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'],
|
|
304
308
|
},
|
|
305
309
|
verify: {
|
|
306
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.',
|
|
@@ -331,15 +335,32 @@ const COMMAND_HELP = {
|
|
|
331
335
|
},
|
|
332
336
|
specs: {
|
|
333
337
|
summary: 'Maintain the deterministic spec lifecycle and evidence registry.',
|
|
334
|
-
usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] [--
|
|
338
|
+
usage: 'docguard specs [--check|--write] | docguard specs preflight [--path <spec>] | docguard specs complete --id <spec-id> [--since <ref>] [--write --reason <text>]',
|
|
335
339
|
flags: [
|
|
336
340
|
['--check', 'Exit 2 when the committed registry is missing, stale, or inconsistent'],
|
|
337
341
|
['--write', 'Refresh observed evidence while preserving reviewed lifecycle fields'],
|
|
338
342
|
['preflight', 'Brief prior specs, or gate a generated draft with --path'],
|
|
343
|
+
['complete', 'Plan or apply the implemented→verified evidence transaction'],
|
|
344
|
+
['--id <spec-id>', 'Immutable spec identity to complete'],
|
|
345
|
+
['--since <ref>', 'First reconciliation baseline when none is recorded'],
|
|
346
|
+
['--reason <text>', 'Reviewed implementation outcome required for completion writes'],
|
|
347
|
+
['--deviation <text>', 'Accepted deviation to record; repeatable'],
|
|
348
|
+
['--successor <id>', 'Approved current successor spec to record'],
|
|
339
349
|
['--path <spec>', 'Generated spec to compare against current lifecycle state'],
|
|
340
350
|
['--format json', 'Machine-readable registry or preflight result'],
|
|
341
351
|
],
|
|
342
|
-
examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight
|
|
352
|
+
examples: ['docguard specs --check', 'docguard specs --write', 'docguard specs preflight --path specs/007-feature/spec.md', 'docguard specs complete --id acme.feature --since main --write --reason "Reviewed implementation"'],
|
|
353
|
+
},
|
|
354
|
+
reconcile: {
|
|
355
|
+
summary: 'Classify changed implementation facts, approved intent, decisions, and unsupported evidence.',
|
|
356
|
+
usage: 'docguard reconcile --since <ref> [--check|--write] [--format json]',
|
|
357
|
+
flags: [
|
|
358
|
+
['--since <ref>', 'Required Git baseline for the review graph'],
|
|
359
|
+
['--check', 'Exit 2 while reviewed reconciliation remains'],
|
|
360
|
+
['--write', 'Apply only deterministic generated-section refreshes'],
|
|
361
|
+
['--format json', 'Machine-readable nodes, edges, classifications, and write plan'],
|
|
362
|
+
],
|
|
363
|
+
examples: ['docguard reconcile --since main --format json', 'docguard reconcile --since HEAD~1 --write'],
|
|
343
364
|
},
|
|
344
365
|
};
|
|
345
366
|
|
|
@@ -589,12 +610,33 @@ async function main() {
|
|
|
589
610
|
} else if (args[i] === '--retention-ref' && args[i + 1]) {
|
|
590
611
|
flags.retentionRef = args[i + 1];
|
|
591
612
|
i++;
|
|
613
|
+
} else if (args[i] === '--id' && args[i + 1]) {
|
|
614
|
+
flags.id = args[i + 1];
|
|
615
|
+
i++;
|
|
616
|
+
} else if (args[i] === '--deviation' && args[i + 1]) {
|
|
617
|
+
flags.deviations = flags.deviations || [];
|
|
618
|
+
flags.deviations.push(args[i + 1]);
|
|
619
|
+
i++;
|
|
620
|
+
} else if (args[i] === '--successor' && args[i + 1]) {
|
|
621
|
+
flags.successor = args[i + 1];
|
|
622
|
+
i++;
|
|
592
623
|
} else if (args[i] === '--code') {
|
|
593
624
|
flags.code = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : '';
|
|
594
625
|
} else if (args[i] === '--all') {
|
|
595
626
|
flags.all = true;
|
|
596
627
|
} else if (args[i] === '--preview') {
|
|
597
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++;
|
|
598
640
|
} else if (args[i] === '--signals') {
|
|
599
641
|
flags.signals = true;
|
|
600
642
|
} else if (args[i] === '--debate') {
|
|
@@ -890,6 +932,9 @@ async function main() {
|
|
|
890
932
|
case 'specs':
|
|
891
933
|
runSpecs(projectDir, config, flags);
|
|
892
934
|
break;
|
|
935
|
+
case 'reconcile':
|
|
936
|
+
runReconcile(projectDir, config, flags);
|
|
937
|
+
break;
|
|
893
938
|
case 'demo':
|
|
894
939
|
// v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
|
|
895
940
|
// fixture (templates/demo-fixture/) and prints curated drift findings
|
|
@@ -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
|
+
}
|
|
@@ -7,53 +7,28 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { dirname, resolve } from 'node:path';
|
|
9
9
|
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
10
|
+
import { readSpecRegistry } from './spec-registry.mjs';
|
|
11
|
+
import { readRetirementManifest } from './retirement-manifest.mjs';
|
|
10
12
|
|
|
11
13
|
const RETIRED_STATUSES = new Set(['archived', 'deprecated', 'obsolete', 'superseded']);
|
|
12
14
|
const COMPLETION_STATUSES = new Set(['complete', 'completed']);
|
|
13
15
|
const EXCLUDED_PREFIXES = ['.git', '.local', '.docguard'];
|
|
14
|
-
const
|
|
16
|
+
const SUPPRESSIBLE_DELIVERY = new Set(['verified', 'released']);
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
&& typeof entry?.retentionRef === 'string'
|
|
31
|
-
&& /^(?:sha1|sha256)$/.test(entry?.objectFormat || '');
|
|
32
|
-
if (typeof path !== 'string' || !path || path.startsWith('/')
|
|
33
|
-
|| path.replaceAll('\\', '/').split('/').includes('..')
|
|
34
|
-
|| typeof entry?.archivedFrom !== 'string'
|
|
35
|
-
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.archivedFrom)
|
|
36
|
-
|| typeof entry?.blob !== 'string'
|
|
37
|
-
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.blob)
|
|
38
|
-
|| typeof entry?.reason !== 'string' || !entry.reason.trim()
|
|
39
|
-
|| (entry?.requirementIds !== undefined && (!Array.isArray(entry.requirementIds)
|
|
40
|
-
|| entry.requirementIds.some(id => typeof id !== 'string' || !id || id.length > 128 || /[\s#\0]/.test(id))))
|
|
41
|
-
|| (entry?.specId !== undefined && (typeof entry.specId !== 'string'
|
|
42
|
-
|| !/^[a-z0-9][a-z0-9._-]{2,127}$/.test(entry.specId)))
|
|
43
|
-
|| (!globalRecovery && !entryRecovery)) {
|
|
44
|
-
throw new Error(`invalid recovery entry for ${path || '<unknown path>'}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return {
|
|
48
|
-
ok: true,
|
|
49
|
-
paths: new Set(parsed.entries.map(entry => entry.path)),
|
|
50
|
-
entries: parsed.entries,
|
|
51
|
-
retention: parsed.retention || null,
|
|
52
|
-
error: null,
|
|
53
|
-
};
|
|
54
|
-
} catch (error) {
|
|
55
|
-
return { ok: false, paths: new Set(), entries: [], retention: null, error: `${MANIFEST_PATH}: ${error.message}` };
|
|
56
|
-
}
|
|
18
|
+
/**
|
|
19
|
+
* Read only the narrow registry state needed to avoid asking users to retire a
|
|
20
|
+
* verified living specification. Any malformed or unknown shape fails closed:
|
|
21
|
+
* the ordinary lifecycle review signal remains visible.
|
|
22
|
+
* @implements docguard.document-lifecycle#FR-002
|
|
23
|
+
*/
|
|
24
|
+
function readVerifiedLivingSpecPaths(projectDir) {
|
|
25
|
+
const registry = readSpecRegistry(projectDir);
|
|
26
|
+
if (!registry.exists || registry.error) return new Set();
|
|
27
|
+
return new Set(registry.value.specs
|
|
28
|
+
.filter(spec => spec.reviewed.lifecycle.context === 'current'
|
|
29
|
+
&& spec.reviewed.lifecycle.persistenceModel === 'living'
|
|
30
|
+
&& SUPPRESSIBLE_DELIVERY.has(spec.reviewed.lifecycle.delivery))
|
|
31
|
+
.map(spec => spec.path));
|
|
57
32
|
}
|
|
58
33
|
|
|
59
34
|
function trackedFiles(projectDir) {
|
|
@@ -110,6 +85,7 @@ export function scanDocumentLifecycle(projectDir, config = {}) {
|
|
|
110
85
|
};
|
|
111
86
|
}
|
|
112
87
|
const tracked = new Set(inventory.files);
|
|
88
|
+
const verifiedLivingSpecs = readVerifiedLivingSpecPaths(projectDir);
|
|
113
89
|
const markdown = inventory.files.filter(path => /\.md$/i.test(path));
|
|
114
90
|
const candidates = [];
|
|
115
91
|
const unreadable = [];
|
|
@@ -159,7 +135,7 @@ export function scanDocumentLifecycle(projectDir, config = {}) {
|
|
|
159
135
|
}
|
|
160
136
|
if (path.startsWith('specs/') && /(?:^|\/)spec\.md$/i.test(path)) {
|
|
161
137
|
const tasks = completedTaskSignal(projectDir, path, content, tracked);
|
|
162
|
-
if (tasks) {
|
|
138
|
+
if (tasks && !verifiedLivingSpecs.has(path)) {
|
|
163
139
|
candidates.push({
|
|
164
140
|
code: 'DLC002',
|
|
165
141
|
path: dirname(path),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic active-intent projection for AI tools and lifecycle hooks.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-014
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-017
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { relative, resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
12
|
+
const posix = path => path.split(sep).join('/');
|
|
13
|
+
|
|
14
|
+
function safeDigest(projectDir, path) {
|
|
15
|
+
if (!path || path.split(/[\\/]/).includes('.local')) return null;
|
|
16
|
+
try {
|
|
17
|
+
const root = realpathSync(projectDir);
|
|
18
|
+
const absolute = resolve(projectDir, path);
|
|
19
|
+
const real = realpathSync(absolute);
|
|
20
|
+
if (!existsSync(absolute) || !lstatSync(absolute).isFile() || lstatSync(absolute).isSymbolicLink()
|
|
21
|
+
|| (real !== root && !real.startsWith(`${root}${sep}`))) return null;
|
|
22
|
+
return digest(readFileSync(absolute));
|
|
23
|
+
} catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildLifecycleContext(projectDir, registry, revision) {
|
|
27
|
+
const specs = registry.specs
|
|
28
|
+
.filter(spec => spec.reviewed.lifecycle.context === 'current'
|
|
29
|
+
&& spec.reviewed.lifecycle.approval === 'approved')
|
|
30
|
+
.map(spec => ({
|
|
31
|
+
specId: spec.specId,
|
|
32
|
+
path: posix(relative(projectDir, resolve(projectDir, spec.path))),
|
|
33
|
+
digest: spec.observed.artifacts.find(artifact => artifact.path === spec.path)?.digest || safeDigest(projectDir, spec.path),
|
|
34
|
+
delivery: spec.reviewed.lifecycle.delivery,
|
|
35
|
+
requirements: [...spec.intent.requirements].sort(),
|
|
36
|
+
canonicalDocs: spec.reviewed.scope.canonicalDocs.map(path => ({ path, digest: safeDigest(projectDir, path) })),
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => a.specId.localeCompare(b.specId));
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
generatedFrom: revision,
|
|
42
|
+
assurance: 'pointers-and-content-hashes-only',
|
|
43
|
+
factualAccuracy: null,
|
|
44
|
+
specs,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function serializeLifecycleContext(projectDir, registry, revision) {
|
|
49
|
+
return `${JSON.stringify(buildLifecycleContext(projectDir, registry, revision), null, 2)}\n`;
|
|
50
|
+
}
|