From a879aaaf5ec76ebbfdfb1ca51ee20281f9819397 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 23 Mar 2026 13:19:42 +0100 Subject: [PATCH] feat: add file arguments to check command Accept file paths as positional arguments to scope rule checks: archgate check src/foo.ts src/bar.ts git diff --name-only | archgate check --json When files are provided, only ADRs whose file patterns match are executed, and scopedFiles is narrowed to the intersection. ADRs with no matching files are skipped entirely. Stdin piping is supported for integration with git and other tools. This enables efficient editor integrations (VS Code, Cursor) and CI pipelines to check only changed files instead of the full codebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/check.ts | 14 ++++++++++++- src/engine/runner.ts | 25 +++++++++++++++++++++-- tests/integration/check.test.ts | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index 4fc716ce..5706056e 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -21,7 +21,8 @@ export function registerCheckCommand(program: Command) { .option("--staged", "Only check git-staged files") .option("--adr ", "Only check rules from a specific ADR") .option("--verbose", "Show passing rules and timing info") - .action(async (opts) => { + .argument("[files...]", "Only check rules relevant to these files") + .action(async (files, opts) => { const projectRoot = findProjectRoot(); if (!projectRoot) { logError( @@ -67,8 +68,19 @@ export function registerCheckCommand(program: Command) { process.exit(0); } + // Collect file paths from arguments and/or stdin pipe + 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]; + } + const result = await runChecks(projectRoot, loadedAdrs, { staged: opts.staged, + files: filterFiles.length > 0 ? filterFiles : undefined, }); if (opts.ci) { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 64f919a2..11540add 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -180,20 +180,41 @@ function createRuleContext( export async function runChecks( projectRoot: string, loadedAdrs: LoadedAdr[], - options: { staged?: boolean } = {} + options: { staged?: boolean; files?: string[] } = {} ): Promise { const startTime = performance.now(); const changedFiles = options.staged ? await getStagedFiles(projectRoot) : []; const results: RuleResult[] = []; + // Resolve user-specified files to relative paths for intersection + let filterFiles: Set | undefined; + if (options.files && options.files.length > 0) { + filterFiles = new Set( + options.files.map((f) => { + const absPath = safePath(projectRoot, f); + return relative(projectRoot, absPath).replaceAll("\\", "/"); + }) + ); + } + // Run ADRs in parallel const adrResults = await Promise.allSettled( loadedAdrs.map(async ({ adr, ruleSet }) => { - const scopedFiles = await resolveScopedFiles( + let scopedFiles = await resolveScopedFiles( projectRoot, adr.frontmatter.files ); + // When files are specified, narrow scopedFiles to the intersection + if (filterFiles) { + scopedFiles = scopedFiles.filter((f) => filterFiles.has(f)); + } + + // Skip this ADR entirely if no specified files are in scope + if (filterFiles && scopedFiles.length === 0) { + return []; + } + const adrRuleResults: RuleResult[] = []; // Run rules within an ADR sequentially diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index b9264c64..841f0f31 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -238,6 +238,42 @@ describe("check integration", () => { expect(stdout).toContain("ms"); }); + test("file args → scopes checks to specified files", async () => { + scaffoldProject(dir); + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "docs"), { recursive: true }); + writeFileSync(join(dir, "src", "good.ts"), "const x = 1;\n"); + writeFileSync(join(dir, "src", "bad.ts"), 'console.log("bad");\n'); + writeFileSync(join(dir, "docs", "readme.md"), "# Hello\n"); + const noConsoleRule = `export default { rules: { "no-console": { description: "No console.log", async check(ctx) { + for (const f of ctx.scopedFiles) { for (const m of await ctx.grep(f, /console\\.log/)) ctx.report.violation({ message: "found", file: m.file, line: m.line }); } + } } } };`; + writeAdr( + dir, + "FILE-001.md", + makeAdr({ + id: "FILE-001", + title: "X", + rules: true, + files: ["src/**/*.ts"], + }) + ); + writeRules(dir, "FILE-001.rules.ts", noConsoleRule); + + const good = await runCli(["check", "--json", "src/good.ts"], dir); + expect(good.exitCode).toBe(0); + expect(JSON.parse(good.stdout).pass).toBe(true); + + const bad = await runCli(["check", "--json", "src/bad.ts"], dir); + expect(bad.exitCode).toBe(1); + expect(JSON.parse(bad.stdout).pass).toBe(false); + + // Out-of-scope file → ADR skipped + const oos = await runCli(["check", "--json", "docs/readme.md"], dir); + expect(oos.exitCode).toBe(0); + expect(JSON.parse(oos.stdout).pass).toBe(true); + }); + test("exit non-zero when no .archgate project found", async () => { // dir has no .archgate scaffold const { exitCode, stderr } = await runCli(["check"], dir);