From e96b9621cab7c4aaa9dd759ac566ab083a0347c1 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 23 Mar 2026 20:46:28 +0100 Subject: [PATCH] feat: validate rule file syntax conventions (triple-slash reference + satisfies RuleSet) Rule files that omit the `/// ` directive or the `satisfies RuleSet` type assertion are now reported as blocked violations during `archgate check`, preventing execution of malformed rules. --- src/engine/loader.ts | 91 ++++++++++++++++++++++- tests/commands/check-security.test.ts | 6 +- tests/commands/check.test.ts | 18 +++-- tests/engine/loader-security.test.ts | 30 +++++--- tests/engine/loader.test.ts | 102 +++++++++++++++++++++++++- tests/integration/cli-harness.ts | 11 ++- 6 files changed, 232 insertions(+), 26 deletions(-) diff --git a/src/engine/loader.ts b/src/engine/loader.ts index 1be7ca2d..0310a0ba 100644 --- a/src/engine/loader.ts +++ b/src/engine/loader.ts @@ -55,10 +55,15 @@ export type LoadResult = /** Convert a BlockedAdr into a RuleResult-shaped object for reporting. */ export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) { const id = b.adr.frontmatter.id; + const isSyntax = b.error.includes("syntax convention"); + const ruleId = isSyntax ? "syntax-check" : "security-scan"; + const description = isSyntax + ? "Rule file syntax conventions" + : "Rule file security scan"; return { - ruleId: "security-scan", + ruleId, adrId: id, - description: "Rule file security scan", + description, violations: b.violations.map( (v): ViolationDetail => ({ message: v.message, @@ -68,7 +73,7 @@ export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) { endColumn: v.endColumn, severity: "error", adrId: id, - ruleId: "security-scan", + ruleId, }) ), error: b.error, @@ -76,6 +81,62 @@ export function blockedToRuleResult(projectRoot: string, b: BlockedAdr) { }; } +interface SyntaxViolation { + message: string; + line: number; + column: number; + endLine: number; + endColumn: number; +} + +/** + * Check that a `.rules.ts` file follows the required syntax conventions: + * 1. Triple-slash reference directive: `/// ` + * pointing to `rules.d.ts` (provides ambient types without imports). + * 2. `satisfies RuleSet` on the default export (compile-time validation). + * + * These are authoring conventions that ensure rule files get proper + * type-checking and remain self-documenting. + */ +function checkRuleSyntax(source: string): SyntaxViolation[] { + const violations: SyntaxViolation[] = []; + + // Check for triple-slash reference to rules.d.ts + const hasTripleSlash = + /^\/\/\/\s*$/m.test( + source + ); + if (!hasTripleSlash) { + violations.push({ + message: + 'Missing triple-slash reference directive. Add /// at the top of the file.', + line: 1, + column: 0, + endLine: 1, + endColumn: + source.indexOf("\n") === -1 ? source.length : source.indexOf("\n"), + }); + } + + // Check for `satisfies RuleSet` on the default export + const hasSatisfies = /\bsatisfies\s+RuleSet\b/.test(source); + if (!hasSatisfies) { + // Point to the last line as a reasonable location for the missing satisfies + const lines = source.split("\n"); + const lastLine = lines.length; + violations.push({ + message: + "Missing `satisfies RuleSet` on default export. The export must use `} satisfies RuleSet;` for compile-time type validation.", + line: lastLine, + column: 0, + endLine: lastLine, + endColumn: lines[lastLine - 1]?.length ?? 0, + }); + } + + return violations; +} + /** * Discover ADRs with rules: true and dynamically import their companion .rules.ts files. */ @@ -163,11 +224,33 @@ export async function loadRuleAdrs( }; } + const ruleSource = await Bun.file(rulesFile).text(); + + // Syntax gate: ensure rule files follow the required conventions + // (triple-slash reference directive + `satisfies RuleSet`). + const syntaxViolations = checkRuleSyntax(ruleSource); + if (syntaxViolations.length > 0) { + return { + type: "blocked", + value: { + adr, + error: `ADR ${adr.frontmatter.id}: rule file has syntax convention violations (${syntaxViolations.length} violation${syntaxViolations.length === 1 ? "" : "s"})`, + violations: syntaxViolations.map((v) => ({ + message: v.message, + file: rulesFile, + line: v.line, + column: v.column, + endLine: v.endLine, + endColumn: v.endColumn, + })), + }, + }; + } + // Security gate: scan rule source for banned patterns before executing. // This blocks dangerous imports (node:fs, child_process), Bun APIs // (Bun.spawn, Bun.file), network access (fetch), eval, and obfuscation // patterns (computed property access, dynamic imports). - const ruleSource = await Bun.file(rulesFile).text(); const scanViolations = scanRuleSource(ruleSource); if (scanViolations.length > 0) { return { diff --git a/tests/commands/check-security.test.ts b/tests/commands/check-security.test.ts index 20f96aea..52883697 100644 --- a/tests/commands/check-security.test.ts +++ b/tests/commands/check-security.test.ts @@ -32,7 +32,11 @@ describe("check command security", () => { function writeAdrAndRule(id: string, ruleCode: string): void { writeFileSync(join(adrsDir, `${id}-sec.md`), adrTemplate(id)); - writeFileSync(join(adrsDir, `${id}-sec.rules.ts`), ruleCode); + // Wrap rule code with required syntax conventions + const wrapped = + `/// \n\n` + + ruleCode.trimEnd().replace(/};\s*$/, "} satisfies RuleSet;\n"); + writeFileSync(join(adrsDir, `${id}-sec.rules.ts`), wrapped); } test("blocks readFile path traversal via on-disk rule", async () => { diff --git a/tests/commands/check.test.ts b/tests/commands/check.test.ts index ea2bd5b7..5e9d0466 100644 --- a/tests/commands/check.test.ts +++ b/tests/commands/check.test.ts @@ -40,14 +40,16 @@ rules: true ); writeFileSync( join(tempDir, ".archgate", "adrs", "TEST-001-passing.rules.ts"), - `export default { + `/// + +export default { rules: { "always-pass": { description: "Always passes", async check() {}, }, }, -}; +} satisfies RuleSet; ` ); @@ -74,7 +76,9 @@ files: ["src/**/*.ts"] ); writeFileSync( join(tempDir, ".archgate", "adrs", "TEST-002-failing.rules.ts"), - `export default { + `/// + +export default { rules: { "no-console": { description: "No console.log", @@ -92,7 +96,7 @@ files: ["src/**/*.ts"] }, }, }, -}; +} satisfies RuleSet; ` ); @@ -117,14 +121,16 @@ rules: true ); writeFileSync( join(tempDir, ".archgate", "adrs", "TEST-003-filter.rules.ts"), - `export default { + `/// + +export default { rules: { "filtered": { description: "Filtered rule", async check() {}, }, }, -}; +} satisfies RuleSet; ` ); diff --git a/tests/engine/loader-security.test.ts b/tests/engine/loader-security.test.ts index 0f912122..6100d68d 100644 --- a/tests/engine/loader-security.test.ts +++ b/tests/engine/loader-security.test.ts @@ -41,7 +41,8 @@ Test decision. writeAdrMd(adrsDir, "SEC-001", "SEC-001-malicious-fs"); writeFileSync( join(adrsDir, "SEC-001-malicious-fs.rules.ts"), - `import { readFileSync } from "node:fs"; + `/// +import { readFileSync } from "node:fs"; export default { rules: { "steal-secrets": { @@ -51,7 +52,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); @@ -68,7 +69,8 @@ export default { writeAdrMd(adrsDir, "SEC-002", "SEC-002-malicious-spawn"); writeFileSync( join(adrsDir, "SEC-002-malicious-spawn.rules.ts"), - `export default { + `/// +export default { rules: { "run-command": { description: "Run arbitrary command", @@ -77,7 +79,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); @@ -94,7 +96,8 @@ export default { writeAdrMd(adrsDir, "SEC-003", "SEC-003-malicious-fetch"); writeFileSync( join(adrsDir, "SEC-003-malicious-fetch.rules.ts"), - `export default { + `/// +export default { rules: { "exfiltrate": { description: "Exfiltrate data", @@ -103,7 +106,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); @@ -120,7 +123,8 @@ export default { writeAdrMd(adrsDir, "SEC-004", "SEC-004-malicious-eval"); writeFileSync( join(adrsDir, "SEC-004-malicious-eval.rules.ts"), - `export default { + `/// +export default { rules: { "eval-attack": { description: "Execute arbitrary code", @@ -129,7 +133,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); @@ -146,7 +150,8 @@ export default { writeAdrMd(adrsDir, "SEC-005", "SEC-005-clean-rule"); writeFileSync( join(adrsDir, "SEC-005-clean-rule.rules.ts"), - `export default { + `/// +export default { rules: { "safe-rule": { description: "A well-behaved rule", @@ -161,7 +166,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); @@ -179,7 +184,8 @@ export default { writeAdrMd(adrsDir, "SEC-006", "SEC-006-safe-imports"); writeFileSync( join(adrsDir, "SEC-006-safe-imports.rules.ts"), - `import { join } from "node:path"; + `/// +import { join } from "node:path"; export default { rules: { @@ -190,7 +196,7 @@ export default { }, }, }, -}; +} satisfies RuleSet; ` ); diff --git a/tests/engine/loader.test.ts b/tests/engine/loader.test.ts index 114195d1..e72e78b4 100644 --- a/tests/engine/loader.test.ts +++ b/tests/engine/loader.test.ts @@ -28,14 +28,16 @@ describe("loadRuleAdrs", () => { function writeRulesTs(adrsDir: string, baseName: string) { writeFileSync( join(adrsDir, `${baseName}.rules.ts`), - `export default { + `/// + +export default { rules: { "sample-rule": { description: "Sample rule", async check(ctx) {}, }, }, -}; +} satisfies RuleSet; ` ); } @@ -103,4 +105,100 @@ describe("loadRuleAdrs", () => { const loaded = await loadRuleAdrs(tempDir); expect(loaded).toHaveLength(0); }); + + test("returns blocked result when triple-slash reference is missing", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + copyFileSync( + join(fixturesDir, "TEST-001-sample.md"), + join(adrsDir, "TEST-001-sample.md") + ); + writeFileSync( + join(adrsDir, "TEST-001-sample.rules.ts"), + `export default { + rules: { + "sample-rule": { + description: "Sample rule", + async check(ctx) {}, + }, + }, +} satisfies RuleSet; +` + ); + + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + const blocked = results[0] as Extract< + (typeof results)[0], + { type: "blocked" } + >; + expect(blocked.value.error).toContain("syntax convention"); + expect(blocked.value.violations).toHaveLength(1); + expect(blocked.value.violations[0].message).toContain( + "triple-slash reference" + ); + }); + + test("returns blocked result when satisfies RuleSet is missing", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + copyFileSync( + join(fixturesDir, "TEST-001-sample.md"), + join(adrsDir, "TEST-001-sample.md") + ); + writeFileSync( + join(adrsDir, "TEST-001-sample.rules.ts"), + `/// + +export default { + rules: { + "sample-rule": { + description: "Sample rule", + async check(ctx) {}, + }, + }, +}; +` + ); + + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + const blocked = results[0] as Extract< + (typeof results)[0], + { type: "blocked" } + >; + expect(blocked.value.error).toContain("syntax convention"); + expect(blocked.value.violations).toHaveLength(1); + expect(blocked.value.violations[0].message).toContain("satisfies RuleSet"); + }); + + test("returns blocked with two violations when both conventions are missing", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + copyFileSync( + join(fixturesDir, "TEST-001-sample.md"), + join(adrsDir, "TEST-001-sample.md") + ); + writeFileSync( + join(adrsDir, "TEST-001-sample.rules.ts"), + `export default { + rules: { + "sample-rule": { + description: "Sample rule", + async check(ctx) {}, + }, + }, +}; +` + ); + + const results = await loadRuleAdrs(tempDir); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("blocked"); + const blocked = results[0] as Extract< + (typeof results)[0], + { type: "blocked" } + >; + expect(blocked.value.error).toContain("2 violations"); + expect(blocked.value.violations).toHaveLength(2); + }); }); diff --git a/tests/integration/cli-harness.ts b/tests/integration/cli-harness.ts index 6f7e61b7..f6ccfd3c 100644 --- a/tests/integration/cli-harness.ts +++ b/tests/integration/cli-harness.ts @@ -72,13 +72,22 @@ export function writeAdr(dir: string, filename: string, content: string): void { /** * Write a companion .rules.ts file to the project's adrs directory. + * Automatically wraps the content with required syntax conventions + * (triple-slash reference + satisfies RuleSet) if missing. */ export function writeRules( dir: string, filename: string, content: string ): void { - writeFileSync(join(dir, ".archgate", "adrs", filename), content); + let wrapped = content; + if (!content.includes("/// \n\n${wrapped}`; + } + if (!content.includes("satisfies RuleSet")) { + wrapped = wrapped.trimEnd().replace(/};\s*$/, "} satisfies RuleSet;\n"); + } + writeFileSync(join(dir, ".archgate", "adrs", filename), wrapped); } /**