From 1d9f34d27083830ae71d37805147522d0ae53698 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:06:50 +0200 Subject: [PATCH 1/7] feat: add `respectGitignore` frontmatter field and gitignore filtering for ctx.glob/ctx.grepFiles Close the gitignore filtering gap in the rule context: `ctx.glob()` and `ctx.grepFiles()` now exclude `.gitignore`d files by default, matching the existing behavior of `ctx.scopedFiles`. Add `respectGitignore` boolean field to ADR frontmatter schema to allow opting out when rules need to inspect ignored files (e.g., build output). Signed-off-by: Rhuan Barreto --- docs/src/content/docs/concepts/adrs.mdx | 17 ++- docs/src/content/docs/guides/writing-adrs.mdx | 13 ++ .../src/content/docs/guides/writing-rules.mdx | 4 +- docs/src/content/docs/pt-br/concepts/adrs.mdx | 17 ++- .../docs/pt-br/guides/writing-adrs.mdx | 13 ++ .../docs/pt-br/guides/writing-rules.mdx | 4 +- .../docs/pt-br/reference/adr-schema.mdx | 28 +++- .../content/docs/pt-br/reference/rule-api.mdx | 4 +- .../src/content/docs/reference/adr-schema.mdx | 28 +++- docs/src/content/docs/reference/rule-api.mdx | 4 +- src/engine/git-files.ts | 10 +- src/engine/runner.ts | 26 +++- src/formats/adr.ts | 1 + tests/engine/git-files.test.ts | 32 ++++ tests/engine/runner.test.ts | 144 ++++++++++++++++++ 15 files changed, 301 insertions(+), 44 deletions(-) diff --git a/docs/src/content/docs/concepts/adrs.mdx b/docs/src/content/docs/concepts/adrs.mdx index 73be5c60..736c91f3 100644 --- a/docs/src/content/docs/concepts/adrs.mdx +++ b/docs/src/content/docs/concepts/adrs.mdx @@ -47,16 +47,19 @@ The prefix comes from the ADR's domain (see [Domains](/concepts/domains/)). The Every ADR document starts with a YAML frontmatter block between `---` delimiters. The frontmatter is the machine-readable metadata that Archgate uses to load, filter, and scope rules. -| Field | Type | Required | Description | -| -------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | -| `title` | string | Yes | Human-readable title of the decision | -| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | -| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | -| `files` | string array | No | Glob patterns that scope which files the rules check | +| Field | Type | Required | Description | +| ------------------ | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | +| `title` | string | Yes | Human-readable title of the decision | +| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | +| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | +| `files` | string array | No | Glob patterns that scope which files the rules check | +| `respectGitignore` | boolean | No | Whether to filter out `.gitignore`d files. Defaults to `true`. | The `files` field is optional. When present, it restricts rule execution to only the files matching the given globs. When absent, rules run against all project files. For example, `files: ["src/commands/**/*.ts"]` limits checks to command files only. +The `respectGitignore` field is also optional. By default, files listed in `.gitignore` are excluded from all file-scanning operations (`ctx.scopedFiles`, `ctx.glob()`, `ctx.grepFiles()`). Set `respectGitignore: false` to include gitignored files -- useful for rules that need to inspect build output or generated files. + ## ADR Body Sections After the frontmatter, the ADR body follows a standard section structure: diff --git a/docs/src/content/docs/guides/writing-adrs.mdx b/docs/src/content/docs/guides/writing-adrs.mdx index 3c713d63..aacd8dc1 100644 --- a/docs/src/content/docs/guides/writing-adrs.mdx +++ b/docs/src/content/docs/guides/writing-adrs.mdx @@ -244,6 +244,19 @@ files: ["src/commands/**/*.ts"] If `files` is omitted, `ctx.scopedFiles` includes all project files. This is appropriate for project-wide rules like dependency policies. +By default, files listed in `.gitignore` (e.g., `node_modules/`, `dist/`) are automatically excluded from `ctx.scopedFiles`, `ctx.glob()`, and `ctx.grepFiles()`. To include gitignored files, set `respectGitignore: false`: + +```yaml +--- +id: BUILD-001 +title: Build Output Structure +domain: architecture +rules: true +respectGitignore: false +files: ["dist/**/*.js"] +--- +``` + Common patterns: | Pattern | Matches | diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index 25d5875f..c7e831e0 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -145,7 +145,7 @@ for (const match of matches) { ### ctx.grepFiles(pattern, fileGlob) -Search across multiple files matching a glob pattern. Returns a flat array of `GrepMatch` objects from all matching files. +Search across multiple files matching a glob pattern. Returns a flat array of `GrepMatch` objects from all matching files. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const matches = await ctx.grepFiles(/TODO:/, "src/**/*.ts"); @@ -160,7 +160,7 @@ for (const match of matches) { ### ctx.glob(pattern) -Find files by glob pattern. Returns an array of file paths relative to the project root. +Find files by glob pattern. Returns an array of file paths relative to the project root. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); diff --git a/docs/src/content/docs/pt-br/concepts/adrs.mdx b/docs/src/content/docs/pt-br/concepts/adrs.mdx index 6044d5b5..98a16f14 100644 --- a/docs/src/content/docs/pt-br/concepts/adrs.mdx +++ b/docs/src/content/docs/pt-br/concepts/adrs.mdx @@ -47,16 +47,19 @@ O prefixo vem do domínio do ADR (veja [Domínios](/concepts/domains/)). O núme Todo documento ADR começa com um bloco de frontmatter YAML entre delimitadores `---`. O frontmatter é o metadado legível por máquina que o Archgate usa para carregar, filtrar e definir o escopo das regras. -| Campo | Tipo | Obrigatório | Descrição | -| -------- | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Sim | Identificador único como `ARCH-001` ou `BE-003` | -| `title` | string | Sim | Título legível da decisão | -| `domain` | string | Sim | Nome de domínio registrado. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. [Domínios personalizados](/concepts/domains/#domínios-personalizados) podem ser adicionados via `archgate adr domain add`. | -| `rules` | boolean | Sim | Se este ADR tem um arquivo `.rules.ts` complementar | -| `files` | string array | Não | Padrões glob que definem quais arquivos as regras verificam | +| Campo | Tipo | Obrigatório | Descrição | +| ------------------ | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Sim | Identificador único como `ARCH-001` ou `BE-003` | +| `title` | string | Sim | Título legível da decisão | +| `domain` | string | Sim | Nome de domínio registrado. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. [Domínios personalizados](/concepts/domains/#domínios-personalizados) podem ser adicionados via `archgate adr domain add`. | +| `rules` | boolean | Sim | Se este ADR tem um arquivo `.rules.ts` complementar | +| `files` | string array | Não | Padrões glob que definem quais arquivos as regras verificam | +| `respectGitignore` | boolean | Não | Se deve filtrar arquivos ignorados pelo `.gitignore`. Padrão: `true`. | O campo `files` é opcional. Quando presente, restringe a execução das regras apenas aos arquivos que correspondem aos globs fornecidos. Quando ausente, as regras são executadas contra todos os arquivos do projeto. Por exemplo, `files: ["src/commands/**/*.ts"]` limita as verificações apenas aos arquivos de comando. +O campo `respectGitignore` também é opcional. Por padrão, arquivos listados no `.gitignore` são excluídos de todas as operações de busca de arquivos (`ctx.scopedFiles`, `ctx.glob()`, `ctx.grepFiles()`). Defina `respectGitignore: false` para incluir arquivos ignorados pelo git -- útil para regras que precisam inspecionar saída de build ou arquivos gerados. + ## Seções do Corpo do ADR Após o frontmatter, o corpo do ADR segue uma estrutura de seções padrão: diff --git a/docs/src/content/docs/pt-br/guides/writing-adrs.mdx b/docs/src/content/docs/pt-br/guides/writing-adrs.mdx index 622c83d9..8eb81172 100644 --- a/docs/src/content/docs/pt-br/guides/writing-adrs.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-adrs.mdx @@ -244,6 +244,19 @@ files: ["src/commands/**/*.ts"] Se `files` for omitido, `ctx.scopedFiles` inclui todos os arquivos do projeto. Isso é apropriado para regras que abrangem todo o projeto, como políticas de dependências. +Por padrão, arquivos listados no `.gitignore` (ex.: `node_modules/`, `dist/`) são automaticamente excluídos de `ctx.scopedFiles`, `ctx.glob()` e `ctx.grepFiles()`. Para incluir arquivos ignorados pelo git, defina `respectGitignore: false`: + +```yaml +--- +id: BUILD-001 +title: Build Output Structure +domain: architecture +rules: true +respectGitignore: false +files: ["dist/**/*.js"] +--- +``` + Padrões comuns: | Padrão | Corresponde a | 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 657d7e89..cc626173 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -145,7 +145,7 @@ for (const match of matches) { ### ctx.grepFiles(pattern, fileGlob) -Busca em múltiplos arquivos que correspondem a um padrão glob. Retorna um array plano de objetos `GrepMatch` de todos os arquivos correspondentes. +Busca em múltiplos arquivos que correspondem a um padrão glob. Retorna um array plano de objetos `GrepMatch` de todos os arquivos correspondentes. Arquivos ignorados pelo `.gitignore` são excluídos por padrão. Defina `respectGitignore: false` no frontmatter do ADR para incluí-los. ```typescript const matches = await ctx.grepFiles(/TODO:/, "src/**/*.ts"); @@ -160,7 +160,7 @@ for (const match of matches) { ### ctx.glob(pattern) -Encontra arquivos por padrão glob. Retorna um array de caminhos de arquivo relativos à raiz do projeto. +Encontra arquivos por padrão glob. Retorna um array de caminhos de arquivo relativos à raiz do projeto. Arquivos ignorados pelo `.gitignore` são excluídos por padrão. Defina `respectGitignore: false` no frontmatter do ADR para incluí-los. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); diff --git a/docs/src/content/docs/pt-br/reference/adr-schema.mdx b/docs/src/content/docs/pt-br/reference/adr-schema.mdx index 3003460b..6e52e9b3 100644 --- a/docs/src/content/docs/pt-br/reference/adr-schema.mdx +++ b/docs/src/content/docs/pt-br/reference/adr-schema.mdx @@ -21,13 +21,14 @@ files: ["src/commands/**/*.ts"] ### Campos -| Campo | Tipo | Obrigatório | Descrição | -| -------- | ---------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Sim | Identificador único. Não pode ser vazio. Convenção: `PREFIX-NNN` (ex.: `ARCH-001`). | -| `title` | `string` | Sim | Título legível da decisão. Não pode ser vazio. | -| `domain` | `string` | Sim | Nome de domínio registrado em kebab-case minúsculo. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. Domínios personalizados são aqueles registrados via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | -| `rules` | `boolean` | Sim | Se este ADR possui um arquivo `.rules.ts` complementar com verificações automatizadas. | -| `files` | `string[]` | Não | Padrões glob que definem o escopo de quais arquivos as regras se aplicam. | +| Campo | Tipo | Obrigatório | Descrição | +| ------------------ | ---------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Sim | Identificador único. Não pode ser vazio. Convenção: `PREFIX-NNN` (ex.: `ARCH-001`). | +| `title` | `string` | Sim | Título legível da decisão. Não pode ser vazio. | +| `domain` | `string` | Sim | Nome de domínio registrado em kebab-case minúsculo. Integrados: `backend`, `frontend`, `data`, `architecture`, `general`. Domínios personalizados são aqueles registrados via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Sim | Se este ADR possui um arquivo `.rules.ts` complementar com verificações automatizadas. | +| `files` | `string[]` | Não | Padrões glob que definem o escopo de quais arquivos as regras se aplicam. | +| `respectGitignore` | `boolean` | Não | Se deve filtrar arquivos ignorados pelo `.gitignore`. Padrão: `true`. | ### id @@ -63,6 +64,19 @@ Múltiplos padrões podem ser especificados: files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] ``` +### respectGitignore + +Controla se arquivos ignorados pelo `.gitignore` são excluídos de `ctx.scopedFiles`, `ctx.glob()` e `ctx.grepFiles()`. O padrão é `true` quando omitido -- arquivos ignorados pelo git (ex.: `node_modules/`, `dist/`) são filtrados automaticamente. + +Defina como `false` quando uma regra precisa intencionalmente inspecionar arquivos ignorados, como verificar a estrutura da saída de build: + +```yaml +respectGitignore: false +files: ["dist/**/*.js"] +``` + +Quando `respectGitignore` é `false`, todos os arquivos correspondentes aos globs de `files` são incluídos independentemente das regras do `.gitignore`. Quando não estiver dentro de um repositório git, este campo não tem efeito -- todos os arquivos correspondentes são incluídos. + --- ## Prefixos de Domínio 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 1f675453..968a5012 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -111,7 +111,7 @@ A interface de relatório para registrar violações, avisos e mensagens informa glob(pattern: string): Promise; ``` -Encontra arquivos correspondentes a um padrão glob relativo à raiz do projeto. Retorna um array de caminhos absolutos de arquivo. +Encontra arquivos correspondentes a um padrão glob relativo à raiz do projeto. Retorna um array de caminhos de arquivo. Arquivos ignorados pelo `.gitignore` são excluídos por padrão. Defina `respectGitignore: false` no frontmatter do ADR para incluí-los. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); @@ -135,7 +135,7 @@ const matches = await ctx.grep(file, /console\.error\(/); grepFiles(pattern: RegExp, fileGlob: string): Promise; ``` -Busca em múltiplos arquivos correspondentes a um padrão glob por linhas que correspondam a uma expressão regular. Combina `glob` e `grep` em uma única chamada. +Busca em múltiplos arquivos correspondentes a um padrão glob por linhas que correspondam a uma expressão regular. Combina `glob` e `grep` em uma única chamada. Arquivos ignorados pelo `.gitignore` são excluídos por padrão. Defina `respectGitignore: false` no frontmatter do ADR para incluí-los. ```typescript const matches = await ctx.grepFiles(/TODO:/i, "src/**/*.ts"); diff --git a/docs/src/content/docs/reference/adr-schema.mdx b/docs/src/content/docs/reference/adr-schema.mdx index 2a7d2243..130e0f5b 100644 --- a/docs/src/content/docs/reference/adr-schema.mdx +++ b/docs/src/content/docs/reference/adr-schema.mdx @@ -21,13 +21,14 @@ files: ["src/commands/**/*.ts"] ### Fields -| Field | Type | Required | Description | -| -------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | -| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | -| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | -| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | -| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| Field | Type | Required | Description | +| ------------------ | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | +| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | +| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | +| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| `respectGitignore` | `boolean` | No | Whether to filter out `.gitignore`d files. Defaults to `true`. | ### id @@ -63,6 +64,19 @@ Multiple patterns can be specified: files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] ``` +### respectGitignore + +Controls whether `.gitignore`d files are excluded from `ctx.scopedFiles`, `ctx.glob()`, and `ctx.grepFiles()`. Defaults to `true` when omitted -- gitignored files (e.g., `node_modules/`, `dist/`) are automatically filtered out. + +Set to `false` when a rule intentionally needs to inspect ignored files, such as checking build output structure: + +```yaml +respectGitignore: false +files: ["dist/**/*.js"] +``` + +When `respectGitignore` is `false`, all files matched by the `files` globs are included regardless of `.gitignore` rules. When not inside a git repository, this field has no effect -- all matched files are included. + --- ## Domain Prefixes diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index d260908f..200f08cd 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -111,7 +111,7 @@ The reporting interface for recording violations, warnings, and informational me glob(pattern: string): Promise; ``` -Find files matching a glob pattern relative to the project root. Returns an array of absolute file paths. +Find files matching a glob pattern relative to the project root. Returns an array of file paths. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); @@ -135,7 +135,7 @@ const matches = await ctx.grep(file, /console\.error\(/); grepFiles(pattern: RegExp, fileGlob: string): Promise; ``` -Search multiple files matching a glob pattern for lines matching a regular expression. Combines `glob` and `grep` into a single call. +Search multiple files matching a glob pattern for lines matching a regular expression. Combines `glob` and `grep` into a single call. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const matches = await ctx.grepFiles(/TODO:/i, "src/**/*.ts"); diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 6475ed23..a394136e 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -58,13 +58,17 @@ export function getGitTrackedFiles( return promise; } -/** Resolve scoped files for an ADR based on its files globs. Respects .gitignore. */ +/** Resolve scoped files for an ADR based on its files globs. Respects .gitignore by default. */ export async function resolveScopedFiles( projectRoot: string, - adrFileGlobs?: string[] + adrFileGlobs?: string[], + options?: { respectGitignore?: boolean } ): Promise { const patterns = adrFileGlobs ?? ["**/*"]; - const trackedFiles = await getGitTrackedFiles(projectRoot); + const respectGitignore = options?.respectGitignore !== false; + const trackedFiles = respectGitignore + ? await getGitTrackedFiles(projectRoot) + : null; const results = await Promise.all( patterns.map(async (pattern) => { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index dc176e09..d922c3ce 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -10,7 +10,11 @@ import type { ViolationDetail, } from "../formats/rules"; import { logDebug } from "../helpers/log"; -import { resolveScopedFiles, getStagedFiles } from "./git-files"; +import { + resolveScopedFiles, + getStagedFiles, + getGitTrackedFiles, +} from "./git-files"; import { type LoadResult, blockedToRuleResult } from "./loader"; /** @@ -81,7 +85,8 @@ function createRuleContext( changedFiles: string[], adrId: string, ruleId: string, - violations: ViolationDetail[] + violations: ViolationDetail[], + trackedFiles: Set | null ): RuleContext { const report: RuleReport = { violation(detail) { @@ -109,7 +114,9 @@ function createRuleContext( // `.husky/`, `.vscode/` — first-class source dirs in code repos. // See https://github.com/archgate/cli/issues/222. for await (const file of g.scan({ cwd: projectRoot, dot: true })) { - results.push(file.replaceAll("\\", "/")); + const normalized = file.replaceAll("\\", "/"); + if (trackedFiles && !trackedFiles.has(normalized)) continue; + results.push(normalized); } return results.sort(); }, @@ -144,6 +151,7 @@ function createRuleContext( // See https://github.com/archgate/cli/issues/222. for await (const file of g.scan({ cwd: projectRoot, dot: true })) { const normalized = file.replaceAll("\\", "/"); + if (trackedFiles && !trackedFiles.has(normalized)) continue; const absPath = safePath(projectRoot, file); try { const content = await Bun.file(absPath).text(); @@ -211,12 +219,19 @@ export async function runChecks( ); } + // Resolve tracked files once (cached per-process) for gitignore filtering + const allTrackedFiles = await getGitTrackedFiles(projectRoot); + // Run ADRs in parallel const adrResults = await Promise.allSettled( loadedAdrs.map(async ({ adr, ruleSet }) => { + const respectGitignore = adr.frontmatter.respectGitignore !== false; + const trackedFiles = respectGitignore ? allTrackedFiles : null; + let scopedFiles = await resolveScopedFiles( projectRoot, - adr.frontmatter.files + adr.frontmatter.files, + { respectGitignore } ); // When files are specified, narrow scopedFiles to the intersection @@ -242,7 +257,8 @@ export async function runChecks( changedFiles, adr.frontmatter.id, ruleId, - violations + violations, + trackedFiles ); try { diff --git a/src/formats/adr.ts b/src/formats/adr.ts index 5c72656d..fda9ce29 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -41,6 +41,7 @@ export const AdrFrontmatterSchema = z.object({ ), rules: z.boolean(), files: z.array(z.string()).optional(), + respectGitignore: z.boolean().optional(), }); export type AdrFrontmatter = z.infer; diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 71a66711..fdd118fd 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -96,6 +96,38 @@ describe("git-files", () => { expect(files).not.toContain("src/bar.md"); }); + test("excludes gitignored files by default", async () => { + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "src"), { recursive: true }); + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync(join(tempDir, "src", "app.ts"), "export const x = 1;"); + writeFileSync(join(tempDir, "dist", "app.js"), "var x = 1;"); + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", "src/app.ts", ".gitignore"], tempDir); + const files = await resolveScopedFiles(tempDir, ["**/*.ts", "**/*.js"]); + expect(files).toContain("src/app.ts"); + expect(files).not.toContain("dist/app.js"); + }); + + test("includes gitignored files when respectGitignore is false", async () => { + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "src"), { recursive: true }); + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync(join(tempDir, "src", "app.ts"), "export const x = 1;"); + writeFileSync(join(tempDir, "dist", "app.js"), "var x = 1;"); + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", "src/app.ts", ".gitignore"], tempDir); + const files = await resolveScopedFiles(tempDir, ["**/*.ts", "**/*.js"], { + respectGitignore: false, + }); + expect(files).toContain("src/app.ts"); + expect(files).toContain("dist/app.js"); + }); + // Regression: archgate/cli#222 — ADR `files:` globs must match // dot-prefixed source dirs like `.github/`. Bun.Glob with `dot: false` // silently drops these on Windows, so ADRs scoped to `.github/**` had diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts index c14afd93..5354562b 100644 --- a/tests/engine/runner.test.ts +++ b/tests/engine/runner.test.ts @@ -8,7 +8,9 @@ import { join } from "node:path"; import type { LoadResult } from "../../src/engine/loader"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; +import type { GrepMatch } from "../../src/formats/rules"; import type { RuleSet } from "../../src/formats/rules"; +import { git, safeRmSync } from "../test-utils"; describe("runChecks", () => { let tempDir: string; @@ -329,3 +331,145 @@ describe("runChecks", () => { expect(result.results[0].durationMs).toBeGreaterThanOrEqual(0); }); }); + +describe("runChecks gitignore filtering", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-gitignore-test-")); + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "src"), { recursive: true }); + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync( + join(tempDir, "src", "app.ts"), + 'export const x = "hello";\n' + ); + writeFileSync(join(tempDir, "dist", "app.js"), 'var x = "hello";\n'); + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", "src/app.ts", ".gitignore"], tempDir); + await git(["commit", "-m", "init"], tempDir); + }); + + afterEach(() => { + safeRmSync(tempDir); + }); + + function makeLoadedAdr( + overrides: Partial = {}, + ruleSet: { + rules: Record< + string, + { description: string; check: (ctx: any) => Promise } + >; + } + ): LoadResult { + return { + type: "loaded", + value: { + adr: { + frontmatter: { + id: "TEST-001", + title: "Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }, + }; + } + + test("ctx.glob excludes gitignored files by default", async () => { + let globResults: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "glob-gitignore-test": { + description: "Test glob respects gitignore", + async check(ctx) { + globResults = await ctx.glob("**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(globResults).toContain("src/app.ts"); + expect(globResults).not.toContain("dist/app.js"); + }); + + test("ctx.glob includes gitignored files when respectGitignore is false", async () => { + let globResults: string[] = []; + + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { + rules: { + "glob-no-gitignore-test": { + description: "Test glob ignores gitignore", + async check(ctx) { + globResults = await ctx.glob("**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(globResults).toContain("src/app.ts"); + expect(globResults).toContain("dist/app.js"); + }); + + test("ctx.grepFiles excludes gitignored files by default", async () => { + let matches: GrepMatch[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "grep-gitignore-test": { + description: "Test grepFiles respects gitignore", + async check(ctx) { + matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(1); + expect(matches[0].file).toBe("src/app.ts"); + }); + + test("ctx.grepFiles includes gitignored files when respectGitignore is false", async () => { + let matches: GrepMatch[] = []; + + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { + rules: { + "grep-no-gitignore-test": { + description: "Test grepFiles ignores gitignore", + async check(ctx) { + matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(2); + const matchedFiles = matches.map((m) => m.file).sort(); + expect(matchedFiles).toEqual(["dist/app.js", "src/app.ts"]); + }); +}); From 6a1bd98183ed10ac1d4df5bbd0c58050cb9ddb91 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Mon, 18 May 2026 18:08:32 +0000 Subject: [PATCH 2/7] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 66 ++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 13106fa7..48b0c0d5 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -461,16 +461,19 @@ The prefix comes from the ADR's domain (see [Domains](/concepts/domains/)). The Every ADR document starts with a YAML frontmatter block between `---` delimiters. The frontmatter is the machine-readable metadata that Archgate uses to load, filter, and scope rules. -| Field | Type | Required | Description | -| -------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | -| `title` | string | Yes | Human-readable title of the decision | -| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | -| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | -| `files` | string array | No | Glob patterns that scope which files the rules check | +| Field | Type | Required | Description | +| ------------------ | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Yes | Unique identifier like `ARCH-001` or `BE-003` | +| `title` | string | Yes | Human-readable title of the decision | +| `domain` | string | Yes | Registered domain name. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. [Custom domains](/concepts/domains/#custom-domains) can be added via `archgate adr domain add`. | +| `rules` | boolean | Yes | Whether this ADR has a companion `.rules.ts` file | +| `files` | string array | No | Glob patterns that scope which files the rules check | +| `respectGitignore` | boolean | No | Whether to filter out `.gitignore`d files. Defaults to `true`. | The `files` field is optional. When present, it restricts rule execution to only the files matching the given globs. When absent, rules run against all project files. For example, `files: ["src/commands/**/*.ts"]` limits checks to command files only. +The `respectGitignore` field is also optional. By default, files listed in `.gitignore` are excluded from all file-scanning operations (`ctx.scopedFiles`, `ctx.glob()`, `ctx.grepFiles()`). Set `respectGitignore: false` to include gitignored files -- useful for rules that need to inspect build output or generated files. + ## ADR Body Sections After the frontmatter, the ADR body follows a standard section structure: @@ -2540,6 +2543,19 @@ files: ["src/commands/**/*.ts"] If `files` is omitted, `ctx.scopedFiles` includes all project files. This is appropriate for project-wide rules like dependency policies. +By default, files listed in `.gitignore` (e.g., `node_modules/`, `dist/`) are automatically excluded from `ctx.scopedFiles`, `ctx.glob()`, and `ctx.grepFiles()`. To include gitignored files, set `respectGitignore: false`: + +```yaml +--- +id: BUILD-001 +title: Build Output Structure +domain: architecture +rules: true +respectGitignore: false +files: ["dist/**/*.js"] +--- +``` + Common patterns: | Pattern | Matches | @@ -2749,7 +2765,7 @@ for (const match of matches) { ### ctx.grepFiles(pattern, fileGlob) -Search across multiple files matching a glob pattern. Returns a flat array of `GrepMatch` objects from all matching files. +Search across multiple files matching a glob pattern. Returns a flat array of `GrepMatch` objects from all matching files. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const matches = await ctx.grepFiles(/TODO:/, "src/**/*.ts"); @@ -2764,7 +2780,7 @@ for (const match of matches) { ### ctx.glob(pattern) -Find files by glob pattern. Returns an array of file paths relative to the project root. +Find files by glob pattern. Returns an array of file paths relative to the project root. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); @@ -2927,13 +2943,14 @@ files: ["src/commands/**/*.ts"] ### Fields -| Field | Type | Required | Description | -| -------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | -| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | -| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | -| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | -| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| Field | Type | Required | Description | +| ------------------ | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Yes | Unique identifier. Must be non-empty. Convention: `PREFIX-NNN` (e.g., `ARCH-001`). | +| `title` | `string` | Yes | Human-readable title of the decision. Must be non-empty. | +| `domain` | `string` | Yes | A registered domain name in lowercase kebab-case. Built-ins: `backend`, `frontend`, `data`, `architecture`, `general`. Custom domains are those registered via [`archgate adr domain add`](/reference/cli/adr/#archgate-adr-domain). | +| `rules` | `boolean` | Yes | Whether this ADR has a companion `.rules.ts` file with automated checks. | +| `files` | `string[]` | No | Glob patterns that scope which files the rules apply to. | +| `respectGitignore` | `boolean` | No | Whether to filter out `.gitignore`d files. Defaults to `true`. | ### id @@ -2969,6 +2986,19 @@ Multiple patterns can be specified: files: ["src/api/**/*.ts", "src/middleware/**/*.ts"] ``` +### respectGitignore + +Controls whether `.gitignore`d files are excluded from `ctx.scopedFiles`, `ctx.glob()`, and `ctx.grepFiles()`. Defaults to `true` when omitted -- gitignored files (e.g., `node_modules/`, `dist/`) are automatically filtered out. + +Set to `false` when a rule intentionally needs to inspect ignored files, such as checking build output structure: + +```yaml +respectGitignore: false +files: ["dist/**/*.js"] +``` + +When `respectGitignore` is `false`, all files matched by the `files` globs are included regardless of `.gitignore` rules. When not inside a git repository, this field has no effect -- all matched files are included. + --- ## Domain Prefixes @@ -4554,7 +4584,7 @@ The reporting interface for recording violations, warnings, and informational me glob(pattern: string): Promise; ``` -Find files matching a glob pattern relative to the project root. Returns an array of absolute file paths. +Find files matching a glob pattern relative to the project root. Returns an array of file paths. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const testFiles = await ctx.glob("tests/**/*.test.ts"); @@ -4578,7 +4608,7 @@ const matches = await ctx.grep(file, /console\.error\(/); grepFiles(pattern: RegExp, fileGlob: string): Promise; ``` -Search multiple files matching a glob pattern for lines matching a regular expression. Combines `glob` and `grep` into a single call. +Search multiple files matching a glob pattern for lines matching a regular expression. Combines `glob` and `grep` into a single call. Files ignored by `.gitignore` are excluded by default. Set `respectGitignore: false` in the ADR frontmatter to include them. ```typescript const matches = await ctx.grepFiles(/TODO:/i, "src/**/*.ts"); From 94c7d2afbda32a76e9afa28f28989e21c0f26b6c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:34:17 +0200 Subject: [PATCH 3/7] fix: treat empty files array same as omitted in resolveScopedFiles An empty `files: []` in ADR frontmatter now falls back to `["**/*"]` (scan all files), matching the behavior of omitting `files` entirely. Previously, an empty array yielded zero scoped files. Signed-off-by: Rhuan Barreto --- src/engine/git-files.ts | 2 +- tests/engine/git-files.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index a394136e..c5280e13 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -64,7 +64,7 @@ export async function resolveScopedFiles( adrFileGlobs?: string[], options?: { respectGitignore?: boolean } ): Promise { - const patterns = adrFileGlobs ?? ["**/*"]; + const patterns = adrFileGlobs?.length ? adrFileGlobs : ["**/*"]; const respectGitignore = options?.respectGitignore !== false; const trackedFiles = respectGitignore ? await getGitTrackedFiles(projectRoot) diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index fdd118fd..4bf766a2 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -128,6 +128,19 @@ describe("git-files", () => { expect(files).toContain("dist/app.js"); }); + test("treats empty files array same as omitted (scans all)", async () => { + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "src"), { recursive: true }); + writeFileSync(join(tempDir, "src", "app.ts"), "export const x = 1;"); + await git(["add", "src/app.ts"], tempDir); + const withEmpty = await resolveScopedFiles(tempDir, []); + const withOmitted = await resolveScopedFiles(tempDir); + expect(withEmpty).toEqual(withOmitted); + expect(withEmpty).toContain("src/app.ts"); + }); + // Regression: archgate/cli#222 — ADR `files:` globs must match // dot-prefixed source dirs like `.github/`. Bun.Glob with `dot: false` // silently drops these on Windows, so ADRs scoped to `.github/**` had From a7f6dfadb225cd3f6c649e328818ca94e4b5226c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:36:20 +0200 Subject: [PATCH 4/7] feat: warn when respectGitignore is false without files scope Emit a logWarn when an ADR sets respectGitignore: false but has no files patterns, since the fallback to **/* will scan everything on disk including node_modules/, .git/, etc. and may be very slow. Signed-off-by: Rhuan Barreto --- src/engine/runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/engine/runner.ts b/src/engine/runner.ts index d922c3ce..190d07f8 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -9,7 +9,7 @@ import type { RuleReport, ViolationDetail, } from "../formats/rules"; -import { logDebug } from "../helpers/log"; +import { logDebug, logWarn } from "../helpers/log"; import { resolveScopedFiles, getStagedFiles, @@ -228,6 +228,12 @@ export async function runChecks( const respectGitignore = adr.frontmatter.respectGitignore !== false; const trackedFiles = respectGitignore ? allTrackedFiles : null; + if (!respectGitignore && !adr.frontmatter.files?.length) { + logWarn( + `ADR ${adr.frontmatter.id}: respectGitignore is false without a files scope — scanning all files including node_modules/, .git/, etc. This may be very slow. Add a files pattern to narrow the scope.` + ); + } + let scopedFiles = await resolveScopedFiles( projectRoot, adr.frontmatter.files, From ad6d92b6e4ae6921816c3251afa88ca186781277 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:39:31 +0200 Subject: [PATCH 5/7] feat: warn when file patterns match only gitignored files When an ADR specifies explicit files patterns but scopedFiles resolves to zero results, do a second unfiltered scan. If files exist on disk but were all excluded by .gitignore, warn the user to set respectGitignore: false. Signed-off-by: Rhuan Barreto --- src/engine/runner.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 190d07f8..435265b1 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -240,6 +240,24 @@ export async function runChecks( { respectGitignore } ); + // Warn when explicit file patterns yield zero results due to gitignore + if ( + respectGitignore && + adr.frontmatter.files?.length && + scopedFiles.length === 0 + ) { + const unfiltered = await resolveScopedFiles( + projectRoot, + adr.frontmatter.files, + { respectGitignore: false } + ); + if (unfiltered.length > 0) { + logWarn( + `ADR ${adr.frontmatter.id}: files patterns matched ${unfiltered.length} file(s) but all are excluded by .gitignore. Set respectGitignore: false in the ADR frontmatter to include them.` + ); + } + } + // When files are specified, narrow scopedFiles to the intersection if (filterFiles) { scopedFiles = scopedFiles.filter((f) => filterFiles.has(f)); From 030c2ffca699158f5b4ac0f2f31eb482d3a2a954 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:44:39 +0200 Subject: [PATCH 6/7] test: add coverage for gitignore warnings, extract to separate file Extract gitignore-related runner tests into runner-gitignore.test.ts to stay under the 500-line max-lines lint rule. Add 3 new tests covering: - warns when respectGitignore is false without files scope - warns when file patterns match only gitignored files - does not warn when file patterns match tracked files Signed-off-by: Rhuan Barreto --- tests/engine/runner-gitignore.test.ts | 207 ++++++++++++++++++++++++++ tests/engine/runner.test.ts | 144 ------------------ 2 files changed, 207 insertions(+), 144 deletions(-) create mode 100644 tests/engine/runner-gitignore.test.ts diff --git a/tests/engine/runner-gitignore.test.ts b/tests/engine/runner-gitignore.test.ts new file mode 100644 index 00000000..69c8ee4b --- /dev/null +++ b/tests/engine/runner-gitignore.test.ts @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { LoadResult } from "../../src/engine/loader"; +import { runChecks } from "../../src/engine/runner"; +import type { AdrDocument } from "../../src/formats/adr"; +import type { GrepMatch } from "../../src/formats/rules"; +import { git, safeRmSync } from "../test-utils"; + +describe("runChecks gitignore filtering", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-gitignore-test-")); + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "src"), { recursive: true }); + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync( + join(tempDir, "src", "app.ts"), + 'export const x = "hello";\n' + ); + writeFileSync(join(tempDir, "dist", "app.js"), 'var x = "hello";\n'); + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", "src/app.ts", ".gitignore"], tempDir); + await git(["commit", "-m", "init"], tempDir); + }); + + afterEach(() => { + safeRmSync(tempDir); + }); + + function makeLoadedAdr( + overrides: Partial = {}, + ruleSet: { + rules: Record< + string, + { description: string; check: (ctx: any) => Promise } + >; + } + ): LoadResult { + return { + type: "loaded", + value: { + adr: { + frontmatter: { + id: "TEST-001", + title: "Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }, + }; + } + + test("ctx.glob excludes gitignored files by default", async () => { + let globResults: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "glob-gitignore-test": { + description: "Test glob respects gitignore", + async check(ctx) { + globResults = await ctx.glob("**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(globResults).toContain("src/app.ts"); + expect(globResults).not.toContain("dist/app.js"); + }); + + test("ctx.glob includes gitignored files when respectGitignore is false", async () => { + let globResults: string[] = []; + + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { + rules: { + "glob-no-gitignore-test": { + description: "Test glob ignores gitignore", + async check(ctx) { + globResults = await ctx.glob("**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(globResults).toContain("src/app.ts"); + expect(globResults).toContain("dist/app.js"); + }); + + test("ctx.grepFiles excludes gitignored files by default", async () => { + let matches: GrepMatch[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "grep-gitignore-test": { + description: "Test grepFiles respects gitignore", + async check(ctx) { + matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(1); + expect(matches[0].file).toBe("src/app.ts"); + }); + + test("ctx.grepFiles includes gitignored files when respectGitignore is false", async () => { + let matches: GrepMatch[] = []; + + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { + rules: { + "grep-no-gitignore-test": { + description: "Test grepFiles ignores gitignore", + async check(ctx) { + matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(2); + const matchedFiles = matches.map((m) => m.file).sort(); + expect(matchedFiles).toEqual(["dist/app.js", "src/app.ts"]); + }); + + test("warns when respectGitignore is false without files scope", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect( + warnCalls.some((msg) => + msg.includes("respectGitignore is false without a files scope") + ) + ).toBe(true); + warnSpy.mockRestore(); + }); + + test("warns when file patterns match only gitignored files", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const loaded = makeLoadedAdr( + { files: ["dist/**/*.js"] }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect( + warnCalls.some((msg) => msg.includes("all are excluded by .gitignore")) + ).toBe(true); + warnSpy.mockRestore(); + }); + + test("does not warn when file patterns match tracked files", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect( + warnCalls.some((msg) => msg.includes("excluded by .gitignore")) + ).toBe(false); + expect( + warnCalls.some((msg) => msg.includes("respectGitignore is false")) + ).toBe(false); + warnSpy.mockRestore(); + }); +}); diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts index 5354562b..c14afd93 100644 --- a/tests/engine/runner.test.ts +++ b/tests/engine/runner.test.ts @@ -8,9 +8,7 @@ import { join } from "node:path"; import type { LoadResult } from "../../src/engine/loader"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; -import type { GrepMatch } from "../../src/formats/rules"; import type { RuleSet } from "../../src/formats/rules"; -import { git, safeRmSync } from "../test-utils"; describe("runChecks", () => { let tempDir: string; @@ -331,145 +329,3 @@ describe("runChecks", () => { expect(result.results[0].durationMs).toBeGreaterThanOrEqual(0); }); }); - -describe("runChecks gitignore filtering", () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-gitignore-test-")); - await git(["init"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - mkdirSync(join(tempDir, "src"), { recursive: true }); - mkdirSync(join(tempDir, "dist"), { recursive: true }); - writeFileSync( - join(tempDir, "src", "app.ts"), - 'export const x = "hello";\n' - ); - writeFileSync(join(tempDir, "dist", "app.js"), 'var x = "hello";\n'); - writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); - await git(["add", "src/app.ts", ".gitignore"], tempDir); - await git(["commit", "-m", "init"], tempDir); - }); - - afterEach(() => { - safeRmSync(tempDir); - }); - - function makeLoadedAdr( - overrides: Partial = {}, - ruleSet: { - rules: Record< - string, - { description: string; check: (ctx: any) => Promise } - >; - } - ): LoadResult { - return { - type: "loaded", - value: { - adr: { - frontmatter: { - id: "TEST-001", - title: "Test", - domain: "general", - rules: true, - ...overrides, - }, - body: "", - filePath: "/test.md", - }, - ruleSet, - }, - }; - } - - test("ctx.glob excludes gitignored files by default", async () => { - let globResults: string[] = []; - - const loaded = makeLoadedAdr( - {}, - { - rules: { - "glob-gitignore-test": { - description: "Test glob respects gitignore", - async check(ctx) { - globResults = await ctx.glob("**/*.{ts,js}"); - }, - }, - }, - } - ); - - await runChecks(tempDir, [loaded]); - expect(globResults).toContain("src/app.ts"); - expect(globResults).not.toContain("dist/app.js"); - }); - - test("ctx.glob includes gitignored files when respectGitignore is false", async () => { - let globResults: string[] = []; - - const loaded = makeLoadedAdr( - { respectGitignore: false }, - { - rules: { - "glob-no-gitignore-test": { - description: "Test glob ignores gitignore", - async check(ctx) { - globResults = await ctx.glob("**/*.{ts,js}"); - }, - }, - }, - } - ); - - await runChecks(tempDir, [loaded]); - expect(globResults).toContain("src/app.ts"); - expect(globResults).toContain("dist/app.js"); - }); - - test("ctx.grepFiles excludes gitignored files by default", async () => { - let matches: GrepMatch[] = []; - - const loaded = makeLoadedAdr( - {}, - { - rules: { - "grep-gitignore-test": { - description: "Test grepFiles respects gitignore", - async check(ctx) { - matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); - }, - }, - }, - } - ); - - await runChecks(tempDir, [loaded]); - expect(matches).toHaveLength(1); - expect(matches[0].file).toBe("src/app.ts"); - }); - - test("ctx.grepFiles includes gitignored files when respectGitignore is false", async () => { - let matches: GrepMatch[] = []; - - const loaded = makeLoadedAdr( - { respectGitignore: false }, - { - rules: { - "grep-no-gitignore-test": { - description: "Test grepFiles ignores gitignore", - async check(ctx) { - matches = await ctx.grepFiles(/hello/u, "**/*.{ts,js}"); - }, - }, - }, - } - ); - - await runChecks(tempDir, [loaded]); - expect(matches).toHaveLength(2); - const matchedFiles = matches.map((m) => m.file).sort(); - expect(matchedFiles).toEqual(["dist/app.js", "src/app.ts"]); - }); -}); From f0d6f3c3827719a10bd1c473a10a508399a79239 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 18 May 2026 20:50:49 +0200 Subject: [PATCH 7/7] refactor: move gitignore warnings from runner into resolveScopedFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warnings for misconfigured respectGitignore now live in resolveScopedFiles itself, so any caller gets them — not just runChecks. Add optional adrId to options for contextual warning messages. Add direct resolveScopedFiles tests for both warnings and the adrId label. Signed-off-by: Rhuan Barreto --- src/engine/git-files.ts | 28 +++++++++++++++++-- src/engine/runner.ts | 28 ++----------------- tests/engine/git-files.test.ts | 50 +++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index c5280e13..1e998ff7 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -2,7 +2,7 @@ // Copyright 2026 Archgate /** Git file-listing utilities for ADR scope resolution and change detection. */ -import { logDebug } from "../helpers/log"; +import { logDebug, logWarn } from "../helpers/log"; /** * Run a git command using Bun.spawn (cross-platform, no shell). @@ -62,10 +62,19 @@ export function getGitTrackedFiles( export async function resolveScopedFiles( projectRoot: string, adrFileGlobs?: string[], - options?: { respectGitignore?: boolean } + options?: { respectGitignore?: boolean; adrId?: string } ): Promise { - const patterns = adrFileGlobs?.length ? adrFileGlobs : ["**/*"]; + const hasExplicitFiles = (adrFileGlobs?.length ?? 0) > 0; + const patterns = hasExplicitFiles ? adrFileGlobs! : ["**/*"]; const respectGitignore = options?.respectGitignore !== false; + const label = options?.adrId ? `ADR ${options.adrId}` : "resolveScopedFiles"; + + if (!respectGitignore && !hasExplicitFiles) { + logWarn( + `${label}: respectGitignore is false without a files scope — scanning all files including node_modules/, .git/, etc. This may be very slow. Add a files pattern to narrow the scope.` + ); + } + const trackedFiles = respectGitignore ? await getGitTrackedFiles(projectRoot) : null; @@ -93,6 +102,19 @@ export async function resolveScopedFiles( "from patterns:", patterns.join(", ") ); + + // Warn when explicit file patterns yield zero results due to gitignore + if (respectGitignore && hasExplicitFiles && all.length === 0) { + const unfiltered = await resolveScopedFiles(projectRoot, adrFileGlobs, { + respectGitignore: false, + }); + if (unfiltered.length > 0) { + logWarn( + `${label}: files patterns matched ${unfiltered.length} file(s) but all are excluded by .gitignore. Set respectGitignore: false in the ADR frontmatter to include them.` + ); + } + } + return all; } diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 435265b1..3b7aef44 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -9,7 +9,7 @@ import type { RuleReport, ViolationDetail, } from "../formats/rules"; -import { logDebug, logWarn } from "../helpers/log"; +import { logDebug } from "../helpers/log"; import { resolveScopedFiles, getStagedFiles, @@ -228,36 +228,12 @@ export async function runChecks( const respectGitignore = adr.frontmatter.respectGitignore !== false; const trackedFiles = respectGitignore ? allTrackedFiles : null; - if (!respectGitignore && !adr.frontmatter.files?.length) { - logWarn( - `ADR ${adr.frontmatter.id}: respectGitignore is false without a files scope — scanning all files including node_modules/, .git/, etc. This may be very slow. Add a files pattern to narrow the scope.` - ); - } - let scopedFiles = await resolveScopedFiles( projectRoot, adr.frontmatter.files, - { respectGitignore } + { respectGitignore, adrId: adr.frontmatter.id } ); - // Warn when explicit file patterns yield zero results due to gitignore - if ( - respectGitignore && - adr.frontmatter.files?.length && - scopedFiles.length === 0 - ) { - const unfiltered = await resolveScopedFiles( - projectRoot, - adr.frontmatter.files, - { respectGitignore: false } - ); - if (unfiltered.length > 0) { - logWarn( - `ADR ${adr.frontmatter.id}: files patterns matched ${unfiltered.length} file(s) but all are excluded by .gitignore. Set respectGitignore: false in the ADR frontmatter to include them.` - ); - } - } - // When files are specified, narrow scopedFiles to the intersection if (filterFiles) { scopedFiles = scopedFiles.filter((f) => filterFiles.has(f)); diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 4bf766a2..8be7d997 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -163,5 +163,53 @@ describe("git-files", () => { expect(files).toContain(".github/workflows/release.yml"); expect(files).toContain(".github/workflows/ci.yml"); }); + + test("warns when respectGitignore is false without files scope", async () => { + await git(["init"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + await resolveScopedFiles(tempDir, [], { respectGitignore: false }); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect( + warnCalls.some((msg) => + msg.includes("respectGitignore is false without a files scope") + ) + ).toBe(true); + warnSpy.mockRestore(); + }); + + test("warns when file patterns match only gitignored files", async () => { + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync(join(tempDir, "dist", "app.js"), "var x = 1;"); + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", ".gitignore"], tempDir); + await git(["commit", "-m", "init"], tempDir); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const files = await resolveScopedFiles(tempDir, ["dist/**/*.js"]); + expect(files).toHaveLength(0); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect( + warnCalls.some((msg) => msg.includes("all are excluded by .gitignore")) + ).toBe(true); + warnSpy.mockRestore(); + }); + + test("includes adrId in warning when provided", async () => { + await git(["init"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + await resolveScopedFiles(tempDir, [], { + respectGitignore: false, + adrId: "BUILD-001", + }); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect(warnCalls.some((msg) => msg.includes("ADR BUILD-001"))).toBe(true); + warnSpy.mockRestore(); + }); }); });