docguard-cli 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +8 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/extension.yml +2 -3
  42. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  43. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  44. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  45. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  46. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  49. package/package.json +1 -1
  50. package/schemas/docguard-config.schema.json +43 -1
  51. 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
- // Skip explanation patterns (comments that justify the skip)
77
- const SKIP_REASON_PATTERN = /\/\/\s*(REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:/i;
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 lines = content.split('\n');
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}:${i + 1}. ` +
206
+ message: `Skipped test without explanation at ${relPath}:${line}. ` +
174
207
  `Add a // REASON: comment explaining why the test is skipped`,
175
- location: `${relPath}:${i + 1}`,
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
- * found (REQ-001, FR-001, etc.), the check silently passes. Once you add IDs,
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(f =>
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
- total++;
170
- const docPath = resolve(docsDir, docName);
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: `docs-canonical/${docName}`,
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: `docs-canonical/${docName}`,
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 requirement IDs found anywhere → silently passes (0 checks)
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 = `Add an @req ${reqId} comment to the test that verifies this requirement`;
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 = `${top.id} looks like it already tests this (${pct}% similar) add @req ${reqId} there, or if unrelated, write the missing test`;
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 coverage.${softHint || ' Add @req ' + reqId + ' comment to the test that verifies this requirement'}`,
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: 'fix', text: softText },
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(lines[i])) !== null) {
376
- const reqId = match[0]; // e.g., "REQ-001"
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
- // capture the line text (the requirement description) for IR soft-match
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(f =>
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 lines = content.split('\n');
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(lines[i])) !== null) {
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
- testRefs.get(reqId).push({ file: relPath, line: i + 1 });
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
- if (existsSync(p) && !paths.includes(p)) paths.push(p);
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)
@@ -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.
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.35.0"
6
+ version: "0.36.0"
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"
@@ -76,8 +76,7 @@ provides:
76
76
  hooks:
77
77
  after_implement:
78
78
  command: "speckit.docguard.guard"
79
- optional: true
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:
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.35.0
9
+ version: 0.36.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.35.0 -->
12
+ <!-- docguard:version: 0.36.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.35.0
10
+ version: 0.36.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.35.0 -->
13
+ <!-- docguard:version: 0.36.0 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.35.0
9
+ version: 0.36.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.35.0 -->
12
+ <!-- docguard:version: 0.36.0 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.35.0
9
+ version: 0.36.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.35.0 -->
12
+ <!-- docguard:version: 0.36.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.35.0
7
+ version: 0.36.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.35.0 -->
10
+ <!-- docguard:version: 0.36.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
@@ -17,8 +17,7 @@ hooks:
17
17
  command: speckit.docguard.guard
18
18
  description: "Validate documentation passes CDD standards after implementation"
19
19
  enabled: true
20
- optional: true
21
- prompt: "Run DocGuard guard to verify documentation quality after implementation changes?"
20
+ optional: false
22
21
 
23
22
  # Run DocGuard review before /speckit.tasks to catch doc drift early
24
23
  before_tasks: