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
10 changes: 4 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async function main() {
addBreadcrumb("command", `Running: ${fullCommand}`);
// Collect which options were used (presence only, no values)
const opts = actionCommand.opts();
const optionFlags: Record<string, boolean> = {};
const optionFlags: Record<string, unknown> = {};
const optionsUsed: string[] = [];
for (const key of Object.keys(opts)) {
const val = opts[key];
Expand All @@ -131,11 +131,9 @@ async function main() {
if (used) optionsUsed.push(key);
}
const depth = fullCommand === "root" ? 0 : fullCommand.split(" ").length;
trackCommand(fullCommand, {
...optionFlags,
command_depth: depth,
options_used: optionsUsed,
});
optionFlags.command_depth = depth;
optionFlags.options_used = optionsUsed;
trackCommand(fullCommand, optionFlags);
beginCommand(fullCommand);
});

Expand Down
2 changes: 1 addition & 1 deletion src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function registerCheckCommand(program: Command) {
Bun.sleep(100).then(() => ""),
]);
const piped = stdin.trim().split(/\r?\n/u).filter(Boolean);
filterFiles = [...filterFiles, ...piped];
for (const f of piped) filterFiles.push(f);
} catch {
// stdin not readable — ignore
}
Expand Down
11 changes: 9 additions & 2 deletions src/engine/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,16 @@ export function briefAdr(
};
}

/** Cache compiled Bun.Glob instances — same patterns repeat across ADRs and files. */
const globCache = new Map<string, Bun.Glob>();

function fileMatchesGlobs(filePath: string, globs: string[]): boolean {
for (const pattern of globs) {
const glob = new Bun.Glob(pattern);
let glob = globCache.get(pattern);
if (!glob) {
glob = new Bun.Glob(pattern);
globCache.set(pattern, glob);
}
// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp
if (glob.match(filePath)) return true;
}
Expand Down Expand Up @@ -150,7 +157,7 @@ export function matchFilesToAdrs(
}
}
} else {
matchingFiles.push(...changedFiles);
for (const f of changedFiles) matchingFiles.push(f);
}

