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.
Files changed (35) hide show
  1. package/README.md +7 -5
  2. package/cli/commands/feedback.mjs +147 -6
  3. package/cli/commands/reconcile.mjs +42 -0
  4. package/cli/commands/retire.mjs +11 -15
  5. package/cli/commands/specs.mjs +198 -3
  6. package/cli/commands/sync.mjs +16 -44
  7. package/cli/docguard.mjs +51 -6
  8. package/cli/feedback-fixture.mjs +188 -0
  9. package/cli/scanners/document-lifecycle.mjs +19 -43
  10. package/cli/scanners/lifecycle-context.mjs +50 -0
  11. package/cli/scanners/reconciliation.mjs +141 -0
  12. package/cli/scanners/requirement-evidence.mjs +41 -8
  13. package/cli/scanners/retirement-manifest.mjs +52 -0
  14. package/cli/scanners/spec-registry.mjs +36 -9
  15. package/cli/shared-sync-scope.mjs +35 -0
  16. package/cli/validators/security.mjs +5 -4
  17. package/cli/validators/traceability.mjs +2 -1
  18. package/cli/writers/file-transaction.mjs +85 -0
  19. package/cli/writers/spec-outcomes.mjs +27 -0
  20. package/extensions/spec-kit-docguard/README.md +6 -2
  21. package/extensions/spec-kit-docguard/commands/complete.md +38 -0
  22. package/extensions/spec-kit-docguard/extension.yml +20 -4
  23. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  25. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
  29. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  30. package/package.json +2 -1
  31. package/schemas/docguard-benchmark.schema.json +84 -0
  32. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  33. package/schemas/docguard-specs.schema.json +19 -2
  34. package/templates/ci/github-actions.yml +1 -1
  35. package/templates/feedback-fixture.json +18 -0
@@ -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
- /** Pure requirement-reference evidence shared by validators and registry projections. */
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
- export function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
100
- const testRefs = new Map();
101
- for (const relPath of projectFiles.filter(isTestSource)) {
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 testDeclarations(content, relPath)) {
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 (!testRefs.has(reqId)) testRefs.set(reqId, []);
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
- testRefs.get(reqId).push({ file: relPath, line, scope });
138
+ refs.get(reqId).push({ file: relPath, line, scope });
121
139
  }
122
140
  }
123
141
  }
124
142
  }
125
- return testRefs;
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 './document-lifecycle.mjs';
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 = 1;
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: { lastReviewedRevision: revision },
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 !== SPEC_REGISTRY_SCHEMA_VERSION
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 1.` };
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
+ }
@@ -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
  ];
@@ -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/document-lifecycle.mjs';
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: # Mandatory quality gate after /speckit.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.