docguard-cli 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PHILOSOPHY.md +59 -106
- package/README.md +23 -1
- package/cli/commands/diagnose.mjs +157 -52
- package/cli/commands/fix.mjs +113 -1
- package/cli/commands/generate.mjs +91 -0
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/cdk.mjs +10 -0
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/iac.mjs +235 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +149 -0
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-ignore.mjs +29 -2
- package/cli/shared-source.mjs +2 -1
- package/cli/validators/api-surface.mjs +112 -37
- package/cli/validators/changelog.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +125 -6
- package/cli/validators/docs-sync.mjs +49 -8
- package/cli/validators/metadata-sync.mjs +6 -1
- package/cli/validators/metrics-consistency.mjs +5 -2
- package/cli/validators/test-spec.mjs +129 -11
- package/cli/validators/todo-tracking.mjs +55 -2
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/docs/doc-sections.md +37 -0
- package/extensions/spec-kit-docguard/README.md +7 -4
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +13 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- package/templates/ARCHITECTURE.md.template +52 -0
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
18
18
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
19
|
+
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
20
|
+
import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
|
|
19
21
|
|
|
20
22
|
const IGNORE_DIRS = new Set([
|
|
21
|
-
'node_modules', '.git', '.next', 'dist', 'build', '
|
|
22
|
-
'.cache', '__pycache__', '.venv', 'vendor',
|
|
23
|
+
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
24
|
+
'coverage', '.cache', '__pycache__', '.venv', 'vendor',
|
|
25
|
+
'.turbo', '.vercel', '.svelte-kit', 'cdk.out', '.claude',
|
|
26
|
+
'target', '.gradle',
|
|
23
27
|
]);
|
|
24
28
|
|
|
25
29
|
// Dotfiles that are universally common and don't need documentation
|
|
@@ -50,8 +54,12 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
50
54
|
return { errors: [], warnings, passed: 0, total: 0 };
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
// IaC detection runs once and informs both Check 3 (suppression) and
|
|
58
|
+
// Check 6 (consolidated warning). One scan, two consumers.
|
|
59
|
+
const iac = detectIaC(projectDir);
|
|
60
|
+
|
|
53
61
|
// ── Check 1: Project-specific config/dotfiles referenced in docs ──
|
|
54
|
-
const configChecks = checkConfigFiles(projectDir, allDocContent);
|
|
62
|
+
const configChecks = checkConfigFiles(projectDir, allDocContent, config);
|
|
55
63
|
total += configChecks.total;
|
|
56
64
|
passed += configChecks.passed;
|
|
57
65
|
warnings.push(...configChecks.warnings);
|
|
@@ -63,7 +71,7 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
63
71
|
warnings.push(...binChecks.warnings);
|
|
64
72
|
|
|
65
73
|
// ── Check 3: Source directory structure matches ARCHITECTURE.md ──
|
|
66
|
-
const dirChecks = checkSourceDirs(projectDir, allDocContent, config);
|
|
74
|
+
const dirChecks = checkSourceDirs(projectDir, allDocContent, config, iac);
|
|
67
75
|
total += dirChecks.total;
|
|
68
76
|
passed += dirChecks.passed;
|
|
69
77
|
warnings.push(...dirChecks.warnings);
|
|
@@ -80,6 +88,12 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
80
88
|
passed += readmeChecks.passed;
|
|
81
89
|
warnings.push(...readmeChecks.warnings);
|
|
82
90
|
|
|
91
|
+
// ── Check 6: IaC-aware Infrastructure documentation ──
|
|
92
|
+
const iacChecks = checkIaCDocumentation(projectDir, iac);
|
|
93
|
+
total += iacChecks.total;
|
|
94
|
+
passed += iacChecks.passed;
|
|
95
|
+
warnings.push(...iacChecks.warnings);
|
|
96
|
+
|
|
83
97
|
return { errors: [], warnings, passed, total };
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -88,8 +102,10 @@ export function validateDocsCoverage(projectDir, config) {
|
|
|
88
102
|
/**
|
|
89
103
|
* Check 1: Project-specific config/dotfiles are mentioned in docs.
|
|
90
104
|
* Skips universally common files (.gitignore, .eslintrc, etc.).
|
|
105
|
+
* Honors config.ignore (FR-015 — applies user-configured ignore patterns
|
|
106
|
+
* consistently across all docs-coverage checks).
|
|
91
107
|
*/
|
|
92
|
-
function checkConfigFiles(projectDir, allDocContent) {
|
|
108
|
+
function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
93
109
|
const warnings = [];
|
|
94
110
|
let passed = 0;
|
|
95
111
|
let total = 0;
|
|
@@ -111,6 +127,17 @@ function checkConfigFiles(projectDir, allDocContent) {
|
|
|
111
127
|
if (COMMON_DOTFILES.has(entry)) continue;
|
|
112
128
|
if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
|
|
113
129
|
|
|
130
|
+
// Skip directories — this check is for configuration FILES, not dirs.
|
|
131
|
+
// Build-cache dotdirs (.nuxt, .next, .turbo, etc.) are handled by IGNORE_DIRS.
|
|
132
|
+
try {
|
|
133
|
+
if (statSync(join(projectDir, entry)).isDirectory()) continue;
|
|
134
|
+
} catch { continue; }
|
|
135
|
+
|
|
136
|
+
// Honor user-configured ignore patterns (FR-015 / IR-5).
|
|
137
|
+
// Same dual-form check as checkSourceDirs: relative path and trailing-slash
|
|
138
|
+
// form so dotfile-style patterns and dir-style patterns both apply.
|
|
139
|
+
if (shouldIgnore(entry, config) || shouldIgnore(entry + '/', config)) continue;
|
|
140
|
+
|
|
114
141
|
total++;
|
|
115
142
|
if (lowerDocContent.includes(entry.toLowerCase())) {
|
|
116
143
|
passed++;
|
|
@@ -160,8 +187,13 @@ function checkPackageBins(projectDir, allDocContent) {
|
|
|
160
187
|
|
|
161
188
|
/**
|
|
162
189
|
* Check 3: Source directories are referenced in ARCHITECTURE.md.
|
|
190
|
+
*
|
|
191
|
+
* Honors config.ignore (FR-006). When IaC is detected and the Infrastructure
|
|
192
|
+
* heading is missing, per-directory warnings inside the IaC package roots
|
|
193
|
+
* are suppressed — Check 6 emits one consolidated warning per IaC tool
|
|
194
|
+
* instead (FR-011).
|
|
163
195
|
*/
|
|
164
|
-
function checkSourceDirs(projectDir, allDocContent, config = {}) {
|
|
196
|
+
function checkSourceDirs(projectDir, allDocContent, config = {}, iac = { isIaC: false, tools: [] }) {
|
|
165
197
|
const warnings = [];
|
|
166
198
|
let passed = 0;
|
|
167
199
|
let total = 0;
|
|
@@ -173,6 +205,15 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
|
|
|
173
205
|
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed, total }; }
|
|
174
206
|
|
|
175
207
|
const lowerArchContent = archContent.toLowerCase();
|
|
208
|
+
const infraDocumented = hasInfrastructureHeading(archContent);
|
|
209
|
+
|
|
210
|
+
// Only suppress per-dir warnings when IaC exists AND no Infrastructure
|
|
211
|
+
// heading is present — Check 6 will fire the consolidated message instead.
|
|
212
|
+
const suppressIaCDirs = iac.isIaC && !infraDocumented;
|
|
213
|
+
|
|
214
|
+
// Flatten every IaC tool's package dirs into a single Set for fast lookup.
|
|
215
|
+
const iacPackageDirs = [];
|
|
216
|
+
for (const tool of iac.tools) iacPackageDirs.push(...tool.packageDirs);
|
|
176
217
|
|
|
177
218
|
// Monorepo-aware: honor config.sourceRoot + workspaces instead of a hardcoded list.
|
|
178
219
|
for (const rootDir of resolveSourceRoots(projectDir, config)) {
|
|
@@ -189,6 +230,22 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
|
|
|
189
230
|
|
|
190
231
|
if (IGNORE_DIRS.has(entry) || entry.startsWith('.') || entry === '__tests__' || entry === '__test__') continue;
|
|
191
232
|
|
|
233
|
+
const relPath = relative(projectDir, fullPath);
|
|
234
|
+
|
|
235
|
+
// Honor user-configured ignore patterns (FR-006 / IR-5).
|
|
236
|
+
// Patterns like `**/cdk.out/**` are written to match files INSIDE the
|
|
237
|
+
// directory; appending '/' lets us match the directory itself too.
|
|
238
|
+
if (shouldIgnore(relPath, config) || shouldIgnore(relPath + '/', config)) continue;
|
|
239
|
+
|
|
240
|
+
// Suppress per-dir warnings for IaC-relevant subdirs inside an IaC
|
|
241
|
+
// package — the consolidated Check 6 warning covers them. Includes CDK
|
|
242
|
+
// (bin/, lib/, stacks/, constructs/), Terraform (modules/, environments/),
|
|
243
|
+
// Pulumi (stacks/), SAM (events/, src/), Serverless (handlers/, src/).
|
|
244
|
+
if (suppressIaCDirs && isInsideIaCPackage(relPath, iacPackageDirs)
|
|
245
|
+
&& IAC_SUBDIR_NAMES.has(entry)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
192
249
|
total++;
|
|
193
250
|
const searchName = entry.toLowerCase();
|
|
194
251
|
if (lowerArchContent.includes(searchName) || lowerArchContent.includes(root + '/' + entry)) {
|
|
@@ -204,6 +261,68 @@ function checkSourceDirs(projectDir, allDocContent, config = {}) {
|
|
|
204
261
|
return { warnings, passed, total };
|
|
205
262
|
}
|
|
206
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Subdirectory names recognized as IaC-relevant across all supported tools.
|
|
266
|
+
* When IaC is detected and the Infrastructure heading is missing, these dirs
|
|
267
|
+
* inside the IaC package are suppressed from Check 3 to avoid double-warning.
|
|
268
|
+
*/
|
|
269
|
+
const IAC_SUBDIR_NAMES = new Set([
|
|
270
|
+
// CDK
|
|
271
|
+
'bin', 'lib', 'stacks', 'constructs',
|
|
272
|
+
// Terraform
|
|
273
|
+
'modules', 'environments',
|
|
274
|
+
// SAM / Serverless / Pulumi
|
|
275
|
+
'handlers', 'events', 'src',
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* True if `relPath` is inside any of the IaC package directories.
|
|
280
|
+
* Both inputs are project-relative POSIX paths.
|
|
281
|
+
*/
|
|
282
|
+
function isInsideIaCPackage(relPath, packageDirs) {
|
|
283
|
+
if (!packageDirs || packageDirs.length === 0) return false;
|
|
284
|
+
const normalized = relPath.split('\\').join('/');
|
|
285
|
+
return packageDirs.some(pkgDir => {
|
|
286
|
+
const p = pkgDir === '.' ? '' : pkgDir.split('\\').join('/');
|
|
287
|
+
if (p === '') return true;
|
|
288
|
+
return normalized === p || normalized.startsWith(p + '/');
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check 6: IaC projects should document their Infrastructure layer.
|
|
294
|
+
*
|
|
295
|
+
* Emits ONE consolidated warning per detected IaC tool when ARCHITECTURE.md
|
|
296
|
+
* has no Infrastructure heading. Suppresses the generic per-directory
|
|
297
|
+
* warnings that would otherwise fire for bin/, lib/, modules/, handlers/, etc.
|
|
298
|
+
*/
|
|
299
|
+
function checkIaCDocumentation(projectDir, iac) {
|
|
300
|
+
const warnings = [];
|
|
301
|
+
if (!iac || !iac.isIaC) return { warnings, passed: 0, total: 0 };
|
|
302
|
+
|
|
303
|
+
const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
|
|
304
|
+
if (!existsSync(archPath)) {
|
|
305
|
+
// No ARCHITECTURE.md at all — structure validator will catch that.
|
|
306
|
+
// Don't double-warn here.
|
|
307
|
+
return { warnings, passed: 0, total: 0 };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let archContent;
|
|
311
|
+
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed: 0, total: 0 }; }
|
|
312
|
+
|
|
313
|
+
if (hasInfrastructureHeading(archContent)) {
|
|
314
|
+
// One pass per tool — counted as total per IaC tool present.
|
|
315
|
+
return { warnings, passed: iac.tools.length, total: iac.tools.length };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// One actionable warning per detected IaC tool. Most projects use one tool,
|
|
319
|
+
// but a multi-tool monorepo gets one targeted message each.
|
|
320
|
+
for (const tool of iac.tools) {
|
|
321
|
+
warnings.push(buildIaCWarning(tool));
|
|
322
|
+
}
|
|
323
|
+
return { warnings, passed: 0, total: iac.tools.length };
|
|
324
|
+
}
|
|
325
|
+
|
|
207
326
|
/**
|
|
208
327
|
* Check 4: Config files that code actually READS are documented.
|
|
209
328
|
*
|
|
@@ -7,10 +7,38 @@ import { resolve, join, extname, basename } from 'node:path';
|
|
|
7
7
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
8
8
|
|
|
9
9
|
const IGNORE_DIRS = new Set([
|
|
10
|
-
'node_modules', '.git', '.next', 'dist', 'build',
|
|
10
|
+
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
11
11
|
'coverage', '.cache', '__pycache__', '.venv', 'vendor',
|
|
12
|
+
// Co-located test dirs — these are not the source under documentation.
|
|
13
|
+
'__tests__', '__test__',
|
|
12
14
|
]);
|
|
13
15
|
|
|
16
|
+
// Files that are tests, not source. Matched against the relative path AND
|
|
17
|
+
// the basename. Covers Jest/Vitest/Mocha/Jasmine/pytest/Go/Java conventions.
|
|
18
|
+
const TEST_PATH_RE = /(^|\/)__tests?__\//;
|
|
19
|
+
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|go)$/;
|
|
20
|
+
|
|
21
|
+
// Next.js App Router uses a strict filename convention for route handlers.
|
|
22
|
+
// Other files in the app/api/ tree (helpers, types) are NOT routes.
|
|
23
|
+
const NEXTJS_ROUTE_FILE_RE = /(^|\/)route\.(ts|tsx|js|jsx|mjs)$/;
|
|
24
|
+
const NEXTJS_API_DIR_RE = /(^|\/)app\/api(\/|$)/;
|
|
25
|
+
|
|
26
|
+
function isTestFile(relPath) {
|
|
27
|
+
return TEST_PATH_RE.test(relPath) || TEST_FILE_RE.test(relPath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* For Next.js App Router directories (app/api/...), only `route.{ts,js}` files
|
|
32
|
+
* are actual route handlers. Helpers and types in the same tree should not be
|
|
33
|
+
* treated as routes.
|
|
34
|
+
*/
|
|
35
|
+
function isValidRouteFile(relPath) {
|
|
36
|
+
if (NEXTJS_API_DIR_RE.test(relPath)) {
|
|
37
|
+
return NEXTJS_ROUTE_FILE_RE.test(relPath);
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
/**
|
|
15
43
|
* Expand sub-path patterns (e.g. 'routes', 'src/routes') against the project
|
|
16
44
|
* root AND every configured source root, returning de-duplicated existing dirs.
|
|
@@ -53,15 +81,22 @@ export function validateDocsSync(projectDir, config) {
|
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
// Find route/API files (monorepo-aware) and check they're mentioned in docs.
|
|
56
|
-
|
|
84
|
+
// Note: bare 'api' is intentionally excluded — it collides with frontend
|
|
85
|
+
// API client conventions (src/api/client.ts). Backend routes use
|
|
86
|
+
// src/routes/ or routes/ (Express). Next.js App Router uses src/app/api/
|
|
87
|
+
// or app/api/ with strict route.{ts,js} filename matching applied below.
|
|
88
|
+
const routeDirs = expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api']);
|
|
57
89
|
for (const routeDir of routeDirs) {
|
|
58
90
|
const files = getFilesRecursive(routeDir);
|
|
59
91
|
for (const file of files) {
|
|
60
92
|
const ext = extname(file);
|
|
61
|
-
if (!['.ts', '.js', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
93
|
+
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
62
94
|
|
|
63
|
-
results.total++;
|
|
64
95
|
const relPath = file.replace(projectDir + '/', '');
|
|
96
|
+
if (isTestFile(relPath)) continue;
|
|
97
|
+
if (!isValidRouteFile(relPath)) continue;
|
|
98
|
+
|
|
99
|
+
results.total++;
|
|
65
100
|
const name = basename(file, ext);
|
|
66
101
|
|
|
67
102
|
// Check if the file path or name is mentioned in any canonical doc
|
|
@@ -79,10 +114,12 @@ export function validateDocsSync(projectDir, config) {
|
|
|
79
114
|
const files = getFilesRecursive(serviceDir);
|
|
80
115
|
for (const file of files) {
|
|
81
116
|
const ext = extname(file);
|
|
82
|
-
if (!['.ts', '.js', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
117
|
+
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
83
118
|
|
|
84
|
-
results.total++;
|
|
85
119
|
const relPath = file.replace(projectDir + '/', '');
|
|
120
|
+
if (isTestFile(relPath)) continue;
|
|
121
|
+
|
|
122
|
+
results.total++;
|
|
86
123
|
const name = basename(file, ext);
|
|
87
124
|
|
|
88
125
|
if (canonicalContent.includes(relPath) || canonicalContent.includes(name)) {
|
|
@@ -117,11 +154,15 @@ export function validateDocsSync(projectDir, config) {
|
|
|
117
154
|
|
|
118
155
|
if (openapiContent && openapiFile) {
|
|
119
156
|
// Check that route files have corresponding paths in OpenAPI spec (monorepo-aware)
|
|
120
|
-
for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'api'])) {
|
|
157
|
+
for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api'])) {
|
|
121
158
|
const files = getFilesRecursive(routeDir);
|
|
122
159
|
for (const file of files) {
|
|
123
160
|
const ext = extname(file);
|
|
124
|
-
if (!['.ts', '.js', '.mjs'].includes(ext)) continue;
|
|
161
|
+
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
|
|
162
|
+
|
|
163
|
+
const relPathForFilter = file.replace(projectDir + '/', '');
|
|
164
|
+
if (isTestFile(relPathForFilter)) continue;
|
|
165
|
+
if (!isValidRouteFile(relPathForFilter)) continue;
|
|
125
166
|
|
|
126
167
|
// Skip index/middleware files
|
|
127
168
|
const rawName = basename(file, ext).toLowerCase();
|
|
@@ -23,6 +23,7 @@ const IGNORE_DIRS = new Set([
|
|
|
23
23
|
*/
|
|
24
24
|
export function validateMetadataSync(projectDir, config) {
|
|
25
25
|
const warnings = [];
|
|
26
|
+
const fixes = [];
|
|
26
27
|
let passed = 0;
|
|
27
28
|
let total = 0;
|
|
28
29
|
|
|
@@ -60,6 +61,7 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
60
61
|
warnings.push(
|
|
61
62
|
`${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
|
|
62
63
|
);
|
|
64
|
+
fixes.push({ type: 'replace-version', file: relPath, found: versionMatch[1], actual: currentVersion });
|
|
63
65
|
} else {
|
|
64
66
|
passed++;
|
|
65
67
|
}
|
|
@@ -120,6 +122,9 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
120
122
|
warnings.push(
|
|
121
123
|
`${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
|
|
122
124
|
);
|
|
125
|
+
if (!fixes.some(f => f.file === relPath && f.found === foundVersion)) {
|
|
126
|
+
fixes.push({ type: 'replace-version', file: relPath, found: foundVersion, actual: currentVersion });
|
|
127
|
+
}
|
|
123
128
|
} else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
|
|
124
129
|
total++;
|
|
125
130
|
passed++;
|
|
@@ -128,7 +133,7 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
return { errors: [], warnings, passed, total };
|
|
136
|
+
return { errors: [], warnings, passed, total, fixes };
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -24,6 +24,7 @@ const IGNORE_DIRS = new Set([
|
|
|
24
24
|
*/
|
|
25
25
|
export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
26
26
|
const warnings = [];
|
|
27
|
+
const fixes = [];
|
|
27
28
|
let passed = 0;
|
|
28
29
|
let total = 0;
|
|
29
30
|
|
|
@@ -83,8 +84,10 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
83
84
|
const found = parseInt(match[1], 10);
|
|
84
85
|
if (found !== actuals[key] && found > 0) {
|
|
85
86
|
warnings.push(
|
|
86
|
-
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}.
|
|
87
|
+
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
|
|
87
88
|
);
|
|
89
|
+
// Deterministic, surgical token replacement — safe to auto-apply.
|
|
90
|
+
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
|
|
88
91
|
} else {
|
|
89
92
|
passed++;
|
|
90
93
|
}
|
|
@@ -92,7 +95,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
return { errors: [], warnings, passed, total };
|
|
98
|
+
return { errors: [], warnings, passed, total, fixes };
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -126,17 +126,22 @@ export function validateTestSpec(projectDir, config) {
|
|
|
126
126
|
continue;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// For a ✅ journey, verify the referenced test file actually
|
|
130
|
-
// rather than trusting the glyph.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
129
|
+
// For a ✅ journey, verify the referenced test file(s) actually exist
|
|
130
|
+
// rather than trusting the glyph. Cells may list multiple paths in
|
|
131
|
+
// backticks separated by commas (e.g. `a.test.ts`, `b.test.ts`) and
|
|
132
|
+
// may include "(N suites)" annotations or globs.
|
|
133
|
+
if (testFile && testFile.trim() !== '—' && !testFile.includes('N/A')) {
|
|
134
|
+
const paths = parseTestPathCell(testFile);
|
|
135
|
+
if (paths.length > 0) {
|
|
136
|
+
results.total++;
|
|
137
|
+
const anyExists = paths.some(p => testEvidenceExists(projectDir, p));
|
|
138
|
+
if (anyExists) {
|
|
139
|
+
results.passed++;
|
|
140
|
+
} else {
|
|
141
|
+
results.warnings.push(
|
|
142
|
+
`E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${paths.join(', ')}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
}
|
|
@@ -182,6 +187,119 @@ export function validateTestSpec(projectDir, config) {
|
|
|
182
187
|
return results;
|
|
183
188
|
}
|
|
184
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Parse a TEST-SPEC.md table cell into a list of test path strings.
|
|
192
|
+
*
|
|
193
|
+
* Real-world Journey rows commonly list multiple test files in one cell:
|
|
194
|
+
* `path/a.test.ts`, `path/b.test.ts`
|
|
195
|
+
* `idor_*.test.ts (3 suites)`
|
|
196
|
+
*
|
|
197
|
+
* Strategy:
|
|
198
|
+
* 1. Split on commas that are OUTSIDE backticks.
|
|
199
|
+
* 2. For each segment: strip backticks, strip trailing "(N suites)" or
|
|
200
|
+
* "(N tests)" annotations, trim whitespace.
|
|
201
|
+
* 3. Drop empties.
|
|
202
|
+
*
|
|
203
|
+
* The "(N suites)" annotation is preserved as evidence — if a glob like
|
|
204
|
+
* `idor_*.test.ts` doesn't expand to a literal file, testEvidenceExists()
|
|
205
|
+
* accepts the annotation as the author's claim of coverage.
|
|
206
|
+
*/
|
|
207
|
+
export function parseTestPathCell(cell) {
|
|
208
|
+
if (!cell) return [];
|
|
209
|
+
// Split on commas that are NOT inside backticks. Track backtick parity.
|
|
210
|
+
const segments = [];
|
|
211
|
+
let buf = '';
|
|
212
|
+
let inBackticks = false;
|
|
213
|
+
for (const ch of cell) {
|
|
214
|
+
if (ch === '`') { inBackticks = !inBackticks; buf += ch; continue; }
|
|
215
|
+
if (ch === ',' && !inBackticks) {
|
|
216
|
+
segments.push(buf);
|
|
217
|
+
buf = '';
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
buf += ch;
|
|
221
|
+
}
|
|
222
|
+
if (buf) segments.push(buf);
|
|
223
|
+
|
|
224
|
+
const result = [];
|
|
225
|
+
for (let seg of segments) {
|
|
226
|
+
seg = seg.replace(/`/g, '').trim();
|
|
227
|
+
if (!seg || seg === '—') continue;
|
|
228
|
+
result.push(seg);
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True if a TEST-SPEC.md path segment has supporting evidence on disk.
|
|
235
|
+
*
|
|
236
|
+
* Accepts: exact file match, glob expansion (e.g. `foo_*.test.ts`), or an
|
|
237
|
+
* "(N suites)" / "(N tests)" annotation when the literal path doesn't exist.
|
|
238
|
+
* The annotation is the author's explicit claim of coverage — believe it
|
|
239
|
+
* rather than reject the row outright; the audit trail is in the markdown.
|
|
240
|
+
*/
|
|
241
|
+
export function testEvidenceExists(projectDir, pathSegment) {
|
|
242
|
+
if (!pathSegment) return false;
|
|
243
|
+
|
|
244
|
+
// Strip a trailing "(N suites)" / "(N tests)" annotation for the file check.
|
|
245
|
+
const annotationMatch = pathSegment.match(/\s*\((\d+)\s+(?:suites?|tests?)\)\s*$/i);
|
|
246
|
+
const pathOnly = annotationMatch ? pathSegment.slice(0, annotationMatch.index).trim() : pathSegment;
|
|
247
|
+
const hasAnnotation = !!annotationMatch;
|
|
248
|
+
|
|
249
|
+
if (!pathOnly) return hasAnnotation;
|
|
250
|
+
|
|
251
|
+
// Glob support — if the segment contains *, ?, or [, walk the parent dir.
|
|
252
|
+
if (/[*?[]/.test(pathOnly)) {
|
|
253
|
+
const matches = expandGlob(projectDir, pathOnly);
|
|
254
|
+
if (matches.length > 0) return true;
|
|
255
|
+
// Glob with annotation but no expansion → trust the annotation.
|
|
256
|
+
return hasAnnotation;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Plain path — must exist on disk.
|
|
260
|
+
if (existsSync(resolve(projectDir, pathOnly))) return true;
|
|
261
|
+
// Plain path with explicit annotation → still trust the author's claim.
|
|
262
|
+
return hasAnnotation;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Minimal glob expansion: only handles the `*` and `?` wildcards in a single
|
|
267
|
+
* path segment. e.g. `backend/src/test-helpers/security/idor_*.test.ts`.
|
|
268
|
+
* Pure Node.js built-ins; zero dependencies.
|
|
269
|
+
*/
|
|
270
|
+
function expandGlob(projectDir, pattern) {
|
|
271
|
+
const parts = pattern.split('/');
|
|
272
|
+
const start = resolve(projectDir);
|
|
273
|
+
let candidates = [start];
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
if (!/[*?[]/.test(part)) {
|
|
276
|
+
candidates = candidates.map(c => resolve(c, part)).filter(c => existsSync(c));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const re = globPartToRegex(part);
|
|
280
|
+
const next = [];
|
|
281
|
+
for (const dir of candidates) {
|
|
282
|
+
let entries;
|
|
283
|
+
try { entries = readdirSync(dir); } catch { continue; }
|
|
284
|
+
for (const e of entries) {
|
|
285
|
+
if (re.test(e)) next.push(resolve(dir, e));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
candidates = next;
|
|
289
|
+
if (candidates.length === 0) return [];
|
|
290
|
+
}
|
|
291
|
+
return candidates;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function globPartToRegex(part) {
|
|
295
|
+
const escaped = part
|
|
296
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
297
|
+
.replace(/\\\[/g, '[').replace(/\\\]/g, ']') // restore character classes
|
|
298
|
+
.replace(/\*/g, '.*')
|
|
299
|
+
.replace(/\?/g, '.');
|
|
300
|
+
return new RegExp(`^${escaped}$`);
|
|
301
|
+
}
|
|
302
|
+
|
|
185
303
|
/** Recursively check if a directory contains test files */
|
|
186
304
|
function hasTestFilesRecursive(dir) {
|
|
187
305
|
const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
@@ -38,6 +38,25 @@ const TEST_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
|
38
38
|
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[(:]/;
|
|
39
39
|
const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?!late|orar)|WORKAROUND)\s*[:(]?\s*(.+)/;
|
|
40
40
|
|
|
41
|
+
// Matches a comment-opening marker. Real TODOs live in comments — restricting
|
|
42
|
+
// matches to text AFTER a comment marker prevents false positives from regex
|
|
43
|
+
// literals or strings that happen to contain a TODO keyword.
|
|
44
|
+
// // — JS/TS/C/C++/Rust/Go/Java line comment
|
|
45
|
+
// # — Python/Ruby/shell/YAML
|
|
46
|
+
// /* — JS/C/C++ block comment open
|
|
47
|
+
// * — block comment continuation (when at start of line)
|
|
48
|
+
// <!-- — HTML/Markdown
|
|
49
|
+
const COMMENT_MARKER = /(?:\/\/|#|\/\*|<!--|^\s*\*\s)/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return the portion of a line after the first comment marker, or null if
|
|
53
|
+
* the line has no comment. Used to constrain TODO matching to comments.
|
|
54
|
+
*/
|
|
55
|
+
function commentPortion(line) {
|
|
56
|
+
const m = line.match(COMMENT_MARKER);
|
|
57
|
+
return m ? line.slice(m.index + m[0].length) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
// Test skip patterns for common test frameworks
|
|
42
61
|
const SKIP_PATTERNS = [
|
|
43
62
|
/\btest\.skip\s*\(/,
|
|
@@ -290,10 +309,31 @@ function findTestFiles(rootDir, dir, files, config) {
|
|
|
290
309
|
}
|
|
291
310
|
}
|
|
292
311
|
|
|
312
|
+
// Test-file path patterns — TODO scanning skips these by default to avoid
|
|
313
|
+
// false positives from test fixture strings (writeFileSync(..., '// xxxxx:')
|
|
314
|
+
// inside template literals is a comment marker for the regex but not a real
|
|
315
|
+
// annotation to track). Set config.todoTracking.includeTestFiles = true to override.
|
|
316
|
+
const TEST_FILE_RE = /(^|\/)__tests?__\//;
|
|
317
|
+
const TEST_NAME_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|go)$/;
|
|
318
|
+
|
|
319
|
+
// The validator's own source file describes the keyword list in its docstring
|
|
320
|
+
// and code. Skipping itself avoids self-referential false positives.
|
|
321
|
+
const SELF_PATH = new URL(import.meta.url).pathname;
|
|
322
|
+
|
|
323
|
+
function isTestFilePath(relPath) {
|
|
324
|
+
return TEST_FILE_RE.test(relPath) || TEST_NAME_RE.test(relPath);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isSelfPath(fullPath) {
|
|
328
|
+
return fullPath === SELF_PATH;
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
function findTodos(rootDir, dir, todos, config) {
|
|
294
332
|
let entries;
|
|
295
333
|
try { entries = readdirSync(dir); } catch { return; }
|
|
296
334
|
|
|
335
|
+
const includeTests = config?.todoTracking?.includeTestFiles === true;
|
|
336
|
+
|
|
297
337
|
for (const entry of entries) {
|
|
298
338
|
if (IGNORE_DIRS.has(entry)) continue;
|
|
299
339
|
if (entry.startsWith('.')) continue;
|
|
@@ -310,6 +350,15 @@ function findTodos(rootDir, dir, todos, config) {
|
|
|
310
350
|
|
|
311
351
|
const relPath = relative(rootDir, full);
|
|
312
352
|
|
|
353
|
+
// Skip test files unless explicitly opted in — test fixture strings
|
|
354
|
+
// commonly contain comment markers inside template literals that the
|
|
355
|
+
// single-line heuristic can't distinguish from real comments.
|
|
356
|
+
if (!includeTests && isTestFilePath(relPath)) continue;
|
|
357
|
+
|
|
358
|
+
// Skip the validator's own source file — its docstring legitimately
|
|
359
|
+
// names the annotation keywords it scans for.
|
|
360
|
+
if (isSelfPath(full)) continue;
|
|
361
|
+
|
|
313
362
|
// Apply config ignore patterns (todoIgnore + global ignore)
|
|
314
363
|
if (config && shouldIgnore(relPath, config, 'todoIgnore')) continue;
|
|
315
364
|
|
|
@@ -322,8 +371,12 @@ function findTodos(rootDir, dir, todos, config) {
|
|
|
322
371
|
const lines = content.split('\n');
|
|
323
372
|
|
|
324
373
|
for (let i = 0; i < lines.length; i++) {
|
|
325
|
-
|
|
326
|
-
|
|
374
|
+
// Restrict scanning to text inside a comment — keeps the regex from
|
|
375
|
+
// matching its own keyword list when DocGuard reads its own source.
|
|
376
|
+
const commentText = commentPortion(lines[i]);
|
|
377
|
+
if (commentText === null) continue;
|
|
378
|
+
if (TODO_PATTERN.test(commentText)) {
|
|
379
|
+
const match = commentText.match(TODO_EXTRACT);
|
|
327
380
|
if (match) {
|
|
328
381
|
todos.push({
|
|
329
382
|
keyword: match[1].toUpperCase(),
|