docguard-cli 0.28.0 → 0.29.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.es.md +102 -0
- package/README.md +64 -31
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +136 -8
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- package/cli/docguard.mjs +31 -2
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- package/cli/scanners/speckit.mjs +98 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +113 -26
- package/cli/validators/architecture.mjs +66 -43
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- 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/package.json +1 -1
- package/schemas/docguard-config.schema.json +17 -0
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- package/templates/commands/docguard.update.md +14 -4
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative } from 'node:path';
|
|
11
|
-
import { loadIgnorePatterns,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
]);
|
|
11
|
+
import { loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
|
|
12
|
+
// v0.29 consolidation: walker + glob counting live in shared-ignore.mjs (the
|
|
13
|
+
// single implementations) — this file previously carried private copies.
|
|
14
|
+
import { walkFiles, countGlobFiles } from '../shared-ignore.mjs';
|
|
15
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Validate metrics consistency across documentation.
|
|
@@ -22,8 +21,11 @@ const IGNORE_DIRS = new Set([
|
|
|
22
21
|
* @param {object} [guardResults] - Results from runGuardInternal (optional)
|
|
23
22
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
24
23
|
*/
|
|
24
|
+
// v0.29: migrated to structured findings (MET001 built-in meta-counts, MET002
|
|
25
|
+
// declared collections). Messages are byte-identical to the legacy strings;
|
|
26
|
+
// the `fixes` array is preserved for the fix applier.
|
|
25
27
|
export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
26
|
-
const
|
|
28
|
+
const findings = [];
|
|
27
29
|
const fixes = [];
|
|
28
30
|
let passed = 0;
|
|
29
31
|
let total = 0;
|
|
@@ -52,18 +54,56 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
52
54
|
|
|
53
55
|
// If no actuals to compare, skip
|
|
54
56
|
if (Object.keys(actuals).length === 0) {
|
|
55
|
-
return
|
|
57
|
+
return resultFromFindings([], { passed: 0, total: 0 });
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
// ── Scan markdown files for hardcoded numbers ──
|
|
59
61
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
60
62
|
const mdFiles = findMarkdownFiles(projectDir, config);
|
|
61
|
-
// Patterns must match standalone number references, not ratio-style "8/8 checks"
|
|
63
|
+
// Patterns must match standalone number references, not ratio-style "8/8 checks".
|
|
64
|
+
// `requireBind`: built-in DocGuard meta-counts (checks/validators) describe a
|
|
65
|
+
// generic noun, so they only fire when the line is bound to "docguard" (Bug #2).
|
|
66
|
+
// `subject`: human phrasing for the warning. `actualSource`: records WHAT the
|
|
67
|
+
// actual count describes so the fix applier can confirm both sides are the same
|
|
68
|
+
// subject before overwriting.
|
|
62
69
|
const patterns = [
|
|
63
|
-
{ key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
|
|
64
|
-
{ key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators' },
|
|
70
|
+
{ key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.checks' },
|
|
71
|
+
{ key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.validators' },
|
|
65
72
|
];
|
|
66
73
|
|
|
74
|
+
// v0.29 (field report #6): project-declared collections. `config.collections`
|
|
75
|
+
// maps a documentation noun (e.g. "extractors") to a glob whose matching-file
|
|
76
|
+
// count is the source of truth. This catches the exact class that shipped a
|
|
77
|
+
// wrong "16 extractors" past a green guard — deterministically, in `guard`, with
|
|
78
|
+
// no LLM. A declared collection IS the opt-in binding (the user named this noun),
|
|
79
|
+
// so unlike the built-ins it does NOT require "docguard" on the line. Fail-safe:
|
|
80
|
+
// an unresolved glob (0 matches) never asserts "0", so a misconfigured pattern
|
|
81
|
+
// can't manufacture a false drift. Reserved nouns keep the built-in count.
|
|
82
|
+
const RESERVED = new Set(['checks', 'validators', 'tests']);
|
|
83
|
+
const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // noun stems, not globs
|
|
84
|
+
const collections = (config && config.collections && typeof config.collections === 'object') ? config.collections : {};
|
|
85
|
+
for (const [noun, glob] of Object.entries(collections)) {
|
|
86
|
+
const key = String(noun).toLowerCase();
|
|
87
|
+
if (RESERVED.has(key) || typeof glob !== 'string' || !glob.trim()) continue;
|
|
88
|
+
const count = countGlobFiles(projectDir, glob);
|
|
89
|
+
// <= 0 covers BOTH "unresolved/empty glob" (0) and "walk incomplete" (-1,
|
|
90
|
+
// e.g. permission-denied subtree). Either way: never assert a count we
|
|
91
|
+
// can't stand behind — a partial count auto-"fixing" a correct doc number
|
|
92
|
+
// is the tool's worst failure mode.
|
|
93
|
+
if (count <= 0) continue;
|
|
94
|
+
actuals[key] = count;
|
|
95
|
+
const stem = escapeRegExp(String(noun).replace(/s$/i, ''));
|
|
96
|
+
patterns.push({
|
|
97
|
+
key,
|
|
98
|
+
regex: new RegExp(`(?<!\\d\\/)\\b(\\d+)\\s+${stem}s?\\b`, 'gi'),
|
|
99
|
+
label: String(noun),
|
|
100
|
+
requireBind: false,
|
|
101
|
+
isCollection: true,
|
|
102
|
+
glob,
|
|
103
|
+
actualSource: `docguard.collections.${key}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
// v0.14.1-N1: dedup by (file, label, found) — a file that mentions the
|
|
68
108
|
// stale number multiple times produces ONE warning, not one per occurrence.
|
|
69
109
|
// The replace-count applier already uses replace-all semantics, so a single
|
|
@@ -82,7 +122,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
82
122
|
let content;
|
|
83
123
|
try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
|
|
84
124
|
|
|
85
|
-
for (const { key, regex, label } of patterns) {
|
|
125
|
+
for (const { key, regex, label, requireBind, subject, actualSource, isCollection, glob } of patterns) {
|
|
86
126
|
if (actuals[key] === undefined) continue;
|
|
87
127
|
|
|
88
128
|
regex.lastIndex = 0;
|
|
@@ -93,11 +133,13 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
93
133
|
// "19" on line 50 are two distinct drifts.
|
|
94
134
|
const distinctFoundInFile = new Set();
|
|
95
135
|
while ((match = regex.exec(content)) !== null) {
|
|
96
|
-
// Bug #2 (subject-binding):
|
|
97
|
-
// An unbound "N checks" (a proof harness, a CI
|
|
98
|
-
// describes a DIFFERENT subject — comparing it to
|
|
99
|
-
// is a false positive, and auto-fixing it overwrites a
|
|
100
|
-
|
|
136
|
+
// Bug #2 (subject-binding): for the built-in meta-counts, only validate a
|
|
137
|
+
// number BOUND to DocGuard. An unbound "N checks" (a proof harness, a CI
|
|
138
|
+
// job, a third-party tool) describes a DIFFERENT subject — comparing it to
|
|
139
|
+
// DocGuard's own count is a false positive, and auto-fixing it overwrites a
|
|
140
|
+
// correct number. Project-declared collections (requireBind:false) skip
|
|
141
|
+
// this: naming the noun in `config.collections` IS the explicit binding.
|
|
142
|
+
if (requireBind && !isDocguardBound(content, match.index)) continue;
|
|
101
143
|
distinctFoundInFile.add(parseInt(match[1], 10));
|
|
102
144
|
}
|
|
103
145
|
if (distinctFoundInFile.size === 0) continue;
|
|
@@ -108,13 +150,27 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
108
150
|
if (reportedDrift.has(driftKey)) continue;
|
|
109
151
|
reportedDrift.add(driftKey);
|
|
110
152
|
total++;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
153
|
+
const phrase = isCollection
|
|
154
|
+
? `the code has ${actuals[key]} (${glob})`
|
|
155
|
+
: `${subject} ${label} count is ${actuals[key]}`;
|
|
156
|
+
findings.push(mkFinding({
|
|
157
|
+
code: isCollection ? 'MET002' : 'MET001',
|
|
158
|
+
validator: 'metricsConsistency',
|
|
159
|
+
severity: 'warn',
|
|
160
|
+
message: `${relPath} says "${found} ${label}" but ${phrase}. Fix with \`docguard fix --write\``,
|
|
161
|
+
location: relPath,
|
|
162
|
+
suggestion: {
|
|
163
|
+
kind: 'fix',
|
|
164
|
+
text: isCollection
|
|
165
|
+
? `Confirm which side is right, then rewrite the stale count (${found} → ${actuals[key]})`
|
|
166
|
+
: `Rewrite the stale docguard-bound count (${found} → ${actuals[key]})`,
|
|
167
|
+
command: 'docguard fix --write',
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
114
170
|
// actualSource records WHAT the actual count describes, so the applier
|
|
115
171
|
// (and a human) can confirm both sides are the same subject before any
|
|
116
172
|
// overwrite. Without it the fix is refused (fail-closed). See Bug #2.
|
|
117
|
-
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource
|
|
173
|
+
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource });
|
|
118
174
|
} else {
|
|
119
175
|
// Matches the actual count — one pass per (file, label), not per occurrence.
|
|
120
176
|
const passKey = `${relPath}|${label}`;
|
|
@@ -127,7 +183,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
127
183
|
}
|
|
128
184
|
}
|
|
129
185
|
|
|
130
|
-
return {
|
|
186
|
+
return { ...resultFromFindings(findings, { passed, total }), fixes };
|
|
131
187
|
}
|
|
132
188
|
|
|
133
189
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -208,17 +264,20 @@ function findMarkdownFiles(dir, config = {}) {
|
|
|
208
264
|
}
|
|
209
265
|
} catch { /* unreadable root */ }
|
|
210
266
|
|
|
211
|
-
// Configured canonical docs (wherever they live), plus
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
267
|
+
// Configured canonical docs (wherever they live), plus every resolved doc
|
|
268
|
+
// home — scanned in full (recursive). v0.29 (field report #6, follow-up): the
|
|
269
|
+
// doc-home set is no longer the hardcoded trio; resolveDocDirs auto-detects
|
|
270
|
+
// conventional doc dirs (docs/, documentation/, guides/, …) or honors an
|
|
271
|
+
// explicit config.docs.dirs. NAMED dirs only — code/tooling dirs (security/,
|
|
272
|
+
// backend/, src/, …) and arbitrary subdirs are still NEVER walked (the
|
|
273
|
+
// wu-whatsappinbox false-positive flood the scoping fix removed).
|
|
215
274
|
const canonical = config && config.requiredFiles && Array.isArray(config.requiredFiles.canonical)
|
|
216
275
|
? config.requiredFiles.canonical : [];
|
|
217
276
|
for (const rel of canonical) {
|
|
218
277
|
const full = resolve(dir, rel);
|
|
219
278
|
if (existsSync(full)) { try { if (statSync(full).isFile()) add(full); } catch { /* skip */ } }
|
|
220
279
|
}
|
|
221
|
-
for (const sub of
|
|
280
|
+
for (const sub of resolveDocDirs(dir, config)) {
|
|
222
281
|
const searchDir = resolve(dir, sub);
|
|
223
282
|
if (existsSync(searchDir)) walkFiles(searchDir, add);
|
|
224
283
|
}
|
|
@@ -226,23 +285,6 @@ function findMarkdownFiles(dir, config = {}) {
|
|
|
226
285
|
return mdFiles;
|
|
227
286
|
}
|
|
228
287
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
try { entries = readdirSync(dir); } catch { return; }
|
|
233
|
-
|
|
234
|
-
for (const entry of entries) {
|
|
235
|
-
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
236
|
-
const fullPath = join(dir, entry);
|
|
237
|
-
try {
|
|
238
|
-
const stat = statSync(fullPath);
|
|
239
|
-
if (stat.isDirectory()) {
|
|
240
|
-
walkFiles(fullPath, callback);
|
|
241
|
-
} else if (stat.isFile()) {
|
|
242
|
-
callback(fullPath);
|
|
243
|
-
}
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.error(`${c.red}Error reading file or directory: ${err.message}${c.reset}`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
288
|
+
// Local walker + glob→count helpers were removed in the v0.29 consolidation —
|
|
289
|
+
// `walkFiles` / `countGlobFiles` are imported from ../shared-ignore.mjs, the
|
|
290
|
+
// single canonical implementations.
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
13
|
import { resolve, join, relative, extname, basename } from 'node:path';
|
|
14
14
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
15
|
+
import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
16
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
15
17
|
|
|
16
18
|
const IGNORE_DIRS = new Set([
|
|
17
19
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -77,9 +79,15 @@ const SCHEMA_DETECTORS = [
|
|
|
77
79
|
|
|
78
80
|
/**
|
|
79
81
|
* Main validator entry point.
|
|
82
|
+
*
|
|
83
|
+
* v0.29: migrated to structured findings (SCH001–SCH002). Messages are
|
|
84
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
85
|
+
* errors/warnings arrays from the same findings array.
|
|
80
86
|
*/
|
|
81
87
|
export function validateSchemaSync(projectDir, config) {
|
|
82
|
-
const
|
|
88
|
+
const findings = [];
|
|
89
|
+
let passed = 0;
|
|
90
|
+
let total = 0;
|
|
83
91
|
|
|
84
92
|
// Check if DATA-MODEL.md exists
|
|
85
93
|
const dataModelPath = resolve(projectDir, 'docs-canonical', 'DATA-MODEL.md');
|
|
@@ -88,13 +96,18 @@ export function validateSchemaSync(projectDir, config) {
|
|
|
88
96
|
// Only warn if we detect schema files
|
|
89
97
|
const detectedModels = detectAllModels(projectDir, config);
|
|
90
98
|
if (detectedModels.length > 0) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
total++;
|
|
100
|
+
findings.push(mkFinding({
|
|
101
|
+
code: 'SCH001',
|
|
102
|
+
validator: 'schemaSync',
|
|
103
|
+
severity: 'warn',
|
|
104
|
+
message: `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
|
|
105
|
+
`but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`,
|
|
106
|
+
location: 'docs-canonical/DATA-MODEL.md',
|
|
107
|
+
suggestion: { kind: 'fix', text: 'Create DATA-MODEL.md, then document the detected models in it', command: 'docguard init' },
|
|
108
|
+
}));
|
|
96
109
|
}
|
|
97
|
-
return
|
|
110
|
+
return resultFromFindings(findings, { passed, total });
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
const dataModelContent = readFileSync(dataModelPath, 'utf-8').toLowerCase();
|
|
@@ -104,12 +117,12 @@ export function validateSchemaSync(projectDir, config) {
|
|
|
104
117
|
|
|
105
118
|
if (detectedModels.length === 0) {
|
|
106
119
|
// No schema files found — silently pass
|
|
107
|
-
return
|
|
120
|
+
return resultFromFindings(findings, { passed, total });
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
// Check each model appears in DATA-MODEL.md
|
|
111
124
|
for (const model of detectedModels) {
|
|
112
|
-
|
|
125
|
+
total++;
|
|
113
126
|
|
|
114
127
|
// Check if model name appears in DATA-MODEL.md (case-insensitive)
|
|
115
128
|
const modelLower = model.name.toLowerCase();
|
|
@@ -120,16 +133,21 @@ export function validateSchemaSync(projectDir, config) {
|
|
|
120
133
|
(modelLower.endsWith('s') && dataModelContent.includes(modelLower.slice(0, -1)));
|
|
121
134
|
|
|
122
135
|
if (found) {
|
|
123
|
-
|
|
136
|
+
passed++;
|
|
124
137
|
} else {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
findings.push(mkFinding({
|
|
139
|
+
code: 'SCH002',
|
|
140
|
+
validator: 'schemaSync',
|
|
141
|
+
severity: 'warn',
|
|
142
|
+
message: `${model.framework} model "${model.name}" (${model.file}) not documented in DATA-MODEL.md. ` +
|
|
143
|
+
`Add it to the Entity Definitions section`,
|
|
144
|
+
location: model.file,
|
|
145
|
+
suggestion: { kind: 'fix', text: 'Document the model in the Entity Definitions section of docs-canonical/DATA-MODEL.md' },
|
|
146
|
+
}));
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
return
|
|
150
|
+
return resultFromFindings(findings, { passed, total });
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
// ──── Model Detection ──────────────────────────────────────────────────────
|
|
@@ -193,24 +211,11 @@ function findSchemaFiles(projectDir, detector, config = {}) {
|
|
|
193
211
|
return files;
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
196
215
|
function scanSchemaDir(dir, filePattern, files) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
for (const entry of entries) {
|
|
201
|
-
if (IGNORE_DIRS.has(entry)) continue;
|
|
202
|
-
if (entry.startsWith('.')) continue;
|
|
203
|
-
|
|
204
|
-
const full = join(dir, entry);
|
|
205
|
-
let stat;
|
|
206
|
-
try { stat = statSync(full); } catch { continue; }
|
|
207
|
-
|
|
208
|
-
if (stat.isDirectory()) {
|
|
209
|
-
scanSchemaDir(full, filePattern, files);
|
|
210
|
-
} else if (filePattern.test(entry)) {
|
|
211
|
-
files.push(full);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
216
|
+
sharedWalkFiles(dir, (full) => {
|
|
217
|
+
if (filePattern.test(basename(full))) files.push(full);
|
|
218
|
+
}, { ignoreDirs: IGNORE_DIRS });
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
/**
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { resolve, join, extname } from 'node:path';
|
|
10
|
-
import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
10
|
+
import { shouldIgnore, relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
11
11
|
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
12
12
|
|
|
13
13
|
// Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
|
|
@@ -233,24 +233,11 @@ export function validateSecurity(projectDir, config) {
|
|
|
233
233
|
return resultFromFindings(findings, { passed, total });
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// v0.29 consolidation: traversal delegates to the shared canonical walker.
|
|
237
|
+
// keepDot('.env') is LOAD-BEARING — a secrets validator must scan dotenv files.
|
|
236
238
|
function walkDir(dir, callback) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (IGNORE_DIRS.has(entry)) continue;
|
|
242
|
-
if (entry.startsWith('.') && entry !== '.env') continue;
|
|
243
|
-
|
|
244
|
-
const fullPath = join(dir, entry);
|
|
245
|
-
try {
|
|
246
|
-
const stat = statSync(fullPath);
|
|
247
|
-
if (stat.isDirectory()) {
|
|
248
|
-
walkDir(fullPath, callback);
|
|
249
|
-
} else if (stat.isFile()) {
|
|
250
|
-
callback(fullPath);
|
|
251
|
-
}
|
|
252
|
-
} catch {
|
|
253
|
-
// Skip unreadable files
|
|
254
|
-
}
|
|
255
|
-
}
|
|
239
|
+
sharedWalkFiles(dir, callback, {
|
|
240
|
+
ignoreDirs: IGNORE_DIRS,
|
|
241
|
+
keepDot: (entry) => entry === '.env',
|
|
242
|
+
});
|
|
256
243
|
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Re-exports `validateSpecKitIntegration` from the scanner. Importers should
|
|
11
11
|
* use this path (`validators/spec-kit.mjs`) going forward.
|
|
12
|
+
*
|
|
13
|
+
* v0.29: the validator emits structured findings (SPK001–SPK007); the
|
|
14
|
+
* migration lives in `scanners/speckit.mjs` alongside the validation logic.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
export { validateSpecKitIntegration } from '../scanners/speckit.mjs';
|
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structure Validator — Checks that all required CDD files exist
|
|
3
|
+
*
|
|
4
|
+
* v0.29: migrated to structured findings (STR001–STR003). Messages are
|
|
5
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
6
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
7
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
11
|
import { resolve } from 'node:path';
|
|
7
12
|
import { docHasSection } from '../shared.mjs';
|
|
13
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
8
14
|
|
|
9
15
|
export function validateStructure(projectDir, config) {
|
|
10
|
-
const
|
|
16
|
+
const findings = [];
|
|
17
|
+
let passed = 0;
|
|
18
|
+
let total = 0;
|
|
19
|
+
|
|
20
|
+
const missingFile = (file) => mkFinding({
|
|
21
|
+
code: 'STR001',
|
|
22
|
+
validator: 'structure',
|
|
23
|
+
severity: 'error',
|
|
24
|
+
message: `Missing required file: ${file}`,
|
|
25
|
+
location: file,
|
|
26
|
+
suggestion: { kind: 'fix', text: 'Create it from the professional template', command: 'docguard init' },
|
|
27
|
+
});
|
|
11
28
|
|
|
12
29
|
// Check canonical docs
|
|
13
30
|
for (const file of config.requiredFiles.canonical) {
|
|
14
|
-
|
|
31
|
+
total++;
|
|
15
32
|
const fullPath = resolve(projectDir, file);
|
|
16
33
|
if (existsSync(fullPath)) {
|
|
17
|
-
|
|
34
|
+
passed++;
|
|
18
35
|
} else {
|
|
19
|
-
|
|
36
|
+
findings.push(missingFile(file));
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
|
|
@@ -27,44 +44,53 @@ export function validateStructure(projectDir, config) {
|
|
|
27
44
|
? config.requiredFiles.agentFile
|
|
28
45
|
: (typeof config.requiredFiles?.agentFile === 'string' ? [config.requiredFiles.agentFile] : []);
|
|
29
46
|
if (agentFiles.length > 0) {
|
|
30
|
-
|
|
47
|
+
total++;
|
|
31
48
|
const agentFileFound = agentFiles.some(f =>
|
|
32
49
|
existsSync(resolve(projectDir, f))
|
|
33
50
|
);
|
|
34
51
|
if (agentFileFound) {
|
|
35
|
-
|
|
52
|
+
passed++;
|
|
36
53
|
} else {
|
|
37
|
-
|
|
54
|
+
findings.push(mkFinding({
|
|
55
|
+
code: 'STR002',
|
|
56
|
+
validator: 'structure',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
message: `Missing agent file: ${agentFiles.join(' or ')}`,
|
|
59
|
+
location: agentFiles[0],
|
|
60
|
+
suggestion: { kind: 'fix', text: 'Create the agent instructions file', command: 'docguard init' },
|
|
61
|
+
}));
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
// Check changelog — same defensive pattern.
|
|
42
66
|
const changelogPath = config.requiredFiles?.changelog;
|
|
43
67
|
if (changelogPath) {
|
|
44
|
-
|
|
68
|
+
total++;
|
|
45
69
|
if (existsSync(resolve(projectDir, changelogPath))) {
|
|
46
|
-
|
|
70
|
+
passed++;
|
|
47
71
|
} else {
|
|
48
|
-
|
|
72
|
+
findings.push(missingFile(changelogPath));
|
|
49
73
|
}
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
// Check drift log
|
|
53
|
-
|
|
77
|
+
total++;
|
|
54
78
|
if (existsSync(resolve(projectDir, config.requiredFiles.driftLog))) {
|
|
55
|
-
|
|
79
|
+
passed++;
|
|
56
80
|
} else {
|
|
57
|
-
|
|
81
|
+
findings.push(missingFile(config.requiredFiles.driftLog));
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
return
|
|
84
|
+
return { name: 'structure', ...resultFromFindings(findings, { passed, total }) };
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/**
|
|
64
88
|
* Check that canonical doc files contain required sections
|
|
65
89
|
*/
|
|
66
90
|
export function validateDocSections(projectDir, config) {
|
|
67
|
-
const
|
|
91
|
+
const findings = [];
|
|
92
|
+
let passed = 0;
|
|
93
|
+
let total = 0;
|
|
68
94
|
const ptc = config.projectTypeConfig || {};
|
|
69
95
|
|
|
70
96
|
const requiredSections = {
|
|
@@ -86,7 +112,7 @@ export function validateDocSections(projectDir, config) {
|
|
|
86
112
|
const content = readFileSync(fullPath, 'utf-8');
|
|
87
113
|
|
|
88
114
|
for (const section of sections) {
|
|
89
|
-
|
|
115
|
+
total++;
|
|
90
116
|
// Match a real heading (H2–H6), not a substring in a TOC link or code
|
|
91
117
|
// block. v0.24: synonym- and section-number-tolerant via docHasSection, so
|
|
92
118
|
// arc42/C4 docs ("## 5.4 Layer boundaries", "## Building Block View")
|
|
@@ -110,19 +136,28 @@ export function validateDocSections(projectDir, config) {
|
|
|
110
136
|
'i'
|
|
111
137
|
);
|
|
112
138
|
if (docHasSection(content, section)) {
|
|
113
|
-
|
|
139
|
+
passed++;
|
|
114
140
|
} else if (naRe.test(content)) {
|
|
115
141
|
// v0.16-P7: explicit N/A — counts as passed (the project has owned
|
|
116
142
|
// the absence) and doesn't pollute the warnings list.
|
|
117
|
-
|
|
143
|
+
passed++;
|
|
118
144
|
} else {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
findings.push(mkFinding({
|
|
146
|
+
code: 'STR003',
|
|
147
|
+
validator: 'structure',
|
|
148
|
+
severity: 'warn',
|
|
149
|
+
message: `${file}: missing section "${section}". ` +
|
|
150
|
+
`If genuinely not applicable, add: <!-- docguard:section ${slug} n/a — your reason -->`,
|
|
151
|
+
location: file,
|
|
152
|
+
suggestion: {
|
|
153
|
+
kind: 'suppress',
|
|
154
|
+
text: 'Add the section, or own the absence with the inline N/A marker',
|
|
155
|
+
pragma: `<!-- docguard:section ${slug} n/a — your reason -->`,
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
123
158
|
}
|
|
124
159
|
}
|
|
125
160
|
}
|
|
126
161
|
|
|
127
|
-
return
|
|
162
|
+
return { name: 'doc-sections', ...resultFromFindings(findings, { passed, total }) };
|
|
128
163
|
}
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
61
61
|
import { resolve, join, basename, extname, relative, dirname } from 'node:path';
|
|
62
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Expand a simple glob (only supports `*` at the leaf segment level —
|
|
@@ -223,10 +224,13 @@ function extractDocumentedTokens(content) {
|
|
|
223
224
|
|
|
224
225
|
/**
|
|
225
226
|
* Validate surface drift for a single surface against its target docs.
|
|
226
|
-
* Returns
|
|
227
|
+
* Returns findings, fixes, passed count, and total count.
|
|
228
|
+
*
|
|
229
|
+
* v0.29: migrated to structured findings (SSY001–SSY002). Messages are
|
|
230
|
+
* byte-identical to the legacy strings.
|
|
227
231
|
*/
|
|
228
232
|
function checkSurface(projectDir, surface) {
|
|
229
|
-
const out = {
|
|
233
|
+
const out = { findings: [], fixes: [], passed: 0, total: 0 };
|
|
230
234
|
const name = surface.name || 'unnamed';
|
|
231
235
|
const extractor = surface.extract || 'basename-no-ext';
|
|
232
236
|
const ignore = new Set(surface.ignore || []);
|
|
@@ -236,9 +240,14 @@ function checkSurface(projectDir, surface) {
|
|
|
236
240
|
|
|
237
241
|
// Discover code-truth set from glob.
|
|
238
242
|
if (!surface.glob || typeof surface.glob !== 'string') {
|
|
239
|
-
out.
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
out.findings.push(mkFinding({
|
|
244
|
+
code: 'SSY001',
|
|
245
|
+
validator: 'surfaceSync',
|
|
246
|
+
severity: 'warn',
|
|
247
|
+
message: `surfaceSync: surface "${name}" has no \`glob\` — skipping. Add a glob like "cli/commands/*.mjs".`,
|
|
248
|
+
location: '.docguard.json',
|
|
249
|
+
suggestion: { kind: 'fix', text: `Add a \`glob\` to the "${name}" surface entry under surfaceSync.surfaces in .docguard.json` },
|
|
250
|
+
}));
|
|
242
251
|
return out;
|
|
243
252
|
}
|
|
244
253
|
const files = expandGlob(projectDir, surface.glob);
|
|
@@ -319,7 +328,14 @@ function checkSurface(projectDir, surface) {
|
|
|
319
328
|
const tail = extra > 0 ? ` (+${extra} more)` : '';
|
|
320
329
|
parts.push(`${missingFromCode.length} listed in ${docRel} but not found in code: ${shown}${tail}`);
|
|
321
330
|
}
|
|
322
|
-
out.
|
|
331
|
+
out.findings.push(mkFinding({
|
|
332
|
+
code: 'SSY002',
|
|
333
|
+
validator: 'surfaceSync',
|
|
334
|
+
severity: 'warn',
|
|
335
|
+
message: `Surface "${name}" drift: ${parts.join('; ')}`,
|
|
336
|
+
location: docRel,
|
|
337
|
+
suggestion: { kind: 'review', text: `Update the "${name}" list/table in ${docRel} to match the code-truth set — add missing items, remove ghosts, or put aliases in the surface's ignore list` },
|
|
338
|
+
}));
|
|
323
339
|
}
|
|
324
340
|
|
|
325
341
|
return out;
|
|
@@ -347,21 +363,24 @@ export function validateSurfaceSync(projectDir, config) {
|
|
|
347
363
|
? config.surfaceSync.surfaces
|
|
348
364
|
: [];
|
|
349
365
|
|
|
350
|
-
const result = { errors: [], warnings: [], fixes: [], passed: 0, total: 0 };
|
|
351
|
-
|
|
352
366
|
if (surfaceCfg.length === 0) {
|
|
353
367
|
// No surfaces configured → N/A. The validator infrastructure surfaces
|
|
354
|
-
// this as "nothing to validate" rather than a fail.
|
|
355
|
-
return
|
|
368
|
+
// this as "nothing to validate" rather than a fail. Legacy shape kept.
|
|
369
|
+
return { errors: [], warnings: [], fixes: [], passed: 0, total: 0 };
|
|
356
370
|
}
|
|
357
371
|
|
|
372
|
+
const findings = [];
|
|
373
|
+
const fixes = [];
|
|
374
|
+
let passed = 0;
|
|
375
|
+
let total = 0;
|
|
376
|
+
|
|
358
377
|
for (const surface of surfaceCfg) {
|
|
359
378
|
const r = checkSurface(projectDir, surface);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
379
|
+
findings.push(...r.findings);
|
|
380
|
+
fixes.push(...r.fixes);
|
|
381
|
+
passed += r.passed;
|
|
382
|
+
total += r.total;
|
|
364
383
|
}
|
|
365
384
|
|
|
366
|
-
return
|
|
385
|
+
return { ...resultFromFindings(findings, { passed, total }), fixes };
|
|
367
386
|
}
|