diff --git a/src/cli.ts b/src/cli.ts index f1460c7b..62c26966 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 = {}; + const optionFlags: Record = {}; const optionsUsed: string[] = []; for (const key of Object.keys(opts)) { const val = opts[key]; @@ -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); }); diff --git a/src/commands/check.ts b/src/commands/check.ts index a73e0fee..1245c4a6 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -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 } diff --git a/src/engine/context.ts b/src/engine/context.ts index 881a7a0b..aa9b3d92 100644 --- a/src/engine/context.ts +++ b/src/engine/context.ts @@ -114,9 +114,16 @@ export function briefAdr( }; } +/** Cache compiled Bun.Glob instances — same patterns repeat across ADRs and files. */ +const globCache = new Map(); + 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; } @@ -150,7 +157,7 @@ export function matchFilesToAdrs( } } } else { - matchingFiles.push(...changedFiles); + for (const f of changedFiles) matchingFiles.push(f); } if (matchingFiles.length > 0) { diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 3dd56ebe..593ca243 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -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(); + 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( @@ -169,12 +168,10 @@ export async function getChangedFiles(projectRoot: string): Promise { 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 []; @@ -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 { diff --git a/src/engine/js-parser.ts b/src/engine/js-parser.ts index 121c9e73..60fa0692 100644 --- a/src/engine/js-parser.ts +++ b/src/engine/js-parser.ts @@ -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 }); } diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index f8c0d848..34e850df 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -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; @@ -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 @@ -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); } diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 040a2caa..26578ae9 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -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` @@ -151,6 +150,7 @@ function createRuleContext( trackedFiles: Set | null, interpreterCache: Map> ): RuleContext { + const resolvedRoot = resolve(projectRoot); const report: RuleReport = { violation(detail) { violations.push({ ...detail, ruleId, adrId, severity: "error" }); @@ -177,7 +177,7 @@ function createRuleContext( async function astImpl(path: string, language: "ruby"): Promise; 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. @@ -287,7 +287,7 @@ function createRuleContext( }, async grep(file: string, pattern: RegExp): Promise { - 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[] = []; @@ -327,7 +327,7 @@ function createRuleContext( seen.add(normalized); } } - const files = [...seen]; + const files = Array.from(seen); const BATCH_SIZE = 32; const allMatches: GrepMatch[] = []; @@ -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"); @@ -363,7 +363,7 @@ function createRuleContext( }) ); for (const matches of batchResults) { - allMatches.push(...matches); + for (const m of matches) allMatches.push(m); } } @@ -371,12 +371,12 @@ function createRuleContext( }, readFile(path: string): Promise { - const absPath = safePath(projectRoot, path); + const absPath = safePath(resolvedRoot, path); return Bun.file(absPath).text(); }, readJSON(path: string): Promise { - const absPath = safePath(projectRoot, path); + const absPath = safePath(resolvedRoot, path); return Bun.file(absPath).json(); }, @@ -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("\\", "/"); }) ); @@ -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); } } diff --git a/src/engine/source-positions.ts b/src/engine/source-positions.ts index 672d329c..42dc31a8 100644 --- a/src/engine/source-positions.ts +++ b/src/engine/source-positions.ts @@ -104,6 +104,25 @@ function isInNonCode(offset: number, ranges: Array<[number, number]>): boolean { return false; } +/** + * Binary-search a sorted newline-offset index to find the line and column + * for a given character offset. The index has a sentinel -1 at position 0 + * representing the virtual newline before line 1. + */ +function offsetToPos( + offset: number, + newlineOffsets: number[] +): { line: number; column: number } { + let lo = 0; + let hi = newlineOffsets.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (newlineOffsets[mid] < offset) lo = mid; + else hi = mid - 1; + } + return { line: lo + 1, column: offset - newlineOffsets[lo] - 1 }; +} + /** * Find all code-only occurrences of `needle` in `source`, skipping * matches inside comments and string literals. @@ -111,7 +130,8 @@ function isInNonCode(offset: number, ranges: Array<[number, number]>): boolean { function findCodeOccurrences( source: string, needle: string, - nonCodeRanges: Array<[number, number]> + nonCodeRanges: Array<[number, number]>, + newlineOffsets: number[] ): SourcePos[] { const results: SourcePos[] = []; let idx = 0; @@ -124,27 +144,15 @@ function findCodeOccurrences( 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; + const start = offsetToPos(found, newlineOffsets); + const end = offsetToPos(found + needle.length, newlineOffsets); - results.push({ line, column, endLine, endColumn }); + results.push({ + line: start.line, + column: start.column, + endLine: end.line, + endColumn: end.column, + }); idx = found + 1; } return results; @@ -161,12 +169,23 @@ export function remapViolations( rawViolations: RawViolation[] ): Array<{ message: string } & SourcePos> { const nonCodeRanges = buildNonCodeRanges(original); + // Build newline offset index once per source — shared across all searchTexts. + // Sentinel -1 at index 0 represents the virtual newline before line 1. + const newlineOffsets = [-1]; + for (let i = 0; i < original.length; i++) { + if (original[i] === "\n") newlineOffsets.push(i); + } const occurrenceCache = new Map(); return rawViolations.map((rv) => { let positions = occurrenceCache.get(rv.searchText); if (!positions) { - positions = findCodeOccurrences(original, rv.searchText, nonCodeRanges); + positions = findCodeOccurrences( + original, + rv.searchText, + nonCodeRanges, + newlineOffsets + ); occurrenceCache.set(rv.searchText, positions); } diff --git a/src/engine/suppressions.ts b/src/engine/suppressions.ts index 6fc1966c..6f98997c 100644 --- a/src/engine/suppressions.ts +++ b/src/engine/suppressions.ts @@ -152,7 +152,7 @@ export async function applySuppressions( // Read files in parallel and parse suppressions const fileSuppressions = new Map(); - const readPromises = [...filePathsNeeded].map(async (relPath) => { + const readPromises = Array.from(filePathsNeeded, async (relPath) => { try { const absPath = resolve(projectRoot, relPath); const content = await Bun.file(absPath).text(); diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index 5c42c2bd..bee7bf66 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -26,8 +26,9 @@ export function getNextId(adrsDir: string, prefix: string): string { const files = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); let maxNum = 0; + const idPattern = new RegExp(`^${prefix}-(\\d+)`, "u"); for (const file of files) { - const match = file.match(new RegExp(`^${prefix}-(\\d+)`, "u")); + const match = file.match(idPattern); if (match) { const num = Math.trunc(Number(match[1])); if (num > maxNum) maxNum = num; diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index 185da991..dbf5fc6d 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -45,7 +45,7 @@ export const ARCHGATE_CLAUDE_SETTINGS = { * Deduplicate an array of strings while preserving order. */ function dedup(arr: string[]): string[] { - return [...new Set(arr)]; + return Array.from(new Set(arr)); } /** @@ -59,22 +59,19 @@ export function mergeClaudeSettings( existing: ClaudeSettings, archgate: typeof ARCHGATE_CLAUDE_SETTINGS ): ClaudeSettings { - const merged: ClaudeSettings = { ...existing }; - // Scalar: set only if absent or invalid (caught to undefined by schema) - if (!merged.agent) { - merged.agent = archgate.agent; + if (!existing.agent) { + existing.agent = archgate.agent; } // Nested permissions object: merge allow array with dedup, preserve deny - const existingPermissions = merged.permissions ?? { allow: [], deny: [] }; - - merged.permissions = { - ...existingPermissions, - allow: dedup([...existingPermissions.allow, ...archgate.permissions.allow]), - }; + const existingPermissions = existing.permissions ?? { allow: [], deny: [] }; + existingPermissions.allow = dedup( + existingPermissions.allow.concat(archgate.permissions.allow) + ); + existing.permissions = existingPermissions; - return merged; + return existing; } /** diff --git a/src/helpers/install-info.ts b/src/helpers/install-info.ts index 2d1b55fc..409a2d3f 100644 --- a/src/helpers/install-info.ts +++ b/src/helpers/install-info.ts @@ -115,7 +115,7 @@ export function getProjectContext(): ProjectContext { hasProject: true, adrCount: mdFiles.length, adrWithRulesCount: rulesFiles.length, - domains: [...domainSet].sort(), + domains: Array.from(domainSet).sort(), }; } catch { return { hasProject: true, adrCount: 0, adrWithRulesCount: 0, domains: [] }; diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts index fd976c79..c2898467 100644 --- a/src/helpers/opencode-settings.ts +++ b/src/helpers/opencode-settings.ts @@ -40,13 +40,11 @@ type OpencodeConfig = z.infer; export function mergeOpencodeSettings( existing: OpencodeConfig ): OpencodeConfig { - const merged: OpencodeConfig = { ...existing }; - - if (!("default_agent" in merged)) { - merged.default_agent = DEFAULT_AGENT; + if (!("default_agent" in existing)) { + existing.default_agent = DEFAULT_AGENT; } - return merged; + return existing; } /** diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 030637e1..22afe90f 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -184,8 +184,8 @@ async function mergeCursorHooks(cursorDir: string): Promise { }, ]; - const merged = [...filtered, ...archgateHooks]; - await Bun.write(hooksPath, JSON.stringify(merged, null, 2) + "\n"); + for (const h of archgateHooks) filtered.push(h); + await Bun.write(hooksPath, JSON.stringify(filtered, null, 2) + "\n"); logDebug("Merged archgate hooks into", hooksPath); } catch { // If existing hooks.json is malformed, leave it alone @@ -266,9 +266,10 @@ async function installEditorPluginBundle(opts: { // Clean old archgate skill directories (archgate-*/SKILL.md) const staleSkillDirs = new Set( - [ - ...new Bun.Glob("archgate-*/*").scanSync({ cwd: skillsDir, dot: true }), - ].map((f) => f.split(/[/\\]/u)[0]) + Array.from( + new Bun.Glob("archgate-*/*").scanSync({ cwd: skillsDir, dot: true }), + (f) => f.split(/[/\\]/u)[0] + ) ); for (const dir of staleSkillDirs) { rmSync(join(skillsDir, dir), { recursive: true, force: true }); diff --git a/src/helpers/repo.ts b/src/helpers/repo.ts index 5338783f..0cc44dab 100644 --- a/src/helpers/repo.ts +++ b/src/helpers/repo.ts @@ -236,7 +236,7 @@ export function parseRemoteUrl(raw: string): ParsedRemote { const vsHostMatch = lowerHost.match(/^([^.]+)\.visualstudio\.com$/u); if (vsHostMatch && !segments.some((s) => s === vsHostMatch[1])) { - segments = [vsHostMatch[1], ...segments]; + segments.unshift(vsHostMatch[1]); } } diff --git a/src/helpers/telemetry-config.ts b/src/helpers/telemetry-config.ts index 5b371bd0..926c4a1a 100644 --- a/src/helpers/telemetry-config.ts +++ b/src/helpers/telemetry-config.ts @@ -90,10 +90,8 @@ export function loadTelemetryConfig(): TelemetryConfig { const text = readFileSync(path, "utf-8"); const result = TelemetryConfigSchema.safeParse(JSON.parse(text)); if (result.success) { - cachedConfig = { - ...result.data, - createdAt: result.data.createdAt ?? new Date().toISOString(), - }; + result.data.createdAt ??= new Date().toISOString(); + cachedConfig = result.data; logDebug("Telemetry config loaded:", cachedConfig.installId); return cachedConfig; } diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 6b395a5f..c84e9adf 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -272,7 +272,7 @@ export function trackEvent( client.capture({ distinctId, event, - properties: { ...getCommonProperties(), ...properties }, + properties: Object.assign({}, getCommonProperties(), properties), }); logDebug("Telemetry event captured:", event); } catch { diff --git a/src/helpers/user-error.ts b/src/helpers/user-error.ts index 37697591..70cf680b 100644 --- a/src/helpers/user-error.ts +++ b/src/helpers/user-error.ts @@ -32,7 +32,7 @@ */ export class UserError extends Error { constructor(message: string, ...rest: string[]) { - super(rest.length > 0 ? [message, ...rest].join(" ") : message); + super(rest.length > 0 ? `${message} ${rest.join(" ")}` : message); this.name = "UserError"; } } diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index 1e767b7e..9018a478 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -38,7 +38,7 @@ const VSCODE_DEFAULT_MARKETPLACES = [ * Deduplicate an array of strings while preserving order. */ function dedup(arr: string[]): string[] { - return [...new Set(arr)]; + return Array.from(new Set(arr)); } /** @@ -52,20 +52,18 @@ export function mergeMarketplaceUrl( existing: VscodeUserSettings, marketplaceUrl: string ): VscodeUserSettings { - const merged: VscodeUserSettings = { ...existing }; - const hasExplicitMarketplaces = "chat.plugins.marketplaces" in existing; - const existingMarketplaces = merged["chat.plugins.marketplaces"] ?? []; + const existingMarketplaces = existing["chat.plugins.marketplaces"] ?? []; // When the key is absent, seed with VS Code's built-in defaults so we don't // silently override them by setting the key explicitly. const base = hasExplicitMarketplaces ? existingMarketplaces - : [...VSCODE_DEFAULT_MARKETPLACES, ...existingMarketplaces]; + : VSCODE_DEFAULT_MARKETPLACES.concat(existingMarketplaces); - merged["chat.plugins.marketplaces"] = dedup([...base, marketplaceUrl]); + existing["chat.plugins.marketplaces"] = dedup(base.concat(marketplaceUrl)); - return merged; + return existing; } /**