docguard-cli 0.37.0 → 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.
Files changed (29) hide show
  1. package/README.md +6 -4
  2. package/cli/commands/reconcile.mjs +42 -0
  3. package/cli/commands/retire.mjs +11 -15
  4. package/cli/commands/specs.mjs +179 -3
  5. package/cli/commands/sync.mjs +16 -44
  6. package/cli/docguard.mjs +46 -9
  7. package/cli/scanners/document-lifecycle.mjs +19 -43
  8. package/cli/scanners/lifecycle-context.mjs +50 -0
  9. package/cli/scanners/reconciliation.mjs +141 -0
  10. package/cli/scanners/requirement-evidence.mjs +41 -8
  11. package/cli/scanners/retirement-manifest.mjs +52 -0
  12. package/cli/scanners/spec-registry.mjs +36 -9
  13. package/cli/shared-sync-scope.mjs +35 -0
  14. package/cli/validators/traceability.mjs +2 -1
  15. package/cli/writers/file-transaction.mjs +85 -0
  16. package/cli/writers/spec-outcomes.mjs +27 -0
  17. package/extensions/spec-kit-docguard/README.md +6 -2
  18. package/extensions/spec-kit-docguard/commands/complete.md +38 -0
  19. package/extensions/spec-kit-docguard/extension.yml +20 -4
  20. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  21. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  22. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  23. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  25. package/extensions/spec-kit-docguard/templates/extensions.yml +16 -0
  26. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  27. package/package.json +1 -1
  28. package/schemas/docguard-specs.schema.json +19 -2
  29. package/templates/ci/github-actions.yml +1 -1
@@ -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 MANIFEST_PATH = '.docguard-archive.json';
16
+ const SUPPRESSIBLE_DELIVERY = new Set(['verified', 'released']);
15
17
 
16
- export function readRetirementManifest(projectDir) {
17
- const path = resolve(projectDir, MANIFEST_PATH);
18
- if (!existsSync(path)) return { ok: true, paths: new Set(), entries: [], retention: null, error: null };
19
- try {
20
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
- if (parsed?.schemaVersion !== 1 || parsed?.strategy !== 'git-history' || !Array.isArray(parsed.entries)) {
22
- throw new Error('unsupported schema');
23
- }
24
- const globalRecovery = parsed.retention?.recoverability === 'verified'
25
- && typeof parsed.retention?.ref === 'string'
26
- && /^(?:sha1|sha256)$/.test(parsed.retention?.objectFormat || '');
27
- for (const entry of parsed.entries) {
28
- const path = entry?.path;
29
- const entryRecovery = entry?.recoverability === 'verified'
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
+ }
@@ -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
+ }
@@ -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
+ }