docguard-cli 0.8.0 → 0.9.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.
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Docs-Coverage Validator — Detects code features not referenced in docs.
3
+ *
4
+ * Generic validator for ANY project type. Scans the project for
5
+ * "documentable artifacts" and checks if at least one canonical doc
6
+ * or README references them.
7
+ *
8
+ * What it catches:
9
+ * - Config/dotfiles at root not mentioned in docs
10
+ * - Config filenames referenced in source code (resolve/readFile calls) but not documented
11
+ * - package.json bin entries not documented
12
+ * - Source directories not referenced in ARCHITECTURE.md
13
+ * - README.md missing standard sections (inspired by Standard README spec)
14
+ */
15
+
16
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
+ import { resolve, join, relative, basename, extname } from 'node:path';
18
+
19
+ const IGNORE_DIRS = new Set([
20
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
21
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
22
+ ]);
23
+
24
+ // Dotfiles that are universally common and don't need documentation
25
+ const COMMON_DOTFILES = new Set([
26
+ '.gitignore', '.gitattributes', '.git', '.DS_Store',
27
+ '.editorconfig', '.prettierrc', '.prettierignore',
28
+ '.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.cjs',
29
+ '.eslintignore', '.nvmrc', '.node-version', '.npmrc', '.npmignore',
30
+ '.env', '.env.local', '.env.development', '.env.production',
31
+ '.vscode', '.idea', '.github', '.husky',
32
+ '.babelrc', '.browserslistrc', '.stylelintrc',
33
+ ]);
34
+
35
+ /**
36
+ * Validate that code artifacts are referenced in documentation.
37
+ * @param {string} projectDir - Project root directory
38
+ * @param {object} config - DocGuard config
39
+ * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
40
+ */
41
+ export function validateDocsCoverage(projectDir, config) {
42
+ const warnings = [];
43
+ let passed = 0;
44
+ let total = 0;
45
+
46
+ // Collect all doc content for searching
47
+ const allDocContent = collectDocContent(projectDir);
48
+ if (!allDocContent) {
49
+ return { errors: [], warnings, passed: 0, total: 0 };
50
+ }
51
+
52
+ // ── Check 1: Project-specific config/dotfiles referenced in docs ──
53
+ const configChecks = checkConfigFiles(projectDir, allDocContent);
54
+ total += configChecks.total;
55
+ passed += configChecks.passed;
56
+ warnings.push(...configChecks.warnings);
57
+
58
+ // ── Check 2: package.json bin entries documented ──
59
+ const binChecks = checkPackageBins(projectDir, allDocContent);
60
+ total += binChecks.total;
61
+ passed += binChecks.passed;
62
+ warnings.push(...binChecks.warnings);
63
+
64
+ // ── Check 3: Source directory structure matches ARCHITECTURE.md ──
65
+ const dirChecks = checkSourceDirs(projectDir, allDocContent);
66
+ total += dirChecks.total;
67
+ passed += dirChecks.passed;
68
+ warnings.push(...dirChecks.warnings);
69
+
70
+ // ── Check 4: Config filenames referenced in source code but not documented ──
71
+ const codeConfigChecks = checkCodeReferencedConfigs(projectDir, allDocContent);
72
+ total += codeConfigChecks.total;
73
+ passed += codeConfigChecks.passed;
74
+ warnings.push(...codeConfigChecks.warnings);
75
+
76
+ // ── Check 5: README section completeness (Standard README spec) ──
77
+ const readmeChecks = checkReadmeSections(projectDir);
78
+ total += readmeChecks.total;
79
+ passed += readmeChecks.passed;
80
+ warnings.push(...readmeChecks.warnings);
81
+
82
+ return { errors: [], warnings, passed, total };
83
+ }
84
+
85
+ // ── Check Functions ─────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Check 1: Project-specific config/dotfiles are mentioned in docs.
89
+ * Skips universally common files (.gitignore, .eslintrc, etc.).
90
+ */
91
+ function checkConfigFiles(projectDir, allDocContent) {
92
+ const warnings = [];
93
+ let passed = 0;
94
+ let total = 0;
95
+
96
+ let entries;
97
+ try { entries = readdirSync(projectDir); } catch { return { warnings, passed, total }; }
98
+
99
+ const lowerDocContent = allDocContent.toLowerCase();
100
+
101
+ for (const entry of entries) {
102
+ const isDotFile = entry.startsWith('.');
103
+ const isProjectConfig = entry.endsWith('.config.js') ||
104
+ entry.endsWith('.config.ts') ||
105
+ entry.endsWith('.config.mjs') ||
106
+ entry.endsWith('.config.cjs') ||
107
+ entry.endsWith('.json') && !['package.json', 'package-lock.json', 'tsconfig.json'].includes(entry);
108
+
109
+ if (!isDotFile && !isProjectConfig) continue;
110
+ if (COMMON_DOTFILES.has(entry)) continue;
111
+ if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
112
+
113
+ total++;
114
+ if (lowerDocContent.includes(entry.toLowerCase())) {
115
+ passed++;
116
+ } else {
117
+ warnings.push(
118
+ `Config file "${entry}" exists but is not mentioned in any documentation. Document its purpose in ARCHITECTURE.md or README.md`
119
+ );
120
+ }
121
+ }
122
+
123
+ return { warnings, passed, total };
124
+ }
125
+
126
+ /**
127
+ * Check 2: package.json bin entries (CLI commands users run) are documented.
128
+ */
129
+ function checkPackageBins(projectDir, allDocContent) {
130
+ const warnings = [];
131
+ let passed = 0;
132
+ let total = 0;
133
+
134
+ const pkgPath = resolve(projectDir, 'package.json');
135
+ if (!existsSync(pkgPath)) return { warnings, passed, total };
136
+
137
+ let pkg;
138
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { warnings, passed, total }; }
139
+
140
+ const bins = typeof pkg.bin === 'string'
141
+ ? { [pkg.name]: pkg.bin }
142
+ : (pkg.bin || {});
143
+
144
+ const lowerDocContent = allDocContent.toLowerCase();
145
+
146
+ for (const [binName] of Object.entries(bins)) {
147
+ total++;
148
+ if (lowerDocContent.includes(binName.toLowerCase())) {
149
+ passed++;
150
+ } else {
151
+ warnings.push(
152
+ `package.json defines CLI command "${binName}" but it's not mentioned in any documentation`
153
+ );
154
+ }
155
+ }
156
+
157
+ return { warnings, passed, total };
158
+ }
159
+
160
+ /**
161
+ * Check 3: Source directories are referenced in ARCHITECTURE.md.
162
+ */
163
+ function checkSourceDirs(projectDir, allDocContent) {
164
+ const warnings = [];
165
+ let passed = 0;
166
+ let total = 0;
167
+
168
+ const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
169
+ if (!existsSync(archPath)) return { warnings, passed, total };
170
+
171
+ let archContent;
172
+ try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed, total }; }
173
+
174
+ const lowerArchContent = archContent.toLowerCase();
175
+ const sourceRoots = ['src', 'lib', 'app', 'cli', 'server', 'api'];
176
+
177
+ for (const root of sourceRoots) {
178
+ const rootDir = resolve(projectDir, root);
179
+ if (!existsSync(rootDir)) continue;
180
+
181
+ let entries;
182
+ try { entries = readdirSync(rootDir); } catch { continue; }
183
+
184
+ for (const entry of entries) {
185
+ const fullPath = join(rootDir, entry);
186
+ try {
187
+ const stat = statSync(fullPath);
188
+ if (!stat.isDirectory()) continue;
189
+ } catch { continue; }
190
+
191
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.') || entry === '__tests__' || entry === '__test__') continue;
192
+
193
+ total++;
194
+ const searchName = entry.toLowerCase();
195
+ if (lowerArchContent.includes(searchName) || lowerArchContent.includes(root + '/' + entry)) {
196
+ passed++;
197
+ } else {
198
+ warnings.push(
199
+ `Source directory "${root}/${entry}/" is not referenced in ARCHITECTURE.md`
200
+ );
201
+ }
202
+ }
203
+ }
204
+
205
+ return { warnings, passed, total };
206
+ }
207
+
208
+ /**
209
+ * Check 4: Config files that code actually READS are documented.
210
+ *
211
+ * Scans source code for resolve(dir, '.configname') and existsSync('.configname')
212
+ * patterns — these are configs the project USES. Avoids matching config names
213
+ * sitting in arrays (scan patterns for detecting other projects' configs).
214
+ */
215
+ function checkCodeReferencedConfigs(projectDir, allDocContent) {
216
+ const warnings = [];
217
+ let passed = 0;
218
+ let total = 0;
219
+
220
+ const lowerDocContent = allDocContent.toLowerCase();
221
+ const foundConfigs = new Set();
222
+
223
+ // Only match config filenames inside function calls that actually USE the file:
224
+ // resolve(dir, '.docguardignore'), existsSync('.env.example'), readFileSync('vitest.config.ts')
225
+ const usageRegex = /(?:resolve|join|existsSync|readFileSync|accessSync|writeFileSync)\s*\([^)]*['"`]([^'"`\n]{2,})['"`]/g;
226
+
227
+ const sourceRoots = ['src', 'lib', 'cli', 'bin', 'server', 'api', 'app'];
228
+
229
+ const scanFile = (filePath) => {
230
+ const ext = extname(filePath);
231
+ if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return;
232
+ let content;
233
+ try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
234
+
235
+ usageRegex.lastIndex = 0;
236
+ let match;
237
+ while ((match = usageRegex.exec(content)) !== null) {
238
+ const name = match[1];
239
+ // Must be a dotfile (.something) or *.config.* — not a path
240
+ if (name.includes('/') || name.startsWith('..')) continue;
241
+ const isDotConfig = name.startsWith('.') && name.length > 2;
242
+ const isNamedConfig = /^[\w-]+\.config\.\w+$/.test(name);
243
+ if (!isDotConfig && !isNamedConfig) continue;
244
+ // Skip bare extensions
245
+ if (/^\.[a-z]{1,4}$/i.test(name)) continue;
246
+ foundConfigs.add(name);
247
+ }
248
+ };
249
+
250
+ for (const root of sourceRoots) {
251
+ const rootDir = resolve(projectDir, root);
252
+ if (!existsSync(rootDir)) continue;
253
+ walkFiles(rootDir, scanFile);
254
+ }
255
+
256
+ for (const configName of foundConfigs) {
257
+ if (COMMON_DOTFILES.has(configName)) continue;
258
+ total++;
259
+ if (lowerDocContent.includes(configName.toLowerCase())) {
260
+ passed++;
261
+ } else {
262
+ warnings.push(
263
+ `Code references config file "${configName}" but no documentation mentions it. Add it to README.md or ARCHITECTURE.md`
264
+ );
265
+ }
266
+ }
267
+
268
+ return { warnings, passed, total };
269
+ }
270
+
271
+ /**
272
+ * Check 5: README section completeness.
273
+ * Inspired by Standard README (https://github.com/RichardLitt/standard-readme)
274
+ * and Make a README (https://www.makeareadme.com/).
275
+ */
276
+ function checkReadmeSections(projectDir) {
277
+ const warnings = [];
278
+ let passed = 0;
279
+ let total = 0;
280
+
281
+ const readmePath = resolve(projectDir, 'README.md');
282
+ if (!existsSync(readmePath)) return { warnings, passed, total };
283
+
284
+ let content;
285
+ try { content = readFileSync(readmePath, 'utf-8'); } catch { return { warnings, passed, total }; }
286
+
287
+ const lowerContent = content.toLowerCase();
288
+
289
+ // Required sections — every well-documented project should have these
290
+ const requiredSections = [
291
+ { name: 'Installation', patterns: ['install', 'getting started', 'setup', 'quickstart', 'quick start'] },
292
+ { name: 'Usage', patterns: ['usage', 'how to use', 'examples', 'getting started'] },
293
+ { name: 'License', patterns: ['license', 'licence'] },
294
+ ];
295
+
296
+ // Recommended — count toward score but don't warn
297
+ const recommendedSections = [
298
+ { name: 'Contributing', patterns: ['contributing', 'contribution', 'how to contribute'] },
299
+ { name: 'Description', patterns: ['## what', '## about', '## description', '## overview'] },
300
+ ];
301
+
302
+ for (const section of requiredSections) {
303
+ total++;
304
+ if (section.patterns.some(p => lowerContent.includes(p))) {
305
+ passed++;
306
+ } else {
307
+ warnings.push(`README.md is missing a "${section.name}" section (Standard README spec)`);
308
+ }
309
+ }
310
+
311
+ for (const section of recommendedSections) {
312
+ total++;
313
+ if (section.patterns.some(p => lowerContent.includes(p))) {
314
+ passed++;
315
+ }
316
+ }
317
+
318
+ return { warnings, passed, total };
319
+ }
320
+
321
+ // ── Helpers ──────────────────────────────────────────────────────────────────
322
+
323
+ /**
324
+ * Collect all documentation content into a single searchable string.
325
+ */
326
+ function collectDocContent(projectDir) {
327
+ const docPaths = [];
328
+
329
+ const rootDocs = ['README.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'STANDARD.md'];
330
+ for (const doc of rootDocs) {
331
+ const p = resolve(projectDir, doc);
332
+ if (existsSync(p)) docPaths.push(p);
333
+ }
334
+
335
+ const canonDir = resolve(projectDir, 'docs-canonical');
336
+ if (existsSync(canonDir)) {
337
+ try {
338
+ for (const entry of readdirSync(canonDir)) {
339
+ if (entry.endsWith('.md')) docPaths.push(resolve(canonDir, entry));
340
+ }
341
+ } catch { /* skip */ }
342
+ }
343
+
344
+ const extDir = resolve(projectDir, 'extensions');
345
+ if (existsSync(extDir)) {
346
+ walkFiles(extDir, (f) => {
347
+ if (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml')) {
348
+ docPaths.push(f);
349
+ }
350
+ });
351
+ }
352
+
353
+ for (const docsDir of ['docs', 'docs-implementation']) {
354
+ const d = resolve(projectDir, docsDir);
355
+ if (existsSync(d)) {
356
+ walkFiles(d, (f) => {
357
+ if (f.endsWith('.md')) docPaths.push(f);
358
+ });
359
+ }
360
+ }
361
+
362
+ if (docPaths.length === 0) return null;
363
+ const parts = [];
364
+ for (const p of docPaths) {
365
+ try { parts.push(readFileSync(p, 'utf-8')); } catch { /* skip */ }
366
+ }
367
+ return parts.join('\n');
368
+ }
369
+
370
+ function walkFiles(dir, callback) {
371
+ if (!existsSync(dir)) return;
372
+ let entries;
373
+ try { entries = readdirSync(dir); } catch { return; }
374
+
375
+ for (const entry of entries) {
376
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
377
+ const fullPath = join(dir, entry);
378
+ try {
379
+ const stat = statSync(fullPath);
380
+ if (stat.isDirectory()) {
381
+ walkFiles(fullPath, callback);
382
+ } else if (stat.isFile()) {
383
+ callback(fullPath);
384
+ }
385
+ } catch { /* skip */ }
386
+ }
387
+ }
@@ -84,6 +84,59 @@ export function validateDocsSync(projectDir, config) {
84
84
  }
85
85
  }
86
86
 
87
+ // ── Cross-check route files against OpenAPI spec ──
88
+ // If an OpenAPI spec exists AND route files exist, verify routes have matching paths
89
+ const openapiPatterns = [
90
+ 'openapi.yaml', 'openapi.yml', 'openapi.json',
91
+ 'swagger.yaml', 'swagger.yml', 'swagger.json',
92
+ 'api/openapi.yaml', 'api/openapi.yml', 'api/openapi.json',
93
+ 'docs/openapi.yaml', 'docs/openapi.yml',
94
+ ];
95
+
96
+ let openapiContent = '';
97
+ let openapiFile = null;
98
+ for (const pattern of openapiPatterns) {
99
+ const specPath = resolve(projectDir, pattern);
100
+ if (existsSync(specPath)) {
101
+ try {
102
+ openapiContent = readFileSync(specPath, 'utf-8').toLowerCase();
103
+ openapiFile = pattern;
104
+ } catch { /* ignore */ }
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (openapiContent && openapiFile) {
110
+ // Check that route files have corresponding paths in OpenAPI spec
111
+ for (const { dir } of routePatterns) {
112
+ const routeDir = resolve(projectDir, dir);
113
+ if (!existsSync(routeDir)) continue;
114
+
115
+ const files = getFilesRecursive(routeDir);
116
+ for (const file of files) {
117
+ const ext = extname(file);
118
+ if (!['.ts', '.js', '.mjs'].includes(ext)) continue;
119
+
120
+ // Skip index/middleware files
121
+ const name = basename(file, ext).toLowerCase();
122
+ if (name === 'index' || name === 'middleware' || name.startsWith('_')) continue;
123
+
124
+ results.total++;
125
+
126
+ // Check if a likely route path exists in the OpenAPI spec
127
+ // Route file "users.ts" → check for "/users" in spec
128
+ if (openapiContent.includes(`/${name}`) || openapiContent.includes(`"${name}"`)) {
129
+ results.passed++;
130
+ } else {
131
+ results.warnings.push(
132
+ `Route file ${basename(file)} exists but no /${name} path found in ${openapiFile}. ` +
133
+ `Run your spec generator (e.g., zod-to-openapi) to update the API spec`
134
+ );
135
+ }
136
+ }
137
+ }
138
+ }
139
+
87
140
  return results;
88
141
  }
89
142
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Metadata Sync Validator — Detects stale version references across docs.
3
+ *
4
+ * Cross-checks package.json version against extension.yml and all .md files.
5
+ * Flags outdated version strings (e.g., README references v0.7.2 but package.json is 0.8.0).
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
+ import { resolve, join, relative, extname } from 'node:path';
10
+ import { loadIgnorePatterns } from '../shared.mjs';
11
+
12
+ const IGNORE_DIRS = new Set([
13
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
14
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
15
+ ]);
16
+
17
+ /**
18
+ * Validate version/metadata consistency across project files.
19
+ * @param {string} projectDir - Project root directory
20
+ * @param {object} config - DocGuard config
21
+ * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
22
+ */
23
+ export function validateMetadataSync(projectDir, config) {
24
+ const warnings = [];
25
+ let passed = 0;
26
+ let total = 0;
27
+
28
+ // ── Get source of truth: package.json version ──
29
+ const pkgPath = resolve(projectDir, 'package.json');
30
+ if (!existsSync(pkgPath)) {
31
+ return { errors: [], warnings, passed: 0, total: 0 };
32
+ }
33
+
34
+ let pkg;
35
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { errors: [], warnings, passed: 0, total: 0 }; }
36
+ const currentVersion = pkg.version;
37
+ if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
38
+
39
+ // Parse into components for smart comparison
40
+ const vParts = currentVersion.split('.');
41
+ const major = parseInt(vParts[0], 10);
42
+ const minor = parseInt(vParts[1], 10);
43
+
44
+ // ── Check 1: extension.yml version sync ──
45
+ const extFiles = findExtensionYmls(projectDir);
46
+ for (const extFile of extFiles) {
47
+ total++;
48
+ const relPath = relative(projectDir, extFile);
49
+ try {
50
+ const content = readFileSync(extFile, 'utf-8');
51
+ const versionMatch = content.match(/version:\s*["']?(\d+\.\d+\.\d+)["']?/);
52
+ if (versionMatch) {
53
+ if (versionMatch[1] !== currentVersion) {
54
+ warnings.push(
55
+ `${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
56
+ );
57
+ } else {
58
+ passed++;
59
+ }
60
+ }
61
+ } catch { /* skip unreadable */ }
62
+ }
63
+
64
+ // ── Check 2: Version references in markdown files ──
65
+ const isIgnored = loadIgnorePatterns(projectDir);
66
+ const mdFiles = findMarkdownFiles(projectDir);
67
+ // Version patterns to find: v0.7.2, @0.7.2, /v0.7.2/, docguard-cli@0.7.2
68
+ const versionRegex = /(?:v|@|\/v?)(\d+\.\d+\.\d+)/g;
69
+
70
+ for (const mdFile of mdFiles) {
71
+ const relPath = relative(projectDir, mdFile);
72
+ // Skip CHANGELOG.md and DRIFT-LOG.md — these are historical by definition
73
+ const baseName = relPath.toLowerCase();
74
+ if (baseName.includes('changelog') || baseName.includes('drift-log')) continue;
75
+ // Skip files matched by .docguardignore
76
+ if (isIgnored(relPath)) continue;
77
+
78
+ let content;
79
+ try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
80
+
81
+ // Only flag version references in actionable contexts:
82
+ // - URLs (download, install, archive links)
83
+ // - version: declarations (YAML-style)
84
+ // - npm install / npx commands
85
+ // - Badge URLs
86
+ // NOT in prose text like "In v0.2.0 we added..." or roadmap discussions
87
+ const actionablePatterns = [
88
+ // URLs with version: /v0.7.2/, /tags/v0.7.2, @0.7.2
89
+ /(?:archive|tags|releases|download)\/v?(\d+\.\d+\.\d+)/g,
90
+ // npm install/npx commands: docguard-cli@0.7.2
91
+ /@(\d+\.\d+\.\d+)/g,
92
+ // YAML-style: version: "0.7.2" or version: 0.7.2
93
+ /version:\s*["']?(\d+\.\d+\.\d+)["']?/g,
94
+ ];
95
+
96
+ for (const pattern of actionablePatterns) {
97
+ pattern.lastIndex = 0;
98
+ let match;
99
+ while ((match = pattern.exec(content)) !== null) {
100
+ const foundVersion = match[1];
101
+ const fParts = foundVersion.split('.');
102
+ const fMajor = parseInt(fParts[0], 10);
103
+ const fMinor = parseInt(fParts[1], 10);
104
+
105
+ // Only flag if same major but older minor (same package, stale ref)
106
+ if (fMajor === major && fMinor < minor && foundVersion !== currentVersion) {
107
+ total++;
108
+ warnings.push(
109
+ `${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
110
+ );
111
+ } else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
112
+ total++;
113
+ passed++;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return { errors: [], warnings, passed, total };
120
+ }
121
+
122
+ // ── Helpers ──────────────────────────────────────────────────────────────────
123
+
124
+ function findExtensionYmls(dir) {
125
+ const results = [];
126
+ const extDir = resolve(dir, 'extensions');
127
+ if (existsSync(extDir)) {
128
+ walkFiles(extDir, (f) => {
129
+ if (f.endsWith('extension.yml') || f.endsWith('extension.yaml')) {
130
+ results.push(f);
131
+ }
132
+ });
133
+ }
134
+ // Also check root
135
+ const rootExt = resolve(dir, 'extension.yml');
136
+ if (existsSync(rootExt)) results.push(rootExt);
137
+ return results;
138
+ }
139
+
140
+ function findMarkdownFiles(dir) {
141
+ const seen = new Set();
142
+ const mdFiles = [];
143
+ const searchDirs = [
144
+ dir,
145
+ resolve(dir, 'docs-canonical'),
146
+ resolve(dir, 'extensions'),
147
+ ];
148
+
149
+ for (const searchDir of searchDirs) {
150
+ if (!existsSync(searchDir)) continue;
151
+ walkFiles(searchDir, (f) => {
152
+ if (f.endsWith('.md') && !seen.has(f)) {
153
+ seen.add(f);
154
+ mdFiles.push(f);
155
+ }
156
+ });
157
+ }
158
+
159
+ return mdFiles;
160
+ }
161
+
162
+ function walkFiles(dir, callback) {
163
+ if (!existsSync(dir)) return;
164
+ let entries;
165
+ try { entries = readdirSync(dir); } catch { return; }
166
+
167
+ for (const entry of entries) {
168
+ if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
169
+ const fullPath = join(dir, entry);
170
+ try {
171
+ const stat = statSync(fullPath);
172
+ if (stat.isDirectory()) {
173
+ walkFiles(fullPath, callback);
174
+ } else if (stat.isFile()) {
175
+ callback(fullPath);
176
+ }
177
+ } catch { /* skip */ }
178
+ }
179
+ }