docguard-cli 0.36.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -18
- package/cli/commands/diagnose.mjs +3 -22
- 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 +24 -35
- package/cli/config.mjs +2 -0
- package/cli/docguard.mjs +74 -14
- 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/docs-coverage.mjs +111 -61
- package/cli/validators/document-lifecycle.mjs +51 -0
- package/cli/validators/schema-sync.mjs +16 -11
- package/cli/validators/spec-registry.mjs +47 -0
- package/cli/validators/traceability.mjs +73 -199
- package/docs/ai-integration.md +18 -5
- package/docs/commands.md +45 -0
- package/docs/configuration.md +15 -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,126 @@
|
|
|
1
|
+
/** Pure requirement-reference evidence shared by validators and registry projections. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { extname, resolve } from 'node:path';
|
|
5
|
+
import { TEST_PATTERNS } from '../shared-trace-patterns.mjs';
|
|
6
|
+
import { parseJsTs, walk } from './js-ast.mjs';
|
|
7
|
+
|
|
8
|
+
export function isTestSource(file) {
|
|
9
|
+
return /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|sh)$/.test(file)
|
|
10
|
+
&& (TEST_PATTERNS.some(pattern => pattern.test(file)) || /(?:^|\/)(?:__tests__|tests?)\//.test(file));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveRequirementReferences(definitions, references, retiredDefinitions = new Set()) {
|
|
14
|
+
const byId = new Map();
|
|
15
|
+
for (const [key, definition] of definitions) {
|
|
16
|
+
if (!byId.has(definition.id)) byId.set(definition.id, []);
|
|
17
|
+
byId.get(definition.id).push(key);
|
|
18
|
+
}
|
|
19
|
+
for (const key of retiredDefinitions) {
|
|
20
|
+
const id = key.slice(key.lastIndexOf('#') + 1);
|
|
21
|
+
if (!byId.has(id)) byId.set(id, []);
|
|
22
|
+
if (!byId.get(id).includes(key)) byId.get(id).push(key);
|
|
23
|
+
}
|
|
24
|
+
const resolved = new Map();
|
|
25
|
+
for (const [id, refs] of references) {
|
|
26
|
+
const candidates = byId.get(id) || [];
|
|
27
|
+
for (const ref of refs) {
|
|
28
|
+
let key = null;
|
|
29
|
+
if (ref.scope) {
|
|
30
|
+
const direct = `${ref.scope}#${id}`;
|
|
31
|
+
if (definitions.has(direct)) key = direct;
|
|
32
|
+
else {
|
|
33
|
+
const aliases = candidates.filter(candidate => definitions.get(candidate)?.specId === ref.scope);
|
|
34
|
+
if (aliases.length === 1) key = aliases[0];
|
|
35
|
+
}
|
|
36
|
+
} else if (candidates.length === 1) key = candidates[0];
|
|
37
|
+
if (!key || !definitions.has(key)) continue;
|
|
38
|
+
if (!resolved.has(key)) resolved.set(key, []);
|
|
39
|
+
resolved.get(key).push(ref);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function testDeclarations(content, filename) {
|
|
46
|
+
const declarations = [];
|
|
47
|
+
const comment = (text, line) => {
|
|
48
|
+
for (const [offset, raw] of text.split('\n').entries()) {
|
|
49
|
+
const body = raw.replace(/^\s*\*?\s*/, '');
|
|
50
|
+
if (/^(?:@(?:req|task|covers)\s|Testing\s)/i.test(body)) declarations.push({ text: body, line: line + offset });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const labelName = /^(?:test|it|describe|context|specify|Run|DisplayName)$/;
|
|
54
|
+
const ext = extname(filename);
|
|
55
|
+
if (/^\.(?:[cm]?[jt]s|[jt]sx)$/.test(ext)) {
|
|
56
|
+
const { ast, ok } = parseJsTs(content, filename);
|
|
57
|
+
if (ok) {
|
|
58
|
+
for (const c of ast.comments || []) comment(c.value, c.loc.start.line);
|
|
59
|
+
const isLabelCall = callee => {
|
|
60
|
+
if (callee?.type === 'Identifier') return labelName.test(callee.name);
|
|
61
|
+
if (callee?.type !== 'MemberExpression' || callee.computed) return false;
|
|
62
|
+
return labelName.test(callee.property.name)
|
|
63
|
+
|| (/^(?:only|skip|todo|concurrent|serial)$/.test(callee.property.name) && isLabelCall(callee.object));
|
|
64
|
+
};
|
|
65
|
+
walk(ast.program, node => {
|
|
66
|
+
if (node.type !== 'CallExpression' || !isLabelCall(node.callee)) return;
|
|
67
|
+
const label = node.arguments[0];
|
|
68
|
+
if (label?.type === 'StringLiteral' || (label?.type === 'TemplateLiteral' && label.expressions.length === 0)) {
|
|
69
|
+
declarations.push({ text: content.slice(label.start + 1, label.end - 1), line: label.loc.start.line });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return declarations.sort((a, b) => a.line - b.line);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tokens = /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|\#[^\n]*|"""[\s\S]*?(?:"""|$)|'''[\s\S]*?(?:'''|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g;
|
|
77
|
+
const hashComments = /\.(?:py|rb|php|sh)$/.test(ext);
|
|
78
|
+
let end = 0;
|
|
79
|
+
let line = 1;
|
|
80
|
+
let code = '';
|
|
81
|
+
for (const token of content.matchAll(tokens)) {
|
|
82
|
+
const gap = content.slice(end, token.index);
|
|
83
|
+
line += (gap.match(/\n/g) || []).length;
|
|
84
|
+
code += gap;
|
|
85
|
+
const text = token[0];
|
|
86
|
+
if (text.startsWith('//') || text.startsWith('/*') || (hashComments && text.startsWith('#'))) {
|
|
87
|
+
comment(text.replace(/^(?:\/\/|\/\*|#)/, ''), line);
|
|
88
|
+
} else if (/^["'`]/.test(text)
|
|
89
|
+
&& /\b(?:test|it|describe|context|specify|Run|DisplayName)(?:\.(?:only|skip|todo|concurrent|serial))*\s*\(?\s*$/.test(code)) {
|
|
90
|
+
declarations.push({ text: text.slice(1, -1), line });
|
|
91
|
+
}
|
|
92
|
+
line += (text.match(/\n/g) || []).length;
|
|
93
|
+
code = text.startsWith('/') || text.startsWith('#') ? code + ' ' : ';';
|
|
94
|
+
end = token.index + text.length;
|
|
95
|
+
}
|
|
96
|
+
return declarations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
100
|
+
const testRefs = new Map();
|
|
101
|
+
for (const relPath of projectFiles.filter(isTestSource)) {
|
|
102
|
+
const fullPath = resolve(projectDir, relPath);
|
|
103
|
+
if (!existsSync(fullPath)) continue;
|
|
104
|
+
let content;
|
|
105
|
+
try { content = readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
106
|
+
const hasMatch = patterns.some(pattern => { pattern.lastIndex = 0; return pattern.test(content); });
|
|
107
|
+
if (!hasMatch) continue;
|
|
108
|
+
for (const declaration of testDeclarations(content, relPath)) {
|
|
109
|
+
for (const pattern of patterns) {
|
|
110
|
+
pattern.lastIndex = 0;
|
|
111
|
+
let match;
|
|
112
|
+
while ((match = pattern.exec(declaration.text)) !== null) {
|
|
113
|
+
if (!match[0]) { pattern.lastIndex++; continue; }
|
|
114
|
+
const reqId = match[0];
|
|
115
|
+
if (!testRefs.has(reqId)) testRefs.set(reqId, []);
|
|
116
|
+
const line = declaration.line + (declaration.text.slice(0, match.index).match(/\n/g) || []).length;
|
|
117
|
+
const prefix = declaration.text.slice(0, match.index);
|
|
118
|
+
const qualifier = prefix.match(/([^\s`"'<>()[\]{}]+)#$/);
|
|
119
|
+
const scope = qualifier ? qualifier[1].replaceAll('\\', '/').replace(/^\.\//, '') : null;
|
|
120
|
+
testRefs.get(reqId).push({ file: relPath, line, scope });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return testRefs;
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|