From dcc14675c5b1c13b860412db9e3ede5504dcfa9c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 25 Apr 2026 01:38:56 +0200 Subject: [PATCH] fix(engine): match dot-prefixed dirs in ctx.glob() and ADR file scopes (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Bun.Glob.scan({ dot: false })` silently dropped dot-prefixed path segments (`.github/`, `.husky/`, `.vscode/`) — even when the pattern explicitly named the directory. This turned rules targeting `.github/workflows/*.yml` into no-ops on Windows while still working on Linux CI, because Bun's glob behavior differs across platforms. Change `dot: false` → `dot: true` in three call sites: - `ctx.glob()` in src/engine/runner.ts - `ctx.grepFiles()` in src/engine/runner.ts - `resolveScopedFiles()` in src/engine/git-files.ts The git-tracked-files filter in `resolveScopedFiles` already excludes gitignored files, so `dot: true` does not surface unwanted entries. Closes #222 --- .../agent-memory/archgate-developer/MEMORY.md | 1 + src/engine/git-files.ts | 5 +- src/engine/runner.ts | 9 ++- tests/engine/git-files.test.ts | 23 ++++++ tests/engine/runner.test.ts | 74 +++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 5ea28e44..b97f417d 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -39,6 +39,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **Git credential tests need system-level isolation on Windows** — Overriding `Bun.env.HOME` is NOT sufficient to isolate `git credential fill/approve` calls in tests. Windows Credential Manager is a system-level API, not file-based. Tests MUST set `Bun.env.GIT_CONFIG_NOSYSTEM = "1"` and `Bun.env.GIT_CONFIG_GLOBAL = ` to prevent git from reading the real credential helper config. Without this, tests on machines with stored credentials will pick up real tokens. - **GCM prompt suppression requires 5 env vars** — `GIT_TERMINAL_PROMPT=0` alone does NOT prevent Git Credential Manager (GCM) from showing GUI prompts on Windows or askpass prompts on Linux. The full set for `gitCredentialEnv()` in `src/helpers/credential-store.ts` is: `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, `GCM_GUI_PROMPT=false`, `GIT_ASKPASS=""`, `SSH_ASKPASS=""`. Omitting any one can trigger unexpected prompts in editor contexts where the CLI runs as a subprocess. - **Module-level `{ ...Bun.env }` captures env at import time** — Spreading `Bun.env` into a module-level constant freezes the env snapshot. Tests that override `Bun.env.HOME` after import won't affect the constant. Fix: use a function that returns `{ ...Bun.env, ... }` on each call so it picks up test-time overrides. Applied in `src/helpers/credential-store.ts`. +- **`Bun.Glob.scan({ dot: false })` silently drops dot-prefixed segments — even on explicit paths** — `dot: false` (the default) skips matches whose path contains a `.`-prefixed segment, including patterns that explicitly name the dir (e.g. `.github/workflows/release.yml`). Behavior also varies across platforms — Windows reliably drops the match while Linux can match the same pattern, so a rule appears to "work in CI" but no-ops locally. For code repos where `.github/`, `.husky/`, `.vscode/` are first-class source dirs, ALWAYS pass `dot: true` to `Bun.Glob.scan()`. Applied in `src/engine/runner.ts` (`ctx.glob`, `ctx.grepFiles`) and `src/engine/git-files.ts` (`resolveScopedFiles`). See archgate/cli#222. ## Validation Pipeline diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index a3d50fb5..a308830b 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -73,7 +73,10 @@ export async function resolveScopedFiles( patterns.map(async (pattern) => { const glob = new Bun.Glob(pattern); const files: string[] = []; - for await (const file of glob.scan({ cwd: projectRoot, dot: false })) { + // dot: true so ADR `files:` globs can target dot-prefixed source dirs + // like `.github/`, `.husky/`, `.vscode/`. The git-tracked-files filter + // below already excludes ignored files. See archgate/cli#222. + for await (const file of glob.scan({ cwd: projectRoot, dot: true })) { const normalized = file.replaceAll("\\", "/"); if (trackedFiles && !trackedFiles.has(normalized)) continue; files.push(normalized); diff --git a/src/engine/runner.ts b/src/engine/runner.ts index cb9f90c9..8510028c 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -103,7 +103,10 @@ function createRuleContext( safeGlob(pattern); const g = new Bun.Glob(pattern); const results: string[] = []; - for await (const file of g.scan({ cwd: projectRoot, dot: false })) { + // dot: true so rules can target dot-prefixed paths like `.github/`, + // `.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("\\", "/")); } return results.sort(); @@ -135,7 +138,9 @@ function createRuleContext( const g = new Bun.Glob(fileGlob); const allMatches: GrepMatch[] = []; - for await (const file of g.scan({ cwd: projectRoot, dot: false })) { + // dot: true to match dot-prefixed source dirs (`.github/`, etc.). + // See https://github.com/archgate/cli/issues/222. + for await (const file of g.scan({ cwd: projectRoot, dot: true })) { const normalized = file.replaceAll("\\", "/"); const absPath = safePath(projectRoot, file); try { diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index a22e2af9..5133a484 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -93,5 +93,28 @@ describe("git-files", () => { expect(files).toContain("src/foo.ts"); expect(files).not.toContain("src/bar.md"); }); + + // 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 + // empty scopedFiles on Windows local-dev runs. + test("resolves dot-prefixed paths (regression archgate/cli#222)", async () => { + await git(["init"], tempDir); + mkdirSync(join(tempDir, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(tempDir, ".github", "workflows", "release.yml"), + "name: release\n" + ); + writeFileSync( + join(tempDir, ".github", "workflows", "ci.yml"), + "name: ci\n" + ); + await git(["add", "."], tempDir); + const files = await resolveScopedFiles(tempDir, [ + ".github/workflows/*.yml", + ]); + expect(files).toContain(".github/workflows/release.yml"); + expect(files).toContain(".github/workflows/ci.yml"); + }); }); }); diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts index 3ec79fdb..2e9e9ecf 100644 --- a/tests/engine/runner.test.ts +++ b/tests/engine/runner.test.ts @@ -182,6 +182,80 @@ describe("runChecks", () => { expect(foundFiles).toContain("src/b.ts"); }); + // Regression: archgate/cli#222 — ctx.glob() must match dot-prefixed source + // dirs like `.github/`, `.husky/`, `.vscode/`. Bun.Glob with `dot: false` + // silently drops these matches on Windows, turning rules targeting + // `.github/workflows/*.yml` into no-ops on local Windows runs while still + // working on Linux CI. + test("glob matches dot-prefixed directories (regression archgate/cli#222)", async () => { + mkdirSync(join(tempDir, ".github", "workflows"), { recursive: true }); + writeFileSync(join(tempDir, ".github", "workflows", "release.yml"), ""); + writeFileSync(join(tempDir, ".github", "workflows", "ci.yml"), ""); + + let exactMatch: string[] = []; + let starMatch: string[] = []; + let recursiveMatch: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "dot-glob-test": { + description: "Test dot-prefixed glob", + async check(ctx) { + exactMatch = await ctx.glob(".github/workflows/release.yml"); + starMatch = await ctx.glob(".github/workflows/*.yml"); + recursiveMatch = await ctx.glob(".github/**/*.yml"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(exactMatch).toEqual([".github/workflows/release.yml"]); + expect(starMatch).toContain(".github/workflows/release.yml"); + expect(starMatch).toContain(".github/workflows/ci.yml"); + expect(recursiveMatch).toContain(".github/workflows/release.yml"); + expect(recursiveMatch).toContain(".github/workflows/ci.yml"); + }); + + test("grepFiles matches dot-prefixed directories (regression archgate/cli#222)", async () => { + mkdirSync(join(tempDir, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(tempDir, ".github", "workflows", "release.yml"), + "name: release\non: push\n" + ); + + let matches: Array<{ + file: string; + line: number; + column: number; + content: string; + }> = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "dot-grep-test": { + description: "Test grepFiles dot-prefix", + async check(ctx) { + matches = await ctx.grepFiles( + /release/, + ".github/workflows/*.yml" + ); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(1); + expect(matches[0].file).toBe(".github/workflows/release.yml"); + }); + test("grepFiles helper works in rule context", async () => { writeFileSync(join(tempDir, "src", "a.ts"), 'const x = "hello";\n'); writeFileSync(join(tempDir, "src", "b.ts"), "const y = 42;\n");