From 367919fd3e4925e1ee5b8642038d4d98caaa74f4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 01:24:34 +0200 Subject: [PATCH 1/4] feat(check): add --base flag for cross-file rule parity between local and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ctx.changedFiles` was empty by default, making cross-file dependency rules (e.g., "if config.yml changed, manifest.yml must also change") silently pass locally. These rules only worked in CI via a non-obvious `git reset --soft` workaround. Now `archgate check` auto-detects the base branch and populates `changedFiles` with the branch diff (`git diff ...HEAD`). The resolution priority is: `--staged` flag (skips base) → `--base ` flag → `.archgate/config.json` `baseBranch` → git auto-detect → empty. Changes: - Add `detectBaseRef()` and `getFilesChangedSinceRef()` to git-files.ts - Add `--base [ref]` flag to `check` and `review-context` commands - Extend `ProjectConfigSchema` with optional `baseBranch` field - Extend `runChecks` and `buildReviewContext` options with `base` - Add `used_base` to check telemetry event - Add unit tests for new git helpers and integration tests for --base - Document Bun.env parallel test leakage pattern in agent memory Closes #338 Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 1 + src/commands/check.ts | 30 ++- src/commands/review-context.ts | 26 ++- src/engine/context.ts | 17 +- src/engine/git-files.ts | 79 ++++++++ src/engine/runner.ts | 9 +- src/formats/project-config.ts | 1 + src/helpers/project-config.ts | 9 + src/helpers/telemetry.ts | 1 + tests/engine/git-files.test.ts | 82 ++++++++ tests/helpers/telemetry.test.ts | 2 + tests/integration/check.test.ts | 188 +++++++++++++++++- 12 files changed, 437 insertions(+), 8 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 65c98e3b..6787a483 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -56,6 +56,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **GitHub CodeQL "default setup" can silently skip PRs — use an explicit workflow** — The repository-level "default setup" for CodeQL does not guarantee analysis on every PR. PR #279 (Renovate deps update) had zero CodeQL analyses, dropping the Scorecard SAST score from 10 to 9. Fix: add an explicit `.github/workflows/codeql.yml` that runs on `push: [main]`, `pull_request: [main]`, and a weekly schedule. After merging, disable the "default setup" in repository Settings > Code security > Code scanning to avoid duplicate analyses. The explicit workflow gives Scorecard a detectable `github/codeql-action` reference and guarantees coverage. - **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run. +- **`Bun.env` modifications in parallel test files leak into integration test subprocesses** — Bun test runner runs all test files in a single process sharing `Bun.env`. Tests that set `Bun.env.HOME`, `Bun.env.GIT_CONFIG_NOSYSTEM`, or `Bun.env.GIT_CONFIG_GLOBAL` (e.g., `auth.test.ts`, `credential-store.test.ts`) modify the shared environment. Integration tests that spawn CLI subprocesses via `runCli()` spread `process.env` (which IS `Bun.env`) into the child, inheriting the leaked values. Symptom: git operations in the subprocess fail with "not a git repo" or similar, but the test passes in isolation. Fix: integration tests that rely on git must explicitly reset git-related env vars in the `runCli` call: `runCli(args, dir, { GIT_CONFIG_NOSYSTEM: "", GIT_CONFIG_GLOBAL: "" })`. Applied in `tests/integration/check.test.ts` for the `--base` tests. - **Cross-command I/O sharing: export from the existing command file, don't create shared files** — When two commands need to share I/O functions (console.log with styleText), you CANNOT put them in `src/helpers/` (ARCH-002 forbids console.log in helpers) or create a new file under `src/commands//` without a register function (ARCH-001 requires register\*Command export, ARCH-016 requires docs heading). The correct pattern: export the shared functions from the command file that already defines them (e.g., `plugin/install.ts` exports `installForEditor()` and `printManualInstructions()`) and import them in the other command. Applied in `upgrade.ts` importing from `./plugin/install`. ## Validation Pipeline diff --git a/src/commands/check.ts b/src/commands/check.ts index d909ac33..e20edd5e 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -2,6 +2,7 @@ // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; +import { detectBaseRef } from "../engine/git-files"; import { loadRuleAdrs } from "../engine/loader"; import { reportConsole, @@ -12,9 +13,10 @@ import { } from "../engine/reporter"; import { runChecks } from "../engine/runner"; import { exitWith } from "../helpers/exit"; -import { logError } from "../helpers/log"; +import { logDebug, logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; +import { getConfiguredBaseBranch } from "../helpers/project-config"; import { trackCheckResult } from "../helpers/telemetry"; export function registerCheckCommand(program: Command) { @@ -24,6 +26,10 @@ export function registerCheckCommand(program: Command) { .option("--json", "Output results as JSON") .option("--ci", "Output GitHub Actions annotations") .option("--staged", "Only check git-staged files") + .option( + "--base [ref]", + "Compare changed files against a base ref (auto-detects when omitted)" + ) .option("--adr ", "Only check rules from a specific ADR") .option("--verbose", "Show passing rules and timing info") .argument("[files...]", "Only check rules relevant to these files") @@ -96,8 +102,29 @@ export function registerCheckCommand(program: Command) { } } + // Resolve base ref for branch-level change detection. + // Priority: --staged (skips base) → --base → config → auto-detect + let resolvedBase: string | undefined; + if (!opts.staged) { + if (typeof opts.base === "string") { + // --base explicitly provided + resolvedBase = opts.base; + logDebug("Using explicit base ref:", resolvedBase); + } else { + // --base (no arg) or no flag at all → try config, then auto-detect + const configBase = getConfiguredBaseBranch(projectRoot); + if (configBase) { + resolvedBase = configBase; + logDebug("Using configured base branch:", resolvedBase); + } else { + resolvedBase = (await detectBaseRef(projectRoot)) ?? undefined; + } + } + } + const result = await runChecks(projectRoot, loadResults, { staged: opts.staged, + base: resolvedBase, files: filterFiles.length > 0 ? filterFiles : undefined, }); @@ -128,6 +155,7 @@ export function registerCheckCommand(program: Command) { pass: summary.pass, output_format: outputFormat, used_staged: Boolean(opts.staged), + used_base: Boolean(resolvedBase), used_file_filter: filterFiles.length > 0, used_adr_filter: Boolean(opts.adr), files_scanned: filterFiles.length, diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 83d2ee02..9118fe61 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -3,10 +3,12 @@ import type { Command } from "@commander-js/extra-typings"; import { buildReviewContext } from "../engine/context"; +import { detectBaseRef } from "../engine/git-files"; import { exitWith } from "../helpers/exit"; -import { logError } from "../helpers/log"; +import { logDebug, logError } from "../helpers/log"; import { formatJSON } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; +import { getConfiguredBaseBranch } from "../helpers/project-config"; export function registerReviewContextCommand(program: Command) { program @@ -15,6 +17,10 @@ export function registerReviewContextCommand(program: Command) { "Pre-compute review context with ADR briefings for changed files" ) .option("--staged", "Only include git-staged files") + .option( + "--base [ref]", + "Compare changed files against a base ref (auto-detects when omitted)" + ) .option("--run-checks", "Include ADR compliance check results") .option("--domain ", "Filter to a single domain") .action(async (opts) => { @@ -27,9 +33,27 @@ export function registerReviewContextCommand(program: Command) { return; } + // Resolve base ref: --staged skips base detection + let resolvedBase: string | undefined; + if (!opts.staged) { + if (typeof opts.base === "string") { + resolvedBase = opts.base; + logDebug("Using explicit base ref:", resolvedBase); + } else { + const configBase = getConfiguredBaseBranch(projectRoot); + if (configBase) { + resolvedBase = configBase; + logDebug("Using configured base branch:", resolvedBase); + } else { + resolvedBase = (await detectBaseRef(projectRoot)) ?? undefined; + } + } + } + try { const context = await buildReviewContext(projectRoot, { staged: opts.staged, + base: resolvedBase, runChecks: opts.runChecks, domain: opts.domain, }); diff --git a/src/engine/context.ts b/src/engine/context.ts index 0c8b4bde..234c0337 100644 --- a/src/engine/context.ts +++ b/src/engine/context.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { AdrDocument, AdrDomain } from "../formats/adr"; -import { getChangedFiles, getStagedFiles } from "./git-files"; +import { + getChangedFiles, + getFilesChangedSinceRef, + getStagedFiles, +} from "./git-files"; import { loadRuleAdrs, parseAllAdrs } from "./loader"; import type { ReportSummary } from "./reporter"; import { buildSummary } from "./reporter"; @@ -195,6 +199,7 @@ const EMPTY_SUMMARY: ReportSummary = { interface BuildReviewContextOptions { runChecks?: boolean; staged?: boolean; + base?: string; domain?: AdrDomain; maxChangedFiles?: number; maxSectionChars?: number; @@ -211,9 +216,12 @@ export async function buildReviewContext( const maxSectionChars = options?.maxSectionChars ?? DEFAULT_MAX_SECTION_CHARS; const maxViolationsPerRule = options?.maxViolationsPerRule ?? 20; + const base = options?.base; const rawChangedFiles = staged ? await getStagedFiles(projectRoot) - : await getChangedFiles(projectRoot); + : base + ? await getFilesChangedSinceRef(projectRoot, base) + : await getChangedFiles(projectRoot); const truncatedFiles = maxFiles > 0 && rawChangedFiles.length > maxFiles; const allChangedFiles = truncatedFiles @@ -228,7 +236,10 @@ export async function buildReviewContext( if (options?.runChecks) { const loadResults = await loadRuleAdrs(projectRoot); if (loadResults.length > 0) { - const checkResult = await runChecks(projectRoot, loadResults, { staged }); + const checkResult = await runChecks(projectRoot, loadResults, { + staged, + base, + }); checkSummary = buildSummary(checkResult, { maxViolationsPerRule }); } else { checkSummary = { ...EMPTY_SUMMARY }; diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 4611340d..75c31fef 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -167,3 +167,82 @@ export async function getChangedFiles(projectRoot: string): Promise { return []; } } + +/** + * Detect the base ref to compare against for branch-level change detection. + * + * Resolution order: + * 1. Remote HEAD symref (e.g. `origin/main`) — fast, local, no network + * 2. `origin/main` or `origin/master` tracking refs + * 3. Local `main` or `master` branches (repos without remotes) + * 4. `null` — detection failed, caller falls back to empty changedFiles + */ +export async function detectBaseRef( + projectRoot: string +): Promise { + // 1. Try remote HEAD symref (most reliable when origin exists) + try { + const symRef = await runGit( + ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + projectRoot + ); + const trimmed = symRef.trim(); + if (trimmed) { + logDebug("Detected base ref from remote HEAD:", trimmed); + return trimmed; + } + } catch { + logDebug("No remote HEAD symref found"); + } + + // 2. Check common remote tracking branches (sequential — stop at first match) + for (const ref of ["origin/main", "origin/master"]) { + try { + // oxlint-disable-next-line no-await-in-loop -- sequential probe, stop at first hit + await runGit(["rev-parse", "--verify", ref], projectRoot); + logDebug("Detected base ref from tracking branch:", ref); + return ref; + } catch { + // ref doesn't exist + } + } + + // 3. Check local branches (repos without remotes, sequential — stop at first match) + for (const ref of ["main", "master"]) { + try { + // oxlint-disable-next-line no-await-in-loop -- sequential probe, stop at first hit + await runGit(["rev-parse", "--verify", ref], projectRoot); + logDebug("Detected base ref from local branch:", ref); + return ref; + } catch { + // ref doesn't exist + } + } + + logDebug("Could not detect base ref — changedFiles will be empty"); + return null; +} + +/** + * Get files changed between a base ref and HEAD. + * Uses three-dot diff (`base...HEAD`) to find the merge-base automatically. + */ +export async function getFilesChangedSinceRef( + projectRoot: string, + ref: string +): Promise { + try { + const result = await runGit( + ["diff", "--name-only", `${ref}...HEAD`], + projectRoot + ); + const files = [ + ...new Set(result.trim().split("\n").filter(Boolean)), + ].sort(); + logDebug(`Files changed since ${ref}:`, files.length); + return files; + } catch { + logDebug(`Failed to get files changed since ${ref}`); + return []; + } +} diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 3b7aef44..4647509a 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -13,6 +13,7 @@ import { logDebug } from "../helpers/log"; import { resolveScopedFiles, getStagedFiles, + getFilesChangedSinceRef, getGitTrackedFiles, } from "./git-files"; import { type LoadResult, blockedToRuleResult } from "./loader"; @@ -194,10 +195,14 @@ function createRuleContext( export async function runChecks( projectRoot: string, loadResults: LoadResult[], - options: { staged?: boolean; files?: string[] } = {} + options: { staged?: boolean; files?: string[]; base?: string } = {} ): Promise { const startTime = performance.now(); - const changedFiles = options.staged ? await getStagedFiles(projectRoot) : []; + const changedFiles = options.staged + ? await getStagedFiles(projectRoot) + : options.base + ? await getFilesChangedSinceRef(projectRoot, options.base) + : []; const results: RuleResult[] = loadResults .filter((lr) => lr.type === "blocked") .map((lr) => blockedToRuleResult(projectRoot, lr.value)); diff --git a/src/formats/project-config.ts b/src/formats/project-config.ts index cf051663..221c5550 100644 --- a/src/formats/project-config.ts +++ b/src/formats/project-config.ts @@ -47,6 +47,7 @@ export const ProjectConfigSchema = z .object({ domains: z.record(DomainNameSchema, DomainPrefixSchema).default({}), paths: PathsConfigSchema.optional(), + baseBranch: z.string().min(1).optional(), }) .default({ domains: {} }); diff --git a/src/helpers/project-config.ts b/src/helpers/project-config.ts index 9e0769d2..20cdab9c 100644 --- a/src/helpers/project-config.ts +++ b/src/helpers/project-config.ts @@ -107,6 +107,15 @@ export function resolveDomainPrefix( return prefix; } +/** + * Read the `baseBranch` value from `.archgate/config.json`. + * Returns `null` when unconfigured. + */ +export function getConfiguredBaseBranch(projectRoot: string): string | null { + const config = loadProjectConfig(projectRoot); + return config.baseBranch ?? null; +} + export function isDefaultDomain(domain: string): boolean { return (DEFAULT_DOMAINS as readonly string[]).includes(domain); } diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index 6b203efd..6ce044f5 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -327,6 +327,7 @@ export function trackCheckResult(properties: { pass: boolean; output_format: "console" | "json" | "ci"; used_staged: boolean; + used_base: boolean; used_file_filter: boolean; used_adr_filter: boolean; files_scanned?: number; diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index a3b2cea3..11fa30a6 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -9,6 +9,8 @@ import { getGitTrackedFiles, getStagedFiles, getChangedFiles, + detectBaseRef, + getFilesChangedSinceRef, resolveScopedFiles, SCOPE_FILE_WARN_THRESHOLD, } from "../../src/engine/git-files"; @@ -80,6 +82,86 @@ describe("git-files", () => { }, 15_000); }); + describe("detectBaseRef", () => { + test("returns null for non-git directory", async () => { + const ref = await detectBaseRef(tempDir); + expect(ref).toBeNull(); + }); + + test("detects local main branch", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + const ref = await detectBaseRef(tempDir); + expect(ref).toBe("main"); + }); + + test("detects local master branch", async () => { + await git(["init", "--initial-branch=master"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + const ref = await detectBaseRef(tempDir); + expect(ref).toBe("master"); + }); + }); + + describe("getFilesChangedSinceRef", () => { + test("returns empty array for non-git directory", async () => { + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toEqual([]); + }); + + test("returns files changed on a feature branch", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); + await git(["add", "base.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + // Create feature branch and add files + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "new-file.ts"), "export const y = 2;"); + await git(["add", "new-file.ts"], tempDir); + await git(["commit", "-m", "add new file"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toContain("new-file.ts"); + expect(files).not.toContain("base.ts"); + }, 15_000); + + test("returns empty when on the base branch with no new commits", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); + await git(["add", "base.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toEqual([]); + }); + + test("returns multiple changed files sorted", async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); + await git(["add", "base.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "z-file.ts"), "export const z = 3;"); + writeFileSync(join(tempDir, "a-file.ts"), "export const a = 1;"); + await git(["add", "."], tempDir); + await git(["commit", "-m", "add files"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toEqual(["a-file.ts", "z-file.ts"]); + }, 15_000); + }); + describe("resolveScopedFiles", () => { test("returns empty array for non-git directory with no files", async () => { const files = await resolveScopedFiles(tempDir, ["**/*.ts"]); diff --git a/tests/helpers/telemetry.test.ts b/tests/helpers/telemetry.test.ts index a7a09d92..f7bbe491 100644 --- a/tests/helpers/telemetry.test.ts +++ b/tests/helpers/telemetry.test.ts @@ -163,6 +163,7 @@ describe("telemetry", () => { pass: false, output_format: "console", used_staged: false, + used_base: false, used_file_filter: false, used_adr_filter: false, }); @@ -183,6 +184,7 @@ describe("telemetry", () => { pass: true, output_format: "json", used_staged: true, + used_base: true, used_file_filter: true, used_adr_filter: true, files_scanned: 42, diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index 1b439a38..c0070895 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { safeRmSync } from "../test-utils"; +import { git, safeRmSync } from "../test-utils"; import { runCli, createTempProject, @@ -283,3 +283,189 @@ describe("check integration", () => { expect(stderr.toLowerCase()).toContain("error"); }); }); + +describe("check --base integration", () => { + let dir: string; + + // Parallel tests (auth.test.ts, credential-store.test.ts) modify + // Bun.env.HOME / GIT_CONFIG_GLOBAL which leaks into process.env. + // Explicitly reset git env vars so the CLI subprocess sees a clean env. + const gitCleanEnv = { GIT_CONFIG_NOSYSTEM: "", GIT_CONFIG_GLOBAL: "" }; + + beforeEach(() => { + dir = createTempProject(); + }); + + afterEach(() => { + safeRmSync(dir); + }); + + test("--base populates changedFiles for cross-file rules", async () => { + scaffoldProject(dir); + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + + // Write a cross-file rule: if config.yml changes, manifest.yml must also change + mkdirSync(join(dir, "config"), { recursive: true }); + mkdirSync(join(dir, "deploy"), { recursive: true }); + writeFileSync(join(dir, "config", "database.yml"), "db: postgres\n"); + writeFileSync(join(dir, "deploy", "manifest.yml"), "version: 1\n"); + + writeAdr( + dir, + "CROSS-001.md", + makeAdr({ id: "CROSS-001", title: "Cross File Check", rules: true }) + ); + writeRules( + dir, + "CROSS-001.rules.ts", + `export default { + rules: { + "config-requires-manifest": { + description: "config change requires manifest bump", + async check(ctx) { + if (!ctx.changedFiles.includes("config/database.yml")) return; + if (!ctx.changedFiles.includes("deploy/manifest.yml")) { + ctx.report.violation({ + message: "config/database.yml changed but deploy/manifest.yml was not updated", + file: "config/database.yml", + }); + } + }, + }, + }, +};` + ); + + // Commit everything on main + await git(["add", "."], dir); + await git(["commit", "-m", "initial"], dir); + + // Create feature branch, change only config (not manifest) + await git(["checkout", "-b", "feature"], dir); + writeFileSync(join(dir, "config", "database.yml"), "db: mysql\n"); + await git(["add", "."], dir); + await git(["commit", "-m", "change config"], dir); + + // With --base main, the rule should fire (config changed, manifest didn't) + const result = await runCli( + ["check", "--base", "main", "--json"], + dir, + gitCleanEnv + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.pass).toBe(false); + const violations = json.results.flatMap( + (r: { violations: unknown[] }) => r.violations + ); + expect(violations.length).toBeGreaterThan(0); + }, 30_000); + + test("--base with no violations when both files change", async () => { + scaffoldProject(dir); + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + + mkdirSync(join(dir, "config"), { recursive: true }); + mkdirSync(join(dir, "deploy"), { recursive: true }); + writeFileSync(join(dir, "config", "database.yml"), "db: postgres\n"); + writeFileSync(join(dir, "deploy", "manifest.yml"), "version: 1\n"); + + writeAdr( + dir, + "CROSS-002.md", + makeAdr({ id: "CROSS-002", title: "Cross File Pass", rules: true }) + ); + writeRules( + dir, + "CROSS-002.rules.ts", + `export default { + rules: { + "config-requires-manifest": { + description: "config change requires manifest bump", + async check(ctx) { + if (!ctx.changedFiles.includes("config/database.yml")) return; + if (!ctx.changedFiles.includes("deploy/manifest.yml")) { + ctx.report.violation({ + message: "config/database.yml changed but deploy/manifest.yml was not updated", + file: "config/database.yml", + }); + } + }, + }, + }, +};` + ); + + await git(["add", "."], dir); + await git(["commit", "-m", "initial"], dir); + + // Both files change on the feature branch — rule should pass + await git(["checkout", "-b", "feature"], dir); + writeFileSync(join(dir, "config", "database.yml"), "db: mysql\n"); + writeFileSync(join(dir, "deploy", "manifest.yml"), "version: 2\n"); + await git(["add", "."], dir); + await git(["commit", "-m", "change both"], dir); + + const result = await runCli( + ["check", "--base", "main", "--json"], + dir, + gitCleanEnv + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.pass).toBe(true); + }, 30_000); + + test("--staged takes precedence over auto-detection", async () => { + scaffoldProject(dir); + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + + mkdirSync(join(dir, "config"), { recursive: true }); + writeFileSync(join(dir, "config", "database.yml"), "db: postgres\n"); + + writeAdr( + dir, + "STAGE-001.md", + makeAdr({ id: "STAGE-001", title: "Stage Test", rules: true }) + ); + writeRules( + dir, + "STAGE-001.rules.ts", + `export default { + rules: { + "check-changed": { + description: "reports changedFiles count", + async check(ctx) { + if (ctx.changedFiles.length === 0) return; + ctx.report.violation({ + message: "changedFiles has " + ctx.changedFiles.length + " file(s): " + ctx.changedFiles.join(", "), + file: ctx.changedFiles[0], + }); + }, + }, + }, +};` + ); + + await git(["add", "."], dir); + await git(["commit", "-m", "initial"], dir); + await git(["checkout", "-b", "feature"], dir); + writeFileSync(join(dir, "config", "database.yml"), "db: mysql\n"); + + // With --staged, only staged files are in changedFiles (none staged yet) + const result = await runCli( + ["check", "--staged", "--json"], + dir, + gitCleanEnv + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.pass).toBe(true); + }, 30_000); +}); From 6d3a545db4276692238212554e346710069a553f Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 01:41:58 +0200 Subject: [PATCH 2/4] refactor: extract resolveBaseRef helper and add review-context --base tests Extract duplicated base resolution logic from check.ts and review-context.ts into a shared resolveBaseRef() function in git-files.ts. Add integration tests for review-context --base to close the coverage gap. Signed-off-by: Rhuan Barreto --- src/commands/check.ts | 26 ++------ src/commands/review-context.ts | 24 ++----- src/engine/git-files.ts | 30 +++++++++ tests/integration/review-context.test.ts | 83 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 36 deletions(-) diff --git a/src/commands/check.ts b/src/commands/check.ts index e20edd5e..d68ec68c 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -2,7 +2,7 @@ // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { detectBaseRef } from "../engine/git-files"; +import { resolveBaseRef } from "../engine/git-files"; import { loadRuleAdrs } from "../engine/loader"; import { reportConsole, @@ -13,7 +13,7 @@ import { } from "../engine/reporter"; import { runChecks } from "../engine/runner"; import { exitWith } from "../helpers/exit"; -import { logDebug, logError } from "../helpers/log"; +import { logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; import { getConfiguredBaseBranch } from "../helpers/project-config"; @@ -104,23 +104,11 @@ export function registerCheckCommand(program: Command) { // Resolve base ref for branch-level change detection. // Priority: --staged (skips base) → --base → config → auto-detect - let resolvedBase: string | undefined; - if (!opts.staged) { - if (typeof opts.base === "string") { - // --base explicitly provided - resolvedBase = opts.base; - logDebug("Using explicit base ref:", resolvedBase); - } else { - // --base (no arg) or no flag at all → try config, then auto-detect - const configBase = getConfiguredBaseBranch(projectRoot); - if (configBase) { - resolvedBase = configBase; - logDebug("Using configured base branch:", resolvedBase); - } else { - resolvedBase = (await detectBaseRef(projectRoot)) ?? undefined; - } - } - } + const resolvedBase = await resolveBaseRef(projectRoot, { + staged: opts.staged, + base: opts.base, + configBase: getConfiguredBaseBranch(projectRoot), + }); const result = await runChecks(projectRoot, loadResults, { staged: opts.staged, diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 9118fe61..bf753728 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -3,9 +3,9 @@ import type { Command } from "@commander-js/extra-typings"; import { buildReviewContext } from "../engine/context"; -import { detectBaseRef } from "../engine/git-files"; +import { resolveBaseRef } from "../engine/git-files"; import { exitWith } from "../helpers/exit"; -import { logDebug, logError } from "../helpers/log"; +import { logError } from "../helpers/log"; import { formatJSON } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; import { getConfiguredBaseBranch } from "../helpers/project-config"; @@ -34,21 +34,11 @@ export function registerReviewContextCommand(program: Command) { } // Resolve base ref: --staged skips base detection - let resolvedBase: string | undefined; - if (!opts.staged) { - if (typeof opts.base === "string") { - resolvedBase = opts.base; - logDebug("Using explicit base ref:", resolvedBase); - } else { - const configBase = getConfiguredBaseBranch(projectRoot); - if (configBase) { - resolvedBase = configBase; - logDebug("Using configured base branch:", resolvedBase); - } else { - resolvedBase = (await detectBaseRef(projectRoot)) ?? undefined; - } - } - } + const resolvedBase = await resolveBaseRef(projectRoot, { + staged: opts.staged, + base: opts.base, + configBase: getConfiguredBaseBranch(projectRoot), + }); try { const context = await buildReviewContext(projectRoot, { diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 75c31fef..ada143da 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -223,6 +223,36 @@ export async function detectBaseRef( return null; } +/** + * Resolve the base ref for branch-level change detection. + * + * Priority: explicit flag → project config → git auto-detect → undefined. + * The `staged` flag short-circuits to `undefined` (caller uses staged files + * instead of a branch diff). + */ +export async function resolveBaseRef( + projectRoot: string, + options: { + staged?: boolean; + base?: string | true; + configBase?: string | null; + } +): Promise { + if (options.staged) return undefined; + + if (typeof options.base === "string") { + logDebug("Using explicit base ref:", options.base); + return options.base; + } + + if (options.configBase) { + logDebug("Using configured base branch:", options.configBase); + return options.configBase; + } + + return (await detectBaseRef(projectRoot)) ?? undefined; +} + /** * Get files changed between a base ref and HEAD. * Uses three-dot diff (`base...HEAD`) to find the merge-base automatically. diff --git a/tests/integration/review-context.test.ts b/tests/integration/review-context.test.ts index 8d683379..8f19ad80 100644 --- a/tests/integration/review-context.test.ts +++ b/tests/integration/review-context.test.ts @@ -148,4 +148,87 @@ describe("review-context integration", () => { expect(exitCode).not.toBe(0); expect(stderr.toLowerCase()).toContain("error"); }); + + test("--base populates allChangedFiles from branch diff", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "ARCH-020.md", + makeAdr({ + id: "ARCH-020", + title: "Base Test ADR", + domain: "architecture", + rules: false, + body: "## Decision\nTest.\n\n## Do's and Don'ts\nDo test.", + }) + ); + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "base.ts"), "export const x = 1;\n"); + await commitAll(dir, "initial commit"); + + // Create feature branch and add a file + await git(["checkout", "-b", "feature"], dir); + writeFileSync(join(dir, "src", "new-feature.ts"), "export const y = 2;\n"); + await commitAll(dir, "add feature"); + + const { exitCode, stdout } = await runCli( + ["review-context", "--base", "main"], + dir, + { GIT_CONFIG_NOSYSTEM: "", GIT_CONFIG_GLOBAL: "" } + ); + expect(exitCode).toBe(0); + + const ctx = JSON.parse(stdout) as { + allChangedFiles: string[]; + domains: Array<{ domain: string; changedFiles: string[] }>; + }; + expect(ctx.allChangedFiles).toContain("src/new-feature.ts"); + expect(ctx.allChangedFiles).not.toContain("src/base.ts"); + }, 30_000); + + test("--staged takes precedence over --base for review-context", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "ARCH-021.md", + makeAdr({ + id: "ARCH-021", + title: "Staged Test ADR", + domain: "architecture", + rules: false, + body: "## Decision\nTest.\n\n## Do's and Don'ts\nDo test.", + }) + ); + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "base.ts"), "export const x = 1;\n"); + await commitAll(dir, "initial commit"); + + await git(["checkout", "-b", "feature"], dir); + writeFileSync(join(dir, "src", "committed.ts"), "export const c = 3;\n"); + await commitAll(dir, "committed change"); + + // Stage a different file (not committed yet) + writeFileSync(join(dir, "src", "staged.ts"), "export const s = 4;\n"); + await git(["add", "src/staged.ts"], dir); + + // --staged should only show staged.ts, not committed.ts + const { exitCode, stdout } = await runCli( + ["review-context", "--staged"], + dir, + { GIT_CONFIG_NOSYSTEM: "", GIT_CONFIG_GLOBAL: "" } + ); + expect(exitCode).toBe(0); + + const ctx = JSON.parse(stdout) as { allChangedFiles: string[] }; + expect(ctx.allChangedFiles).toContain("src/staged.ts"); + expect(ctx.allChangedFiles).not.toContain("src/committed.ts"); + }, 30_000); }); From b58c43909930dcc150fd80f1d4f1504a06c048db Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 25 May 2026 01:48:34 +0200 Subject: [PATCH 3/4] docs: document --base flag and changedFiles auto-detection Update CLI reference, rule API, concepts, writing-rules guide, and CI integration guide to document the new --base flag and automatic base branch detection for changedFiles. Includes both English and Portuguese (pt-br) translations. Signed-off-by: Rhuan Barreto --- docs/src/content/docs/concepts/rules.mdx | 12 +++--- .../content/docs/guides/ci-integration.mdx | 10 +++++ .../src/content/docs/guides/writing-rules.mdx | 16 ++++++-- .../src/content/docs/pt-br/concepts/rules.mdx | 4 +- .../docs/pt-br/guides/ci-integration.mdx | 10 +++++ .../docs/pt-br/guides/writing-rules.mdx | 16 ++++++-- .../docs/pt-br/reference/cli/check.mdx | 21 ++++++---- .../pt-br/reference/cli/review-context.mdx | 11 ++--- .../content/docs/pt-br/reference/rule-api.mdx | 2 +- docs/src/content/docs/reference/cli/check.mdx | 41 +++++++++++++++---- .../docs/reference/cli/review-context.mdx | 11 ++--- docs/src/content/docs/reference/rule-api.mdx | 2 +- 12 files changed, 114 insertions(+), 42 deletions(-) diff --git a/docs/src/content/docs/concepts/rules.mdx b/docs/src/content/docs/concepts/rules.mdx index 576cc52b..032d64d0 100644 --- a/docs/src/content/docs/concepts/rules.mdx +++ b/docs/src/content/docs/concepts/rules.mdx @@ -55,11 +55,11 @@ The `check` function receives a `RuleContext` object that provides everything a ### Project Information -| Property | Type | Description | -| ------------------ | ---------- | -------------------------------------------------------------------------------- | -| `ctx.projectRoot` | `string` | Absolute path to the project root directory | -| `ctx.scopedFiles` | `string[]` | Files matching the ADR's `files` globs, or all project files if no globs are set | -| `ctx.changedFiles` | `string[]` | Files changed in git (populated when running with `--staged`) | +| Property | Type | Description | +| ------------------ | ---------- | ---------------------------------------------------------------------------------- | +| `ctx.projectRoot` | `string` | Absolute path to the project root directory | +| `ctx.scopedFiles` | `string[]` | Files matching the ADR's `files` globs, or all project files if no globs are set | +| `ctx.changedFiles` | `string[]` | Files changed in git (auto-detected from branch diff, or from `--staged`/`--base`) | ### File Operations @@ -163,7 +163,7 @@ Rules execute with the following guarantees: - **Parallel across ADRs** -- Rules from different ADRs run concurrently for faster execution. - **Sequential within an ADR** -- Rules belonging to the same ADR run one after another, so earlier rules can establish context for later ones. - **Scoped files are pre-resolved** -- The `ctx.scopedFiles` array is populated before your `check` function is called, based on the ADR's `files` globs. -- **Changed files for staged mode** -- When running `archgate check --staged`, `ctx.changedFiles` contains only the files staged in git, letting rules skip unchanged files for faster feedback. +- **Changed files auto-detected** -- `ctx.changedFiles` is automatically populated with the branch diff against the base branch (e.g., `main`). Use `--staged` for pre-commit hooks (staged files only) or `--base ` for an explicit base. This enables cross-file dependency rules to work locally, not just in CI. :::tip[Run checks automatically with editor plugins] The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) run `archgate check` automatically after every code change. The agent reads the applicable ADRs, writes compliant code, and validates -- no manual check commands needed. [Sign up for beta access](https://plugins.archgate.dev). diff --git a/docs/src/content/docs/guides/ci-integration.mdx b/docs/src/content/docs/guides/ci-integration.mdx index f47361b9..0fdf0fcc 100644 --- a/docs/src/content/docs/guides/ci-integration.mdx +++ b/docs/src/content/docs/guides/ci-integration.mdx @@ -142,6 +142,16 @@ Warnings (severity `warning`) are logged but do not affect the exit code. Only ` ## Narrowing scope +### Check files changed in the PR + +Use `--base` to compare against the PR's base branch. This gives cross-file dependency rules the full picture of what changed: + +```yaml +- run: archgate check --base origin/${{ github.base_ref }} +``` + +Without `--base`, the base branch is auto-detected from `origin/HEAD`. The explicit form is recommended in CI for deterministic behavior. + ### Check only staged files Use `--staged` to limit checking to git-staged files. This is useful in pre-commit hooks or when you only want to validate what is about to be committed: diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index c7e831e0..cf2a8af0 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -99,14 +99,22 @@ Use `ctx.scopedFiles` when your rule should only apply to files the ADR governs. ### ctx.changedFiles -An array of file paths that have been modified (git staged or changed). Useful for incremental checking -- only validate files that were actually touched. +An array of file paths that differ from the base branch. Auto-detected by default, or populated from `--staged` / `--base `. Useful for incremental checking and cross-file dependency rules. ```typescript +// Incremental checking -- only validate changed files const filesToCheck = ctx.scopedFiles.filter((f) => ctx.changedFiles.includes(f) ); -for (const file of filesToCheck) { - // Only check changed files + +// Cross-file dependency -- if file A changed, file B must also change +if (ctx.changedFiles.includes("config/database.yml")) { + if (!ctx.changedFiles.includes("deploy/manifest.yml")) { + ctx.report.violation({ + message: "config changed but manifest was not bumped", + file: "config/database.yml", + }); + } } ``` @@ -277,7 +285,7 @@ ARCH-006/no-unapproved-deps } ``` -2. **Use `ctx.changedFiles` for incremental checking.** When running `archgate check --staged`, `ctx.changedFiles` contains only the git-staged files. Filter `ctx.scopedFiles` against it to check only what changed. +2. **Use `ctx.changedFiles` for incremental checking.** `ctx.changedFiles` is auto-populated with the branch diff (or staged files with `--staged`). Filter `ctx.scopedFiles` against it to check only what changed, or use it directly for cross-file dependency rules. 3. **Keep rules focused on one concern.** A rule that checks both naming conventions and import patterns should be split into two rules with separate IDs. diff --git a/docs/src/content/docs/pt-br/concepts/rules.mdx b/docs/src/content/docs/pt-br/concepts/rules.mdx index 09edd575..bc36532a 100644 --- a/docs/src/content/docs/pt-br/concepts/rules.mdx +++ b/docs/src/content/docs/pt-br/concepts/rules.mdx @@ -59,7 +59,7 @@ A função `check` recebe um objeto `RuleContext` que fornece tudo que uma regra | ------------------ | ---------- | ------------------------------------------------------------------------------------------------------ | | `ctx.projectRoot` | `string` | Caminho absoluto para o diretório raiz do projeto | | `ctx.scopedFiles` | `string[]` | Arquivos que correspondem aos globs `files` do ADR, ou todos os arquivos se não houver globs definidos | -| `ctx.changedFiles` | `string[]` | Arquivos alterados no git (preenchido ao executar com `--staged`) | +| `ctx.changedFiles` | `string[]` | Arquivos alterados no git (auto-detectado a partir do diff da branch, ou via `--staged`/`--base`) | ### Operações de Arquivo @@ -163,7 +163,7 @@ As regras são executadas com as seguintes garantias: - **Paralelo entre ADRs** -- Regras de diferentes ADRs são executadas concorrentemente para maior velocidade. - **Sequencial dentro de um ADR** -- Regras pertencentes ao mesmo ADR são executadas uma após a outra, para que regras anteriores possam estabelecer contexto para as posteriores. - **Arquivos no escopo são pré-resolvidos** -- O array `ctx.scopedFiles` é preenchido antes que sua função `check` seja chamada, com base nos globs `files` do ADR. -- **Arquivos alterados para modo staged** -- Ao executar `archgate check --staged`, `ctx.changedFiles` contém apenas os arquivos staged no git, permitindo que regras pulem arquivos não alterados para feedback mais rápido. +- **Arquivos alterados auto-detectados** -- `ctx.changedFiles` é automaticamente preenchido com o diff da branch contra a branch base (ex: `main`). Use `--staged` para hooks de pre-commit (apenas arquivos staged) ou `--base ` para uma base explícita. Isso permite que regras de dependência entre arquivos funcionem localmente, não apenas no CI. :::tip[Execute verificações automaticamente com plugins de editor] Os plugins de editor para [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) executam `archgate check` automaticamente após cada alteração de código. O agente lê os ADRs aplicáveis, escreve código em conformidade e valida -- sem necessidade de executar comandos de verificação manualmente. [Inscreva-se para acesso beta](https://plugins.archgate.dev). diff --git a/docs/src/content/docs/pt-br/guides/ci-integration.mdx b/docs/src/content/docs/pt-br/guides/ci-integration.mdx index 300eac83..fadcf397 100644 --- a/docs/src/content/docs/pt-br/guides/ci-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/ci-integration.mdx @@ -142,6 +142,16 @@ Avisos (severidade `warning`) são registrados, mas não afetam o código de sa ## Reduzindo o escopo +### Verificar arquivos alterados no PR + +Use `--base` para comparar contra a branch base do PR. Isso dá às regras de dependência entre arquivos a visão completa do que mudou: + +```yaml +- run: archgate check --base origin/${{ github.base_ref }} +``` + +Sem `--base`, a branch base é auto-detectada a partir de `origin/HEAD`. A forma explícita é recomendada no CI para comportamento determinístico. + ### Verificar apenas arquivos staged Use `--staged` para limitar a verificação aos arquivos staged no git. Isso é útil em hooks de pre-commit ou quando você deseja validar apenas o que está prestes a ser commitado: diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index cc626173..122a8ccb 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -99,14 +99,22 @@ Use `ctx.scopedFiles` quando sua regra deve se aplicar apenas aos arquivos que o ### ctx.changedFiles -Um array de caminhos de arquivo que foram modificados (staged ou alterados no git). Útil para verificação incremental -- valide apenas os arquivos que foram efetivamente alterados. +Um array de caminhos de arquivo que diferem da branch base. Auto-detectado por padrão, ou preenchido via `--staged` / `--base `. Útil para verificação incremental e regras de dependência entre arquivos. ```typescript +// Verificação incremental -- validar apenas arquivos alterados const filesToCheck = ctx.scopedFiles.filter((f) => ctx.changedFiles.includes(f) ); -for (const file of filesToCheck) { - // Only check changed files + +// Dependência entre arquivos -- se o arquivo A mudou, o arquivo B também deve mudar +if (ctx.changedFiles.includes("config/database.yml")) { + if (!ctx.changedFiles.includes("deploy/manifest.yml")) { + ctx.report.violation({ + message: "config changed but manifest was not bumped", + file: "config/database.yml", + }); + } } ``` @@ -277,7 +285,7 @@ ARCH-006/no-unapproved-deps } ``` -2. **Use `ctx.changedFiles` para verificação incremental.** Ao executar `archgate check --staged`, `ctx.changedFiles` contém apenas os arquivos staged no git. Filtre `ctx.scopedFiles` com ele para verificar apenas o que mudou. +2. **Use `ctx.changedFiles` para verificação incremental.** `ctx.changedFiles` é auto-preenchido com o diff da branch (ou arquivos staged com `--staged`). Filtre `ctx.scopedFiles` com ele para verificar apenas o que mudou, ou use-o diretamente para regras de dependência entre arquivos. 3. **Mantenha as regras focadas em uma única preocupação.** Uma regra que verifica tanto convenções de nomenclatura quanto padrões de import deve ser dividida em duas regras com IDs separados. diff --git a/docs/src/content/docs/pt-br/reference/cli/check.mdx b/docs/src/content/docs/pt-br/reference/cli/check.mdx index e50e1acf..47053c0c 100644 --- a/docs/src/content/docs/pt-br/reference/cli/check.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/check.mdx @@ -13,13 +13,14 @@ Carrega cada ADR com `rules: true` no frontmatter, executa o arquivo `.rules.ts` ## Opções -| Opção | Descrição | -| ------------ | ---------------------------------------------------------------------- | -| `--staged` | Verificar apenas arquivos no git stage (útil para hooks de pre-commit) | -| `--json` | Saída JSON legível por máquina | -| `--ci` | Formato de anotação do GitHub Actions | -| `--adr ` | Verificar apenas regras de um ADR específico | -| `--verbose` | Mostrar regras aprovadas e informações de tempo | +| Opção | Descrição | +| -------------- | ----------------------------------------------------------------------------- | +| `--staged` | Verificar apenas arquivos no git stage (útil para hooks de pre-commit) | +| `--base [ref]` | Comparar arquivos alterados contra uma ref base (auto-detecta quando omitido) | +| `--json` | Saída JSON legível por máquina | +| `--ci` | Formato de anotação do GitHub Actions | +| `--adr ` | Verificar apenas regras de um ADR específico | +| `--verbose` | Mostrar regras aprovadas e informações de tempo | ## Argumentos @@ -49,6 +50,12 @@ Verificar apenas arquivos no stage antes de commitar: archgate check --staged ``` +Verificar todos os arquivos alterados na branch atual vs `main`: + +```bash +archgate check --base main +``` + Verificar um único ADR: ```bash diff --git a/docs/src/content/docs/pt-br/reference/cli/review-context.mdx b/docs/src/content/docs/pt-br/reference/cli/review-context.mdx index 0ef6e9ed..9fbd6902 100644 --- a/docs/src/content/docs/pt-br/reference/cli/review-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/review-context.mdx @@ -11,11 +11,12 @@ archgate review-context [options] ## Opções -| Opção | Descrição | -| ------------------- | ---------------------------------------- | -| `--staged` | Incluir apenas arquivos no git stage | -| `--run-checks` | Incluir resultados de verificação de ADR | -| `--domain ` | Filtrar por um único domínio | +| Opção | Descrição | +| ------------------- | ----------------------------------------------------------------------------- | +| `--staged` | Incluir apenas arquivos no git stage | +| `--base [ref]` | Comparar arquivos alterados contra uma ref base (auto-detecta quando omitido) | +| `--run-checks` | Incluir resultados de verificação de ADR | +| `--domain ` | Filtrar por um único domínio | ## Exemplo diff --git a/docs/src/content/docs/pt-br/reference/rule-api.mdx b/docs/src/content/docs/pt-br/reference/rule-api.mdx index 968a5012..48499268 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -93,7 +93,7 @@ Arquivos que correspondem aos padrões glob de `files` do frontmatter do ADR. Se changedFiles: string[]; ``` -Arquivos que foram modificados de acordo com o git. Quando `--staged` é usado, contém apenas arquivos no stage. Quando executado sem `--staged`, contém todos os arquivos alterados (staged e unstaged). Vazio quando nenhuma alteração git é detectada. +Arquivos que foram modificados de acordo com o git. Por padrão, é auto-preenchido com o diff da branch contra a branch base detectada (ex: `origin/main`). Quando `--staged` é usado, contém apenas arquivos staged. Quando `--base ` é usado, contém todos os arquivos alterados desde aquela ref. Vazio quando a detecção falha ou nenhuma alteração é encontrada. Use para construir regras de dependência entre arquivos (ex: "se o arquivo A mudou, o arquivo B também deve mudar"). #### report diff --git a/docs/src/content/docs/reference/cli/check.mdx b/docs/src/content/docs/reference/cli/check.mdx index 47d9b0e9..4f47d83f 100644 --- a/docs/src/content/docs/reference/cli/check.mdx +++ b/docs/src/content/docs/reference/cli/check.mdx @@ -13,13 +13,14 @@ Loads every ADR with `rules: true` in its frontmatter, executes the companion `. ## Options -| Option | Description | -| ------------ | --------------------------------------------------------- | -| `--staged` | Only check git-staged files (useful for pre-commit hooks) | -| `--json` | Machine-readable JSON output | -| `--ci` | GitHub Actions annotation format | -| `--adr ` | Only check rules from a specific ADR | -| `--verbose` | Show passing rules and timing info | +| Option | Description | +| -------------- | -------------------------------------------------------------------- | +| `--staged` | Only check git-staged files (useful for pre-commit hooks) | +| `--base [ref]` | Compare changed files against a base ref (auto-detects when omitted) | +| `--json` | Machine-readable JSON output | +| `--ci` | GitHub Actions annotation format | +| `--adr ` | Only check rules from a specific ADR | +| `--verbose` | Show passing rules and timing info | ## Arguments @@ -49,6 +50,12 @@ Check only staged files before committing: archgate check --staged ``` +Check all files changed on the current branch vs `main`: + +```bash +archgate check --base main +``` + Check a single ADR: ```bash @@ -79,6 +86,26 @@ Get GitHub Actions annotations: archgate check --ci ``` +## Changed files detection + +By default, `archgate check` auto-detects the base branch and populates `ctx.changedFiles` with the branch diff (`git diff ...HEAD`). This enables cross-file dependency rules to work locally -- not just in CI. + +The base ref is resolved in priority order: + +| Priority | Source | `changedFiles` populated with | +| -------- | ------------------------------------ | -------------------------------- | +| 1 | `--staged` | Git staging area only | +| 2 | `--base ` | `git diff ...HEAD` | +| 3 | `.archgate/config.json` `baseBranch` | `git diff ...HEAD` | +| 4 | Git auto-detect | `git diff ...HEAD` | +| 5 | Detection fails | Empty (full-scan mode) | + +Auto-detection tries `origin/HEAD`, then `origin/main`, `origin/master`, local `main`, and local `master`. To set a project default, add `baseBranch` to `.archgate/config.json`: + +```json +{ "baseBranch": "main" } +``` + ## Diagnostics During execution, `archgate check` emits warnings for common misconfigurations that may cause slow or unexpected results: diff --git a/docs/src/content/docs/reference/cli/review-context.mdx b/docs/src/content/docs/reference/cli/review-context.mdx index 348a6bcc..fdcf3fbe 100644 --- a/docs/src/content/docs/reference/cli/review-context.mdx +++ b/docs/src/content/docs/reference/cli/review-context.mdx @@ -11,11 +11,12 @@ archgate review-context [options] ## Options -| Option | Description | -| ------------------- | ------------------------------------ | -| `--staged` | Only include git-staged files | -| `--run-checks` | Include ADR compliance check results | -| `--domain ` | Filter to a single domain | +| Option | Description | +| ------------------- | -------------------------------------------------------------------- | +| `--staged` | Only include git-staged files | +| `--base [ref]` | Compare changed files against a base ref (auto-detects when omitted) | +| `--run-checks` | Include ADR compliance check results | +| `--domain ` | Filter to a single domain | ## Example diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 200f08cd..f2f142d0 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -93,7 +93,7 @@ Files matching the ADR's `files` glob patterns from its frontmatter. If the ADR changedFiles: string[]; ``` -Files that have been modified according to git. When `--staged` is used, this contains only staged files. When running without `--staged`, this contains all changed files (staged and unstaged). Empty when no git changes are detected. +Files that have been modified according to git. By default, this is auto-populated with the branch diff against the detected base branch (e.g., `origin/main`). When `--staged` is used, this contains only staged files. When `--base ` is used, this contains all files changed since that ref. Empty when base detection fails or no changes are found. Use this to build cross-file dependency rules (e.g., "if file A changed, file B must also change"). #### report From 88cb8738b7278dda76f27705a225b6209e34d35b Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Sun, 24 May 2026 23:49:07 +0000 Subject: [PATCH 4/4] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 92 +++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 64b3b8cb..36a48bdd 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -789,11 +789,11 @@ The `check` function receives a `RuleContext` object that provides everything a ### Project Information -| Property | Type | Description | -| ------------------ | ---------- | -------------------------------------------------------------------------------- | -| `ctx.projectRoot` | `string` | Absolute path to the project root directory | -| `ctx.scopedFiles` | `string[]` | Files matching the ADR's `files` globs, or all project files if no globs are set | -| `ctx.changedFiles` | `string[]` | Files changed in git (populated when running with `--staged`) | +| Property | Type | Description | +| ------------------ | ---------- | ---------------------------------------------------------------------------------- | +| `ctx.projectRoot` | `string` | Absolute path to the project root directory | +| `ctx.scopedFiles` | `string[]` | Files matching the ADR's `files` globs, or all project files if no globs are set | +| `ctx.changedFiles` | `string[]` | Files changed in git (auto-detected from branch diff, or from `--staged`/`--base`) | ### File Operations @@ -897,7 +897,7 @@ Rules execute with the following guarantees: - **Parallel across ADRs** -- Rules from different ADRs run concurrently for faster execution. - **Sequential within an ADR** -- Rules belonging to the same ADR run one after another, so earlier rules can establish context for later ones. - **Scoped files are pre-resolved** -- The `ctx.scopedFiles` array is populated before your `check` function is called, based on the ADR's `files` globs. -- **Changed files for staged mode** -- When running `archgate check --staged`, `ctx.changedFiles` contains only the files staged in git, letting rules skip unchanged files for faster feedback. +- **Changed files auto-detected** -- `ctx.changedFiles` is automatically populated with the branch diff against the base branch (e.g., `main`). Use `--staged` for pre-commit hooks (staged files only) or `--base ` for an explicit base. This enables cross-file dependency rules to work locally, not just in CI. The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) run `archgate check` automatically after every code change. The agent reads the applicable ADRs, writes compliant code, and validates -- no manual check commands needed. [Sign up for beta access](https://plugins.archgate.dev). @@ -1045,6 +1045,16 @@ Warnings (severity `warning`) are logged but do not affect the exit code. Only ` ## Narrowing scope +### Check files changed in the PR + +Use `--base` to compare against the PR's base branch. This gives cross-file dependency rules the full picture of what changed: + +```yaml +- run: archgate check --base origin/${{ github.base_ref }} +``` + +Without `--base`, the base branch is auto-detected from `origin/HEAD`. The explicit form is recommended in CI for deterministic behavior. + ### Check only staged files Use `--staged` to limit checking to git-staged files. This is useful in pre-commit hooks or when you only want to validate what is about to be committed: @@ -2721,14 +2731,22 @@ Use `ctx.scopedFiles` when your rule should only apply to files the ADR governs. ### ctx.changedFiles -An array of file paths that have been modified (git staged or changed). Useful for incremental checking -- only validate files that were actually touched. +An array of file paths that differ from the base branch. Auto-detected by default, or populated from `--staged` / `--base `. Useful for incremental checking and cross-file dependency rules. ```typescript +// Incremental checking -- only validate changed files const filesToCheck = ctx.scopedFiles.filter((f) => ctx.changedFiles.includes(f) ); -for (const file of filesToCheck) { - // Only check changed files + +// Cross-file dependency -- if file A changed, file B must also change +if (ctx.changedFiles.includes("config/database.yml")) { + if (!ctx.changedFiles.includes("deploy/manifest.yml")) { + ctx.report.violation({ + message: "config changed but manifest was not bumped", + file: "config/database.yml", + }); + } } ``` @@ -2899,7 +2917,7 @@ ARCH-006/no-unapproved-deps } ``` -2. **Use `ctx.changedFiles` for incremental checking.** When running `archgate check --staged`, `ctx.changedFiles` contains only the git-staged files. Filter `ctx.scopedFiles` against it to check only what changed. +2. **Use `ctx.changedFiles` for incremental checking.** `ctx.changedFiles` is auto-populated with the branch diff (or staged files with `--staged`). Filter `ctx.scopedFiles` against it to check only what changed, or use it directly for cross-file dependency rules. 3. **Keep rules focused on one concern.** A rule that checks both naming conventions and import patterns should be split into two rules with separate IDs. @@ -3571,13 +3589,14 @@ Loads every ADR with `rules: true` in its frontmatter, executes the companion `. ## Options -| Option | Description | -| ------------ | --------------------------------------------------------- | -| `--staged` | Only check git-staged files (useful for pre-commit hooks) | -| `--json` | Machine-readable JSON output | -| `--ci` | GitHub Actions annotation format | -| `--adr ` | Only check rules from a specific ADR | -| `--verbose` | Show passing rules and timing info | +| Option | Description | +| -------------- | -------------------------------------------------------------------- | +| `--staged` | Only check git-staged files (useful for pre-commit hooks) | +| `--base [ref]` | Compare changed files against a base ref (auto-detects when omitted) | +| `--json` | Machine-readable JSON output | +| `--ci` | GitHub Actions annotation format | +| `--adr ` | Only check rules from a specific ADR | +| `--verbose` | Show passing rules and timing info | ## Arguments @@ -3607,6 +3626,12 @@ Check only staged files before committing: archgate check --staged ``` +Check all files changed on the current branch vs `main`: + +```bash +archgate check --base main +``` + Check a single ADR: ```bash @@ -3637,6 +3662,26 @@ Get GitHub Actions annotations: archgate check --ci ``` +## Changed files detection + +By default, `archgate check` auto-detects the base branch and populates `ctx.changedFiles` with the branch diff (`git diff ...HEAD`). This enables cross-file dependency rules to work locally -- not just in CI. + +The base ref is resolved in priority order: + +| Priority | Source | `changedFiles` populated with | +| -------- | ------------------------------------ | -------------------------------- | +| 1 | `--staged` | Git staging area only | +| 2 | `--base ` | `git diff ...HEAD` | +| 3 | `.archgate/config.json` `baseBranch` | `git diff ...HEAD` | +| 4 | Git auto-detect | `git diff ...HEAD` | +| 5 | Detection fails | Empty (full-scan mode) | + +Auto-detection tries `origin/HEAD`, then `origin/main`, `origin/master`, local `main`, and local `master`. To set a project default, add `baseBranch` to `.archgate/config.json`: + +```json +{ "baseBranch": "main" } +``` + ## Diagnostics During execution, `archgate check` emits warnings for common misconfigurations that may cause slow or unexpected results: @@ -4108,11 +4153,12 @@ archgate review-context [options] ## Options -| Option | Description | -| ------------------- | ------------------------------------ | -| `--staged` | Only include git-staged files | -| `--run-checks` | Include ADR compliance check results | -| `--domain ` | Filter to a single domain | +| Option | Description | +| ------------------- | -------------------------------------------------------------------- | +| `--staged` | Only include git-staged files | +| `--base [ref]` | Compare changed files against a base ref (auto-detects when omitted) | +| `--run-checks` | Include ADR compliance check results | +| `--domain ` | Filter to a single domain | ## Example @@ -4624,7 +4670,7 @@ Files matching the ADR's `files` glob patterns from its frontmatter. If the ADR changedFiles: string[]; ``` -Files that have been modified according to git. When `--staged` is used, this contains only staged files. When running without `--staged`, this contains all changed files (staged and unstaged). Empty when no git changes are detected. +Files that have been modified according to git. By default, this is auto-populated with the branch diff against the detected base branch (e.g., `origin/main`). When `--staged` is used, this contains only staged files. When `--base ` is used, this contains all files changed since that ref. Empty when base detection fails or no changes are found. Use this to build cross-file dependency rules (e.g., "if file A changed, file B must also change"). #### report