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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requirement identity parsing shared by traceability and lifecycle recovery.
|
|
3
|
+
* Only declaration-shaped Markdown counts; prose, comments, code fences, and
|
|
4
|
+
* example sections cannot mint identities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REQ_PATTERNS = [
|
|
8
|
+
/\b(REQ)-(\d{2,4})\b/g,
|
|
9
|
+
/\b(FR)-(\d{2,4})\b/g,
|
|
10
|
+
/\b(NFR)-(\d{2,4})\b/g,
|
|
11
|
+
/\b(US)-(\d{2,4})\b/g,
|
|
12
|
+
/\b(STORY)-(\d{2,4})\b/g,
|
|
13
|
+
/\b(AC)-(\d{2,4})\b/g,
|
|
14
|
+
/\b(UC)-(\d{2,4})\b/g,
|
|
15
|
+
/\b(SYS)-(\d{2,4})\b/g,
|
|
16
|
+
/\b(ARCH)-(\d{2,4})\b/g,
|
|
17
|
+
/\b(MOD)-(\d{2,4})\b/g,
|
|
18
|
+
/\b(SC)-(\d{2,4})\b/g,
|
|
19
|
+
/(?<=\[[ xX]\]\s|@(?:req|task|covers)\s)(T)(\d{3,4})\b/g,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function requirementPatterns(config = {}) {
|
|
23
|
+
const customPattern = config.traceability?.requirementPattern;
|
|
24
|
+
return customPattern
|
|
25
|
+
? [new RegExp(customPattern, 'g')]
|
|
26
|
+
: DEFAULT_REQ_PATTERNS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return path-qualified identities from one Markdown artifact.
|
|
31
|
+
* @returns {Map<string, {id:string, file:string, line:number, text:string}>}
|
|
32
|
+
*/
|
|
33
|
+
export function collectRequirementIdsFromContent(content, docName, patterns = DEFAULT_REQ_PATTERNS) {
|
|
34
|
+
const reqIds = new Map();
|
|
35
|
+
const hasMatch = patterns.some(pattern => {
|
|
36
|
+
pattern.lastIndex = 0;
|
|
37
|
+
return pattern.test(content);
|
|
38
|
+
});
|
|
39
|
+
if (!hasMatch) return reqIds;
|
|
40
|
+
|
|
41
|
+
const lines = content.split('\n');
|
|
42
|
+
let fence = null;
|
|
43
|
+
let exampleLevel = null;
|
|
44
|
+
let inComment = false;
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
let line = lines[i];
|
|
47
|
+
if (/^(?: {4}|\t)/.test(line) && !fence && !inComment) continue;
|
|
48
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
49
|
+
if (fence) {
|
|
50
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
|
|
51
|
+
&& line.slice(marker[0].length).trim() === '') fence = null;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (marker) { fence = marker[1]; continue; }
|
|
55
|
+
line = line.replace(/<!--[\s\S]*?-->/g, '');
|
|
56
|
+
if (inComment) {
|
|
57
|
+
const close = line.indexOf('-->');
|
|
58
|
+
if (close < 0) continue;
|
|
59
|
+
line = line.slice(close + 3);
|
|
60
|
+
inComment = false;
|
|
61
|
+
}
|
|
62
|
+
const open = line.indexOf('<!--');
|
|
63
|
+
if (open >= 0) { line = line.slice(0, open); inComment = true; }
|
|
64
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)/);
|
|
65
|
+
if (heading) {
|
|
66
|
+
if (exampleLevel !== null && heading[1].length <= exampleLevel) exampleLevel = null;
|
|
67
|
+
if (exampleLevel === null && /^(?:(?:requirement|task)[ -]+)?(?:examples?|ID[ -]+(?:formats?|syntax|examples?)|(?:formats?|syntax)[ -]+(?:of[ -]+)?IDs?)\b/i.test(heading[2])) {
|
|
68
|
+
exampleLevel = heading[1].length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (exampleLevel !== null) continue;
|
|
72
|
+
for (const pattern of patterns) {
|
|
73
|
+
pattern.lastIndex = 0;
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
76
|
+
const prefix = line.slice(0, match.index);
|
|
77
|
+
if (!/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+|\|\s*)?[\s*`_]*$/.test(prefix)) {
|
|
78
|
+
if (!match[0].length) pattern.lastIndex++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const reqId = match[0];
|
|
82
|
+
if (!reqId.length) { pattern.lastIndex++; continue; }
|
|
83
|
+
const key = `${docName}#${reqId}`;
|
|
84
|
+
if (!reqIds.has(key)) {
|
|
85
|
+
reqIds.set(key, { id: reqId, file: docName, line: i + 1, text: line.trim() });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return reqIds;
|
|
91
|
+
}
|
|
@@ -19,10 +19,12 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
|
19
19
|
* existing tests are unaffected; guard just renders richer output.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
-
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from 'node:fs';
|
|
23
|
+
import { resolve, join, relative, basename, extname, isAbsolute } from 'node:path';
|
|
24
24
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
25
|
-
import { shouldIgnore, walkFiles as sharedWalkFiles,
|
|
25
|
+
import { shouldIgnore, walkFiles as sharedWalkFiles, buildIgnoreFilter, mergeIgnoreFile, DEFAULT_IGNORE_DIRS } from '../shared-ignore.mjs';
|
|
26
|
+
import { resolveDocDirs } from '../shared.mjs';
|
|
27
|
+
import { parseJsTs, walk } from '../scanners/js-ast.mjs';
|
|
26
28
|
import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
|
|
27
29
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
28
30
|
|
|
@@ -73,7 +75,7 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
73
75
|
let total = 0;
|
|
74
76
|
|
|
75
77
|
// Collect all doc content for searching
|
|
76
|
-
const allDocContent = collectDocContent(projectDir);
|
|
78
|
+
const allDocContent = collectDocContent(projectDir, config);
|
|
77
79
|
if (!allDocContent) {
|
|
78
80
|
// Literal legacy shape (no findings key) — tests deepEqual this exact object.
|
|
79
81
|
return { errors: [], warnings: [], passed: 0, total: 0 };
|
|
@@ -172,7 +174,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
|
172
174
|
code: 'DCV001',
|
|
173
175
|
validator: 'docsCoverage',
|
|
174
176
|
severity: 'warn',
|
|
175
|
-
message: `Config file "${entry}" exists but is not mentioned in
|
|
177
|
+
message: `Config file "${entry}" exists but is not mentioned in scanned supported Markdown or extension YAML documentation. Document its purpose in ARCHITECTURE.md or README.md`,
|
|
176
178
|
location: entry,
|
|
177
179
|
suggestion: { kind: 'fix', text: 'Explain what this config file does in ARCHITECTURE.md or README.md' },
|
|
178
180
|
}));
|
|
@@ -372,42 +374,60 @@ function checkIaCDocumentation(projectDir, iac, config = {}) {
|
|
|
372
374
|
}
|
|
373
375
|
|
|
374
376
|
/**
|
|
375
|
-
* Check 4:
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* patterns — these are configs the project USES. Avoids matching config names
|
|
379
|
-
* sitting in arrays (scan patterns for detecting other projects' configs).
|
|
377
|
+
* Check 4: Distinguish direct file IO from config-like path expressions.
|
|
378
|
+
* Parsed calls exclude comments and example strings. Unparsed text supplies
|
|
379
|
+
* review candidates only; it cannot establish file IO or documentation need.
|
|
380
380
|
*/
|
|
381
381
|
function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
|
|
382
382
|
const findings = [];
|
|
383
383
|
let passed = 0;
|
|
384
384
|
let total = 0;
|
|
385
|
-
|
|
386
385
|
const lowerDocContent = allDocContent.toLowerCase();
|
|
387
|
-
const foundConfigs = new
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
// resolve(dir, '.docguardignore'), existsSync('.env.example'), readFileSync('vitest.config.ts')
|
|
391
|
-
const usageRegex = /(?:resolve|join|existsSync|readFileSync|accessSync|writeFileSync)\s*\([^)]*['"`]([^'"`\n]{2,})['"`]/g;
|
|
386
|
+
const foundConfigs = new Map();
|
|
387
|
+
const methods = new Set(['resolve', 'join', 'existsSync', 'accessSync', 'readFileSync', 'writeFileSync']);
|
|
388
|
+
const directIO = new Set(['readFileSync', 'writeFileSync']);
|
|
392
389
|
|
|
393
390
|
const scanFile = (filePath) => {
|
|
394
|
-
|
|
395
|
-
if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return;
|
|
391
|
+
if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(extname(filePath))) return;
|
|
396
392
|
let content;
|
|
397
393
|
try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (name.includes('/') || name.startsWith('..'))
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
if (
|
|
410
|
-
|
|
394
|
+
if (!/(?:resolve|join|existsSync|accessSync|readFileSync|writeFileSync)\s*\(/.test(content)) return;
|
|
395
|
+
// Parsing cannot yield a config candidate without a matching literal prefix.
|
|
396
|
+
// Keep every escaped source conservatively: escapes may encode that prefix.
|
|
397
|
+
if (!/['"\x60](?:\.(?![./])|[\w-]+\.config\.)|\\/.test(content)) return;
|
|
398
|
+
const source = relative(projectDir, filePath).split('\\').join('/');
|
|
399
|
+
const record = (name, method, line, parsed) => {
|
|
400
|
+
if (typeof name !== 'string' || name.includes('/') || name.includes('\\') || name.startsWith('..')) return;
|
|
401
|
+
if (!(name.startsWith('.') && name.length > 2) && !/^[\w-]+\.config\.\w+$/.test(name)) return;
|
|
402
|
+
if (/^\.[a-z]{1,4}$/i.test(name) || COMMON_DOTFILES.has(name)) return;
|
|
403
|
+
const direct = parsed && directIO.has(method);
|
|
404
|
+
// Prefer concrete IO when the same name also occurs in a path expression.
|
|
405
|
+
if (!foundConfigs.has(name) || (direct && !foundConfigs.get(name).direct)) {
|
|
406
|
+
foundConfigs.set(name, { direct, parsed, method, source: source + ':' + line });
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
const { ast, ok } = parseJsTs(content, filePath);
|
|
410
|
+
if (ok && !ast.errors?.length) {
|
|
411
|
+
walk(ast, node => {
|
|
412
|
+
if (node.type !== 'CallExpression') return;
|
|
413
|
+
const callee = node.callee;
|
|
414
|
+
const method = callee.type === 'Identifier' ? callee.name
|
|
415
|
+
: callee.type === 'MemberExpression' && !callee.computed ? callee.property.name : null;
|
|
416
|
+
if (!methods.has(method)) return;
|
|
417
|
+
// IO and existence checks use argument one as the path; join/resolve
|
|
418
|
+
// may supply a filename in any segment. No dynamic-path evaluation.
|
|
419
|
+
const args = ['join', 'resolve'].includes(method) ? node.arguments : node.arguments.slice(0, 1);
|
|
420
|
+
for (const arg of args) {
|
|
421
|
+
const value = arg.type === 'StringLiteral' ? arg.value
|
|
422
|
+
: arg.type === 'TemplateLiteral' && arg.expressions.length === 0 ? arg.quasis[0].value.cooked : null;
|
|
423
|
+
record(value, method, node.loc.start.line, true);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
} else {
|
|
427
|
+
const pattern = /\b(resolve|join|existsSync|accessSync|readFileSync|writeFileSync)\s*\([^)]*?['"\x60]([^'"\x60\n]{2,})['"\x60]/g;
|
|
428
|
+
for (const match of content.matchAll(pattern)) {
|
|
429
|
+
record(match[2], match[1], content.slice(0, match.index).split('\n').length, false);
|
|
430
|
+
}
|
|
411
431
|
}
|
|
412
432
|
};
|
|
413
433
|
|
|
@@ -415,19 +435,26 @@ function checkCodeReferencedConfigs(projectDir, allDocContent, config = {}) {
|
|
|
415
435
|
walkFiles(rootDir, scanFile);
|
|
416
436
|
}
|
|
417
437
|
|
|
418
|
-
for (const configName of foundConfigs) {
|
|
419
|
-
if (COMMON_DOTFILES.has(configName)) continue;
|
|
438
|
+
for (const [configName, evidence] of foundConfigs) {
|
|
420
439
|
total++;
|
|
421
440
|
if (lowerDocContent.includes(configName.toLowerCase())) {
|
|
422
441
|
passed++;
|
|
423
442
|
} else {
|
|
443
|
+
const scope = 'scanned supported Markdown or extension YAML documentation';
|
|
444
|
+
const context = evidence.parsed ? evidence.method + ' call' : 'unparsed source text (parser unavailable or failed)';
|
|
424
445
|
findings.push(mkFinding({
|
|
425
446
|
code: 'DCV004',
|
|
426
447
|
validator: 'docsCoverage',
|
|
427
448
|
severity: 'warn',
|
|
428
|
-
|
|
449
|
+
confidence: evidence.direct ? 'high' : 'low',
|
|
450
|
+
message: evidence.direct
|
|
451
|
+
? 'Direct ' + context + ' references config file "' + configName + '" at ' + evidence.source + ', but it is not mentioned in ' + scope + '.'
|
|
452
|
+
: 'Config-like path "' + configName + '" appears in ' + context + ' at ' + evidence.source + '; file use and documentation need are unverified. No mention was found in ' + scope + '.',
|
|
453
|
+
// Preserve the published filename location; source context is in the message.
|
|
429
454
|
location: configName,
|
|
430
|
-
suggestion:
|
|
455
|
+
suggestion: evidence.direct
|
|
456
|
+
? { kind: 'fix', text: 'Describe this config file (purpose and format) in a supported documentation file' }
|
|
457
|
+
: { kind: 'review', text: 'Inspect this source reference: it may be a directory, generated output, or example. Document it only if appropriate.' },
|
|
431
458
|
}));
|
|
432
459
|
}
|
|
433
460
|
}
|
|
@@ -500,36 +527,59 @@ function checkReadmeSections(projectDir) {
|
|
|
500
527
|
/**
|
|
501
528
|
* Collect all documentation content into a single searchable string.
|
|
502
529
|
*/
|
|
503
|
-
function collectDocContent(projectDir) {
|
|
504
|
-
const docPaths =
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
530
|
+
function collectDocContent(projectDir, config = {}) {
|
|
531
|
+
const docPaths = new Set();
|
|
532
|
+
const visited = new Set();
|
|
533
|
+
const isIgnored = buildIgnoreFilter(mergeIgnoreFile(projectDir, { ...config }).ignore);
|
|
534
|
+
|
|
535
|
+
// Check every ancestor before traversal: the shared walker follows symlinks.
|
|
536
|
+
// Explicit homes must remain scoped inside the project, including private aliases.
|
|
537
|
+
const safePath = (path) => {
|
|
538
|
+
const normalized = path.replace(/\\/g, '/');
|
|
539
|
+
if (isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) || normalized.includes('\0')) return null;
|
|
540
|
+
const parts = normalized.split('/').filter(p => p && p !== '.');
|
|
541
|
+
if (!parts.length || parts.some(p => p === '..' || ['.local', '.git'].includes(p.toLowerCase())
|
|
542
|
+
|| /^\.env(?:\.|$)/i.test(p) || DEFAULT_IGNORE_DIRS.has(p) || IGNORE_DIRS.has(p))) return null;
|
|
543
|
+
let current = resolve(projectDir);
|
|
544
|
+
let rel = '';
|
|
545
|
+
try {
|
|
546
|
+
for (const part of parts) {
|
|
547
|
+
rel = rel ? rel + '/' + part : part;
|
|
548
|
+
if (isIgnored(rel) || isIgnored(rel + '/')) return null;
|
|
549
|
+
current = join(current, part);
|
|
550
|
+
if (lstatSync(current).isSymbolicLink()) return null;
|
|
519
551
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
if (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
552
|
+
return current;
|
|
553
|
+
} catch { return null; }
|
|
554
|
+
};
|
|
555
|
+
const addDoc = (rel) => {
|
|
556
|
+
const abs = safePath(rel);
|
|
557
|
+
if (abs && lstatSync(abs).isFile()) docPaths.add(abs);
|
|
558
|
+
};
|
|
559
|
+
const walkDocs = (rel) => {
|
|
560
|
+
const dir = safePath(rel);
|
|
561
|
+
if (!dir || visited.has(dir)) return;
|
|
562
|
+
visited.add(dir);
|
|
563
|
+
let entries;
|
|
564
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
565
|
+
for (const entry of entries) {
|
|
566
|
+
if (entry.isSymbolicLink()) continue;
|
|
567
|
+
const path = relative(projectDir, join(dir, entry.name)).split('\\').join('/');
|
|
568
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) walkDocs(path);
|
|
569
|
+
else if (entry.isFile() && (/\.md$/i.test(entry.name)
|
|
570
|
+
|| (path.startsWith('extensions/') && /\.ya?ml$/i.test(entry.name)))) addDoc(path);
|
|
529
571
|
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
for (const doc of ['README.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'STANDARD.md']) addDoc(doc);
|
|
575
|
+
for (const dir of resolveDocDirs(projectDir, config)) walkDocs(dir);
|
|
576
|
+
// Resolve roles directly so raw callers get the same boundaries as loadConfig.
|
|
577
|
+
for (const role of Object.keys(config.docs?.roles || {})) {
|
|
578
|
+
const abs = resolveDocRole(projectDir, config, role);
|
|
579
|
+
addDoc(relative(projectDir, abs));
|
|
530
580
|
}
|
|
531
581
|
|
|
532
|
-
if (docPaths.
|
|
582
|
+
if (docPaths.size === 0) return null;
|
|
533
583
|
const parts = [];
|
|
534
584
|
for (const p of docPaths) {
|
|
535
585
|
try { parts.push(readFileSync(p, 'utf-8')); } catch { /* skip */ }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
2
|
+
import { scanDocumentLifecycle } from '../scanners/document-lifecycle.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finds terminal lifecycle declarations and specs whose task list is complete.
|
|
6
|
+
* Both remain review signals; `retire --write` still requires explicit paths.
|
|
7
|
+
*/
|
|
8
|
+
export function validateDocumentLifecycle(projectDir, config = {}) {
|
|
9
|
+
const { scanned, candidates, coverage } = scanDocumentLifecycle(projectDir, config);
|
|
10
|
+
if (coverage.status === 'unavailable' && coverage.reason === 'not-git') {
|
|
11
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
12
|
+
}
|
|
13
|
+
const findings = candidates.map(candidate => mkFinding({
|
|
14
|
+
code: candidate.code,
|
|
15
|
+
validator: 'documentLifecycle',
|
|
16
|
+
severity: 'warn',
|
|
17
|
+
confidence: candidate.confidence === 'high' ? 'high' : 'low',
|
|
18
|
+
message: candidate.code === 'DLC001'
|
|
19
|
+
? `${candidate.path} declares terminal lifecycle status "${candidate.status}" but remains in active AI context.`
|
|
20
|
+
: candidate.code === 'DLC004'
|
|
21
|
+
? `${candidate.path} is recorded as retired but remains in the working tree.`
|
|
22
|
+
: `${candidate.path} has ${candidate.reason}`,
|
|
23
|
+
location: candidate.path,
|
|
24
|
+
suggestion: {
|
|
25
|
+
kind: 'review',
|
|
26
|
+
text: 'Confirm shipped outcomes are represented in current docs, then archive the explicit path.',
|
|
27
|
+
command: `docguard retire --write --path ${candidate.path} --reason "<why this is no longer current>"`,
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
if (coverage.status !== 'complete') {
|
|
31
|
+
findings.push(mkFinding({
|
|
32
|
+
code: 'DLC003',
|
|
33
|
+
validator: 'documentLifecycle',
|
|
34
|
+
severity: 'warn',
|
|
35
|
+
confidence: 'high',
|
|
36
|
+
message: coverage.status === 'unavailable'
|
|
37
|
+
? `Document lifecycle coverage is unavailable: ${coverage.error}`
|
|
38
|
+
: `Document lifecycle coverage is partial; ${coverage.unreadable.length} tracked Markdown file(s) could not be read.`,
|
|
39
|
+
location: coverage.unreadable[0] || null,
|
|
40
|
+
suggestion: {
|
|
41
|
+
kind: 'review',
|
|
42
|
+
text: 'Restore readable Git/document access and rerun guard; an incomplete scan cannot prove lifecycle hygiene.',
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
return resultFromFindings(findings, {
|
|
47
|
+
passed: Math.max(0, scanned - candidates.length),
|
|
48
|
+
total: scanned + (coverage.status === 'unavailable' ? 1 : 0),
|
|
49
|
+
applicable: scanned > 0 || coverage.status !== 'complete',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -10,15 +10,14 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
|
10
10
|
* Zero NPM runtime dependencies — pure Node.js built-ins only.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync
|
|
14
|
-
import { resolve,
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { resolve, relative, basename } from 'node:path';
|
|
15
15
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
16
|
-
import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
16
|
+
import { DEFAULT_IGNORE_DIRS, relPosix, shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
17
17
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
18
18
|
|
|
19
19
|
const IGNORE_DIRS = new Set([
|
|
20
|
-
|
|
21
|
-
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
20
|
+
...DEFAULT_IGNORE_DIRS,
|
|
22
21
|
'.amplify-hosting', '.serverless',
|
|
23
22
|
]);
|
|
24
23
|
|
|
@@ -192,7 +191,8 @@ function detectAllModels(projectDir, config = {}) {
|
|
|
192
191
|
* Find schema files for a given detector configuration.
|
|
193
192
|
*/
|
|
194
193
|
function findSchemaFiles(projectDir, detector, config = {}) {
|
|
195
|
-
|
|
194
|
+
// Deduplicate files, not model names: separate schemas may share a name.
|
|
195
|
+
const files = new Set();
|
|
196
196
|
|
|
197
197
|
// Monorepo-aware: resolve each searchDir against the project root AND every
|
|
198
198
|
// configured source root (config.sourceRoot + workspaces), so schemas under
|
|
@@ -203,19 +203,24 @@ function findSchemaFiles(projectDir, detector, config = {}) {
|
|
|
203
203
|
for (const base of bases) {
|
|
204
204
|
for (const searchDir of detector.searchDirs) {
|
|
205
205
|
const dir = resolve(base, searchDir);
|
|
206
|
-
|
|
206
|
+
const rel = relPosix(projectDir, dir);
|
|
207
|
+
if (seenDirs.has(dir) || !existsSync(dir) ||
|
|
208
|
+
rel.split('/').some(part => IGNORE_DIRS.has(part)) ||
|
|
209
|
+
shouldIgnore(rel + '/', config)) continue;
|
|
207
210
|
seenDirs.add(dir);
|
|
208
|
-
scanSchemaDir(dir, detector.filePattern, files);
|
|
211
|
+
scanSchemaDir(dir, detector.filePattern, files, projectDir, config);
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
214
|
|
|
212
|
-
return files;
|
|
215
|
+
return [...files];
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
216
|
-
function scanSchemaDir(dir, filePattern, files) {
|
|
219
|
+
function scanSchemaDir(dir, filePattern, files, projectDir, config) {
|
|
217
220
|
sharedWalkFiles(dir, (full) => {
|
|
218
|
-
if (filePattern.test(basename(full))
|
|
221
|
+
if (filePattern.test(basename(full)) && !shouldIgnore(relPosix(projectDir, full), config)) {
|
|
222
|
+
files.add(full);
|
|
223
|
+
}
|
|
219
224
|
}, { ignoreDirs: IGNORE_DIRS });
|
|
220
225
|
}
|
|
221
226
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
2
|
+
import { projectSpecRegistry, SPEC_REGISTRY_PATH } from '../scanners/spec-registry.mjs';
|
|
3
|
+
|
|
4
|
+
export function validateSpecRegistry(projectDir, config = {}) {
|
|
5
|
+
const projection = projectSpecRegistry(projectDir, config);
|
|
6
|
+
if (projection.detected === 0 && !projection.exists) {
|
|
7
|
+
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
8
|
+
}
|
|
9
|
+
const findings = projection.issues.map(issue => mkFinding({
|
|
10
|
+
code: issue.code,
|
|
11
|
+
validator: 'specRegistry',
|
|
12
|
+
severity: 'warn',
|
|
13
|
+
confidence: 'high',
|
|
14
|
+
message: issue.message,
|
|
15
|
+
location: issue.path,
|
|
16
|
+
suggestion: {
|
|
17
|
+
kind: 'review',
|
|
18
|
+
text: issue.code === 'SPR002'
|
|
19
|
+
? 'Assign a unique immutable Spec ID in the authoritative spec metadata.'
|
|
20
|
+
: 'Resolve the lifecycle or registry integrity conflict, then refresh the registry.',
|
|
21
|
+
command: 'docguard specs --write',
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
if (!projection.current && projection.issues.length === 0) {
|
|
25
|
+
findings.push(mkFinding({
|
|
26
|
+
code: 'SPR001',
|
|
27
|
+
validator: 'specRegistry',
|
|
28
|
+
severity: 'warn',
|
|
29
|
+
confidence: 'high',
|
|
30
|
+
message: projection.exists
|
|
31
|
+
? `${SPEC_REGISTRY_PATH} does not match the current deterministic spec evidence projection.`
|
|
32
|
+
: `${SPEC_REGISTRY_PATH} is missing while active specifications exist.`,
|
|
33
|
+
location: SPEC_REGISTRY_PATH,
|
|
34
|
+
suggestion: {
|
|
35
|
+
kind: 'fix',
|
|
36
|
+
text: 'Refresh derived evidence without changing reviewed lifecycle fields.',
|
|
37
|
+
command: 'docguard specs --write',
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
const checks = Math.max(1, projection.registry.specs.length + projection.registry.tombstones.length);
|
|
42
|
+
return resultFromFindings(findings, {
|
|
43
|
+
passed: Math.max(0, checks - findings.length),
|
|
44
|
+
total: checks,
|
|
45
|
+
applicable: true,
|
|
46
|
+
});
|
|
47
|
+
}
|