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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 14 additions & 6 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
5 changes: 1 addition & 4 deletions src/engine/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down
102 changes: 50 additions & 52 deletions src/engine/rule-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, number>();
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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -185,5 +183,5 @@ export function scanRuleSource(source: string): ScanViolation[] {
}

walk(ast as unknown as AstNode);
return violations;
return remapViolations(source, rawViolations);
}
Loading
Loading