diff --git a/README.md b/README.md index deccd58..498e807 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,24 @@ This allows projects to override individual schemas while falling back to the st } ``` +### Validation Ignore Paths + +By default `synapse validate` discovers files while ignoring +`node_modules`, `.git`, `templates/`, and `index.md`. Use `validate.ignore` +to exclude additional glob patterns -- for example a free-form documentation +subsection that intentionally does not follow a body grammar: + +```json +{ + "validate": { + "ignore": ["**/210_QA-Memory/**"] + } +} +``` + +These patterns are **merged with** the built-in defaults (they do not +replace them), so there is no need to repeat `node_modules` or `templates/`. + ## Decap CMS (Self-hosted) - Serves under /admin - Configure backend for your Git provider (GitHub/GitLab/Gitea) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 020c005..3fe4d3e 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -12,6 +12,7 @@ import { import { loadBodyRules, validateBody } from "../lib/bodyRules.js"; import { validatePluginMarketplace } from "../lib/validate-plugins.js"; +import { getValidateIgnore } from "../lib/config.js"; export interface ValidationIssue { type: "error" | "warning"; @@ -354,6 +355,10 @@ export async function validate(options: { const pattern = options.pattern || "**/*.md"; const strict = options.strict !== undefined ? options.strict : true; // Default to strict mode + // Resolve ignore globs: built-in defaults merged with `validate.ignore` + // from synapse.config.json (if present). + const ignoreGlobs = getValidateIgnore(contentDir); + const allIssues: ValidationIssue[] = []; let filesValidated = 0; @@ -375,12 +380,7 @@ export async function validate(options: { const files = await glob(pattern, { cwd: contentDir, absolute: true, - ignore: [ - "**/node_modules/**", - "**/.git/**", - "**/templates/**", - "index.md", - ], + ignore: ignoreGlobs, }); // Build set of ALL existing files in the vault for cross-reference validation @@ -401,12 +401,7 @@ export async function validate(options: { const allVaultFiles = await glob("**/*.md", { cwd: rootContentDir, absolute: true, - ignore: [ - "**/node_modules/**", - "**/.git/**", - "**/templates/**", - "index.md", - ], + ignore: ignoreGlobs, }); // Add all vault files to the set for cross-reference checking diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 950edd3..3a12eb4 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -28,6 +28,35 @@ export interface SynapseConfig { customDir?: string; loadBase?: boolean; }; + validate?: { + /** + * Additional glob patterns to exclude from `synapse validate` file + * discovery. Merged with the built-in defaults + * (node_modules, .git, templates, index.md) -- does not replace them. + */ + ignore?: string[]; + }; +} + +/** + * Default glob patterns excluded from `synapse validate` file discovery. + */ +export const DEFAULT_VALIDATE_IGNORE: string[] = [ + '**/node_modules/**', + '**/.git/**', + '**/templates/**', + 'index.md', +]; + +/** + * Resolve the full list of ignore globs for `synapse validate`. + * Merges the built-in defaults with any `validate.ignore` patterns from + * synapse.config.json. Duplicate patterns are de-duplicated. + */ +export function getValidateIgnore(cwd?: string): string[] { + const config = loadConfig(cwd); + const custom = config?.validate?.ignore ?? []; + return [...new Set([...DEFAULT_VALIDATE_IGNORE, ...custom])]; } let cachedConfig: SynapseConfig | null = null; diff --git a/packages/cli/test/commands/validate.spec.ts b/packages/cli/test/commands/validate.spec.ts index 9d66fc7..7f55f6d 100644 --- a/packages/cli/test/commands/validate.spec.ts +++ b/packages/cli/test/commands/validate.spec.ts @@ -2,6 +2,7 @@ import fsExtra from 'fs-extra'; const fs = fsExtra; import * as path from 'path'; import { validate } from '../../src/commands/validate'; +import { clearConfigCache } from '../../src/lib/config'; import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; describe('Validate Command - Duplicate Detection', () => { @@ -733,12 +734,83 @@ See also [[payments-api]] for system details.` ); const result = await validate({ contentDir, schemaDir }); - - const errors = result.issues.filter(i => - i.type === 'error' && + + const errors = result.issues.filter(i => + i.type === 'error' && i.message.includes('Referenced document does not exist') ); expect(errors).toHaveLength(0); }); }); +}); + +describe('Validate Command - validate.ignore config', () => { + const testDir = path.join(process.cwd(), 'test-vault-validate-ignore'); + const contentDir = path.join(testDir, 'content'); + const schemaDir = path.join(contentDir, 'schemas'); + const configPath = path.join(testDir, 'synapse.config.json'); + + beforeEach(async () => { + await fs.ensureDir(path.join(contentDir, '10_Policies')); + await fs.ensureDir(path.join(contentDir, '210_QA-Memory')); + await fs.ensureDir(schemaDir); + + const realSchemaDir = path.resolve(process.cwd(), '../../schemas/frontmatter'); + const schemas = await fs.readdir(realSchemaDir); + for (const schema of schemas) { + if (schema.endsWith('.json')) { + await fs.copy( + path.join(realSchemaDir, schema), + path.join(schemaDir, schema) + ); + } + } + + // A free-form doc that would fail strict body-grammar validation. + await fs.writeFile( + path.join(contentDir, '210_QA-Memory/free-form-notes.md'), + `# Free-form QA memory\n\nUnstructured notes that do not follow any body grammar.\n` + ); + clearConfigCache(); + }); + + afterEach(async () => { + await fs.remove(testDir); + clearConfigCache(); + }); + + it('validates the free-form file when no validate.ignore is configured', async () => { + const result = await validate({ contentDir, schemaDir }); + expect(result.filesValidated).toBe(1); + }); + + it('excludes paths matched by validate.ignore from validation', async () => { + await fs.writeFile( + configPath, + JSON.stringify({ validate: { ignore: ['**/210_QA-Memory/**'] } }, null, 2) + ); + clearConfigCache(); + + const result = await validate({ contentDir, schemaDir }); + expect(result.filesValidated).toBe(0); + expect( + result.issues.some(i => i.file?.includes('210_QA-Memory')) + ).toBe(false); + }); + + it('still validates non-ignored paths when validate.ignore is set', async () => { + await fs.writeFile( + path.join(contentDir, '10_Policies/POL001-data-protection.md'), + `---\ntype: policy\nid: data-protection\ntitle: Data Protection\nowner: Security Team\nsummary: A data protection policy\nscope: All systems\nrationale: Protect data\n---\n\n# Data Protection Policy` + ); + await fs.writeFile( + configPath, + JSON.stringify({ validate: { ignore: ['**/210_QA-Memory/**'] } }, null, 2) + ); + clearConfigCache(); + + const result = await validate({ contentDir, schemaDir }); + // The QA-Memory file is skipped; the policy is still validated. + expect(result.filesValidated).toBe(1); + }); }); \ No newline at end of file diff --git a/packages/cli/test/lib/config.spec.ts b/packages/cli/test/lib/config.spec.ts index d4fd0e0..276752a 100644 --- a/packages/cli/test/lib/config.spec.ts +++ b/packages/cli/test/lib/config.spec.ts @@ -8,6 +8,8 @@ import { getExpectedFolder, getFolderName, clearConfigCache, + getValidateIgnore, + DEFAULT_VALIDATE_IGNORE, type SynapseConfig, } from "../../src/lib/config.js"; @@ -317,4 +319,81 @@ describe("config module", () => { expect(config?.branding?.siteName).toBe("Cache Cleared"); }); }); + + describe("getValidateIgnore", () => { + it("should return only the built-in defaults when no config exists", () => { + const ignore = getValidateIgnore(tmpDir); + expect(ignore).toEqual(DEFAULT_VALIDATE_IGNORE); + }); + + it("should return the built-in defaults when validate.ignore is absent", () => { + const configData: SynapseConfig = { + branding: { siteName: "No Validate Block" }, + }; + + fs.writeFileSync( + path.join(tmpDir, "synapse.config.json"), + JSON.stringify(configData, null, 2) + ); + + const ignore = getValidateIgnore(tmpDir); + expect(ignore).toEqual(DEFAULT_VALIDATE_IGNORE); + }); + + it("should return the built-in defaults when validate.ignore is an empty array", () => { + const configData: SynapseConfig = { + validate: { ignore: [] }, + }; + + fs.writeFileSync( + path.join(tmpDir, "synapse.config.json"), + JSON.stringify(configData, null, 2) + ); + + const ignore = getValidateIgnore(tmpDir); + expect(ignore).toEqual(DEFAULT_VALIDATE_IGNORE); + }); + + it("should merge custom validate.ignore patterns with the defaults", () => { + const configData: SynapseConfig = { + validate: { + ignore: ["**/210_QA-Memory/**", "**/drafts/**"], + }, + }; + + fs.writeFileSync( + path.join(tmpDir, "synapse.config.json"), + JSON.stringify(configData, null, 2) + ); + + const ignore = getValidateIgnore(tmpDir); + // Defaults are preserved... + for (const pattern of DEFAULT_VALIDATE_IGNORE) { + expect(ignore).toContain(pattern); + } + // ...and custom patterns are appended. + expect(ignore).toContain("**/210_QA-Memory/**"); + expect(ignore).toContain("**/drafts/**"); + expect(ignore).toHaveLength(DEFAULT_VALIDATE_IGNORE.length + 2); + }); + + it("should de-duplicate a custom pattern that matches a default", () => { + const configData: SynapseConfig = { + validate: { + ignore: ["**/templates/**", "**/drafts/**"], + }, + }; + + fs.writeFileSync( + path.join(tmpDir, "synapse.config.json"), + JSON.stringify(configData, null, 2) + ); + + const ignore = getValidateIgnore(tmpDir); + // "**/templates/**" is already a default, so it appears exactly once. + expect(ignore.filter((p) => p === "**/templates/**")).toHaveLength(1); + expect(ignore).toContain("**/drafts/**"); + expect(ignore).toHaveLength(DEFAULT_VALIDATE_IGNORE.length + 1); + }); + }); });