docguard-cli 0.35.0 → 0.36.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 +8 -15
- package/cli/commands/agent.mjs +27 -6
- package/cli/commands/ci.mjs +3 -0
- package/cli/commands/diagnose.mjs +8 -2
- package/cli/commands/feedback.mjs +83 -89
- package/cli/commands/fix.mjs +4 -0
- package/cli/commands/generate.mjs +3 -0
- package/cli/commands/guard.mjs +37 -20
- package/cli/commands/hooks.mjs +61 -40
- package/cli/commands/init.mjs +51 -5
- package/cli/commands/memory.mjs +29 -15
- package/cli/commands/report.mjs +12 -7
- package/cli/commands/score.mjs +39 -19
- package/cli/commands/sync.mjs +2 -0
- package/cli/commands/watch.mjs +113 -70
- package/cli/config.mjs +6 -3
- package/cli/docguard.mjs +12 -4
- package/cli/findings.mjs +13 -13
- package/cli/scanners/memory-plan.mjs +279 -134
- package/cli/scanners/project-type.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +176 -26
- package/cli/shared-diff.mjs +22 -1
- package/cli/shared-doc-roles.mjs +59 -0
- package/cli/shared-ignore.mjs +15 -2
- package/cli/shared-source.mjs +223 -1
- package/cli/validator-coverage.mjs +20 -0
- package/cli/validators/api-surface.mjs +94 -70
- package/cli/validators/architecture.mjs +19 -5
- package/cli/validators/diff-suspicion.mjs +45 -9
- package/cli/validators/docs-coverage.mjs +6 -5
- package/cli/validators/docs-diff.mjs +51 -7
- package/cli/validators/environment.mjs +3 -2
- package/cli/validators/freshness.mjs +140 -83
- package/cli/validators/schema-sync.mjs +3 -2
- package/cli/validators/security.mjs +58 -23
- package/cli/validators/structure.mjs +3 -1
- package/cli/validators/test-spec.mjs +3 -2
- package/cli/validators/todo-tracking.mjs +61 -28
- package/cli/validators/traceability.mjs +152 -38
- package/docs/configuration.md +41 -0
- package/extensions/spec-kit-docguard/README.md +6 -6
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +3 -4
- package/extensions/spec-kit-docguard/scripts/bash/common.sh +9 -17
- package/extensions/spec-kit-docguard/scripts/bash/docguard-check-docs.sh +18 -11
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +3 -3
- 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 +1 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +43 -1
- package/templates/ci/github-actions.yml +51 -11
|
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
19
19
|
import { resolve, join, relative, extname } from 'node:path';
|
|
20
20
|
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
21
21
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
22
|
+
import { parseJsTs, walk } from '../scanners/js-ast.mjs';
|
|
22
23
|
|
|
23
24
|
const IGNORE_DIRS = new Set([
|
|
24
25
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -62,7 +63,7 @@ function commentPortion(line) {
|
|
|
62
63
|
|
|
63
64
|
// Test skip patterns for common test frameworks
|
|
64
65
|
const SKIP_PATTERNS = [
|
|
65
|
-
/\btest\.skip\s*\(/,
|
|
66
|
+
/\btest\.(?:skip|fixme)\s*\(/,
|
|
66
67
|
/\bit\.skip\s*\(/,
|
|
67
68
|
/\bdescribe\.skip\s*\(/,
|
|
68
69
|
/\bxit\s*\(/,
|
|
@@ -73,8 +74,61 @@ const SKIP_PATTERNS = [
|
|
|
73
74
|
/\bit\.todo\s*\(/,
|
|
74
75
|
];
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
const SKIP_REASON_PATTERN =
|
|
77
|
+
// Reasons must contain text and belong to this call, not a neighboring test.
|
|
78
|
+
const SKIP_REASON_PATTERN = /(?:REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:\s*\S/i;
|
|
79
|
+
|
|
80
|
+
function hasAdjacentReason(content, call, comments) {
|
|
81
|
+
return comments.some(comment => {
|
|
82
|
+
if (!SKIP_REASON_PATTERN.test(comment.value)) return false;
|
|
83
|
+
if (comment.end <= call.start) {
|
|
84
|
+
// A trailing comment belongs to the preceding statement.
|
|
85
|
+
const lineStart = content.lastIndexOf('\n', comment.start - 1) + 1;
|
|
86
|
+
if (!/^\s*$/.test(content.slice(lineStart, comment.start))) return false;
|
|
87
|
+
const gap = content.slice(comment.end, call.start);
|
|
88
|
+
return call.loc.start.line - comment.loc.end.line <= 3 && /^\s*$/.test(gap);
|
|
89
|
+
}
|
|
90
|
+
if (comment.start >= call.end && comment.loc.start.line === call.loc.end.line) {
|
|
91
|
+
return /^[\s;]*$/.test(content.slice(call.end, comment.start));
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function skippedCalls(content, filename) {
|
|
98
|
+
const { ast, ok } = parseJsTs(content, filename);
|
|
99
|
+
if (!ok || ast.errors?.length) return null;
|
|
100
|
+
const calls = [];
|
|
101
|
+
walk(ast, node => {
|
|
102
|
+
if (node.type !== 'CallExpression') return;
|
|
103
|
+
const callee = content.slice(node.callee.start, node.callee.end).replace(/\s/g, '');
|
|
104
|
+
if (!/^(?:(?:test(?:\.describe)?|it|describe)\.(?:skip|todo)|test\.fixme|xit|xdescribe|xtest)$/.test(callee)) return;
|
|
105
|
+
const [condition, reason] = node.arguments;
|
|
106
|
+
// Playwright's conditional overload takes a reason in argument two.
|
|
107
|
+
// A title followed by a callback is a declaration, not an explanation.
|
|
108
|
+
const explicitReason = /^(?:test\.skip|test\.fixme)$/.test(callee) &&
|
|
109
|
+
condition && condition.type !== 'StringLiteral' && condition.type !== 'TemplateLiteral' &&
|
|
110
|
+
(reason?.type === 'StringLiteral' && reason.value.trim().length > 0 ||
|
|
111
|
+
reason?.type === 'TemplateLiteral' && reason.expressions.length === 0 &&
|
|
112
|
+
reason.quasis.some(part => (part.value.cooked ?? part.value.raw).trim().length > 0));
|
|
113
|
+
calls.push({ line: node.loc.start.line, hasReason: Boolean(explicitReason) ||
|
|
114
|
+
hasAdjacentReason(content, node, ast.comments || []) });
|
|
115
|
+
});
|
|
116
|
+
return calls;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parser failure/unavailability cannot turn an unexplained skip into a pass.
|
|
120
|
+
function fallbackSkippedCalls(content) {
|
|
121
|
+
const lines = content.split('\n');
|
|
122
|
+
const calls = [];
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
if (!SKIP_PATTERNS.some(p => p.test(lines[i]))) continue;
|
|
125
|
+
// Only a directly preceding comment is unambiguous without a parser.
|
|
126
|
+
const previous = lines[i - 1] || '';
|
|
127
|
+
calls.push({ line: i + 1, hasReason: /^\s*\/\//.test(previous) &&
|
|
128
|
+
SKIP_REASON_PATTERN.test(previous) });
|
|
129
|
+
}
|
|
130
|
+
return calls;
|
|
131
|
+
}
|
|
78
132
|
|
|
79
133
|
/**
|
|
80
134
|
* Main validator — checks for untracked TODOs and unexplained test skips.
|
|
@@ -139,29 +193,8 @@ function checkSkippedTests(projectDir, config) {
|
|
|
139
193
|
const hasSkip = SKIP_PATTERNS.some(p => p.test(content));
|
|
140
194
|
if (!hasSkip) continue;
|
|
141
195
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
for (let i = 0; i < lines.length; i++) {
|
|
145
|
-
const line = lines[i];
|
|
146
|
-
|
|
147
|
-
// Check if this line has a test skip pattern
|
|
148
|
-
const isSkipped = SKIP_PATTERNS.some(p => p.test(line));
|
|
149
|
-
if (!isSkipped) continue;
|
|
150
|
-
|
|
151
|
-
// Check surrounding lines (3 above, 1 below, and inline) for explanation
|
|
152
|
-
// Developers commonly place block comments above the skip call
|
|
153
|
-
const surroundingLines = [];
|
|
154
|
-
for (let j = Math.max(0, i - 3); j <= Math.min(lines.length - 1, i + 1); j++) {
|
|
155
|
-
surroundingLines.push(lines[j]);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Also check for block comment pattern: /* REASON: ... */ or /** ... REASON: ... */
|
|
159
|
-
const blockCommentPattern = /\/\*[\s\S]*?(REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:/i;
|
|
160
|
-
|
|
161
|
-
const hasReason =
|
|
162
|
-
surroundingLines.some(l => SKIP_REASON_PATTERN.test(l)) ||
|
|
163
|
-
blockCommentPattern.test(surroundingLines.join('\n'));
|
|
164
|
-
|
|
196
|
+
const calls = skippedCalls(content, relPath) ?? fallbackSkippedCalls(content);
|
|
197
|
+
for (const { line, hasReason } of calls) {
|
|
165
198
|
if (hasReason) {
|
|
166
199
|
skippedWithReason++;
|
|
167
200
|
} else {
|
|
@@ -170,9 +203,9 @@ function checkSkippedTests(projectDir, config) {
|
|
|
170
203
|
code: 'TDO001',
|
|
171
204
|
validator: 'todoTracking',
|
|
172
205
|
severity: 'warn',
|
|
173
|
-
message: `Skipped test without explanation at ${relPath}:${
|
|
206
|
+
message: `Skipped test without explanation at ${relPath}:${line}. ` +
|
|
174
207
|
`Add a // REASON: comment explaining why the test is skipped`,
|
|
175
|
-
location: `${relPath}:${
|
|
208
|
+
location: `${relPath}:${line}`,
|
|
176
209
|
suggestion: {
|
|
177
210
|
kind: 'fix',
|
|
178
211
|
text: 'Add a // REASON: comment on or up to 3 lines above the skip explaining why',
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 2. Requirement Traceability (V-Model): Requirement IDs in docs trace to tests
|
|
7
7
|
*
|
|
8
8
|
* Requirement traceability is opt-in by convention — if no requirement IDs are
|
|
9
|
-
*
|
|
9
|
+
* defined or explicitly annotated (REQ-001, FR-001, etc.), the check silently passes. Once you add IDs,
|
|
10
10
|
* DocGuard automatically enforces traceability.
|
|
11
11
|
*
|
|
12
12
|
* Inspired by ISO/IEC/IEEE 29119, IEEE 1016, and V-Model methodology.
|
|
@@ -20,6 +20,7 @@ import { walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignor
|
|
|
20
20
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
21
|
import { tokenize } from '../shared-diff.mjs';
|
|
22
22
|
import { rankBySimilarity } from '../shared-ir.mjs';
|
|
23
|
+
import { parseJsTs, walk } from '../scanners/js-ast.mjs';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Optional graphify interop (github.com/Graphify-Labs/graphify, MIT).
|
|
@@ -71,13 +72,18 @@ function loadGraphifyDocLinks(projectDir) {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// A test directory also contains fixtures and configuration. Only source files
|
|
76
|
+
// are eligible for annotations or candidate-test similarity hints.
|
|
77
|
+
function isTestSource(file) {
|
|
78
|
+
return /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|sh)$/.test(file)
|
|
79
|
+
&& (TEST_PATTERNS.some(pattern => pattern.test(file)) || /(?:^|\/)(?:__tests__|tests?)\//.test(file));
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
// IR soft-link recovery (feat 5): tokenize test files once so an untraced
|
|
75
83
|
// requirement can be matched to the test that most likely already covers it
|
|
76
84
|
// (TF-IDF cosine, VSM). Capped so a huge test suite can't blow up guard.
|
|
77
85
|
function buildTestCorpus(projectDir, projectFiles, { maxFiles = 250, maxTokens = 400 } = {}) {
|
|
78
|
-
const testFiles = projectFiles.filter(
|
|
79
|
-
TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
|
|
80
|
-
).slice(0, maxFiles);
|
|
86
|
+
const testFiles = projectFiles.filter(isTestSource).slice(0, maxFiles);
|
|
81
87
|
const corpus = [];
|
|
82
88
|
for (const relPath of testFiles) {
|
|
83
89
|
try {
|
|
@@ -134,7 +140,7 @@ export function validateTraceability(projectDir, config) {
|
|
|
134
140
|
let total = 0;
|
|
135
141
|
|
|
136
142
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
137
|
-
if (!existsSync(docsDir)) {
|
|
143
|
+
if (!existsSync(docsDir) && getRequirementDocPaths(projectDir, config).length === 0) {
|
|
138
144
|
// No docs-canonical dir at all — structure validator handles this
|
|
139
145
|
return resultFromFindings([], { passed: 0, total: 0 });
|
|
140
146
|
}
|
|
@@ -166,9 +172,14 @@ export function validateTraceability(projectDir, config) {
|
|
|
166
172
|
// Skip docs not in the user's required list
|
|
167
173
|
if (!requiredDocs.has(docName)) continue;
|
|
168
174
|
|
|
169
|
-
|
|
170
|
-
const docPath = resolve(
|
|
175
|
+
const configuredPath = (config.requiredFiles?.canonical || []).find(file => basename(file) === docName);
|
|
176
|
+
const docPath = configuredPath && (configuredPath.includes('/') || existsSync(resolve(projectDir, configuredPath)))
|
|
177
|
+
? resolve(projectDir, configuredPath) : resolve(docsDir, docName);
|
|
171
178
|
const docExists = existsSync(docPath);
|
|
179
|
+
// Discovering feature specs must not activate missing-canonical findings
|
|
180
|
+
// for a repository without a canonical home. Structure owns that absence.
|
|
181
|
+
if (!existsSync(docsDir) && !docExists) continue;
|
|
182
|
+
total++;
|
|
172
183
|
|
|
173
184
|
if (!docExists) {
|
|
174
185
|
findings.push(mkFinding({
|
|
@@ -176,7 +187,7 @@ export function validateTraceability(projectDir, config) {
|
|
|
176
187
|
validator: 'traceability',
|
|
177
188
|
severity: 'warn',
|
|
178
189
|
message: `${docName} — required but missing, no traceability possible`,
|
|
179
|
-
location:
|
|
190
|
+
location: relative(projectDir, docPath),
|
|
180
191
|
suggestion: { kind: 'fix', text: 'Create the required doc from the professional template', command: 'docguard init' },
|
|
181
192
|
}));
|
|
182
193
|
continue;
|
|
@@ -220,7 +231,7 @@ export function validateTraceability(projectDir, config) {
|
|
|
220
231
|
validator: 'traceability',
|
|
221
232
|
severity: 'warn',
|
|
222
233
|
message: `${docName} — exists but no matching source code found (unlinked doc)`,
|
|
223
|
-
location:
|
|
234
|
+
location: relative(projectDir, docPath),
|
|
224
235
|
suggestion: {
|
|
225
236
|
kind: 'fix',
|
|
226
237
|
text: 'Link a source file explicitly with a header annotation if the code lives in a non-standard location',
|
|
@@ -237,7 +248,7 @@ export function validateTraceability(projectDir, config) {
|
|
|
237
248
|
// finding points at the actual file instead of a fabricated flat one — for
|
|
238
249
|
// a flat tree `doc.rel` already equals the old `docs-canonical/${docFile}`
|
|
239
250
|
// template exactly, so this is a no-op on the flat case.
|
|
240
|
-
for (const doc of listCanonicalDocs(projectDir)) {
|
|
251
|
+
for (const doc of listCanonicalDocs(projectDir, { config })) {
|
|
241
252
|
const docFile = basename(doc.rel);
|
|
242
253
|
if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
|
|
243
254
|
findings.push(mkFinding({
|
|
@@ -266,7 +277,7 @@ export function validateTraceability(projectDir, config) {
|
|
|
266
277
|
* Scan docs for requirement IDs and verify they appear in test files.
|
|
267
278
|
*
|
|
268
279
|
* Behavior:
|
|
269
|
-
* - If no
|
|
280
|
+
* - If no definitions or test declarations exist → silently passes (0 checks)
|
|
270
281
|
* - If IDs found → validates each has a matching test reference
|
|
271
282
|
* - Reports untraced requirements and orphaned test refs
|
|
272
283
|
*/
|
|
@@ -284,11 +295,6 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
284
295
|
// ── Step 1: Collect requirement IDs from documentation ──
|
|
285
296
|
const reqIds = collectRequirementIds(projectDir, config, patterns);
|
|
286
297
|
|
|
287
|
-
// If no requirement IDs found, silently pass — this project doesn't use them
|
|
288
|
-
if (reqIds.size === 0) {
|
|
289
|
-
return { findings, passed, total };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
298
|
// ── Step 2: Scan test files for requirement ID references ──
|
|
293
299
|
const testRefs = scanTestFilesForReferences(projectDir, projectFiles, patterns);
|
|
294
300
|
|
|
@@ -309,7 +315,7 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
309
315
|
} else {
|
|
310
316
|
// Try to recover a likely-but-unannotated test via TF-IDF cosine.
|
|
311
317
|
let softHint = '';
|
|
312
|
-
let softText = `
|
|
318
|
+
let softText = `Review existing tests for this requirement. If a test verifies it, add an @req ${reqId} annotation or requirement ID test label; write a test only if behavioral coverage is actually missing.`;
|
|
313
319
|
const queryText = location.text && location.text.length > reqId.length ? location.text : reqId;
|
|
314
320
|
if (testCorpus === null) testCorpus = buildTestCorpus(projectDir, projectFiles);
|
|
315
321
|
if (testCorpus.length > 0) {
|
|
@@ -318,16 +324,16 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
318
324
|
if (top && top.score >= softThreshold) {
|
|
319
325
|
const pct = (top.score * 100).toFixed(0);
|
|
320
326
|
softHint = ` — IR soft-match: ${top.id} (${pct}% similar) may already cover it`;
|
|
321
|
-
softText =
|
|
327
|
+
softText = `Review ${top.id} as a candidate (${pct}% text similarity, not coverage evidence). Add @req ${reqId} only if it verifies the requirement; otherwise inspect other tests before deciding a new test is needed.`;
|
|
322
328
|
}
|
|
323
329
|
}
|
|
324
330
|
findings.push(mkFinding({
|
|
325
331
|
code: 'TRC004',
|
|
326
332
|
validator: 'traceability',
|
|
327
333
|
severity: 'warn',
|
|
328
|
-
message: `Requirement ${reqId} (${location.file}:${location.line}) has no test
|
|
334
|
+
message: `Requirement ${reqId} (${location.file}:${location.line}) has no recognized test annotation or label; behavioral coverage is unknown.${softHint}`,
|
|
329
335
|
location: `${location.file}:${location.line}`,
|
|
330
|
-
suggestion: { kind: '
|
|
336
|
+
suggestion: { kind: 'review', text: softText },
|
|
331
337
|
}));
|
|
332
338
|
}
|
|
333
339
|
}
|
|
@@ -367,16 +373,53 @@ function collectRequirementIds(projectDir, config, patterns) {
|
|
|
367
373
|
const lines = content.split('\n');
|
|
368
374
|
const docName = relative(projectDir, docPath);
|
|
369
375
|
|
|
376
|
+
let fence = null;
|
|
377
|
+
let exampleLevel = null;
|
|
378
|
+
let inComment = false;
|
|
370
379
|
for (let i = 0; i < lines.length; i++) {
|
|
380
|
+
let line = lines[i];
|
|
381
|
+
if (/^(?: {4}|\t)/.test(line) && !fence && !inComment) continue;
|
|
382
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
383
|
+
if (fence) {
|
|
384
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
|
|
385
|
+
&& line.slice(marker[0].length).trim() === '') fence = null;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (marker) { fence = marker[1]; continue; }
|
|
389
|
+
// Comments and fenced examples cannot define requirements. Preserve
|
|
390
|
+
// physical line numbers instead of scanning a compacted document.
|
|
391
|
+
line = line.replace(/<!--[\s\S]*?-->/g, '');
|
|
392
|
+
if (inComment) {
|
|
393
|
+
const close = line.indexOf('-->');
|
|
394
|
+
if (close < 0) continue;
|
|
395
|
+
line = line.slice(close + 3);
|
|
396
|
+
inComment = false;
|
|
397
|
+
}
|
|
398
|
+
const open = line.indexOf('<!--');
|
|
399
|
+
if (open >= 0) { line = line.slice(0, open); inComment = true; }
|
|
400
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)/);
|
|
401
|
+
if (heading) {
|
|
402
|
+
if (exampleLevel !== null && heading[1].length <= exampleLevel) exampleLevel = null;
|
|
403
|
+
if (exampleLevel === null && /^(?:(?:requirement|task)[ -]+)?(?:examples?|ID[ -]+(?:formats?|syntax|examples?)|(?:formats?|syntax)[ -]+(?:of[ -]+)?IDs?)\b/i.test(heading[2])) {
|
|
404
|
+
exampleLevel = heading[1].length;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (exampleLevel !== null) continue;
|
|
371
408
|
for (const pattern of patterns) {
|
|
372
|
-
// Reset regex lastIndex for each line
|
|
373
409
|
pattern.lastIndex = 0;
|
|
374
410
|
let match;
|
|
375
|
-
while ((match = pattern.exec(
|
|
376
|
-
|
|
411
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
412
|
+
// Definitions lead a line, heading, list item or first table cell.
|
|
413
|
+
// Later prose references must not satisfy a missing requirement ID.
|
|
414
|
+
const prefix = line.slice(0, match.index);
|
|
415
|
+
if (!/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+|\|\s*)?[\s*`_]*$/.test(prefix)) {
|
|
416
|
+
if (!match[0].length) pattern.lastIndex++;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const reqId = match[0];
|
|
420
|
+
if (!reqId.length) { pattern.lastIndex++; continue; }
|
|
377
421
|
if (!reqIds.has(reqId)) {
|
|
378
|
-
|
|
379
|
-
reqIds.set(reqId, { file: docName, line: i + 1, text: lines[i].trim() });
|
|
422
|
+
reqIds.set(reqId, { file: docName, line: i + 1, text: line.trim() });
|
|
380
423
|
}
|
|
381
424
|
}
|
|
382
425
|
}
|
|
@@ -386,12 +429,74 @@ function collectRequirementIds(projectDir, config, patterns) {
|
|
|
386
429
|
return reqIds;
|
|
387
430
|
}
|
|
388
431
|
|
|
432
|
+
// A mention in fixture data is not a coverage declaration. Keep the same ID
|
|
433
|
+
// patterns, but apply them only to annotations and test labels. In particular,
|
|
434
|
+
// prose discussing an annotation ("never annotates @req ...") is not one.
|
|
435
|
+
function testDeclarations(content, filename) {
|
|
436
|
+
const declarations = [];
|
|
437
|
+
const comment = (text, line) => {
|
|
438
|
+
for (const [offset, raw] of text.split('\n').entries()) {
|
|
439
|
+
const body = raw.replace(/^\s*\*?\s*/, '');
|
|
440
|
+
if (/^(?:@(?:req|task|covers)\s|Testing\s)/i.test(body)) {
|
|
441
|
+
declarations.push({ text: body, line: line + offset });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const labelName = /^(?:test|it|describe|context|specify|Run|DisplayName)$/;
|
|
446
|
+
const ext = extname(filename);
|
|
447
|
+
if (/^\.(?:[cm]?[jt]s|[jt]sx)$/.test(ext)) {
|
|
448
|
+
const { ast, ok } = parseJsTs(content, filename);
|
|
449
|
+
if (ok) {
|
|
450
|
+
for (const c of ast.comments || []) comment(c.value, c.loc.start.line);
|
|
451
|
+
const isLabelCall = (callee) => {
|
|
452
|
+
if (callee?.type === 'Identifier') return labelName.test(callee.name);
|
|
453
|
+
if (callee?.type !== 'MemberExpression' || callee.computed) return false;
|
|
454
|
+
return labelName.test(callee.property.name)
|
|
455
|
+
|| (/^(?:only|skip|todo|concurrent|serial)$/.test(callee.property.name)
|
|
456
|
+
&& isLabelCall(callee.object));
|
|
457
|
+
};
|
|
458
|
+
walk(ast.program, node => {
|
|
459
|
+
if (node.type !== 'CallExpression' || !isLabelCall(node.callee)) return;
|
|
460
|
+
const label = node.arguments[0];
|
|
461
|
+
if (label?.type === 'StringLiteral'
|
|
462
|
+
|| (label?.type === 'TemplateLiteral' && label.expressions.length === 0)) {
|
|
463
|
+
// Scan source spelling to retain physical lines and custom patterns.
|
|
464
|
+
declarations.push({ text: content.slice(label.start + 1, label.end - 1), line: label.loc.start.line });
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
return declarations.sort((a, b) => a.line - b.line);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Other languages, and JS/TS without the optional parser: lex comments and
|
|
472
|
+
// strings together so comment-like text inside a fixture stays opaque.
|
|
473
|
+
// This is deliberately a best-effort tier, like the multilingual scanners.
|
|
474
|
+
const tokens = /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|\#[^\n]*|"""[\s\S]*?(?:"""|$)|'''[\s\S]*?(?:'''|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g;
|
|
475
|
+
const hashComments = /\.(?:py|rb|php|sh)$/.test(ext);
|
|
476
|
+
let end = 0;
|
|
477
|
+
let line = 1;
|
|
478
|
+
let code = '';
|
|
479
|
+
for (const token of content.matchAll(tokens)) {
|
|
480
|
+
const gap = content.slice(end, token.index);
|
|
481
|
+
line += (gap.match(/\n/g) || []).length;
|
|
482
|
+
code += gap;
|
|
483
|
+
const text = token[0];
|
|
484
|
+
if (text.startsWith('//') || text.startsWith('/*') || (hashComments && text.startsWith('#'))) {
|
|
485
|
+
comment(text.replace(/^(?:\/\/|\/\*|#)/, ''), line);
|
|
486
|
+
} else if (/^["'`]/.test(text)
|
|
487
|
+
&& /\b(?:test|it|describe|context|specify|Run|DisplayName)(?:\.(?:only|skip|todo|concurrent|serial))*\s*\(?\s*$/.test(code)) {
|
|
488
|
+
declarations.push({ text: text.slice(1, -1), line });
|
|
489
|
+
}
|
|
490
|
+
line += (text.match(/\n/g) || []).length;
|
|
491
|
+
// Strings must break a possible label prefix; comments are whitespace.
|
|
492
|
+
code = text.startsWith('/') || text.startsWith('#') ? code + ' ' : ';';
|
|
493
|
+
end = token.index + text.length;
|
|
494
|
+
}
|
|
495
|
+
return declarations;
|
|
496
|
+
}
|
|
497
|
+
|
|
389
498
|
function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
390
|
-
const testFiles = projectFiles.filter(
|
|
391
|
-
TEST_PATTERNS.some(p => p.test(f)) || // multilingual: JS/TS, Python, Go, Rust, Java/Kotlin, Ruby, PHP
|
|
392
|
-
/__tests__\//.test(f) ||
|
|
393
|
-
/tests?\//.test(f)
|
|
394
|
-
);
|
|
499
|
+
const testFiles = projectFiles.filter(isTestSource);
|
|
395
500
|
|
|
396
501
|
const testRefs = new Map(); // reqId → [{ file, line }]
|
|
397
502
|
|
|
@@ -406,16 +511,16 @@ function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
|
406
511
|
const hasMatch = patterns.some(p => { p.lastIndex = 0; return p.test(content); });
|
|
407
512
|
if (!hasMatch) continue;
|
|
408
513
|
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
for (let i = 0; i < lines.length; i++) {
|
|
514
|
+
for (const declaration of testDeclarations(content, relPath)) {
|
|
412
515
|
for (const pattern of patterns) {
|
|
413
516
|
pattern.lastIndex = 0;
|
|
414
517
|
let match;
|
|
415
|
-
while ((match = pattern.exec(
|
|
518
|
+
while ((match = pattern.exec(declaration.text)) !== null) {
|
|
519
|
+
if (!match[0]) { pattern.lastIndex++; continue; }
|
|
416
520
|
const reqId = match[0];
|
|
417
521
|
if (!testRefs.has(reqId)) testRefs.set(reqId, []);
|
|
418
|
-
|
|
522
|
+
const line = declaration.line + (declaration.text.slice(0, match.index).match(/\n/g) || []).length;
|
|
523
|
+
testRefs.get(reqId).push({ file: relPath, line });
|
|
419
524
|
}
|
|
420
525
|
}
|
|
421
526
|
}
|
|
@@ -434,7 +539,7 @@ function getRequirementDocPaths(projectDir, config) {
|
|
|
434
539
|
// docs-canonical/ directory — recursive. Consumer re-derives the display
|
|
435
540
|
// path via relative(projectDir, docPath), so nested docs already report
|
|
436
541
|
// their real path with no further change needed there.
|
|
437
|
-
for (const doc of listCanonicalDocs(projectDir)) paths.push(doc.abs);
|
|
542
|
+
for (const doc of listCanonicalDocs(projectDir, { config })) paths.push(doc.abs);
|
|
438
543
|
|
|
439
544
|
// Root-level docs
|
|
440
545
|
const rootDocs = ['REQUIREMENTS.md', 'spec.md', 'README.md'];
|
|
@@ -444,10 +549,19 @@ function getRequirementDocPaths(projectDir, config) {
|
|
|
444
549
|
}
|
|
445
550
|
|
|
446
551
|
// User-configured requirement docs
|
|
447
|
-
const configDocs = config.traceability?.requirementDocs || [];
|
|
552
|
+
const configDocs = [...(config.requiredFiles?.canonical || []), ...(config.traceability?.requirementDocs || [])];
|
|
448
553
|
for (const doc of configDocs) {
|
|
449
554
|
const p = resolve(projectDir, doc);
|
|
450
|
-
|
|
555
|
+
const rel = relative(projectDir, p);
|
|
556
|
+
if (rel === '..' || rel.startsWith('../') || rel.split(/[\\/]/).includes('.local')) continue;
|
|
557
|
+
try {
|
|
558
|
+
if (statSync(p).isFile() && !paths.includes(p)) paths.push(p);
|
|
559
|
+
else if (statSync(p).isDirectory()) {
|
|
560
|
+
for (const entry of listCanonicalDocs(projectDir, { dirName: doc, config: {} })) {
|
|
561
|
+
if (!paths.includes(entry.abs)) paths.push(entry.abs);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} catch { /* Structural validators own missing or unreadable doc paths. */ }
|
|
451
565
|
}
|
|
452
566
|
|
|
453
567
|
// Spec Kit artifacts: .specify/specs/*/spec.md (v3+) and specs/*/spec.md (legacy)
|
package/docs/configuration.md
CHANGED
|
@@ -157,3 +157,44 @@ DocGuard auto-detects your project type from `package.json`:
|
|
|
157
157
|
| `library` | ✗ | ✗ | ✗ | ✗ |
|
|
158
158
|
| `webapp` | ✓ | ✓ | ✓ | ✓ |
|
|
159
159
|
| `api` | ✓ | ✓ | ✗ | ✓ |
|
|
160
|
+
|
|
161
|
+
## Existing documentation layouts
|
|
162
|
+
|
|
163
|
+
Use explicit document roles to validate Markdown files in an existing layout. A mapping replaces the default path for that role, makes the mapped file required, and enrolls it in the canonical inventory. Missing files and content defects remain findings. No project names or framework-specific paths are required.
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"docs": {
|
|
168
|
+
"roles": {
|
|
169
|
+
"architecture": "docs/design.md",
|
|
170
|
+
"dataModel": "specs/data-model.md",
|
|
171
|
+
"environment": "operations/setup.md",
|
|
172
|
+
"apiReference": "reference/http.md"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Supported roles are architecture, dataModel, security, testSpec, environment, apiReference, and requirements. Paths must name Markdown files within the project; private directories, parent traversal, absolute paths, and symlink destinations are rejected. Several roles may reference one document. Each role's content checks still apply; a mapping is not a correctness attestation. Default roles remain unchanged unless explicitly mapped.
|
|
179
|
+
|
|
180
|
+
This first version supports validation, scoring, and read-only planning. Legacy automatic document generation, sync writes, and repair writes refuse custom mappings before scaffolding or modifying files. This protects existing documents while writer behavior is extended and reviewed. Read-only plans identify mapped destinations; a human or agent can review the proposed work against the existing document structure.
|
|
181
|
+
|
|
182
|
+
The docs.dirs setting extends document inventory and explicitly opts additional directories into freshness review. Inventory membership does not mean every detector checks every file. Semantic extraction covers canonical Markdown, explicitly mapped Markdown roles, README, and AGENTS within its safety and size limits; other prose remains unverified.
|
|
183
|
+
|
|
184
|
+
## Review signals and historical material
|
|
185
|
+
|
|
186
|
+
Freshness findings describe repository-history review signals with low confidence. They do not establish semantic drift or instruct automatic rewriting. After reviewing the relevant intent and implementation, record a review date or propose the appropriate code/document change.
|
|
187
|
+
|
|
188
|
+
Use an explicit historical, superseded, or deprecated status when a document records past decisions rather than current instructions:
|
|
189
|
+
|
|
190
|
+
```markdown
|
|
191
|
+
<!-- docguard:status historical -->
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
These statuses skip currentness assertions; they do not hide structural or other applicable findings. A filename containing ADR does not automatically exempt an active decision. Existing explicit validator/section exemptions continue to require reasons.
|
|
195
|
+
|
|
196
|
+
## Understanding check coverage
|
|
197
|
+
|
|
198
|
+
Guard JSON includes checkCoverage and an applicability record per validator. States distinguish checked, partial, disabled, not-applicable, missing-prerequisite, unsupported, no-matches, and error. A passing gate means the selected policy passed; it does not mean unsupported languages or unmatched inputs were examined. CI and reports preserve this disclosure. Python import-graph analysis remains unsupported; mixed Python/JS projects disclose partial architecture coverage.
|
|
199
|
+
|
|
200
|
+
Wrangler configuration supplies evidence for Worker classification. Supported typed Worker bindings participate in environment extraction without executing configuration or application code. Dynamic names, alias/dataflow tracking, and unsupported forms remain outside this bounded analysis. The existing optional Babel parser resolves lexical bindings; the fallback covers ordinary tested scopes and has lower syntax coverage.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# DocGuard — CDD Enforcement Extension for Spec Kit
|
|
2
2
|
|
|
3
|
-
Enterprise-grade Canonical-Driven Development (CDD) enforcement and **AI-readable project memory** for [Spec Kit](https://github.com/github/spec-kit). DocGuard builds
|
|
3
|
+
Enterprise-grade Canonical-Driven Development (CDD) enforcement and **AI-readable project memory** for [Spec Kit](https://github.com/github/spec-kit). DocGuard builds source-derived documentation context for supported code (`generate --plan`), refreshes generated sections as code changes (`sync`), and checks configured rules (`guard`) — with deterministic mechanical fixes (`fix --write`) where it can and grounded agent prompts where prose is needed.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **Language-
|
|
7
|
+
- **Configurable validators** — Structure, Security, Doc Quality, Test-Spec, Drift-Comments, API-Surface, Freshness, Cross-Reference, and more; use `docguard --help` for the current surface
|
|
8
|
+
- **Language-specific extraction** — Supported checks cover several languages and monorepos. Coverage varies by detector; unsupported inputs remain unverified.
|
|
9
9
|
- **AI-powered Generate** — `generate --plan` builds the code-truth skeleton in `<!-- docguard:section -->` markers and emits a structured agent task manifest; the AI writes the prose.
|
|
10
|
-
- **
|
|
10
|
+
- **Refresh and review** — `sync` surgically refreshes code-truth doc sections in place, **preserves human prose**, flags prose for agent review.
|
|
11
11
|
- **Mechanical `fix --write`** — deterministic, no-LLM: remove stale documented endpoints, refresh stale "N validators" counts, replace stale version refs, insert missing `## [Unreleased]`.
|
|
12
12
|
- **5 AI Skills** — docguard-fix, docguard-guard, docguard-sync, docguard-review, docguard-score (enterprise-grade behavior protocols, not just step-lists)
|
|
13
13
|
- **Workflow Chaining** — YAML handoffs enable guard → sync → fix → review → score flows
|
|
@@ -20,7 +20,7 @@ Enterprise-grade Canonical-Driven Development (CDD) enforcement and **AI-readabl
|
|
|
20
20
|
npm install -g docguard-cli
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Spec Kit orchestration scripts require a local `node_modules/docguard-cli` installation or `docguard` on PATH; they do not implicitly download a CLI. Direct CLI use via npx remains available:
|
|
24
24
|
```bash
|
|
25
25
|
npx docguard-cli guard
|
|
26
26
|
```
|
|
@@ -45,7 +45,7 @@ docguard score
|
|
|
45
45
|
|
|
46
46
|
| Command | Alias | Purpose |
|
|
47
47
|
|---------|-------|---------|
|
|
48
|
-
| `speckit.docguard.guard` | `docguard.guard` | Run
|
|
48
|
+
| `speckit.docguard.guard` | `docguard.guard` | Run configurable quality gate with severity triage |
|
|
49
49
|
| `speckit.docguard.fix` | `docguard.fix` | AI-driven documentation repair with codebase research |
|
|
50
50
|
| `speckit.docguard.review` | `docguard.review` | Cross-document semantic consistency analysis (read-only) |
|
|
51
51
|
| `speckit.docguard.score` | `docguard.score` | CDD maturity score with ROI improvement roadmap |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Refresh canonical code-truth sections and flag prose for review — refresh code-truth sections in place, preserve human prose
|
|
3
3
|
allowed-tools: Bash, Read, Edit
|
|
4
4
|
---
|
|
5
5
|
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.36.1"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -27,7 +27,7 @@ provides:
|
|
|
27
27
|
commands:
|
|
28
28
|
- name: "speckit.docguard.guard"
|
|
29
29
|
file: "commands/guard.md"
|
|
30
|
-
description: "Run
|
|
30
|
+
description: "Run configurable quality gate with severity triage and remediation plan"
|
|
31
31
|
|
|
32
32
|
- name: "speckit.docguard.fix"
|
|
33
33
|
file: "commands/generate.md"
|
|
@@ -76,8 +76,7 @@ provides:
|
|
|
76
76
|
hooks:
|
|
77
77
|
after_implement:
|
|
78
78
|
command: "speckit.docguard.guard"
|
|
79
|
-
optional:
|
|
80
|
-
prompt: "Run DocGuard validation after implementation?"
|
|
79
|
+
optional: false
|
|
81
80
|
description: "Quality gate — ensures docs stay in sync with code"
|
|
82
81
|
|
|
83
82
|
before_tasks:
|
|
@@ -23,25 +23,17 @@ find_docguard_root() {
|
|
|
23
23
|
|
|
24
24
|
find_docguard_cli() {
|
|
25
25
|
local root="${1:-$(pwd)}"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
echo "docguard-cli"
|
|
36
|
-
return 0
|
|
37
|
-
fi
|
|
38
|
-
|
|
39
|
-
# Fall back to npx
|
|
40
|
-
if command -v npx >/dev/null 2>&1; then
|
|
41
|
-
echo "npx docguard-cli"
|
|
26
|
+
local entry
|
|
27
|
+
for entry in "$root/cli/docguard.mjs" "$root/node_modules/docguard-cli/cli/docguard.mjs"; do
|
|
28
|
+
if [ -f "$entry" ] && command -v node >/dev/null 2>&1; then
|
|
29
|
+
printf '%q %q\n' "$(command -v node)" "$entry"
|
|
30
|
+
return 0
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
if command -v docguard >/dev/null 2>&1; then
|
|
34
|
+
printf '%q\n' "$(command -v docguard)"
|
|
42
35
|
return 0
|
|
43
36
|
fi
|
|
44
|
-
|
|
45
37
|
return 1
|
|
46
38
|
}
|
|
47
39
|
|
|
@@ -104,17 +104,24 @@ if $JSON_MODE; then
|
|
|
104
104
|
# Optionally include score and guard
|
|
105
105
|
EXTRAS=""
|
|
106
106
|
if $VERBOSE; then
|
|
107
|
-
SCORE_OUTPUT=$(eval $CLI_CMD score --format json
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
SCORE_OUTPUT=$(eval "$CLI_CMD score --format json")
|
|
108
|
+
SCORE=$(printf '%s' "$SCORE_OUTPUT" | node -e '
|
|
109
|
+
const d=JSON.parse(require("fs").readFileSync(0,"utf8"));
|
|
110
|
+
if(!Number.isFinite(d.score) || d.score<0 || d.score>100) throw Error("Invalid DocGuard score");
|
|
111
|
+
process.stdout.write(String(d.score));
|
|
112
|
+
')
|
|
113
|
+
GUARD_STATUS=0
|
|
114
|
+
GUARD_OUTPUT=$(eval "$CLI_CMD guard --format json") || GUARD_STATUS=$?
|
|
115
|
+
EXTRAS=$(printf '%s' "$GUARD_OUTPUT" | node -e '
|
|
116
|
+
const d=JSON.parse(require("fs").readFileSync(0,"utf8"));
|
|
117
|
+
const exit=Number(process.argv[2]);
|
|
118
|
+
const expected={PASS:0,WARN:2,FAIL:1};
|
|
119
|
+
if(!Object.hasOwn(expected,d.status) || expected[d.status]!==exit ||
|
|
120
|
+
!Number.isInteger(d.passed) || !Number.isInteger(d.total) ||
|
|
121
|
+
d.passed<0 || d.total<d.passed) throw Error("Invalid DocGuard guard report");
|
|
122
|
+
const fields={score:Number(process.argv[1]),guardPass:d.passed,guardTotal:d.total,guardStatus:d.status};
|
|
123
|
+
process.stdout.write(","+JSON.stringify(fields).slice(1,-1));
|
|
124
|
+
' "$SCORE" "$GUARD_STATUS")
|
|
118
125
|
fi
|
|
119
126
|
|
|
120
127
|
# Check for spec-kit
|