if (matchingFiles.length > 0) {
Expand Down
28 changes: 11 additions & 17 deletions src/engine/git-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,23 @@ export async function resolveScopedFiles(
const expanded = patterns.flatMap((p) => expandBracePattern(p));

const scanStart = performance.now();
const results = await Promise.all(
const dedupSet = new Set<string>();
await Promise.all(
expanded.map(async (pattern) => {
const glob = new Bun.Glob(pattern);
const files: string[] = [];
// dot: true so ADR `files:` globs can target dot-prefixed source dirs
// like `.github/`, `.husky/`, `.vscode/`. The git-tracked-files filter
// below already excludes ignored files. See archgate/cli#222.
for await (const file of glob.scan({ cwd: projectRoot, dot: true })) {
const normalized = file.replaceAll("\\", "/");
if (trackedFiles && !trackedFiles.has(normalized)) continue;
files.push(normalized);
dedupSet.add(normalized);
}
return files;
})
);
const scanMs = performance.now() - scanStart;

const all = [...new Set(results.flat())].sort();
const all = Array.from(dedupSet).sort();

if (all.length > fileWarnThreshold || scanMs > SCOPE_SCAN_WARN_MS) {
logWarn(
Expand Down Expand Up @@ -169,12 +168,10 @@ export async function getChangedFiles(projectRoot: string): Promise<string[]> {
runGit(["diff", "--cached", "--name-only"], projectRoot),
runGit(["diff", "--name-only"], projectRoot),
]);
const all = new Set([
...staged.trim().split("\n").filter(Boolean),
...unstaged.trim().split("\n").filter(Boolean),
]);
const all = new Set(staged.trim().split("\n").filter(Boolean));
for (const f of unstaged.trim().split("\n").filter(Boolean)) all.add(f);
logDebug("Changed files (staged + unstaged):", all.size);
return [...all].sort();
return Array.from(all).sort();
} catch {
logDebug("Failed to get changed files (not a git repo?)");
return [];
Expand Down Expand Up @@ -290,13 +287,10 @@ export async function getFilesChangedSinceRef(
getChangedFiles(projectRoot),
runGit(["ls-files", "--others", "--exclude-standard"], projectRoot),
]);
const files = [
...new Set([
...committed.trim().split("\n").filter(Boolean),
...workingTree,
...untracked.trim().split("\n").filter(Boolean),
]),
].sort();
const dedup = new Set(committed.trim().split("\n").filter(Boolean));
for (const f of workingTree) dedup.add(f);
for (const f of untracked.trim().split("\n").filter(Boolean)) dedup.add(f);
const files = Array.from(dedup).sort();
logDebug(`Files changed since ${ref} (incl. working tree):`, files.length);
return files;
} catch {
Expand Down
10 changes: 3 additions & 7 deletions src/engine/js-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,14 @@ export function parseJsModule(
source: string,
options?: { jsx?: boolean; sourceType?: "module" | "script" }
): MeriyahProgram {
const jsx = options?.jsx === true;
if (options?.sourceType === "script") {
return parseScript(source, {
next: true,
loc: true,
globalReturn: true,
...(options?.jsx ? { jsx: true } : {}),
jsx,
});
}
return parseModule(source, {
next: true,
loc: true,
module: true,
...(options?.jsx ? { jsx: true } : {}),
});
return parseModule(source, { next: true, loc: true, module: true, jsx });
}
64 changes: 36 additions & 28 deletions src/engine/rule-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,39 @@ import { remapViolations, type RawViolation } from "./source-positions";
*
* Returns an empty array if the rule is clean; violations if blocked patterns are found.
*/
export function scanRuleSource(source: string): ScanViolation[] {
const transpiler = new Bun.Transpiler({ loader: "ts" });
/** Shared transpiler — stateless, safe to reuse across calls. */
const tsTranspiler = new Bun.Transpiler({ loader: "ts" });

export function scanRuleSource(
source: string,
preTranspiled?: string
): ScanViolation[] {
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,
},
];
if (preTranspiled) {
js = preTranspiled;
} else {
try {
js = tsTranspiler.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: MeriyahProgram;
Expand Down Expand Up @@ -272,10 +281,9 @@ const IMPORTED_BLOCKED_GLOBALS = new Set(["require", "WebSocket"]);
* - `WebSocket` usage
*/
export function scanImportedRuleSource(source: string): ScanViolation[] {
const transpiler = new Bun.Transpiler({ loader: "ts" });
let js: string;
try {
js = transpiler.transformSync(source);
js = tsTranspiler.transformSync(source);
} catch (err) {
const msg =
err instanceof AggregateError && err.errors.length > 0
Expand Down Expand Up @@ -384,8 +392,8 @@ export function scanImportedRuleSource(source: string): ScanViolation[] {
const importedRoot = parseNode(ast);
if (importedRoot) walkImported(importedRoot);

// Combine: standard scan + imported-only scan
const standardViolations = scanRuleSource(source);
// Combine: standard scan + imported-only scan (reuse transpiled JS)
const standardViolations = scanRuleSource(source, js);
const importedViolations = remapViolations(source, rawViolations);
return [...standardViolations, ...importedViolations];
return standardViolations.concat(importedViolations);
}
30 changes: 15 additions & 15 deletions src/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,16 @@ import { applySuppressions, type SuppressionWarning } from "./suppressions";
* Resolve a user-supplied path and ensure it stays within projectRoot.
* Throws if the resolved path escapes the project boundary or is a symlink.
*/
function safePath(projectRoot: string, userPath: string): string {
const root = resolve(projectRoot);
function safePath(resolvedRoot: string, userPath: string): string {
const absPath = isAbsolute(userPath)
? resolve(userPath)
: resolve(root, userPath);
: resolve(resolvedRoot, userPath);
// On Windows, paths on different drives produce a full absolute relative()
// result rather than a ".." prefix — use startsWith on the normalized paths.
if (
!absPath.startsWith(root + "/") &&
!absPath.startsWith(root + "\\") &&
absPath !== root
!absPath.startsWith(resolvedRoot + "/") &&
!absPath.startsWith(resolvedRoot + "\\") &&
absPath !== resolvedRoot
) {
throw new UserError(
`Path "${userPath}" escapes project root — access denied`
Expand Down Expand Up @@ -151,6 +150,7 @@ function createRuleContext(
trackedFiles: Set<string> | null,
interpreterCache: Map<string, Promise<string | null>>
): RuleContext {
const resolvedRoot = resolve(projectRoot);
const report: RuleReport = {
violation(detail) {
violations.push({ ...detail, ruleId, adrId, severity: "error" });
Expand All @@ -177,7 +177,7 @@ function createRuleContext(
async function astImpl(path: string, language: "ruby"): Promise<RubyAstNode>;
async function astImpl(path: string, language: AstLanguage) {
// Guardrail 1: path safety — same sandbox as readFile/glob.
const absPath = safePath(projectRoot, path);
const absPath = safePath(resolvedRoot, path);

// Guardrail 2: language plausibility — refuse to hand a file to an
// interpreter unless its name plausibly matches the requested language.
Expand Down Expand Up @@ -287,7 +287,7 @@ function createRuleContext(
},

async grep(file: string, pattern: RegExp): Promise<GrepMatch[]> {
const absPath = safePath(projectRoot, file);
const absPath = safePath(resolvedRoot, file);
const content = await Bun.file(absPath).text();
const lines = content.split("\n");
const matches: GrepMatch[] = [];
Expand Down Expand Up @@ -327,7 +327,7 @@ function createRuleContext(
seen.add(normalized);
}
}
const files = [...seen];
const files = Array.from(seen);

const BATCH_SIZE = 32;
const allMatches: GrepMatch[] = [];
Expand All @@ -337,7 +337,7 @@ function createRuleContext(
// oxlint-disable-next-line no-await-in-loop -- batched parallelism with sequential batch boundaries
const batchResults = await Promise.all(
batch.map(async (normalized) => {
const absPath = safePath(projectRoot, normalized);
const absPath = safePath(resolvedRoot, normalized);
try {
const content = await Bun.file(absPath).text();
const lines = content.split("\n");
Expand All @@ -363,20 +363,20 @@ function createRuleContext(
})
);
for (const matches of batchResults) {
allMatches.push(...matches);
for (const m of matches) allMatches.push(m);
}
}

return allMatches;
},

readFile(path: string): Promise<string> {
const absPath = safePath(projectRoot, path);
const absPath = safePath(resolvedRoot, path);
return Bun.file(absPath).text();
},

readJSON(path: string): Promise<any> {
const absPath = safePath(projectRoot, path);
const absPath = safePath(resolvedRoot, path);
return Bun.file(absPath).json();
},

Expand Down Expand Up @@ -419,7 +419,7 @@ export async function runChecks(
if (options.files && options.files.length > 0) {
filterFiles = new Set(
options.files.map((f) => {
const absPath = safePath(projectRoot, f);
const absPath = safePath(resolve(projectRoot), f);
return relative(projectRoot, absPath).replaceAll("\\", "/");
})
);
Expand Down Expand Up @@ -527,7 +527,7 @@ export async function runChecks(
// Collect results
for (const result of adrResults) {
if (result.status === "fulfilled") {
results.push(...result.value);
for (const r of result.value) results.push(r);
}
}

Expand Down
Loading