docguard-cli 0.36.2 → 0.37.1
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 +23 -18
- package/cli/commands/explain.mjs +28 -0
- package/cli/commands/guard.mjs +4 -0
- package/cli/commands/llms.mjs +3 -2
- package/cli/commands/retire.mjs +352 -0
- package/cli/commands/specs.mjs +77 -0
- package/cli/commands/trace.mjs +11 -1
- package/cli/config.mjs +2 -0
- package/cli/docguard.mjs +78 -18
- package/cli/findings.mjs +54 -0
- package/cli/scanners/document-lifecycle.mjs +184 -0
- package/cli/scanners/requirement-evidence.mjs +126 -0
- package/cli/scanners/spec-registry.mjs +517 -0
- package/cli/shared-requirements.mjs +91 -0
- package/cli/validators/document-lifecycle.mjs +51 -0
- package/cli/validators/spec-registry.mjs +47 -0
- package/cli/validators/traceability.mjs +52 -216
- package/docs/ai-integration.md +18 -5
- package/docs/commands.md +45 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +15 -5
- package/extensions/spec-kit-docguard/commands/brief.md +34 -0
- package/extensions/spec-kit-docguard/commands/preflight.md +52 -0
- package/extensions/spec-kit-docguard/extension.yml +18 -5
- 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 +13 -6
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +2 -0
- package/schemas/docguard-specs.schema.json +162 -0
- package/templates/ci/github-actions.yml +1 -1
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Spec Kit lifecycle registry projection.
|
|
3
|
+
*
|
|
4
|
+
* Specs own requirement prose. The registry owns reviewed lifecycle metadata
|
|
5
|
+
* and regenerates only observable facts, so a refresh cannot silently rewrite
|
|
6
|
+
* intent or declare work complete.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import { detectSpecKit } from './speckit.mjs';
|
|
14
|
+
import { readRetirementManifest } from './document-lifecycle.mjs';
|
|
15
|
+
import { collectRequirementIdsFromContent, requirementPatterns } from '../shared-requirements.mjs';
|
|
16
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
17
|
+
import { scanTestFilesForReferences } from './requirement-evidence.mjs';
|
|
18
|
+
|
|
19
|
+
export const SPEC_REGISTRY_PATH = '.docguard-specs.json';
|
|
20
|
+
export const SPEC_REGISTRY_SCHEMA_VERSION = 1;
|
|
21
|
+
export const SPEC_REGISTRY_SCHEMA_URL = 'https://raccioly.github.io/docguard/schemas/docguard-specs.schema.json';
|
|
22
|
+
|
|
23
|
+
const SPEC_ID_RE = /^[a-z0-9][a-z0-9._-]{2,127}$/;
|
|
24
|
+
const APPROVAL = new Set(['draft', 'approved', 'rejected']);
|
|
25
|
+
const DELIVERY = new Set(['planned', 'in_progress', 'implemented', 'verified', 'released']);
|
|
26
|
+
const CONTEXT = new Set(['current', 'retired']);
|
|
27
|
+
const RETIREMENT = new Set([null, 'completed', 'superseded', 'abandoned']);
|
|
28
|
+
const STORAGE = new Set(['working_tree', 'git_history']);
|
|
29
|
+
const PERSISTENCE = new Set([null, 'flow_back', 'flow_forward', 'living']);
|
|
30
|
+
const STOP_TERMS = new Set(['must', 'should', 'with', 'from', 'that', 'this', 'have', 'into']);
|
|
31
|
+
|
|
32
|
+
const posix = path => path.split(sep).join('/').replace(/^\.\//, '');
|
|
33
|
+
const digest = content => `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
34
|
+
const sortedUnique = values => [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
35
|
+
|
|
36
|
+
function isSafeFile(projectDir, path) {
|
|
37
|
+
try {
|
|
38
|
+
const root = realpathSync(projectDir);
|
|
39
|
+
const real = realpathSync(path);
|
|
40
|
+
return lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink()
|
|
41
|
+
&& (real === root || real.startsWith(`${root}${sep}`));
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseSpecId(content) {
|
|
46
|
+
const header = content.split('\n').slice(0, 80).join('\n');
|
|
47
|
+
const comment = header.match(/<!--\s*docguard:spec-id\s+([^\s>]+)\s*-->/i);
|
|
48
|
+
const field = header.match(/(?:^|\n)\s*(?:#{1,6}\s*)?(?:\*\*)?Spec ID(?:\*\*)?\s*:\s*(?:`([^`]+)`|([^\s\n]+))/i);
|
|
49
|
+
return (comment?.[1] || field?.[1] || field?.[2] || '').trim() || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJson(path, label) {
|
|
53
|
+
if (!existsSync(path)) return { exists: false, value: null, error: null };
|
|
54
|
+
try {
|
|
55
|
+
return { exists: true, value: JSON.parse(readFileSync(path, 'utf8')), error: null };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { exists: true, value: null, error: `${label} is not valid JSON: ${error.message}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultControl() {
|
|
62
|
+
return {
|
|
63
|
+
reviewed: {
|
|
64
|
+
lifecycle: {
|
|
65
|
+
approval: 'draft',
|
|
66
|
+
delivery: 'planned',
|
|
67
|
+
context: 'current',
|
|
68
|
+
retirementReason: null,
|
|
69
|
+
storage: 'working_tree',
|
|
70
|
+
persistenceModel: null,
|
|
71
|
+
},
|
|
72
|
+
relations: {
|
|
73
|
+
extends: [],
|
|
74
|
+
duplicates: [],
|
|
75
|
+
conflictsWith: [],
|
|
76
|
+
supersedes: [],
|
|
77
|
+
supersededBy: [],
|
|
78
|
+
},
|
|
79
|
+
scope: { canonicalDocs: [] },
|
|
80
|
+
reconciliation: { lastReviewedRevision: null },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateStringArray(value, label, issues) {
|
|
86
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || !item.trim())) {
|
|
87
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `${label} must be an array of non-empty strings.` });
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return sortedUnique(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateSpecIdArray(value, label, owner, issues) {
|
|
94
|
+
const values = validateStringArray(value, label, issues);
|
|
95
|
+
if (values.some(item => !SPEC_ID_RE.test(item))) {
|
|
96
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `${label} contains an invalid spec ID.` });
|
|
97
|
+
}
|
|
98
|
+
if (values.includes(owner)) {
|
|
99
|
+
issues.push({ code: 'SPR004', path: SPEC_REGISTRY_PATH, message: `${owner} cannot relate to itself through ${label}.` });
|
|
100
|
+
}
|
|
101
|
+
return values;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateCanonicalPaths(value, label, issues) {
|
|
105
|
+
const values = validateStringArray(value, label, issues);
|
|
106
|
+
if (values.some(path => path.startsWith('/') || path.split(/[\\/]/).some(part => part === '..' || part === '.local'))) {
|
|
107
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `${label} must contain safe repository-relative paths outside .local.` });
|
|
108
|
+
}
|
|
109
|
+
return values;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rejectUnknownKeys(value, allowed, label, issues) {
|
|
113
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
114
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `${label} must be an object.` });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const unknown = Object.keys(value).filter(key => !allowed.has(key));
|
|
118
|
+
if (unknown.length) issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `${label} has unknown field(s): ${unknown.join(', ')}.` });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validatedControl(entry, issues) {
|
|
122
|
+
const fallback = defaultControl();
|
|
123
|
+
if (!entry) return fallback;
|
|
124
|
+
const reviewed = entry.reviewed;
|
|
125
|
+
rejectUnknownKeys(reviewed, new Set(['lifecycle', 'relations', 'scope', 'reconciliation']), `${entry.specId}.reviewed`, issues);
|
|
126
|
+
if (!reviewed || typeof reviewed !== 'object' || Array.isArray(reviewed)) return fallback;
|
|
127
|
+
const lifecycle = reviewed.lifecycle || {};
|
|
128
|
+
rejectUnknownKeys(lifecycle, new Set(['approval', 'delivery', 'context', 'retirementReason', 'storage', 'persistenceModel']), `${entry.specId}.reviewed.lifecycle`, issues);
|
|
129
|
+
const checks = [
|
|
130
|
+
['approval', APPROVAL], ['delivery', DELIVERY], ['context', CONTEXT],
|
|
131
|
+
['retirementReason', RETIREMENT], ['storage', STORAGE], ['persistenceModel', PERSISTENCE],
|
|
132
|
+
];
|
|
133
|
+
for (const [key, allowed] of checks) {
|
|
134
|
+
if (!allowed.has(lifecycle[key])) {
|
|
135
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `Invalid lifecycle.${key} for ${entry.specId || '<unknown spec>'}.` });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const relations = reviewed.relations || {};
|
|
139
|
+
const scope = reviewed.scope || {};
|
|
140
|
+
const reconciliation = reviewed.reconciliation || {};
|
|
141
|
+
rejectUnknownKeys(relations, new Set(['extends', 'duplicates', 'conflictsWith', 'supersedes', 'supersededBy']), `${entry.specId}.reviewed.relations`, issues);
|
|
142
|
+
rejectUnknownKeys(scope, new Set(['canonicalDocs']), `${entry.specId}.reviewed.scope`, issues);
|
|
143
|
+
rejectUnknownKeys(reconciliation, new Set(['lastReviewedRevision']), `${entry.specId}.reviewed.reconciliation`, issues);
|
|
144
|
+
const revision = reconciliation.lastReviewedRevision ?? null;
|
|
145
|
+
if (revision !== null && (typeof revision !== 'string' || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(revision))) {
|
|
146
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: `Invalid reconciliation revision for ${entry.specId}.` });
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
reviewed: {
|
|
150
|
+
lifecycle: {
|
|
151
|
+
approval: APPROVAL.has(lifecycle.approval) ? lifecycle.approval : fallback.reviewed.lifecycle.approval,
|
|
152
|
+
delivery: DELIVERY.has(lifecycle.delivery) ? lifecycle.delivery : fallback.reviewed.lifecycle.delivery,
|
|
153
|
+
context: CONTEXT.has(lifecycle.context) ? lifecycle.context : fallback.reviewed.lifecycle.context,
|
|
154
|
+
retirementReason: RETIREMENT.has(lifecycle.retirementReason) ? lifecycle.retirementReason : null,
|
|
155
|
+
storage: STORAGE.has(lifecycle.storage) ? lifecycle.storage : fallback.reviewed.lifecycle.storage,
|
|
156
|
+
persistenceModel: PERSISTENCE.has(lifecycle.persistenceModel) ? lifecycle.persistenceModel : null,
|
|
157
|
+
},
|
|
158
|
+
relations: {
|
|
159
|
+
extends: validateSpecIdArray(relations.extends ?? [], `${entry.specId}.relations.extends`, entry.specId, issues),
|
|
160
|
+
duplicates: validateSpecIdArray(relations.duplicates ?? [], `${entry.specId}.relations.duplicates`, entry.specId, issues),
|
|
161
|
+
conflictsWith: validateSpecIdArray(relations.conflictsWith ?? [], `${entry.specId}.relations.conflictsWith`, entry.specId, issues),
|
|
162
|
+
supersedes: validateSpecIdArray(relations.supersedes ?? [], `${entry.specId}.relations.supersedes`, entry.specId, issues),
|
|
163
|
+
supersededBy: validateSpecIdArray(relations.supersededBy ?? [], `${entry.specId}.relations.supersededBy`, entry.specId, issues),
|
|
164
|
+
},
|
|
165
|
+
scope: {
|
|
166
|
+
canonicalDocs: validateCanonicalPaths(scope.canonicalDocs ?? [], `${entry.specId}.scope.canonicalDocs`, issues),
|
|
167
|
+
},
|
|
168
|
+
reconciliation: { lastReviewedRevision: revision },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function readSpecRegistry(projectDir) {
|
|
174
|
+
const loaded = readJson(resolve(projectDir, SPEC_REGISTRY_PATH), SPEC_REGISTRY_PATH);
|
|
175
|
+
if (!loaded.exists || loaded.error) return loaded;
|
|
176
|
+
const value = loaded.value;
|
|
177
|
+
if (value?.$schema !== SPEC_REGISTRY_SCHEMA_URL || value?.schemaVersion !== SPEC_REGISTRY_SCHEMA_VERSION
|
|
178
|
+
|| !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.` };
|
|
180
|
+
}
|
|
181
|
+
const topKeys = new Set(['$schema', 'schemaVersion', 'specs', 'tombstones']);
|
|
182
|
+
const unknownTop = Object.keys(value).filter(key => !topKeys.has(key));
|
|
183
|
+
if (unknownTop.length) return { exists: true, value: null, error: `${SPEC_REGISTRY_PATH} has unknown field(s): ${unknownTop.join(', ')}.` };
|
|
184
|
+
const shapeIssues = registryShapeIssues(value);
|
|
185
|
+
if (shapeIssues.length > 0) {
|
|
186
|
+
return { exists: true, value: null, error: shapeIssues.map(issue => issue.message).join(' ') };
|
|
187
|
+
}
|
|
188
|
+
return loaded;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function taskCompletion(path) {
|
|
192
|
+
if (!path || !existsSync(path)) return { checked: 0, total: 0 };
|
|
193
|
+
const content = readFileSync(path, 'utf8');
|
|
194
|
+
const checked = content.match(/(?:^|\n)\s*- \[[xX]\]/g)?.length || 0;
|
|
195
|
+
const open = content.match(/(?:^|\n)\s*- \[ \]/g)?.length || 0;
|
|
196
|
+
return { checked, total: checked + open };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function projectFiles(projectDir) {
|
|
200
|
+
const tracked = spawnSync('git', ['ls-files', '-z'], {
|
|
201
|
+
cwd: projectDir,
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
204
|
+
});
|
|
205
|
+
if (tracked.status === 0) return tracked.stdout.split('\0').filter(Boolean).map(posix).sort();
|
|
206
|
+
const files = [];
|
|
207
|
+
const root = realpathSync(projectDir);
|
|
208
|
+
walkFiles(projectDir, path => {
|
|
209
|
+
try {
|
|
210
|
+
if (lstatSync(path).isSymbolicLink()) return;
|
|
211
|
+
const real = realpathSync(path);
|
|
212
|
+
if (real !== root && !real.startsWith(`${root}${sep}`)) return;
|
|
213
|
+
files.push(posix(relative(projectDir, path)));
|
|
214
|
+
} catch { /* unreadable candidates cannot become evidence */ }
|
|
215
|
+
}, {
|
|
216
|
+
keepDot: entry => entry === '.github',
|
|
217
|
+
});
|
|
218
|
+
return files.sort();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function evidenceForSpec(specId, path, requirements, refs) {
|
|
222
|
+
const requirementIds = new Set(requirements.map(requirement => requirement.slice(requirement.lastIndexOf('#') + 1)));
|
|
223
|
+
const evidence = [];
|
|
224
|
+
for (const [requirementId, locations] of refs) {
|
|
225
|
+
if (!requirementIds.has(requirementId)) continue;
|
|
226
|
+
for (const location of locations) {
|
|
227
|
+
// Bare numeric IDs are too ambiguous for lifecycle proof once a repo has
|
|
228
|
+
// multiple specs. Require an immutable ID or exact historical path.
|
|
229
|
+
if (location.scope === path || location.scope === specId) {
|
|
230
|
+
evidence.push({ requirementId, file: location.file, line: location.line });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return evidence.sort((a, b) => a.requirementId.localeCompare(b.requirementId)
|
|
235
|
+
|| a.file.localeCompare(b.file) || a.line - b.line);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function archiveTombstones(entries, retention = null) {
|
|
239
|
+
return entries
|
|
240
|
+
.filter(entry => Array.isArray(entry.requirementIds) && entry.requirementIds.length > 0)
|
|
241
|
+
.map(entry => ({
|
|
242
|
+
specId: typeof entry.specId === 'string' ? entry.specId : null,
|
|
243
|
+
path: entry.path,
|
|
244
|
+
archivedFrom: entry.archivedFrom,
|
|
245
|
+
blob: entry.blob,
|
|
246
|
+
retentionRef: entry.retentionRef || retention?.ref || null,
|
|
247
|
+
objectFormat: entry.objectFormat || retention?.objectFormat || null,
|
|
248
|
+
recoverability: entry.recoverability || retention?.recoverability || null,
|
|
249
|
+
requirements: sortedUnique(entry.requirementIds.map(id => `${entry.specId || entry.path}#${id}`)),
|
|
250
|
+
}))
|
|
251
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function registryShapeIssues(registry) {
|
|
255
|
+
const issues = [];
|
|
256
|
+
if (!registry) return issues;
|
|
257
|
+
const ids = new Set();
|
|
258
|
+
for (const entry of registry.specs) {
|
|
259
|
+
if (!entry || !SPEC_ID_RE.test(entry.specId || '') || typeof entry.path !== 'string') {
|
|
260
|
+
issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: 'Every registry spec needs a valid specId and path.' });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ids.has(entry.specId)) issues.push({ code: 'SPR002', path: SPEC_REGISTRY_PATH, message: `Registry reuses immutable spec ID ${entry.specId}.` });
|
|
264
|
+
ids.add(entry.specId);
|
|
265
|
+
rejectUnknownKeys(entry, new Set(['specId', 'path', 'reviewed', 'intent', 'observed']), `${entry.specId}`, issues);
|
|
266
|
+
validatedControl(entry, issues);
|
|
267
|
+
}
|
|
268
|
+
return issues;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function relationGraphIssues(specs) {
|
|
272
|
+
const issues = [];
|
|
273
|
+
const byId = new Map(specs.map(entry => [entry.specId, entry]));
|
|
274
|
+
const reciprocal = new Map([
|
|
275
|
+
['duplicates', 'duplicates'],
|
|
276
|
+
['conflictsWith', 'conflictsWith'],
|
|
277
|
+
['supersedes', 'supersededBy'],
|
|
278
|
+
['supersededBy', 'supersedes'],
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
for (const entry of specs) {
|
|
282
|
+
for (const [relation, targets] of Object.entries(entry.reviewed.relations)) {
|
|
283
|
+
for (const targetId of targets) {
|
|
284
|
+
const target = byId.get(targetId);
|
|
285
|
+
if (!target) {
|
|
286
|
+
issues.push({
|
|
287
|
+
code: 'SPR004',
|
|
288
|
+
path: entry.path,
|
|
289
|
+
message: `${entry.specId}.${relation} references unknown spec ${targetId}.`,
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const inverse = reciprocal.get(relation);
|
|
294
|
+
if (inverse && !target.reviewed.relations[inverse].includes(entry.specId)) {
|
|
295
|
+
issues.push({
|
|
296
|
+
code: 'SPR004',
|
|
297
|
+
path: entry.path,
|
|
298
|
+
message: `${entry.specId}.${relation} must be mirrored by ${targetId}.${inverse}.`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (entry.reviewed.lifecycle.retirementReason === 'superseded') {
|
|
305
|
+
for (const successorId of entry.reviewed.relations.supersededBy) {
|
|
306
|
+
const successor = byId.get(successorId);
|
|
307
|
+
if (successor && (successor.reviewed.lifecycle.context !== 'current'
|
|
308
|
+
|| successor.reviewed.lifecycle.approval !== 'approved')) {
|
|
309
|
+
issues.push({
|
|
310
|
+
code: 'SPR004',
|
|
311
|
+
path: entry.path,
|
|
312
|
+
message: `Successor ${successorId} must be an approved current spec.`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return issues;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function projectSpecRegistry(projectDir, config = {}, options = {}) {
|
|
322
|
+
const existing = readSpecRegistry(projectDir);
|
|
323
|
+
const issues = [];
|
|
324
|
+
if (existing.error) issues.push({ code: 'SPR003', path: SPEC_REGISTRY_PATH, message: existing.error });
|
|
325
|
+
issues.push(...registryShapeIssues(existing.value));
|
|
326
|
+
const existingById = new Map((existing.value?.specs || []).map(entry => [entry.specId, entry]));
|
|
327
|
+
const detected = detectSpecKit(projectDir);
|
|
328
|
+
const files = projectFiles(projectDir);
|
|
329
|
+
const patterns = requirementPatterns(config);
|
|
330
|
+
const testReferences = scanTestFilesForReferences(projectDir, files, patterns);
|
|
331
|
+
const ids = new Map();
|
|
332
|
+
const specs = [];
|
|
333
|
+
|
|
334
|
+
for (const feature of detected.specs.filter(item => item.hasSpec)) {
|
|
335
|
+
const path = posix(relative(projectDir, feature.specPath));
|
|
336
|
+
if (path === options.excludeSpecPath) continue;
|
|
337
|
+
const artifactsForFeature = [feature.specPath, feature.planPath, feature.tasksPath].filter(Boolean);
|
|
338
|
+
const unsafeArtifact = artifactsForFeature.find(artifact => !isSafeFile(projectDir, artifact));
|
|
339
|
+
if (unsafeArtifact) {
|
|
340
|
+
issues.push({ code: 'SPR003', path: posix(relative(projectDir, unsafeArtifact)), message: 'Spec artifacts must be regular files inside the project; symlinks cannot supply lifecycle evidence.' });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
let content;
|
|
344
|
+
try { content = readFileSync(feature.specPath, 'utf8'); } catch (error) {
|
|
345
|
+
issues.push({ code: 'SPR003', path, message: `Cannot read spec: ${error.message}` });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const specId = parseSpecId(content);
|
|
349
|
+
if (!specId || !SPEC_ID_RE.test(specId)) {
|
|
350
|
+
issues.push({ code: 'SPR002', path, message: 'Spec needs an immutable lowercase `Spec ID` (3-128 letters, digits, dots, underscores, or hyphens).' });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ids.has(specId)) {
|
|
354
|
+
issues.push({ code: 'SPR002', path, message: `Spec ID ${specId} is already declared by ${ids.get(specId)}.` });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
ids.set(specId, path);
|
|
358
|
+
const control = validatedControl(existingById.get(specId), issues);
|
|
359
|
+
if (control.reviewed.lifecycle.context !== 'current' || control.reviewed.lifecycle.storage !== 'working_tree') {
|
|
360
|
+
issues.push({ code: 'SPR004', path, message: `Active spec ${specId} must have context=current and storage=working_tree.` });
|
|
361
|
+
}
|
|
362
|
+
if (control.reviewed.lifecycle.retirementReason !== null) {
|
|
363
|
+
issues.push({ code: 'SPR004', path, message: `Active spec ${specId} cannot have a retirement reason.` });
|
|
364
|
+
}
|
|
365
|
+
const definitions = collectRequirementIdsFromContent(content, path, patterns);
|
|
366
|
+
const requirements = sortedUnique([...definitions.values()].map(definition => `${specId}#${definition.id}`));
|
|
367
|
+
const artifacts = artifactsForFeature
|
|
368
|
+
.map(artifact => ({
|
|
369
|
+
path: posix(relative(projectDir, artifact)),
|
|
370
|
+
digest: digest(readFileSync(artifact, 'utf8')),
|
|
371
|
+
}));
|
|
372
|
+
specs.push({
|
|
373
|
+
specId,
|
|
374
|
+
path,
|
|
375
|
+
...control,
|
|
376
|
+
intent: { requirements },
|
|
377
|
+
observed: {
|
|
378
|
+
artifacts,
|
|
379
|
+
taskCompletion: taskCompletion(feature.tasksPath),
|
|
380
|
+
implementationEvidence: [],
|
|
381
|
+
testEvidence: evidenceForSpec(specId, path, requirements, testReferences),
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const archive = readRetirementManifest(projectDir);
|
|
387
|
+
if (!archive.ok) issues.push({ code: 'SPR004', path: '.docguard-archive.json', message: archive.error });
|
|
388
|
+
const tombstones = archive.ok ? archiveTombstones(archive.entries, archive.retention) : [];
|
|
389
|
+
const archivedPaths = new Set(archive.entries.map(entry => entry.path));
|
|
390
|
+
for (const entry of existing.value?.specs || []) {
|
|
391
|
+
if (ids.has(entry.specId)) continue;
|
|
392
|
+
const control = validatedControl(entry, issues);
|
|
393
|
+
if (control.reviewed.lifecycle.context !== 'retired' || control.reviewed.lifecycle.storage !== 'git_history'
|
|
394
|
+
|| !control.reviewed.lifecycle.retirementReason || !archivedPaths.has(entry.path)) {
|
|
395
|
+
issues.push({ code: 'SPR004', path: entry.path, message: `Registry spec ${entry.specId} is absent from the working tree without a matching retired lifecycle and archive event.` });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
specs.push({ ...entry, ...control });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for (const entry of specs) {
|
|
402
|
+
if (entry.reviewed.lifecycle.retirementReason === 'superseded'
|
|
403
|
+
&& entry.reviewed.relations.supersededBy.length === 0) {
|
|
404
|
+
issues.push({ code: 'SPR004', path: entry.path, message: `Superseded spec ${entry.specId} needs a reviewed supersededBy relationship.` });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
issues.push(...relationGraphIssues(specs));
|
|
408
|
+
|
|
409
|
+
const projected = {
|
|
410
|
+
$schema: SPEC_REGISTRY_SCHEMA_URL,
|
|
411
|
+
schemaVersion: SPEC_REGISTRY_SCHEMA_VERSION,
|
|
412
|
+
specs: specs.sort((a, b) => a.specId.localeCompare(b.specId)),
|
|
413
|
+
tombstones,
|
|
414
|
+
};
|
|
415
|
+
const serialized = `${JSON.stringify(projected, null, 2)}\n`;
|
|
416
|
+
const current = existing.exists && !existing.error
|
|
417
|
+
? `${JSON.stringify(existing.value, null, 2)}\n` === serialized
|
|
418
|
+
: false;
|
|
419
|
+
return {
|
|
420
|
+
registry: projected,
|
|
421
|
+
serialized,
|
|
422
|
+
current,
|
|
423
|
+
exists: existing.exists,
|
|
424
|
+
issues,
|
|
425
|
+
detected: detected.specs.length,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function terms(text) {
|
|
430
|
+
return new Set((text.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) || [])
|
|
431
|
+
.filter(term => !STOP_TERMS.has(term)));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function similarity(left, right) {
|
|
435
|
+
const a = terms(left);
|
|
436
|
+
const b = terms(right);
|
|
437
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
438
|
+
let intersection = 0;
|
|
439
|
+
for (const token of a) if (b.has(token)) intersection++;
|
|
440
|
+
return intersection / new Set([...a, ...b]).size;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function preflightSpec(projectDir, config = {}, draftPath = null) {
|
|
444
|
+
let absolute = null;
|
|
445
|
+
let rel = null;
|
|
446
|
+
if (draftPath) {
|
|
447
|
+
absolute = resolve(projectDir, draftPath);
|
|
448
|
+
rel = posix(relative(projectDir, absolute));
|
|
449
|
+
}
|
|
450
|
+
const projection = projectSpecRegistry(projectDir, config, { excludeSpecPath: rel });
|
|
451
|
+
const briefing = projection.registry.specs
|
|
452
|
+
.filter(entry => entry.reviewed.lifecycle.context === 'current')
|
|
453
|
+
.map(entry => ({
|
|
454
|
+
specId: entry.specId,
|
|
455
|
+
path: entry.path,
|
|
456
|
+
approval: entry.reviewed.lifecycle.approval,
|
|
457
|
+
delivery: entry.reviewed.lifecycle.delivery,
|
|
458
|
+
requirements: entry.intent?.requirements?.length || 0,
|
|
459
|
+
taskCompletion: entry.observed?.taskCompletion || { checked: 0, total: 0 },
|
|
460
|
+
testEvidence: entry.observed?.testEvidence?.length || 0,
|
|
461
|
+
}));
|
|
462
|
+
if (!draftPath) {
|
|
463
|
+
const emptyProject = projection.detected === 0 && !projection.exists && projection.issues.length === 0;
|
|
464
|
+
const ready = projection.current || emptyProject;
|
|
465
|
+
return {
|
|
466
|
+
status: ready ? 'BRIEFING' : 'BLOCKED',
|
|
467
|
+
briefing,
|
|
468
|
+
blockers: ready ? [] : projection.issues.length
|
|
469
|
+
? projection.issues
|
|
470
|
+
: [{ code: 'SPR001', path: SPEC_REGISTRY_PATH, message: 'Spec registry is missing or stale.' }],
|
|
471
|
+
overlaps: [],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const blockers = [...projection.issues];
|
|
476
|
+
let safeDraft = false;
|
|
477
|
+
try {
|
|
478
|
+
const root = realpathSync(projectDir);
|
|
479
|
+
const real = realpathSync(absolute);
|
|
480
|
+
safeDraft = Boolean(rel && rel !== '..' && !rel.startsWith('../')
|
|
481
|
+
&& !rel.split('/').includes('.local')
|
|
482
|
+
&& existsSync(absolute) && lstatSync(absolute).isFile() && !lstatSync(absolute).isSymbolicLink()
|
|
483
|
+
&& (real === root || real.startsWith(`${root}${sep}`)));
|
|
484
|
+
} catch { safeDraft = false; }
|
|
485
|
+
if (!safeDraft) {
|
|
486
|
+
blockers.push({ code: 'SPR003', path: draftPath, message: 'Preflight path must name a readable spec inside the project.' });
|
|
487
|
+
return { status: 'BLOCKED', briefing, blockers, overlaps: [] };
|
|
488
|
+
}
|
|
489
|
+
if (!projection.current) blockers.push({ code: 'SPR001', path: SPEC_REGISTRY_PATH, message: 'Refresh the committed registry before planning a new spec.' });
|
|
490
|
+
const content = readFileSync(absolute, 'utf8');
|
|
491
|
+
const specId = parseSpecId(content);
|
|
492
|
+
if (!specId || !SPEC_ID_RE.test(specId)) blockers.push({ code: 'SPR002', path: rel, message: 'Draft needs a valid immutable Spec ID.' });
|
|
493
|
+
const reused = projection.registry.specs.find(entry => entry.specId === specId && entry.path !== rel);
|
|
494
|
+
if (reused) blockers.push({ code: 'SPR002', path: rel, message: `Spec ID ${specId} already belongs to ${reused.path}.` });
|
|
495
|
+
const draftRequirements = [...collectRequirementIdsFromContent(content, rel, requirementPatterns(config)).values()]
|
|
496
|
+
.map(definition => definition.text).join('\n');
|
|
497
|
+
const overlaps = projection.registry.specs
|
|
498
|
+
.filter(entry => entry.path !== rel && existsSync(resolve(projectDir, entry.path)))
|
|
499
|
+
.map(entry => {
|
|
500
|
+
const priorContent = readFileSync(resolve(projectDir, entry.path), 'utf8');
|
|
501
|
+
const priorRequirements = [...collectRequirementIdsFromContent(priorContent, entry.path, requirementPatterns(config)).values()]
|
|
502
|
+
.map(definition => definition.text).join('\n');
|
|
503
|
+
return {
|
|
504
|
+
specId: entry.specId,
|
|
505
|
+
path: entry.path,
|
|
506
|
+
relationship: 'ambiguous',
|
|
507
|
+
intent: entry.reviewed.lifecycle.context,
|
|
508
|
+
implementation: entry.observed.implementationEvidence.length ? 'present' : 'unsupported',
|
|
509
|
+
provenance: entry.observed.testEvidence.length ? 'spec-linked' : 'unknown',
|
|
510
|
+
confidence: 'low',
|
|
511
|
+
similarity: similarity(draftRequirements, priorRequirements),
|
|
512
|
+
};
|
|
513
|
+
})
|
|
514
|
+
.filter(entry => entry.similarity >= 0.25)
|
|
515
|
+
.sort((a, b) => b.similarity - a.similarity);
|
|
516
|
+
return { status: blockers.length ? 'BLOCKED' : 'READY', specId, path: rel, briefing, blockers, overlaps };
|
|
517
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requirement identity parsing shared by traceability and lifecycle recovery.
|
|
3
|
+
* Only declaration-shaped Markdown counts; prose, comments, code fences, and
|
|
4
|
+
* example sections cannot mint identities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REQ_PATTERNS = [
|
|
8
|
+
/\b(REQ)-(\d{2,4})\b/g,
|
|
9
|
+
/\b(FR)-(\d{2,4})\b/g,
|
|
10
|
+
/\b(NFR)-(\d{2,4})\b/g,
|
|
11
|
+
/\b(US)-(\d{2,4})\b/g,
|
|
12
|
+
/\b(STORY)-(\d{2,4})\b/g,
|
|
13
|
+
/\b(AC)-(\d{2,4})\b/g,
|
|
14
|
+
/\b(UC)-(\d{2,4})\b/g,
|
|
15
|
+
/\b(SYS)-(\d{2,4})\b/g,
|
|
16
|
+
/\b(ARCH)-(\d{2,4})\b/g,
|
|
17
|
+
/\b(MOD)-(\d{2,4})\b/g,
|
|
18
|
+
/\b(SC)-(\d{2,4})\b/g,
|
|
19
|
+
/(?<=\[[ xX]\]\s|@(?:req|task|covers)\s)(T)(\d{3,4})\b/g,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function requirementPatterns(config = {}) {
|
|
23
|
+
const customPattern = config.traceability?.requirementPattern;
|
|
24
|
+
return customPattern
|
|
25
|
+
? [new RegExp(customPattern, 'g')]
|
|
26
|
+
: DEFAULT_REQ_PATTERNS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return path-qualified identities from one Markdown artifact.
|
|
31
|
+
* @returns {Map<string, {id:string, file:string, line:number, text:string}>}
|
|
32
|
+
*/
|
|
33
|
+
export function collectRequirementIdsFromContent(content, docName, patterns = DEFAULT_REQ_PATTERNS) {
|
|
34
|
+
const reqIds = new Map();
|
|
35
|
+
const hasMatch = patterns.some(pattern => {
|
|
36
|
+
pattern.lastIndex = 0;
|
|
37
|
+
return pattern.test(content);
|
|
38
|
+
});
|
|
39
|
+
if (!hasMatch) return reqIds;
|
|
40
|
+
|
|
41
|
+
const lines = content.split('\n');
|
|
42
|
+
let fence = null;
|
|
43
|
+
let exampleLevel = null;
|
|
44
|
+
let inComment = false;
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
let line = lines[i];
|
|
47
|
+
if (/^(?: {4}|\t)/.test(line) && !fence && !inComment) continue;
|
|
48
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
49
|
+
if (fence) {
|
|
50
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
|
|
51
|
+
&& line.slice(marker[0].length).trim() === '') fence = null;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (marker) { fence = marker[1]; continue; }
|
|
55
|
+
line = line.replace(/<!--[\s\S]*?-->/g, '');
|
|
56
|
+
if (inComment) {
|
|
57
|
+
const close = line.indexOf('-->');
|
|
58
|
+
if (close < 0) continue;
|
|
59
|
+
line = line.slice(close + 3);
|
|
60
|
+
inComment = false;
|
|
61
|
+
}
|
|
62
|
+
const open = line.indexOf('<!--');
|
|
63
|
+
if (open >= 0) { line = line.slice(0, open); inComment = true; }
|
|
64
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)/);
|
|
65
|
+
if (heading) {
|
|
66
|
+
if (exampleLevel !== null && heading[1].length <= exampleLevel) exampleLevel = null;
|
|
67
|
+
if (exampleLevel === null && /^(?:(?:requirement|task)[ -]+)?(?:examples?|ID[ -]+(?:formats?|syntax|examples?)|(?:formats?|syntax)[ -]+(?:of[ -]+)?IDs?)\b/i.test(heading[2])) {
|
|
68
|
+
exampleLevel = heading[1].length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (exampleLevel !== null) continue;
|
|
72
|
+
for (const pattern of patterns) {
|
|
73
|
+
pattern.lastIndex = 0;
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
76
|
+
const prefix = line.slice(0, match.index);
|
|
77
|
+
if (!/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+|\|\s*)?[\s*`_]*$/.test(prefix)) {
|
|
78
|
+
if (!match[0].length) pattern.lastIndex++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const reqId = match[0];
|
|
82
|
+
if (!reqId.length) { pattern.lastIndex++; continue; }
|
|
83
|
+
const key = `${docName}#${reqId}`;
|
|
84
|
+
if (!reqIds.has(key)) {
|
|
85
|
+
reqIds.set(key, { id: reqId, file: docName, line: i + 1, text: line.trim() });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return reqIds;
|
|
91
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
2
|
+
import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finds terminal lifecycle declarations and specs whose task list is complete.
|
|
6
|
+
* Both remain review signals; `retire --write` still requires explicit paths.
|
|
7
|
+
*/
|
|
8
|
+
export function validateDocumentLifecycle(projectDir, config = {}) {
|
|
9
|
+
const { scanned, candidates, coverage } = scanDocumentLifecycle(projectDir, config);
|
|
10
|
+
if (coverage.status === 'unavailable' && coverage.reason === 'not-git') {
|
|
11
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
12
|
+
}
|
|
13
|
+
const findings = candidates.map(candidate => mkFinding({
|
|
14
|
+
code: candidate.code,
|
|
15
|
+
validator: 'documentLifecycle',
|
|
16
|
+
severity: 'warn',
|
|
17
|
+
confidence: candidate.confidence === 'high' ? 'high' : 'low',
|
|
18
|
+
message: candidate.code === 'DLC001'
|
|
19
|
+
? `${candidate.path} declares terminal lifecycle status "${candidate.status}" but remains in active AI context.`
|
|
20
|
+
: candidate.code === 'DLC004'
|
|
21
|
+
? `${candidate.path} is recorded as retired but remains in the working tree.`
|
|
22
|
+
: `${candidate.path} has ${candidate.reason}`,
|
|
23
|
+
location: candidate.path,
|
|
24
|
+
suggestion: {
|
|
25
|
+
kind: 'review',
|
|
26
|
+
text: 'Confirm shipped outcomes are represented in current docs, then archive the explicit path.',
|
|
27
|
+
command: `docguard retire --write --path ${candidate.path} --reason "<why this is no longer current>"`,
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
if (coverage.status !== 'complete') {
|
|
31
|
+
findings.push(mkFinding({
|
|
32
|
+
code: 'DLC003',
|
|
33
|
+
validator: 'documentLifecycle',
|
|
34
|
+
severity: 'warn',
|
|
35
|
+
confidence: 'high',
|
|
36
|
+
message: coverage.status === 'unavailable'
|
|
37
|
+
? `Document lifecycle coverage is unavailable: ${coverage.error}`
|
|
38
|
+
: `Document lifecycle coverage is partial; ${coverage.unreadable.length} tracked Markdown file(s) could not be read.`,
|
|
39
|
+
location: coverage.unreadable[0] || null,
|
|
40
|
+
suggestion: {
|
|
41
|
+
kind: 'review',
|
|
42
|
+
text: 'Restore readable Git/document access and rerun guard; an incomplete scan cannot prove lifecycle hygiene.',
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
return resultFromFindings(findings, {
|
|
47
|
+
passed: Math.max(0, scanned - candidates.length),
|
|
48
|
+
total: scanned + (coverage.status === 'unavailable' ? 1 : 0),
|
|
49
|
+
applicable: scanned > 0 || coverage.status !== 'complete',
|
|
50
|
+
});
|
|
51
|
+
}
|