Skip to content
Merged
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
32 changes: 31 additions & 1 deletion src/engine/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,37 @@ export async function loadRuleAdrs(
}

// Use file:// URL to handle Windows backslash paths in import().
const mod = await import(pathToFileURL(rulesFile).href);
let mod: Record<string, unknown>;
try {
mod = await import(pathToFileURL(rulesFile).href);
} catch (err) {
// Bun throws AggregateError with "Parse error" for files with syntax
// errors that pass the transpiler-based scanner but fail the full
// import parser. Surface the first inner error for a useful message.
const msg =
err instanceof AggregateError && err.errors.length > 0
? String(err.errors[0])
: err instanceof Error
? err.message
: String(err);
return {
type: "blocked",
value: {
adr,
error: `ADR ${adr.frontmatter.id}: failed to import companion rule file — ${msg}`,
violations: [
{
message: `Failed to load rule file: ${msg}`,
file: rulesFile,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
},
],
},
};
}
const parsed = RuleSetSchema.safeParse(mod.default);

if (!parsed.success) {
Expand Down
79 changes: 75 additions & 4 deletions src/engine/rule-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,45 @@ import { remapViolations, type RawViolation } from "./source-positions";
*/
export function scanRuleSource(source: string): ScanViolation[] {
const transpiler = new Bun.Transpiler({ loader: "ts" });
const js = transpiler.transformSync(source);
const ast = parseModule(js, { next: true, loc: true, module: true });
let js: string;
try {
js = transpiler.transformSync(source);
} catch (err) {
// Bun.Transpiler throws AggregateError for syntax errors in the source.
// Return a single violation pointing at line 1 so the caller can report
// the file as blocked rather than crashing.
const msg =
err instanceof AggregateError && err.errors.length > 0
? String(err.errors[0])
: err instanceof Error
? err.message
: String(err);
return [
{
message: `Parse error: ${msg}`,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
},
];
}

let ast: ReturnType<typeof parseModule>;
try {
ast = parseModule(js, { next: true, loc: true, module: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return [
{
message: `Parse error: ${msg}`,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
},
];
}
const rawViolations: RawViolation[] = [];

/** Track how many times each searchText has been seen, to match by occurrence. */
Expand Down Expand Up @@ -204,8 +241,42 @@ const IMPORTED_BLOCKED_GLOBALS = new Set(["require", "WebSocket"]);
*/
export function scanImportedRuleSource(source: string): ScanViolation[] {
const transpiler = new Bun.Transpiler({ loader: "ts" });
const js = transpiler.transformSync(source);
const ast = parseModule(js, { next: true, loc: true, module: true });
let js: string;
try {
js = transpiler.transformSync(source);
} catch (err) {
const msg =
err instanceof AggregateError && err.errors.length > 0
? String(err.errors[0])
: err instanceof Error
? err.message
: String(err);
return [
{
message: `Parse error: ${msg}`,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
},
];
}

let ast: ReturnType<typeof parseModule>;
try {
ast = parseModule(js, { next: true, loc: true, module: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return [
{
message: `Parse error: ${msg}`,
line: 1,
column: 0,
endLine: 1,
endColumn: 0,
},
];
}
const rawViolations: RawViolation[] = [];

const seenCounts = new Map<string, number>();
Expand Down
26 changes: 26 additions & 0 deletions tests/engine/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ export default {
expect(blocked.value.violations).toHaveLength(2);
});

test("returns blocked result when .rules.ts has syntax errors", 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"),
`/// <reference path="../rules.d.ts" />

export default { invalid syntax here !!! } 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("security scanner");
expect(blocked.value.violations.length).toBeGreaterThanOrEqual(1);
expect(blocked.value.violations[0].message).toContain("Parse error");
});

test("loads ADRs from custom directory configured in config.json", async () => {
// Create a custom ADR directory outside .archgate/
const customAdrsDir = join(tempDir, "docs", "adrs");
Expand Down
26 changes: 26 additions & 0 deletions tests/engine/rule-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,23 @@ describe("scanRuleSource", () => {
});
});

describe("parse error handling", () => {
test("returns violation instead of throwing for unparseable source", () => {
const source = `export default { invalid syntax here !!! }`;
const violations = scanRuleSource(source);
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain("Parse error");
expect(violations[0].line).toBe(1);
});

test("returns violation for completely broken TypeScript", () => {
const source = `<<<<<<< HEAD\nconst x = 1;\n=======\nconst x = 2;\n>>>>>>> branch`;
const violations = scanRuleSource(source);
expect(violations.length).toBeGreaterThanOrEqual(1);
expect(violations[0].message).toContain("Parse error");
});
});

// Position remapping tests are in rule-scanner-positions.test.ts
});

Expand Down Expand Up @@ -403,6 +420,15 @@ export default {
});
});

describe("parse error handling", () => {
test("returns violation instead of throwing for unparseable source", () => {
const source = `export default { invalid syntax here !!! }`;
const violations = scanImportedRuleSource(source);
expect(violations.length).toBeGreaterThanOrEqual(1);
expect(violations[0].message).toContain("Parse error");
});
});

describe("safe module imports remain allowed", () => {
const safeModules = ["node:path", "node:url", "node:util", "node:crypto"];

Expand Down