docguard-cli 0.37.1 → 0.38.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 +6 -4
- package/cli/commands/reconcile.mjs +42 -0
- package/cli/commands/retire.mjs +11 -15
- package/cli/commands/specs.mjs +179 -3
- package/cli/commands/sync.mjs +16 -44
- package/cli/docguard.mjs +36 -2
- 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/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 +1 -1
- package/schemas/docguard-specs.schema.json +19 -2
- package/templates/ci/github-actions.yml +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a deterministic review graph for code/spec changes since a Git ref.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-011
|
|
5
|
+
* @implements docguard.document-lifecycle#FR-012
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { extname } from 'node:path';
|
|
10
|
+
import { getDiffText, getHeadInfo } from '../shared-git.mjs';
|
|
11
|
+
import { parseUnifiedDiff } from '../shared-diff.mjs';
|
|
12
|
+
import { mechanicalSectionsForChanges } from '../shared-sync-scope.mjs';
|
|
13
|
+
import { projectSpecRegistry } from './spec-registry.mjs';
|
|
14
|
+
|
|
15
|
+
const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.rb', '.php', '.sh', '.cs', '.swift']);
|
|
16
|
+
const TEST = /(?:^|\/)(?:__tests__|tests?|specs?)\/|\.(?:test|spec)\./;
|
|
17
|
+
const DECISION = /(?:^|\/)(?:adr|adrs|decisions?|rfcs?)(?:\/|$)/i;
|
|
18
|
+
|
|
19
|
+
function resolveRevision(projectDir, ref) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], {
|
|
22
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
23
|
+
}).trim() || null;
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fileKind(path, registry) {
|
|
28
|
+
if (registry.specs.some(spec => spec.observed.artifacts.some(artifact => artifact.path === path))) return 'spec_artifact';
|
|
29
|
+
if (path.startsWith('docs-canonical/')) return 'canonical_doc';
|
|
30
|
+
if (DECISION.test(path) && /\.md$/i.test(path)) return 'decision';
|
|
31
|
+
if (TEST.test(path) && CODE.has(extname(path).toLowerCase())) return 'test';
|
|
32
|
+
if (CODE.has(extname(path).toLowerCase())) return 'source';
|
|
33
|
+
return 'other';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function directSpecLinks(path, text, registry) {
|
|
37
|
+
const links = [];
|
|
38
|
+
for (const spec of registry.specs) {
|
|
39
|
+
const evidencePaths = [
|
|
40
|
+
...spec.observed.artifacts.map(item => item.path),
|
|
41
|
+
...spec.observed.implementationEvidence.map(item => item.file),
|
|
42
|
+
...spec.observed.testEvidence.map(item => item.file),
|
|
43
|
+
...spec.reviewed.scope.canonicalDocs,
|
|
44
|
+
];
|
|
45
|
+
const explicit = (spec.intent.requirements || []).some(identity => text.includes(identity));
|
|
46
|
+
if (evidencePaths.includes(path) || explicit) links.push(spec.specId);
|
|
47
|
+
}
|
|
48
|
+
return [...new Set(links)].sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function disposition(kind, linked, companionKinds, currentSpecs) {
|
|
52
|
+
if (kind === 'decision') return { evidenceClass: 'decision', disposition: 'superseded_decision_review', confidence: 'medium' };
|
|
53
|
+
if (kind === 'spec_artifact' || kind === 'canonical_doc') {
|
|
54
|
+
return { evidenceClass: 'approved_intent', disposition: 'intent_change_review', confidence: 'high' };
|
|
55
|
+
}
|
|
56
|
+
if (kind === 'source' || kind === 'test') {
|
|
57
|
+
if (linked.length === 0) return { evidenceClass: 'unsupported', disposition: 'unsupported_or_ambiguous', confidence: 'low' };
|
|
58
|
+
const governingChanged = companionKinds.has('spec_artifact') || companionKinds.has('canonical_doc') || companionKinds.has('decision');
|
|
59
|
+
if (governingChanged) return { evidenceClass: 'approved_intent', disposition: 'intentional_behavior_change_review', confidence: 'medium' };
|
|
60
|
+
const governs = linked.some(id => currentSpecs.has(id));
|
|
61
|
+
if (governs) return { evidenceClass: 'approved_intent', disposition: 'possible_implementation_regression', confidence: 'medium' };
|
|
62
|
+
return { evidenceClass: 'decision', disposition: 'superseded_decision_review', confidence: 'medium' };
|
|
63
|
+
}
|
|
64
|
+
return { evidenceClass: 'unrelated', disposition: 'unrelated_change', confidence: 'high' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildReconciliationPlan(projectDir, config = {}, since) {
|
|
68
|
+
if (!since) throw new Error('Reconcile requires --since <git-ref>.');
|
|
69
|
+
const baseRevision = resolveRevision(projectDir, since);
|
|
70
|
+
const head = getHeadInfo(projectDir);
|
|
71
|
+
if (!baseRevision || !head?.commit) {
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
status: 'UNSUPPORTED',
|
|
75
|
+
since,
|
|
76
|
+
baseRevision,
|
|
77
|
+
revision: head?.commit || null,
|
|
78
|
+
coverage: { status: 'unsupported', reason: 'Git ref or HEAD could not be resolved.' },
|
|
79
|
+
nodes: [], edges: [], classifications: [], writes: [], issues: [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
83
|
+
const diff = parseUnifiedDiff(getDiffText(projectDir, since))
|
|
84
|
+
.filter(file => ![file.oldPath, file.newPath].some(path => path?.split('/').includes('.local')));
|
|
85
|
+
const changed = diff.map(file => file.newPath || file.oldPath).filter(Boolean);
|
|
86
|
+
const textByPath = new Map(diff.map(file => [
|
|
87
|
+
file.newPath || file.oldPath,
|
|
88
|
+
file.hunks.flatMap(hunk => hunk.lines.filter(line => line.op !== ' ').map(line => line.text)).join('\n'),
|
|
89
|
+
]));
|
|
90
|
+
const preliminary = changed.map(path => ({
|
|
91
|
+
path,
|
|
92
|
+
kind: fileKind(path, projection.registry),
|
|
93
|
+
specs: directSpecLinks(path, textByPath.get(path) || '', projection.registry),
|
|
94
|
+
}));
|
|
95
|
+
const currentSpecs = new Set(projection.registry.specs
|
|
96
|
+
.filter(spec => spec.reviewed.lifecycle.context === 'current' && spec.reviewed.lifecycle.approval === 'approved')
|
|
97
|
+
.map(spec => spec.specId));
|
|
98
|
+
const classifications = preliminary.map(item => {
|
|
99
|
+
const companionKinds = new Set(preliminary
|
|
100
|
+
.filter(other => other.specs.some(id => item.specs.includes(id)))
|
|
101
|
+
.map(other => other.kind));
|
|
102
|
+
return { ...item, ...disposition(item.kind, item.specs, companionKinds, currentSpecs) };
|
|
103
|
+
});
|
|
104
|
+
const mechanicalSections = mechanicalSectionsForChanges(changed.filter(path => CODE.has(extname(path).toLowerCase()) || /(?:package\.json|pyproject\.toml|Cargo\.toml|go\.mod|pom\.xml|Gemfile)$/.test(path)));
|
|
105
|
+
if (mechanicalSections.length > 0) {
|
|
106
|
+
classifications.push({
|
|
107
|
+
path: null,
|
|
108
|
+
kind: 'generated_section_projection',
|
|
109
|
+
specs: [],
|
|
110
|
+
evidenceClass: 'mechanical_fact',
|
|
111
|
+
disposition: 'mechanical_fact_refresh',
|
|
112
|
+
confidence: 'high',
|
|
113
|
+
sections: mechanicalSections,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const nodes = [
|
|
117
|
+
...projection.registry.specs.map(spec => ({ id: `spec:${spec.specId}`, type: 'spec', path: spec.path, lifecycle: spec.reviewed.lifecycle })),
|
|
118
|
+
...preliminary.map(item => ({ id: `change:${item.path}`, type: 'change', path: item.path, kind: item.kind })),
|
|
119
|
+
];
|
|
120
|
+
const edges = preliminary.flatMap(item => item.specs.map(specId => ({
|
|
121
|
+
from: `change:${item.path}`,
|
|
122
|
+
to: `spec:${specId}`,
|
|
123
|
+
kind: 'direct_evidence',
|
|
124
|
+
confidence: 'high',
|
|
125
|
+
})));
|
|
126
|
+
const review = classifications.some(item => !['mechanical_fact_refresh', 'unrelated_change'].includes(item.disposition));
|
|
127
|
+
return {
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
status: projection.issues.length ? 'BLOCKED' : review ? 'REVIEW' : 'READY',
|
|
130
|
+
since,
|
|
131
|
+
baseRevision,
|
|
132
|
+
revision: head.commit,
|
|
133
|
+
dirty: head.dirty,
|
|
134
|
+
coverage: { status: 'complete', reason: null },
|
|
135
|
+
nodes,
|
|
136
|
+
edges,
|
|
137
|
+
classifications,
|
|
138
|
+
writes: mechanicalSections.length ? [{ command: `docguard sync --since ${since} --write`, scope: 'mechanical_fact' }] : [],
|
|
139
|
+
issues: projection.issues,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Pure requirement-reference evidence shared by validators and registry projections.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-016
|
|
5
|
+
*/
|
|
2
6
|
|
|
3
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
8
|
import { extname, resolve } from 'node:path';
|
|
@@ -96,31 +100,60 @@ function testDeclarations(content, filename) {
|
|
|
96
100
|
return declarations;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
103
|
+
function implementationDeclarations(content, filename) {
|
|
104
|
+
const declarations = [];
|
|
105
|
+
const ext = extname(filename);
|
|
106
|
+
const hashComments = /\.(?:py|rb|php|sh)$/.test(ext);
|
|
107
|
+
const tokens = /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|\#[^\n]*/g;
|
|
108
|
+
for (const token of content.matchAll(tokens)) {
|
|
109
|
+
if (token[0].startsWith('#') && !hashComments) continue;
|
|
110
|
+
const line = content.slice(0, token.index).split('\n').length;
|
|
111
|
+
const text = token[0].replace(/^(?:\/\/|\/\*|#)/, '');
|
|
112
|
+
if (/@(?:req|implements)\s/i.test(text)) declarations.push({ text, line });
|
|
113
|
+
}
|
|
114
|
+
return declarations;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectReferences(projectDir, projectFiles, patterns, select, declarationsForFile) {
|
|
118
|
+
const refs = new Map();
|
|
119
|
+
for (const relPath of projectFiles.filter(select)) {
|
|
102
120
|
const fullPath = resolve(projectDir, relPath);
|
|
103
121
|
if (!existsSync(fullPath)) continue;
|
|
104
122
|
let content;
|
|
105
123
|
try { content = readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
106
124
|
const hasMatch = patterns.some(pattern => { pattern.lastIndex = 0; return pattern.test(content); });
|
|
107
125
|
if (!hasMatch) continue;
|
|
108
|
-
for (const declaration of
|
|
126
|
+
for (const declaration of declarationsForFile(content, relPath)) {
|
|
109
127
|
for (const pattern of patterns) {
|
|
110
128
|
pattern.lastIndex = 0;
|
|
111
129
|
let match;
|
|
112
130
|
while ((match = pattern.exec(declaration.text)) !== null) {
|
|
113
131
|
if (!match[0]) { pattern.lastIndex++; continue; }
|
|
114
132
|
const reqId = match[0];
|
|
115
|
-
if (!
|
|
133
|
+
if (!refs.has(reqId)) refs.set(reqId, []);
|
|
116
134
|
const line = declaration.line + (declaration.text.slice(0, match.index).match(/\n/g) || []).length;
|
|
117
135
|
const prefix = declaration.text.slice(0, match.index);
|
|
118
136
|
const qualifier = prefix.match(/([^\s`"'<>()[\]{}]+)#$/);
|
|
119
137
|
const scope = qualifier ? qualifier[1].replaceAll('\\', '/').replace(/^\.\//, '') : null;
|
|
120
|
-
|
|
138
|
+
refs.get(reqId).push({ file: relPath, line, scope });
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
}
|
|
124
142
|
}
|
|
125
|
-
return
|
|
143
|
+
return refs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
147
|
+
return collectReferences(projectDir, projectFiles, patterns, isTestSource, testDeclarations);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Source annotations are explicit implementation evidence, never inferred from names. */
|
|
151
|
+
export function scanImplementationFilesForReferences(projectDir, projectFiles, patterns) {
|
|
152
|
+
return collectReferences(
|
|
153
|
+
projectDir,
|
|
154
|
+
projectFiles,
|
|
155
|
+
patterns,
|
|
156
|
+
path => !isTestSource(path) && /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|sh|cs|swift)$/.test(path),
|
|
157
|
+
implementationDeclarations,
|
|
158
|
+
);
|
|
126
159
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate recovery evidence before archived requirements can leave the active
|
|
3
|
+
* working tree or become traceability tombstones.
|
|
4
|
+
* @implements docguard.document-lifecycle#FR-007
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const MANIFEST_PATH = '.docguard-archive.json';
|
|
10
|
+
|
|
11
|
+
export function readRetirementManifest(projectDir) {
|
|
12
|
+
const path = resolve(projectDir, MANIFEST_PATH);
|
|
13
|
+
if (!existsSync(path)) return { ok: true, paths: new Set(), entries: [], retention: null, error: null };
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
+
if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
|
|
17
|
+
throw new Error('unsupported schema');
|
|
18
|
+
}
|
|
19
|
+
const globalRecovery = parsed.retention?.recoverability === 'verified'
|
|
20
|
+
&& typeof parsed.retention?.ref === 'string'
|
|
21
|
+
&& /^(?:sha1|sha256)$/.test(parsed.retention?.objectFormat || '');
|
|
22
|
+
for (const entry of parsed.entries) {
|
|
23
|
+
const path = entry?.path;
|
|
24
|
+
const entryRecovery = entry?.recoverability === 'verified'
|
|
25
|
+
&& typeof entry?.retentionRef === 'string'
|
|
26
|
+
&& /^(?:sha1|sha256)$/.test(entry?.objectFormat || '');
|
|
27
|
+
if (typeof path !== 'string' || !path || path.startsWith('/')
|
|
28
|
+
|| path.replaceAll('\\', '/').split('/').includes('..')
|
|
29
|
+
|| typeof entry?.archivedFrom !== 'string'
|
|
30
|
+
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.archivedFrom)
|
|
31
|
+
|| typeof entry?.blob !== 'string'
|
|
32
|
+
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(entry.blob)
|
|
33
|
+
|| typeof entry?.reason !== 'string' || !entry.reason.trim()
|
|
34
|
+
|| (entry?.requirementIds !== undefined && (!Array.isArray(entry.requirementIds)
|
|
35
|
+
|| entry.requirementIds.some(id => typeof id !== 'string' || !id || id.length > 128 || /[\s#\0]/.test(id))))
|
|
36
|
+
|| (entry?.specId !== undefined && (typeof entry.specId !== 'string'
|
|
37
|
+
|| !/^[a-z0-9][a-z0-9._-]{2,127}$/.test(entry.specId)))
|
|
38
|
+
|| (!globalRecovery && !entryRecovery)) {
|
|
39
|
+
throw new Error(`invalid recovery entry for ${path || '<unknown path>'}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
paths: new Set(parsed.entries.map(entry => entry.path)),
|
|
45
|
+
entries: parsed.entries,
|
|
46
|
+
retention: parsed.retention || null,
|
|
47
|
+
error: null,
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { ok: false, paths: new Set(), entries: [], retention: null, error: `${MANIFEST_PATH}: ${error.message}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Specs own requirement prose. The registry owns reviewed lifecycle metadata
|
|
5
5
|
* and regenerates only observable facts, so a refresh cannot silently rewrite
|
|
6
6
|
* intent or declare work complete.
|
|
7
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
8
|
+
* @implements docguard.document-lifecycle#FR-016
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { createHash } from 'node:crypto';
|
|
@@ -11,13 +13,13 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
11
13
|
import { spawnSync } from 'node:child_process';
|
|
12
14
|
import { dirname, relative, resolve, sep } from 'node:path';
|
|
13
15
|
import { detectSpecKit } from './speckit.mjs';
|
|
14
|
-
import { readRetirementManifest } from './
|
|
16
|
+
import { readRetirementManifest } from './retirement-manifest.mjs';
|
|
15
17
|
import { collectRequirementIdsFromContent, requirementPatterns } from '../shared-requirements.mjs';
|
|
16
18
|
import { walkFiles } from '../shared-ignore.mjs';
|
|
17
|
-
import { scanTestFilesForReferences } from './requirement-evidence.mjs';
|
|
19
|
+
import { scanImplementationFilesForReferences, scanTestFilesForReferences } from './requirement-evidence.mjs';
|
|
18
20
|
|
|
19
21
|
export const SPEC_REGISTRY_PATH = '.docguard-specs.json';
|
|
20
|
-
export const SPEC_REGISTRY_SCHEMA_VERSION =
|
|
22
|
+
export const SPEC_REGISTRY_SCHEMA_VERSION = 2;
|
|
21
23
|
export const SPEC_REGISTRY_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-specs.schema.json';
|
|
22
24
|
|
|
23
25
|
const SPEC_ID_RE = /^[a-z0-9][a-z0-9._-]{2,127}$/;
|
|
@@ -77,7 +79,7 @@ function defaultControl() {
|
|
|
77
79
|
supersededBy: [],
|
|
78
80
|
},
|
|
79
81
|
scope: { canonicalDocs: [] },
|
|
80
|
-
reconciliation: { lastReviewedRevision: null },
|
|
82
|
+
reconciliation: { lastReviewedRevision: null, outcomes: [] },
|
|
81
83
|
},
|
|
82
84
|
};
|
|
83
85
|
}
|
|
@@ -140,11 +142,26 @@ function validatedControl(entry, issues) {
|
|
|
140
142
|
const reconciliation = reviewed.reconciliation || {};
|
|
141
143
|
rejectUnknownKeys(relations, new Set(['extends', 'duplicates', 'conflictsWith', 'supersedes', 'supersededBy']), `${entry.specId}.reviewed.relations`, issues);
|
|
142
144
|
rejectUnknownKeys(scope, new Set(['canonicalDocs']), `${entry.specId}.reviewed.scope`, issues);
|
|
143
|
-
rejectUnknownKeys(reconciliation, new Set(['lastReviewedRevision']), `${entry.specId}.reviewed.reconciliation`, issues);
|
|
145
|
+
rejectUnknownKeys(reconciliation, new Set(['lastReviewedRevision', 'outcomes']), `${entry.specId}.reviewed.reconciliation`, issues);
|
|
144
146
|
const revision = reconciliation.lastReviewedRevision ?? null;
|
|
145
147
|
if (revision !== null && (typeof revision !== 'string' || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(revision))) {
|
|
146
148
|
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `Invalid reconciliation revision for ${entry.specId}.` });
|
|
147
149
|
}
|
|
150
|
+
const outcomes = reconciliation.outcomes ?? [];
|
|
151
|
+
if (!Array.isArray(outcomes) || outcomes.length > 20 || outcomes.some(outcome => {
|
|
152
|
+
if (!outcome || typeof outcome !== 'object' || Array.isArray(outcome)) return true;
|
|
153
|
+
const allowed = new Set(['revision', 'reason', 'evidence', 'deviations', 'successor']);
|
|
154
|
+
return Object.keys(outcome).some(key => !allowed.has(key))
|
|
155
|
+
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(outcome.revision || '')
|
|
156
|
+
|| typeof outcome.reason !== 'string' || !outcome.reason.trim() || outcome.reason.length > 500
|
|
157
|
+
|| !Array.isArray(outcome.evidence) || outcome.evidence.length > 100
|
|
158
|
+
|| outcome.evidence.some(path => typeof path !== 'string' || !path.trim())
|
|
159
|
+
|| !Array.isArray(outcome.deviations) || outcome.deviations.length > 20
|
|
160
|
+
|| outcome.deviations.some(item => typeof item !== 'string' || !item.trim() || item.length > 500)
|
|
161
|
+
|| (outcome.successor !== null && outcome.successor !== undefined && !SPEC_ID_RE.test(outcome.successor));
|
|
162
|
+
})) {
|
|
163
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `Invalid or unbounded reconciliation outcomes for ${entry.specId}.` });
|
|
164
|
+
}
|
|
148
165
|
return {
|
|
149
166
|
reviewed: {
|
|
150
167
|
lifecycle: {
|
|
@@ -165,7 +182,16 @@ function validatedControl(entry, issues) {
|
|
|
165
182
|
scope: {
|
|
166
183
|
canonicalDocs: validateCanonicalPaths(scope.canonicalDocs ?? [], `${entry.specId}.scope.canonicalDocs`, issues),
|
|
167
184
|
},
|
|
168
|
-
reconciliation: {
|
|
185
|
+
reconciliation: {
|
|
186
|
+
lastReviewedRevision: revision,
|
|
187
|
+
outcomes: Array.isArray(outcomes) ? outcomes.slice(-20).map(outcome => ({
|
|
188
|
+
revision: outcome.revision,
|
|
189
|
+
reason: outcome.reason,
|
|
190
|
+
evidence: sortedUnique(outcome.evidence || []),
|
|
191
|
+
deviations: sortedUnique(outcome.deviations || []),
|
|
192
|
+
successor: outcome.successor ?? null,
|
|
193
|
+
})) : [],
|
|
194
|
+
},
|
|
169
195
|
},
|
|
170
196
|
};
|
|
171
197
|
}
|
|
@@ -174,9 +200,9 @@ export function readSpecRegistry(projectDir) {
|
|
|
174
200
|
const loaded = readJson(resolve(projectDir, SPEC_REGISTRY_PATH), SPEC_REGISTRY_PATH);
|
|
175
201
|
if (!loaded.exists || loaded.error) return loaded;
|
|
176
202
|
const value = loaded.value;
|
|
177
|
-
if (value?.$schema !== SPEC_REGISTRY_SCHEMA_URL || value?.schemaVersion
|
|
203
|
+
if (value?.$schema !== SPEC_REGISTRY_SCHEMA_URL || ![1, SPEC_REGISTRY_SCHEMA_VERSION].includes(value?.schemaVersion)
|
|
178
204
|
|| !Array.isArray(value?.specs) || !Array.isArray(value?.tombstones)) {
|
|
179
|
-
return { exists: true, value: null, error: `${SPEC_REGISTRY_PATH} does not use supported schema version
|
|
205
|
+
return { exists: true, value: null, error: `${SPEC_REGISTRY_PATH} does not use a supported schema version.` };
|
|
180
206
|
}
|
|
181
207
|
const topKeys = new Set(['$schema', 'schemaVersion', 'specs', 'tombstones']);
|
|
182
208
|
const unknownTop = Object.keys(value).filter(key => !topKeys.has(key));
|
|
@@ -328,6 +354,7 @@ export function projectSpecRegistry(projectDir, config = {}, options = {}) {
|
|
|
328
354
|
const files = projectFiles(projectDir);
|
|
329
355
|
const patterns = requirementPatterns(config);
|
|
330
356
|
const testReferences = scanTestFilesForReferences(projectDir, files, patterns);
|
|
357
|
+
const implementationReferences = scanImplementationFilesForReferences(projectDir, files, patterns);
|
|
331
358
|
const ids = new Map();
|
|
332
359
|
const specs = [];
|
|
333
360
|
|
|
@@ -377,7 +404,7 @@ export function projectSpecRegistry(projectDir, config = {}, options = {}) {
|
|
|
377
404
|
observed: {
|
|
378
405
|
artifacts,
|
|
379
406
|
taskCompletion: taskCompletion(feature.tasksPath),
|
|
380
|
-
implementationEvidence:
|
|
407
|
+
implementationEvidence: evidenceForSpec(specId, path, requirements, implementationReferences),
|
|
381
408
|
testEvidence: evidenceForSpec(specId, path, requirements, testReferences),
|
|
382
409
|
},
|
|
383
410
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared changed-file to generated-section applicability rules.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-010
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SECTION_FILE_MATCHERS = {
|
|
7
|
+
'tech-stack': (p) => /package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|pom\.xml$|Gemfile$/.test(p),
|
|
8
|
+
'frontend-modules': (p) => /(^|\/)(src\/)?(stores|hooks|contexts|features)\//.test(p),
|
|
9
|
+
'endpoints-table': (p) => /(^|\/)(routes|controllers|handlers|app\/api)\//.test(p)
|
|
10
|
+
|| /\.(yaml|yml|json)$/i.test(p) && /openapi|swagger/i.test(p),
|
|
11
|
+
'entities-table': (p) => /(^|\/)(models|schemas|entities)\//.test(p) || /\.prisma$/.test(p),
|
|
12
|
+
'relationships': (p) => /(^|\/)(models|schemas|entities)\//.test(p) || /\.prisma$/.test(p),
|
|
13
|
+
'screens-table': (p) => /(^|\/)(screens|pages|app)\//.test(p) || /\.(tsx|jsx)$/.test(p),
|
|
14
|
+
'flows': (p) => /(^|\/)(screens|pages|app|routes)\//.test(p),
|
|
15
|
+
'integrations-table':(p) => /package\.json$|pyproject\.toml$|requirements.*\.txt$|Cargo\.toml$/.test(p),
|
|
16
|
+
'features-table': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
17
|
+
'features': (p) => /(^|\/)(features|domains)\//.test(p),
|
|
18
|
+
'env-vars-table': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p)
|
|
19
|
+
|| /\.(ts|tsx|js|jsx|mjs|py|go|rs|java|kt|rb)$/.test(p),
|
|
20
|
+
'setup': (p) => /\.env(\..+)?$|(^|\/)config\//.test(p),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
24
|
+
if (!changedFiles || changedFiles.length === 0) return true;
|
|
25
|
+
const matcher = SECTION_FILE_MATCHERS[sectionId];
|
|
26
|
+
if (!matcher) return true;
|
|
27
|
+
return changedFiles.some(matcher);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mechanicalSectionsForChanges(changedFiles) {
|
|
31
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return [];
|
|
32
|
+
return Object.keys(SECTION_FILE_MATCHERS)
|
|
33
|
+
.filter(section => sectionTouchedByChanges(section, changedFiles))
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Inspired by ISO/IEC/IEEE 29119, IEEE 1016, and V-Model methodology.
|
|
13
13
|
* V-Model concepts informed by spec-kit-v-model (github.com/leocamello/spec-kit-v-model).
|
|
14
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
collectRequirementIdsFromContent,
|
|
31
32
|
requirementPatterns,
|
|
32
33
|
} from '../shared-requirements.mjs';
|
|
33
|
-
import { readRetirementManifest } from '../scanners/
|
|
34
|
+
import { readRetirementManifest } from '../scanners/retirement-manifest.mjs';
|
|
34
35
|
import { parseSpecId } from '../scanners/spec-registry.mjs';
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, dependency-free multi-file transaction for lifecycle state.
|
|
3
|
+
*
|
|
4
|
+
* Every replacement is staged beside its destination before the first visible
|
|
5
|
+
* mutation. Originals are retained in memory and restored if any replacement,
|
|
6
|
+
* deletion, or post-commit validation fails. When the operation returns or
|
|
7
|
+
* throws, callers see the all-old or all-new set. This is in-process rollback,
|
|
8
|
+
* not a durable transaction journal across power loss or forced termination.
|
|
9
|
+
* @implements docguard.document-lifecycle#FR-013
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
copyFileSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
|
|
23
|
+
function cleanup(paths) {
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
try { rmSync(path, { force: true }); } catch { /* best-effort cleanup */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Commit a bounded set of file replacements/deletions.
|
|
31
|
+
*
|
|
32
|
+
* `content: null` deletes a file. `validate` runs after all visible mutations;
|
|
33
|
+
* throwing from it rolls the complete set back. The optional `afterMutation`
|
|
34
|
+
* test seam is intentionally undocumented outside the module tests.
|
|
35
|
+
*/
|
|
36
|
+
export function commitFileTransaction(entries, { validate = null, afterMutation = null } = {}) {
|
|
37
|
+
if (!Array.isArray(entries) || entries.length === 0) throw new Error('File transaction requires at least one entry.');
|
|
38
|
+
const targets = new Set();
|
|
39
|
+
const tx = randomUUID();
|
|
40
|
+
const prepared = [];
|
|
41
|
+
try {
|
|
42
|
+
for (const [index, entry] of entries.entries()) {
|
|
43
|
+
if (!entry?.path || typeof entry.path !== 'string') throw new Error('Every file transaction entry needs a path.');
|
|
44
|
+
if (targets.has(entry.path)) throw new Error(`File transaction repeats target: ${entry.path}`);
|
|
45
|
+
targets.add(entry.path);
|
|
46
|
+
const existed = existsSync(entry.path);
|
|
47
|
+
const original = existed ? readFileSync(entry.path) : null;
|
|
48
|
+
const staged = entry.content === null ? null : `${entry.path}.docguard-${tx}-${index}.tmp`;
|
|
49
|
+
prepared.push({ ...entry, existed, original, staged });
|
|
50
|
+
if (staged) {
|
|
51
|
+
mkdirSync(dirname(entry.path), { recursive: true });
|
|
52
|
+
writeFileSync(staged, entry.content, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
cleanup(prepared.map(entry => entry.staged).filter(Boolean));
|
|
57
|
+
throw new Error(`File transaction preparation failed: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const applied = [];
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of prepared) {
|
|
63
|
+
if (entry.content === null) {
|
|
64
|
+
if (entry.existed) rmSync(entry.path);
|
|
65
|
+
} else {
|
|
66
|
+
// copyFileSync overwrites on every supported Node platform. renameSync
|
|
67
|
+
// cannot replace an existing destination consistently on Windows.
|
|
68
|
+
copyFileSync(entry.staged, entry.path);
|
|
69
|
+
}
|
|
70
|
+
applied.push(entry);
|
|
71
|
+
if (afterMutation) afterMutation(entry, applied.length);
|
|
72
|
+
}
|
|
73
|
+
if (validate) validate();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
for (const entry of [...applied].reverse()) {
|
|
76
|
+
try {
|
|
77
|
+
if (entry.existed) writeFileSync(entry.path, entry.original);
|
|
78
|
+
else rmSync(entry.path, { force: true });
|
|
79
|
+
} catch { /* report the initiating error; callers validate on next run */ }
|
|
80
|
+
}
|
|
81
|
+
cleanup(prepared.map(entry => entry.staged).filter(Boolean));
|
|
82
|
+
throw new Error(`File transaction rolled back: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
cleanup(prepared.map(entry => entry.staged).filter(Boolean));
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append a bounded, machine-owned outcome index without rewriting spec intent.
|
|
3
|
+
* @implements docguard.document-lifecycle#FR-016
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const START = '<!-- docguard:implementation-outcomes:start -->';
|
|
7
|
+
const END = '<!-- docguard:implementation-outcomes:end -->';
|
|
8
|
+
|
|
9
|
+
function clean(value, max = 500) {
|
|
10
|
+
return String(value || '').replace(/\s+/g, ' ').replace(/[<>`]/g, '').trim().slice(0, max);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function appendImplementationOutcome(content, outcome) {
|
|
14
|
+
const reason = clean(outcome.reason);
|
|
15
|
+
if (!reason) throw new Error('Implementation outcome requires a non-empty reviewed reason.');
|
|
16
|
+
const evidence = [...new Set(outcome.evidence || [])].sort().map(path => `\`${path}\``).join(', ') || 'none';
|
|
17
|
+
const deviations = [...new Set(outcome.deviations || [])].sort().map(item => clean(item)).filter(Boolean).join('; ') || 'none';
|
|
18
|
+
const successor = outcome.successor ? `\`${outcome.successor}\`` : 'none';
|
|
19
|
+
const line = `- \`${outcome.revision}\` — ${reason} Evidence: ${evidence}. Accepted deviations: ${deviations}. Successor: ${successor}.`;
|
|
20
|
+
const block = `${START}\n## Implementation Outcomes\n\n${line}\n${END}`;
|
|
21
|
+
const pattern = new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
|
22
|
+
if (!pattern.test(content)) return `${content.trimEnd()}\n\n${block}\n`;
|
|
23
|
+
const prior = content.match(pattern)?.[0]
|
|
24
|
+
.split('\n').filter(item => item.startsWith('- `')) || [];
|
|
25
|
+
const lines = [...prior.filter(item => !item.startsWith(`- \`${outcome.revision}\``)), line].slice(-20);
|
|
26
|
+
return content.replace(pattern, `${START}\n## Implementation Outcomes\n\n${lines.join('\n')}\n${END}`);
|
|
27
|
+
}
|
|
@@ -53,6 +53,7 @@ docguard score
|
|
|
53
53
|
| `speckit.docguard.generate` | — | Reverse-engineer canonical docs from codebase |
|
|
54
54
|
| `speckit.docguard.brief` | — | Load current spec intent before specification |
|
|
55
55
|
| `speckit.docguard.preflight` | — | Gate the generated spec before task generation |
|
|
56
|
+
| `speckit.docguard.complete` | — | Plan reviewed completion and regenerate active context after verification |
|
|
56
57
|
|
|
57
58
|
## AI Skills
|
|
58
59
|
|
|
@@ -78,8 +79,11 @@ DocGuard integrates into the spec-kit workflow through hooks:
|
|
|
78
79
|
hooks:
|
|
79
80
|
before_specify: # Mandatory — read current spec intent first
|
|
80
81
|
command: speckit.docguard.brief
|
|
81
|
-
after_implement: #
|
|
82
|
-
command: speckit.docguard.guard
|
|
82
|
+
after_implement: # Guard is mandatory; completion review is optional
|
|
83
|
+
- command: speckit.docguard.guard
|
|
84
|
+
- command: speckit.docguard.complete
|
|
85
|
+
after_converge: # Optional completion review after convergence
|
|
86
|
+
command: speckit.docguard.complete
|
|
83
87
|
before_tasks: # Mandatory — gate the generated spec
|
|
84
88
|
command: speckit.docguard.preflight
|
|
85
89
|
after_tasks: # Optional — show score after tasks
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Review implementation evidence and plan the spec completion transaction"
|
|
3
|
+
allowed-tools: Bash, Read
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DocGuard Spec Completion Review
|
|
7
|
+
|
|
8
|
+
Run after implementation or convergence. This hook plans the lifecycle gate; it
|
|
9
|
+
does not mark the feature verified without a reviewed rationale.
|
|
10
|
+
|
|
11
|
+
1. Resolve the current feature `spec.md` and read its `Spec ID` metadata.
|
|
12
|
+
2. Run `docguard guard --format json`. Stop on errors.
|
|
13
|
+
3. Refresh deterministic evidence with `docguard specs --write`, then review and
|
|
14
|
+
commit that projection with the implementation.
|
|
15
|
+
4. Select the Git baseline that covers the implementation changes. Prefer the
|
|
16
|
+
registry's prior reconciliation revision; otherwise use the feature branch's
|
|
17
|
+
merge base with its configured default branch.
|
|
18
|
+
5. Run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
docguard specs complete --id <spec-id> --since <ref> --check --format json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
6. Review every reconciliation classification. Unsupported or ambiguous changes
|
|
25
|
+
block completion. A possible implementation regression requires deciding
|
|
26
|
+
whether to fix code, amend approved intent, or record an accepted deviation.
|
|
27
|
+
7. When the evidence is complete, present the exact reviewed write command to
|
|
28
|
+
the maintainer. Apply it only when the maintainer supplies the rationale:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
docguard specs complete --id <spec-id> --since <ref> --write --reason "<reviewed outcome>"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The write records the outcome, advances delivery through `implemented` to
|
|
35
|
+
`verified`, and regenerates `.docguard/current-context.json` as one staged,
|
|
36
|
+
post-validated set. In-process write or validation failures roll the set back;
|
|
37
|
+
the command does not claim a durable journal across power loss or forced
|
|
38
|
+
termination.
|