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);
}
/**