Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 7 additions & 12 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 75 additions & 3 deletions packages/cli/test/commands/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
79 changes: 79 additions & 0 deletions packages/cli/test/lib/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
getExpectedFolder,
getFolderName,
clearConfigCache,
getValidateIgnore,
DEFAULT_VALIDATE_IGNORE,
type SynapseConfig,
} from "../../src/lib/config.js";

Expand Down Expand Up @@ -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);
});
});
});