From d403980a8dfe594a7c93985b7a9b5acdc1697057 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 24 Feb 2026 09:08:57 +0100 Subject: [PATCH 01/11] add more fs rules --- .../src/validation/rules/filesystem.ts | 86 ++++++++++++++++--- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index 3a9fcbab46..98a2ad7886 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -1,23 +1,33 @@ -import { access, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { readdir } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { join, sep } from 'node:path'; import type { Diagnostic, ValidationInput } from '../types.js'; const RULE_HAS_MARKDOWN = 'has-markdown-files'; const RULE_ROOT_INDEX = 'root-index-exists'; +const RULE_NESTED_DIR_INDEX = 'nested-dir-has-index'; +const RULE_NO_SPACES = 'no-spaces-in-names'; +const RULE_VALID_NAMING = 'valid-file-naming'; +const RULE_NO_EMPTY_DIR = 'no-empty-directories'; + +// slug-safe: lowercase letters, digits and hyphens only +const SLUG_SAFE_RE = /^[a-z0-9-]+$/; export async function checkFilesystem(input: ValidationInput): Promise { const diagnostics: Diagnostic[] = []; - // check for at least one .md file - let hasMarkdown = false; + let entries: Dirent[] = []; try { - const entries = await readdir(input.docsPath, { recursive: true }); - hasMarkdown = entries.some((entry) => entry.endsWith('.md')); + entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true }); } catch { - // docsPath doesn't exist or isn't readable - will be caught by has-markdown-files + // docsPath doesn't exist or isn't readable } - if (!hasMarkdown) { + const mdFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.md')); + const dirs = entries.filter((e) => e.isDirectory()); + + // has-markdown-files + if (mdFiles.length === 0) { diagnostics.push({ rule: RULE_HAS_MARKDOWN, severity: 'error', @@ -27,10 +37,9 @@ export async function checkFilesystem(input: ValidationInput): Promise e.name === 'index.md' && e.parentPath === input.docsPath); + if (!hasRootIndex) { diagnostics.push({ rule: RULE_ROOT_INDEX, severity: 'error', @@ -40,5 +49,58 @@ export async function checkFilesystem(input: ValidationInput): Promise e.name === 'index.md' && e.parentPath === dirPath); + if (!hasIndex) { + diagnostics.push({ + rule: RULE_NESTED_DIR_INDEX, + severity: 'warning', + file: dirPath, + title: 'Subdirectory is missing index.md', + detail: `"${dir.name}" has no index.md. Without one, it will appear as an unnamed category using a title-cased directory name.`, + }); + } + + // no-empty-directories: directories with no .md files at any depth serve no purpose + const hasMd = mdFiles.some((e) => e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)); + if (!hasMd) { + diagnostics.push({ + rule: RULE_NO_EMPTY_DIR, + severity: 'warning', + file: dirPath, + title: 'Directory contains no markdown files', + detail: `"${dir.name}" contains no .md files and serves no purpose in the documentation structure. Remove it or add documentation files.`, + }); + } + } + + // no-spaces-in-names (error) and valid-file-naming (info): applies to file stems and dir names + const namesToCheck = [ + ...mdFiles.map((e) => ({ slug: e.name.slice(0, -3), label: e.name, path: join(e.parentPath, e.name) })), + ...dirs.map((e) => ({ slug: e.name, label: e.name, path: join(e.parentPath, e.name) })), + ]; + for (const item of namesToCheck) { + if (/\s/.test(item.slug)) { + diagnostics.push({ + rule: RULE_NO_SPACES, + severity: 'error', + file: item.path, + title: 'Name contains spaces', + detail: `"${item.label}" contains spaces which break URL slugs. Use hyphens instead.`, + }); + } else if (!SLUG_SAFE_RE.test(item.slug)) { + diagnostics.push({ + rule: RULE_VALID_NAMING, + severity: 'info', + file: item.path, + title: 'Name contains non-slug characters', + detail: `"${item.label}" should use only lowercase letters, digits and hyphens for clean URL slugs.`, + }); + } + } + return diagnostics; } From 9e0104afe78dcaf920c22ac260afb9993d0b8fe8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 24 Feb 2026 09:11:00 +0100 Subject: [PATCH 02/11] add tests --- .../src/validation/rules/filesystem.test.ts | 137 +++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index c427f4b3d3..71a74e7e3d 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { join } from 'node:path'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { checkFilesystem } from './filesystem.js'; @@ -48,4 +48,139 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); + + it('should report nested-dir-has-index for subdirectory without index.md', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath }); + + // img/ has no .md files at all, so it definitely has no index.md + const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('img')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); + + it('should not report nested-dir-has-index for subdirectory with index.md', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath }); + + // config/ has an index.md directly inside it + const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('config')); + expect(finding).toBeUndefined(); + }); + + it('should report nested-dir-has-index when subdir lacks index.md', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'page.md'), '---\ntitle: Page\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeDefined(); + }); + + it('should not report nested-dir-has-index when subdir has index.md', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); + }); + + it('should report no-empty-directories for directory with no markdown files', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath }); + + // img/ contains only test.png, no .md files + const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('img')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); + + it('should not report no-empty-directories for directory with markdown files', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath }); + + // config/ has several .md files + const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('config')); + expect(finding).toBeUndefined(); + }); + + it('should report no-empty-directories for subdir with no markdown files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'assets')); + + const findings = await checkFilesystem({ docsPath: tmp }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeDefined(); + }); + + it('should not report no-empty-directories for subdir containing markdown files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); + }); + + it('should not report naming rules for files and dirs with safe names', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath }); + + expect(findings.find((f) => f.rule === 'no-spaces-in-names')).toBeUndefined(); + expect(findings.find((f) => f.rule === 'valid-file-naming')).toBeUndefined(); + }); + + it('should report no-spaces-in-names for filename with a space', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'my guide.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + const finding = findings.find((f) => f.rule === 'no-spaces-in-names'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + expect(finding!.file).toContain('my guide.md'); + }); + + it('should report no-spaces-in-names for directory name with a space', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'my section')); + await writeFile(join(tmp, 'my section', 'index.md'), '---\ntitle: Section\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + const finding = findings.find((f) => f.rule === 'no-spaces-in-names' && f.file?.endsWith('my section')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + }); + + it('should report valid-file-naming for filename with non-slug characters', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'guide!important.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('info'); + }); + + it('should report valid-file-naming for directory name with uppercase', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'MySection')); + await writeFile(join(tmp, 'MySection', 'index.md'), '---\ntitle: Section\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming' && f.file?.endsWith('MySection')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('info'); + }); }); From 584e3babed744967ecad48f9672b1eb288bac2e1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 24 Feb 2026 10:09:12 +0100 Subject: [PATCH 03/11] add suppor for strict mode --- packages/plugin-docs-cli/src/bin/run.ts | 8 ++- .../src/commands/validate.command.ts | 9 ++- .../src/validation/engine.test.ts | 2 +- .../src/validation/rules/filesystem.test.ts | 68 +++++++++++++------ .../src/validation/rules/filesystem.ts | 6 +- .../plugin-docs-cli/src/validation/types.ts | 1 + 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts index 28493f141f..8b59ed8423 100644 --- a/packages/plugin-docs-cli/src/bin/run.ts +++ b/packages/plugin-docs-cli/src/bin/run.ts @@ -65,7 +65,13 @@ async function main() { break; } case 'validate': { - await validateCommand(docsPath); + const validateArgv = minimist(process.argv.slice(3), { + boolean: ['strict'], + default: { + strict: true, + }, + }); + await validateCommand(docsPath, { strict: validateArgv.strict }); break; } default: diff --git a/packages/plugin-docs-cli/src/commands/validate.command.ts b/packages/plugin-docs-cli/src/commands/validate.command.ts index 44fe176bb3..a50cc24cd9 100644 --- a/packages/plugin-docs-cli/src/commands/validate.command.ts +++ b/packages/plugin-docs-cli/src/commands/validate.command.ts @@ -4,10 +4,13 @@ import { allRules } from '../validation/rules/index.js'; const debug = createDebug('plugin-docs-cli:validate'); -export async function validateCommand(docsPath: string): Promise { - debug('Validating docs at: %s', docsPath); +export async function validateCommand( + docsPath: string, + options: { strict: boolean } = { strict: true } +): Promise { + debug('Validating docs at: %s (strict: %s)', docsPath, options.strict); - const result = await validate({ docsPath }, allRules); + const result = await validate({ docsPath, strict: options.strict }, allRules); console.log(JSON.stringify(result, null, 2)); diff --git a/packages/plugin-docs-cli/src/validation/engine.test.ts b/packages/plugin-docs-cli/src/validation/engine.test.ts index 86df64dff2..48a9a2e9e5 100644 --- a/packages/plugin-docs-cli/src/validation/engine.test.ts +++ b/packages/plugin-docs-cli/src/validation/engine.test.ts @@ -3,7 +3,7 @@ import { validate } from './engine.js'; import type { RuleRunner, ValidationInput } from './types.js'; describe('validate', () => { - const input: ValidationInput = { docsPath: '/fake' }; + const input: ValidationInput = { docsPath: '/fake', strict: true }; it('should return valid when no rules are provided', async () => { const result = await validate(input, []); diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index 71a74e7e3d..8f152a2bec 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -8,7 +8,7 @@ describe('checkFilesystem', () => { const testDocsPath = join(__dirname, '..', '..', '__fixtures__', 'test-docs'); it('should report missing root index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); const finding = findings.find((f) => f.rule === 'root-index-exists'); expect(finding).toBeDefined(); @@ -16,7 +16,7 @@ describe('checkFilesystem', () => { }); it('should not report has-markdown-files when docs folder has .md files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); const finding = findings.find((f) => f.rule === 'has-markdown-files'); expect(finding).toBeUndefined(); @@ -29,14 +29,14 @@ describe('checkFilesystem', () => { '---\ntitle: Home\ndescription: Home page\nsidebar_position: 1\n---\n# Home\n' ); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'root-index-exists')).toBeUndefined(); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeUndefined(); }); it('should report has-markdown-files when docs path does not exist', async () => { - const findings = await checkFilesystem({ docsPath: '/nonexistent/path' }); + const findings = await checkFilesystem({ docsPath: '/nonexistent/path', strict: true }); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); @@ -44,13 +44,13 @@ describe('checkFilesystem', () => { it('should report has-markdown-files for empty directory', async () => { const tmp = await mkdtemp(join(tmpdir(), 'docs-empty-')); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); it('should report nested-dir-has-index for subdirectory without index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); // img/ has no .md files at all, so it definitely has no index.md const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('img')); @@ -59,7 +59,7 @@ describe('checkFilesystem', () => { }); it('should not report nested-dir-has-index for subdirectory with index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); // config/ has an index.md directly inside it const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('config')); @@ -72,7 +72,7 @@ describe('checkFilesystem', () => { await mkdir(join(tmp, 'sub')); await writeFile(join(tmp, 'sub', 'page.md'), '---\ntitle: Page\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeDefined(); }); @@ -83,22 +83,22 @@ describe('checkFilesystem', () => { await mkdir(join(tmp, 'sub')); await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); }); it('should report no-empty-directories for directory with no markdown files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); // img/ contains only test.png, no .md files const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('img')); expect(finding).toBeDefined(); - expect(finding!.severity).toBe('warning'); + expect(finding!.severity).toBe('error'); }); it('should not report no-empty-directories for directory with markdown files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); // config/ has several .md files const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('config')); @@ -110,7 +110,7 @@ describe('checkFilesystem', () => { await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); await mkdir(join(tmp, 'assets')); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeDefined(); }); @@ -121,13 +121,13 @@ describe('checkFilesystem', () => { await mkdir(join(tmp, 'sub')); await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); }); it('should not report naming rules for files and dirs with safe names', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); expect(findings.find((f) => f.rule === 'no-spaces-in-names')).toBeUndefined(); expect(findings.find((f) => f.rule === 'valid-file-naming')).toBeUndefined(); @@ -138,7 +138,7 @@ describe('checkFilesystem', () => { await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); await writeFile(join(tmp, 'my guide.md'), '---\ntitle: Guide\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); const finding = findings.find((f) => f.rule === 'no-spaces-in-names'); expect(finding).toBeDefined(); @@ -152,7 +152,7 @@ describe('checkFilesystem', () => { await mkdir(join(tmp, 'my section')); await writeFile(join(tmp, 'my section', 'index.md'), '---\ntitle: Section\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); const finding = findings.find((f) => f.rule === 'no-spaces-in-names' && f.file?.endsWith('my section')); expect(finding).toBeDefined(); @@ -164,11 +164,11 @@ describe('checkFilesystem', () => { await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); await writeFile(join(tmp, 'guide!important.md'), '---\ntitle: Guide\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); const finding = findings.find((f) => f.rule === 'valid-file-naming'); expect(finding).toBeDefined(); - expect(finding!.severity).toBe('info'); + expect(finding!.severity).toBe('error'); }); it('should report valid-file-naming for directory name with uppercase', async () => { @@ -177,10 +177,36 @@ describe('checkFilesystem', () => { await mkdir(join(tmp, 'MySection')); await writeFile(join(tmp, 'MySection', 'index.md'), '---\ntitle: Section\n---\n'); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); const finding = findings.find((f) => f.rule === 'valid-file-naming' && f.file?.endsWith('MySection')); expect(finding).toBeDefined(); - expect(finding!.severity).toBe('info'); + expect(finding!.severity).toBe('error'); + }); + + describe('non-strict mode', () => { + it('should report valid-file-naming as warning in non-strict mode', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'guide!important.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: false }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); + + it('should report no-empty-directories as warning in non-strict mode', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'assets')); + + const findings = await checkFilesystem({ docsPath: tmp, strict: false }); + + const finding = findings.find((f) => f.rule === 'no-empty-directories'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); }); }); diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index 98a2ad7886..84e3439a1c 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -69,7 +69,7 @@ export async function checkFilesystem(input: ValidationInput): Promise ({ slug: e.name.slice(0, -3), label: e.name, path: join(e.parentPath, e.name) })), ...dirs.map((e) => ({ slug: e.name, label: e.name, path: join(e.parentPath, e.name) })), @@ -94,7 +94,7 @@ export async function checkFilesystem(input: ValidationInput): Promise Date: Tue, 24 Feb 2026 10:13:42 +0100 Subject: [PATCH 04/11] self review --- .../src/validation/rules/filesystem.test.ts | 14 ++++----- .../src/validation/rules/filesystem.ts | 29 ++++++++++++------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index 8f152a2bec..a81ac6382e 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -49,13 +49,12 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); - it('should report nested-dir-has-index for subdirectory without index.md', async () => { + it('should not report nested-dir-has-index for directory with no markdown files', async () => { const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - // img/ has no .md files at all, so it definitely has no index.md + // img/ has no .md files at all - caught by no-empty-directories instead const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('img')); - expect(finding).toBeDefined(); - expect(finding!.severity).toBe('warning'); + expect(finding).toBeUndefined(); }); it('should not report nested-dir-has-index for subdirectory with index.md', async () => { @@ -88,13 +87,12 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); }); - it('should report no-empty-directories for directory with no markdown files', async () => { + it('should not report no-empty-directories for img/ asset directory', async () => { const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - // img/ contains only test.png, no .md files + // img/ is the standard image directory and should be skipped const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('img')); - expect(finding).toBeDefined(); - expect(finding!.severity).toBe('error'); + expect(finding).toBeUndefined(); }); it('should not report no-empty-directories for directory with markdown files', async () => { diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index 84e3439a1c..fc995b4463 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -13,6 +13,9 @@ const RULE_NO_EMPTY_DIR = 'no-empty-directories'; // slug-safe: lowercase letters, digits and hyphens only const SLUG_SAFE_RE = /^[a-z0-9-]+$/; +// directories that are expected to contain non-markdown assets +const ASSET_DIRS = new Set(['img']); + export async function checkFilesystem(input: ValidationInput): Promise { const diagnostics: Diagnostic[] = []; @@ -52,16 +55,9 @@ export async function checkFilesystem(input: ValidationInput): Promise e.name === 'index.md' && e.parentPath === dirPath); - if (!hasIndex) { - diagnostics.push({ - rule: RULE_NESTED_DIR_INDEX, - severity: 'warning', - file: dirPath, - title: 'Subdirectory is missing index.md', - detail: `"${dir.name}" has no index.md. Without one, it will appear as an unnamed category using a title-cased directory name.`, - }); + // asset directories like img/ are expected to have no markdown + if (ASSET_DIRS.has(dir.name)) { + continue; } // no-empty-directories: directories with no .md files at any depth serve no purpose @@ -74,6 +70,19 @@ export async function checkFilesystem(input: ValidationInput): Promise e.name === 'index.md' && e.parentPath === dirPath); + if (!hasIndex) { + diagnostics.push({ + rule: RULE_NESTED_DIR_INDEX, + severity: 'warning', + file: dirPath, + title: 'Subdirectory is missing index.md', + detail: `"${dir.name}" has no index.md. Without one, it will appear as an unnamed category using a title-cased directory name.`, + }); } } From 6619d24172608b586fbb15964ebfdadec19da9c4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 09:52:05 +0100 Subject: [PATCH 05/11] align copy with allowed file types --- .../src/commands/build.command.ts | 13 +++++-- .../src/validation/rules/filesystem.ts | 34 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/plugin-docs-cli/src/commands/build.command.ts b/packages/plugin-docs-cli/src/commands/build.command.ts index 602f4d2208..5c90d9e43a 100644 --- a/packages/plugin-docs-cli/src/commands/build.command.ts +++ b/packages/plugin-docs-cli/src/commands/build.command.ts @@ -1,7 +1,8 @@ import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; +import { extname, join, relative } from 'node:path'; import createDebug from 'debug'; import { scanDocsFolder } from '../scanner.js'; +import { ALLOWED_EXTENSIONS } from '../validation/rules/filesystem.js'; const debug = createDebug('plugin-docs-cli:build'); @@ -26,8 +27,14 @@ export async function buildDocs(projectRoot: string, docsPath: string): Promise< await rm(outputDir, { recursive: true, force: true }); await mkdir(outputDir, { recursive: true }); - // copy entire docs folder to output (preserves .md files, images and other assets) - await cp(docsPath, outputDir, { recursive: true }); + // copy only allowed file types to output (mirrors the allowed-file-types validation rule) + await cp(docsPath, outputDir, { + recursive: true, + filter: (src) => { + const ext = extname(src).toLowerCase(); + return ext === '' || ALLOWED_EXTENSIONS.has(ext); + }, + }); debug('Copied docs folder to %s', outputDir); // write generated manifest diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index fc995b4463..26cb8538b1 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -1,6 +1,6 @@ import { readdir } from 'node:fs/promises'; import type { Dirent } from 'node:fs'; -import { join, sep } from 'node:path'; +import { join, extname, sep } from 'node:path'; import type { Diagnostic, ValidationInput } from '../types.js'; const RULE_HAS_MARKDOWN = 'has-markdown-files'; @@ -9,6 +9,8 @@ const RULE_NESTED_DIR_INDEX = 'nested-dir-has-index'; const RULE_NO_SPACES = 'no-spaces-in-names'; const RULE_VALID_NAMING = 'valid-file-naming'; const RULE_NO_EMPTY_DIR = 'no-empty-directories'; +const RULE_NO_SYMLINKS = 'no-symlinks'; +const RULE_ALLOWED_FILE_TYPES = 'allowed-file-types'; // slug-safe: lowercase letters, digits and hyphens only const SLUG_SAFE_RE = /^[a-z0-9-]+$/; @@ -16,6 +18,9 @@ const SLUG_SAFE_RE = /^[a-z0-9-]+$/; // directories that are expected to contain non-markdown assets const ASSET_DIRS = new Set(['img']); +// allowed file extensions in the docs folder (.md + permitted image formats) +export const ALLOWED_EXTENSIONS = new Set(['.md', '.png', '.jpg', '.jpeg', '.webp', '.gif']); + export async function checkFilesystem(input: ValidationInput): Promise { const diagnostics: Diagnostic[] = []; @@ -28,6 +33,33 @@ export async function checkFilesystem(input: ValidationInput): Promise e.isFile() && e.name.endsWith('.md')); const dirs = entries.filter((e) => e.isDirectory()); + const symlinks = entries.filter((e) => e.isSymbolicLink()); + const nonMdFiles = entries.filter((e) => e.isFile() && !e.name.endsWith('.md')); + + // no-symlinks + for (const link of symlinks) { + diagnostics.push({ + rule: RULE_NO_SYMLINKS, + severity: 'error', + file: join(link.parentPath, link.name), + title: 'Symlinks are not allowed', + detail: `"${link.name}" is a symbolic link. Use actual files instead of symlinks.`, + }); + } + + // allowed-file-types: non-.md files must be permitted image formats + for (const file of nonMdFiles) { + const ext = extname(file.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + diagnostics.push({ + rule: RULE_ALLOWED_FILE_TYPES, + severity: input.strict ? 'error' : 'info', + file: join(file.parentPath, file.name), + title: 'File type not allowed', + detail: `"${file.name}" is not an allowed file type. Only .md and image files (png, jpg, jpeg, webp, gif) are permitted in the docs folder.`, + }); + } + } // has-markdown-files if (mdFiles.length === 0) { From e788ec72659594dbeec3f1de2f51547a294587b1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 09:52:22 +0100 Subject: [PATCH 06/11] add pretty print --- packages/plugin-docs-cli/src/bin/run.ts | 7 +- .../src/commands/validate.command.ts | 11 ++- .../src/validation/format.test.ts | 81 +++++++++++++++++++ .../plugin-docs-cli/src/validation/format.ts | 56 +++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-docs-cli/src/validation/format.test.ts create mode 100644 packages/plugin-docs-cli/src/validation/format.ts diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts index 8b59ed8423..0cd3b544ea 100644 --- a/packages/plugin-docs-cli/src/bin/run.ts +++ b/packages/plugin-docs-cli/src/bin/run.ts @@ -22,7 +22,7 @@ async function main() { console.error('Commands:'); console.error(' serve Start the local docs preview server'); console.error(' build Build docs for publishing (generates manifest, copies to dist/)'); - console.error(' validate Validate documentation'); + console.error(' validate Validate documentation (--json for machine-readable output)'); process.exit(1); } @@ -66,12 +66,13 @@ async function main() { } case 'validate': { const validateArgv = minimist(process.argv.slice(3), { - boolean: ['strict'], + boolean: ['strict', 'json'], default: { strict: true, + json: false, }, }); - await validateCommand(docsPath, { strict: validateArgv.strict }); + await validateCommand(docsPath, { strict: validateArgv.strict, json: validateArgv.json }); break; } default: diff --git a/packages/plugin-docs-cli/src/commands/validate.command.ts b/packages/plugin-docs-cli/src/commands/validate.command.ts index a50cc24cd9..bb58b324f1 100644 --- a/packages/plugin-docs-cli/src/commands/validate.command.ts +++ b/packages/plugin-docs-cli/src/commands/validate.command.ts @@ -1,18 +1,23 @@ import createDebug from 'debug'; import { validate } from '../validation/engine.js'; +import { formatResult } from '../validation/format.js'; import { allRules } from '../validation/rules/index.js'; const debug = createDebug('plugin-docs-cli:validate'); export async function validateCommand( docsPath: string, - options: { strict: boolean } = { strict: true } + options: { strict: boolean; json: boolean } = { strict: true, json: false } ): Promise { - debug('Validating docs at: %s (strict: %s)', docsPath, options.strict); + debug('Validating docs at: %s (strict: %s, json: %s)', docsPath, options.strict, options.json); const result = await validate({ docsPath, strict: options.strict }, allRules); - console.log(JSON.stringify(result, null, 2)); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatResult(result)); + } process.exitCode = result.valid ? 0 : 1; } diff --git a/packages/plugin-docs-cli/src/validation/format.test.ts b/packages/plugin-docs-cli/src/validation/format.test.ts new file mode 100644 index 0000000000..d75a89223b --- /dev/null +++ b/packages/plugin-docs-cli/src/validation/format.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { formatResult } from './format.js'; +import type { ValidationResult } from './types.js'; + +describe('formatResult', () => { + it('should show success for valid result with no diagnostics', () => { + const result: ValidationResult = { valid: true, diagnostics: [] }; + const output = formatResult(result); + expect(output).toContain('✓'); + expect(output).toContain('valid'); + }); + + it('should show error count and ✗ icon when errors exist', () => { + const result: ValidationResult = { + valid: false, + diagnostics: [{ rule: 'test', severity: 'error', title: 'Bad thing', detail: 'Fix it' }], + }; + const output = formatResult(result); + expect(output).toContain('✗'); + expect(output).toContain('1 error'); + expect(output).toContain('Bad thing'); + expect(output).toContain('Fix it'); + }); + + it('should show ⚠ icon when only warnings exist', () => { + const result: ValidationResult = { + valid: true, + diagnostics: [{ rule: 'test', severity: 'warning', title: 'Heads up', detail: '' }], + }; + const output = formatResult(result); + expect(output).toContain('⚠'); + expect(output).toContain('1 warning'); + }); + + it('should pluralize counts correctly', () => { + const result: ValidationResult = { + valid: false, + diagnostics: [ + { rule: 'a', severity: 'error', title: 'A', detail: '' }, + { rule: 'b', severity: 'error', title: 'B', detail: '' }, + { rule: 'c', severity: 'warning', title: 'C', detail: '' }, + ], + }; + const output = formatResult(result); + expect(output).toContain('2 errors'); + expect(output).toContain('1 warning'); + }); + + it('should include file path when present', () => { + const result: ValidationResult = { + valid: false, + diagnostics: [{ rule: 'test', severity: 'error', file: 'docs/page.md', title: 'Problem', detail: '' }], + }; + const output = formatResult(result); + expect(output).toContain('docs/page.md'); + }); + + it('should show file:line when line number is present', () => { + const result: ValidationResult = { + valid: false, + diagnostics: [{ rule: 'test', severity: 'error', file: 'page.md', line: 7, title: 'H1', detail: '' }], + }; + const output = formatResult(result); + expect(output).toContain('page.md:7'); + }); + + it('should handle mixed severities', () => { + const result: ValidationResult = { + valid: false, + diagnostics: [ + { rule: 'a', severity: 'error', title: 'Error', detail: '' }, + { rule: 'b', severity: 'warning', title: 'Warning', detail: '' }, + { rule: 'c', severity: 'info', title: 'Info', detail: '' }, + ], + }; + const output = formatResult(result); + expect(output).toContain('1 error'); + expect(output).toContain('1 warning'); + expect(output).toContain('1 info'); + }); +}); diff --git a/packages/plugin-docs-cli/src/validation/format.ts b/packages/plugin-docs-cli/src/validation/format.ts new file mode 100644 index 0000000000..2abc6a08a8 --- /dev/null +++ b/packages/plugin-docs-cli/src/validation/format.ts @@ -0,0 +1,56 @@ +import type { Diagnostic, ValidationResult } from './types.js'; + +const SEVERITY_LABEL: Record = { + error: 'error', + warning: 'warn ', + info: 'info ', +}; + +function countBySeverity(diagnostics: Diagnostic[], severity: string): number { + return diagnostics.filter((d) => d.severity === severity).length; +} + +/** + * Formats a validation result as human-readable text. + */ +export function formatResult(result: ValidationResult): string { + const lines: string[] = []; + + if (result.diagnostics.length === 0) { + lines.push('✓ Documentation is valid'); + return lines.join('\n'); + } + + const errors = countBySeverity(result.diagnostics, 'error'); + const warnings = countBySeverity(result.diagnostics, 'warning'); + const infos = countBySeverity(result.diagnostics, 'info'); + + // summary line + const parts: string[] = []; + if (errors > 0) { + parts.push(`${errors} error${errors !== 1 ? 's' : ''}`); + } + if (warnings > 0) { + parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`); + } + if (infos > 0) { + parts.push(`${infos} info`); + } + + const icon = errors > 0 ? '✗' : '⚠'; + lines.push(`${icon} Documentation has ${parts.join(' and ')}`); + lines.push(''); + + for (const d of result.diagnostics) { + const label = SEVERITY_LABEL[d.severity] ?? d.severity; + const location = d.file ? (d.line ? ` ${d.file}:${d.line}` : ` ${d.file}`) : ''; + lines.push(` ${label}${location}`); + lines.push(` ${d.title}`); + if (d.detail) { + lines.push(` ${d.detail}`); + } + lines.push(''); + } + + return lines.join('\n'); +} From 8f72da248f03b47711c813bef39e6338b3093eeb Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 11:21:20 +0100 Subject: [PATCH 07/11] pr feedback: update filesystem validation to include all allowed file types and improve directory checks --- .../src/validation/rules/filesystem.test.ts | 19 +++++++++--- .../src/validation/rules/filesystem.ts | 30 ++++++++++--------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index a81ac6382e..53a42eb091 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -52,7 +52,7 @@ describe('checkFilesystem', () => { it('should not report nested-dir-has-index for directory with no markdown files', async () => { const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - // img/ has no .md files at all - caught by no-empty-directories instead + // img/ has images but no .md files - nested-dir-has-index only applies to dirs with .md files const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('img')); expect(finding).toBeUndefined(); }); @@ -87,14 +87,25 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); }); - it('should not report no-empty-directories for img/ asset directory', async () => { + it('should not report no-empty-directories for directory containing image files', async () => { const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - // img/ is the standard image directory and should be skipped + // img/ has test.png (an allowed image file) so it is not considered empty const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('img')); expect(finding).toBeUndefined(); }); + it('should not report no-empty-directories for directory with only image files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'images')); + await writeFile(join(tmp, 'images', 'screenshot.png'), ''); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); + }); + it('should not report no-empty-directories for directory with markdown files', async () => { const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); @@ -103,7 +114,7 @@ describe('checkFilesystem', () => { expect(finding).toBeUndefined(); }); - it('should report no-empty-directories for subdir with no markdown files', async () => { + it('should report no-empty-directories for subdir with no allowed files', async () => { const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); await mkdir(join(tmp, 'assets')); diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index 26cb8538b1..fbd682f422 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -15,9 +15,6 @@ const RULE_ALLOWED_FILE_TYPES = 'allowed-file-types'; // slug-safe: lowercase letters, digits and hyphens only const SLUG_SAFE_RE = /^[a-z0-9-]+$/; -// directories that are expected to contain non-markdown assets -const ASSET_DIRS = new Set(['img']); - // allowed file extensions in the docs folder (.md + permitted image formats) export const ALLOWED_EXTENSIONS = new Set(['.md', '.png', '.jpg', '.jpeg', '.webp', '.gif']); @@ -87,25 +84,30 @@ export async function checkFilesystem(input: ValidationInput): Promise e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)); - if (!hasMd) { + // no-empty-directories: directories with no allowed-extension files at any depth serve no purpose + const hasAllowed = entries.some( + (e) => + e.isFile() && + ALLOWED_EXTENSIONS.has(extname(e.name).toLowerCase()) && + (e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)) + ); + if (!hasAllowed) { diagnostics.push({ rule: RULE_NO_EMPTY_DIR, severity: input.strict ? 'error' : 'warning', file: dirPath, - title: 'Directory contains no markdown files', - detail: `"${dir.name}" contains no .md files and serves no purpose in the documentation structure. Remove it or add documentation files.`, + title: 'Directory contains no documentation files', + detail: `"${dir.name}" contains no .md or image files and serves no purpose in the documentation structure. Remove it or add documentation files.`, }); continue; } - // nested-dir-has-index: only relevant for dirs that have .md files but no index.md + // nested-dir-has-index: only relevant for dirs that have .md files; image-only dirs don't need one + const hasMd = mdFiles.some((e) => e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)); + if (!hasMd) { + continue; + } + const hasIndex = mdFiles.some((e) => e.name === 'index.md' && e.parentPath === dirPath); if (!hasIndex) { diagnostics.push({ From 976a4e48b822fadce2163c8e5e9606f697f3d728 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 11:23:39 +0100 Subject: [PATCH 08/11] pr feedback: streamline severity counting in validation result formatting --- .../plugin-docs-cli/src/validation/format.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/format.ts b/packages/plugin-docs-cli/src/validation/format.ts index 2abc6a08a8..2a00e1cb82 100644 --- a/packages/plugin-docs-cli/src/validation/format.ts +++ b/packages/plugin-docs-cli/src/validation/format.ts @@ -1,15 +1,11 @@ -import type { Diagnostic, ValidationResult } from './types.js'; +import type { Severity, ValidationResult } from './types.js'; -const SEVERITY_LABEL: Record = { +const SEVERITY_LABEL: Record = { error: 'error', warning: 'warn ', info: 'info ', }; -function countBySeverity(diagnostics: Diagnostic[], severity: string): number { - return diagnostics.filter((d) => d.severity === severity).length; -} - /** * Formats a validation result as human-readable text. */ @@ -21,9 +17,11 @@ export function formatResult(result: ValidationResult): string { return lines.join('\n'); } - const errors = countBySeverity(result.diagnostics, 'error'); - const warnings = countBySeverity(result.diagnostics, 'warning'); - const infos = countBySeverity(result.diagnostics, 'info'); + const counts: Record = { error: 0, warning: 0, info: 0 }; + for (const d of result.diagnostics) { + counts[d.severity]++; + } + const { error: errors, warning: warnings, info: infos } = counts; // summary line const parts: string[] = []; From f45896f9fb2d22b92967b2cac65d33378f90013f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 11:35:52 +0100 Subject: [PATCH 09/11] pr feedback: remove redundant nested-dir-has-index tests and streamline validation logic --- .../src/validation/rules/filesystem.test.ts | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index 53a42eb091..b864abdfd8 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -49,22 +49,6 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); - it('should not report nested-dir-has-index for directory with no markdown files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - - // img/ has images but no .md files - nested-dir-has-index only applies to dirs with .md files - const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('img')); - expect(finding).toBeUndefined(); - }); - - it('should not report nested-dir-has-index for subdirectory with index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - - // config/ has an index.md directly inside it - const finding = findings.find((f) => f.rule === 'nested-dir-has-index' && f.file?.endsWith('config')); - expect(finding).toBeUndefined(); - }); - it('should report nested-dir-has-index when subdir lacks index.md', async () => { const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); @@ -87,12 +71,15 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); }); - it('should not report no-empty-directories for directory containing image files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); + it('should not report nested-dir-has-index for image-only subdir', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'img')); + await writeFile(join(tmp, 'img', 'screenshot.png'), ''); - // img/ has test.png (an allowed image file) so it is not considered empty - const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('img')); - expect(finding).toBeUndefined(); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); }); it('should not report no-empty-directories for directory with only image files', async () => { @@ -106,14 +93,6 @@ describe('checkFilesystem', () => { expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); }); - it('should not report no-empty-directories for directory with markdown files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); - - // config/ has several .md files - const finding = findings.find((f) => f.rule === 'no-empty-directories' && f.file?.endsWith('config')); - expect(finding).toBeUndefined(); - }); - it('should report no-empty-directories for subdir with no allowed files', async () => { const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); From 4969615a041e6caa5fc1cdf69361f09b548f1a49 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 12:11:05 +0100 Subject: [PATCH 10/11] Update packages/plugin-docs-cli/src/validation/rules/filesystem.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/plugin-docs-cli/src/validation/rules/filesystem.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index fbd682f422..77bf3e063d 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -28,10 +28,10 @@ export async function checkFilesystem(input: ValidationInput): Promise e.isFile() && e.name.endsWith('.md')); + const mdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() === '.md'); const dirs = entries.filter((e) => e.isDirectory()); const symlinks = entries.filter((e) => e.isSymbolicLink()); - const nonMdFiles = entries.filter((e) => e.isFile() && !e.name.endsWith('.md')); + const nonMdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() !== '.md'); // no-symlinks for (const link of symlinks) { From eeb2c4cf12fc8715e901106ecfc1892ae9dad993 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 25 Feb 2026 12:30:23 +0100 Subject: [PATCH 11/11] copilot feedback --- .../plugin-docs-cli/src/commands/build.command.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/plugin-docs-cli/src/commands/build.command.ts b/packages/plugin-docs-cli/src/commands/build.command.ts index 5c90d9e43a..01404613a9 100644 --- a/packages/plugin-docs-cli/src/commands/build.command.ts +++ b/packages/plugin-docs-cli/src/commands/build.command.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdir, rm, writeFile } from 'node:fs/promises'; import { extname, join, relative } from 'node:path'; import createDebug from 'debug'; import { scanDocsFolder } from '../scanner.js'; @@ -30,9 +30,12 @@ export async function buildDocs(projectRoot: string, docsPath: string): Promise< // copy only allowed file types to output (mirrors the allowed-file-types validation rule) await cp(docsPath, outputDir, { recursive: true, - filter: (src) => { - const ext = extname(src).toLowerCase(); - return ext === '' || ALLOWED_EXTENSIONS.has(ext); + filter: async (src) => { + const stat = await lstat(src); + if (stat.isDirectory()) { + return true; + } + return ALLOWED_EXTENSIONS.has(extname(src).toLowerCase()); }, }); debug('Copied docs folder to %s', outputDir);