diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 9bc506af..cc5d09a8 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -57,6 +57,8 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **macOS `/var` → `/private/var` symlink breaks temp dir path comparisons in tests** — On macOS, `/var` is a symlink to `/private/var`. `mkdtempSync(join(tmpdir(), ...))` returns `/var/folders/...` but `process.cwd()` after `chdir()` resolves the symlink to `/private/var/folders/...`. Tests that compare `tempDir` against paths derived from `process.cwd()` or `findProjectRoot()` will fail. Fix: always wrap `mkdtempSync` with `realpathSync` in test setup: `tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-test-")))`. This normalizes the path upfront. Discovered in v0.38.0/v0.39.0 release builds — PR CI runs on ubuntu-latest only, so macOS-specific issues are invisible until the release workflow. - **jq on Windows Git Bash emits CRLF line endings** — `jq -r` output ends lines with `\r\n`. In sh scripts, command substitution strips only trailing newlines, so parsed values carry a trailing `\r` (and multi-line lists get `\r` on every entry except the last). Symptom: charset validations reject valid values, or URLs get an embedded CR. Fix: pipe jq (and grep/sed fallbacks) through `tr -d '\r'`. Bit us in `install.sh` resolve_version — the release-walk skipped every tag except the last one. - **Don't test that well-known tools exist on PATH** — Tests like `expect(resolveCommand("bun")).toBe("bun")` assert CI environment state, not application logic. They fail when the runner installs tools via shims (e.g., proto on macOS ARM64 where `Bun.which` returns null). Delete such tests entirely — the "returns null for non-existent command" tests already cover `resolveCommand`'s actual logic, and WSL-specific tests cover the `.exe` fallback path. +- **`Bun.Glob.scan()` silently fails for brace patterns with path separators** — `new Bun.Glob("svc/{src/env.ts,env.ts}").scan(...)` returns zero results (no error), while `.match()` works correctly for the same pattern. The match engine was rewritten in Bun 1.2.3 (PR #16824) to expand braces recursively, but the scanner (`GlobWalker.zig`) was not updated. Filed upstream as [oven-sh/bun#32596](https://github.com/oven-sh/bun/issues/32596). Workaround: `expandBracePattern()` in `src/engine/runner.ts` pre-expands brace groups containing `/` before scanning. Applied in `ctx.glob()`, `ctx.grepFiles()`, and `resolveScopedFiles()`. +- **ARCH-020 `glob-scan-dot` rule triggers on `.scan()` in comments** — The rule uses regex `/\.scan\(([^)]*)\)/gu` which matches `.scan()` text in JSDoc/inline comments (e.g., `Bun.Glob.scan() silently...`). Workaround: rephrase comments to avoid the exact `.scan()` text — e.g., "Bun.Glob scanning silently..." instead of "Bun.Glob.scan() silently...". ## Translation Quality diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 791f5ea4..e4efb274 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -5,6 +5,7 @@ import { logDebug, logWarn } from "../helpers/log"; import { ensureBaseBranch } from "../helpers/project-config"; import { UserError } from "../helpers/user-error"; +import { expandBracePattern } from "./runner"; /** Warn when an ADR's resolved file scope exceeds this many files. */ export const SCOPE_FILE_WARN_THRESHOLD = 1000; @@ -93,9 +94,13 @@ export async function resolveScopedFiles( ? await getGitTrackedFiles(projectRoot) : null; + // Expand brace patterns with path separators that Bun.Glob scanning drops. + // See https://github.com/oven-sh/bun/issues/32596. + const expanded = patterns.flatMap((p) => expandBracePattern(p)); + const scanStart = performance.now(); const results = await Promise.all( - patterns.map(async (pattern) => { + expanded.map(async (pattern) => { const glob = new Bun.Glob(pattern); const files: string[] = []; // dot: true so ADR `files:` globs can target dot-prefixed source dirs diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 50b6c81d..04134ce7 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -71,6 +71,39 @@ function safeGlob(pattern: string): void { ); } } +/** + * Expand brace patterns that contain path separators into separate patterns. + * + * Bun.Glob scanning silently returns empty results for brace groups whose + * alternatives contain `/` (e.g. `svc/{src/env.ts,env.ts}`). match() handles + * them correctly — only the scanner is broken. Filed upstream as + * https://github.com/oven-sh/bun/issues/32596. + * + * This function detects `{alt1,alt2,...}` groups where at least one alternative + * contains `/` and expands them into separate patterns so each one can be + * scanned individually. Braces whose alternatives are all simple names (no `/`) + * are left for Bun.Glob to handle natively. + */ +export function expandBracePattern(pattern: string): string[] { + const match = pattern.match(/^(.*?)\{([^{}]+)\}(.*)$/u); + if (!match) return [pattern]; + + const [, prefix, alternatives, suffix] = match; + if (!alternatives.includes("/")) { + // This brace group is safe for Bun.Glob, but check the suffix for others. + const expandedSuffixes = expandBracePattern(suffix); + if (expandedSuffixes.length === 1 && expandedSuffixes[0] === suffix) { + return [pattern]; + } + return expandedSuffixes.map((s) => `${prefix}{${alternatives}}${s}`); + } + + const parts = alternatives.split(","); + return parts.flatMap((part) => + expandBracePattern(`${prefix}${part}${suffix}`) + ); +} + const RULE_TIMEOUT_MS = 30_000; export interface RuleResult { @@ -121,17 +154,23 @@ function createRuleContext( async glob(pattern: string): Promise { safeGlob(pattern); - const g = new Bun.Glob(pattern); - const results: string[] = []; - // 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 })) { - const normalized = file.replaceAll("\\", "/"); - if (trackedFiles && !trackedFiles.has(normalized)) continue; - results.push(normalized); + // Expand brace patterns with path separators that Bun.Glob scanning drops. + // See https://github.com/oven-sh/bun/issues/32596. + const patterns = expandBracePattern(pattern); + const seen = new Set(); + for (const p of patterns) { + const g = new Bun.Glob(p); + // 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. + // oxlint-disable-next-line no-await-in-loop -- sequential scan per expanded brace alternative + for await (const file of g.scan({ cwd: projectRoot, dot: true })) { + const normalized = file.replaceAll("\\", "/"); + if (trackedFiles && !trackedFiles.has(normalized)) continue; + seen.add(normalized); + } } - return results.sort(); + return [...seen].sort(); }, async grep(file: string, pattern: RegExp): Promise { @@ -157,17 +196,24 @@ function createRuleContext( async grepFiles(pattern: RegExp, fileGlob: string): Promise { safeGlob(fileGlob); - const g = new Bun.Glob(fileGlob); + // Expand brace patterns with path separators that Bun.Glob scanning drops. + // See https://github.com/oven-sh/bun/issues/32596. + const globs = expandBracePattern(fileGlob); // Collect paths first, then read in parallel batches for I/O throughput. // dot: true to match dot-prefixed source dirs (`.github/`, etc.). // See https://github.com/archgate/cli/issues/222. - const files: string[] = []; - for await (const file of g.scan({ cwd: projectRoot, dot: true })) { - const normalized = file.replaceAll("\\", "/"); - if (trackedFiles && !trackedFiles.has(normalized)) continue; - files.push(normalized); + const seen = new Set(); + for (const p of globs) { + const g = new Bun.Glob(p); + // oxlint-disable-next-line no-await-in-loop -- sequential scan per expanded brace alternative + for await (const file of g.scan({ cwd: projectRoot, dot: true })) { + const normalized = file.replaceAll("\\", "/"); + if (trackedFiles && !trackedFiles.has(normalized)) continue; + seen.add(normalized); + } } + const files = [...seen]; const BATCH_SIZE = 32; const allMatches: GrepMatch[] = []; diff --git a/tests/engine/expand-brace-pattern.test.ts b/tests/engine/expand-brace-pattern.test.ts new file mode 100644 index 00000000..94085fad --- /dev/null +++ b/tests/engine/expand-brace-pattern.test.ts @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, beforeEach, afterEach } 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 { expandBracePattern, runChecks } from "../../src/engine/runner"; +import type { AdrDocument } from "../../src/formats/adr"; +import type { RuleSet } from "../../src/formats/rules"; +import { safeRmSync } from "../test-utils"; + +describe("expandBracePattern", () => { + test("returns pattern unchanged when no braces", () => { + expect(expandBracePattern("src/**/*.ts")).toEqual(["src/**/*.ts"]); + }); + + test("returns pattern unchanged when braces have no path separators", () => { + expect(expandBracePattern("src/{a,b}.ts")).toEqual(["src/{a,b}.ts"]); + }); + + test("expands braces containing path separators", () => { + const result = expandBracePattern("svc/{src/env.ts,env.ts}"); + expect(result).toEqual(["svc/src/env.ts", "svc/env.ts"]); + }); + + test("expands braces with multiple alternatives containing path separators", () => { + const result = expandBracePattern("svc/{src/env.ts,src/lib/env.ts,env.ts}"); + expect(result).toEqual([ + "svc/src/env.ts", + "svc/src/lib/env.ts", + "svc/env.ts", + ]); + }); + + test("expands braces with suffix after closing brace", () => { + const result = expandBracePattern("a/{b/c,d}/e.ts"); + expect(result).toEqual(["a/b/c/e.ts", "a/d/e.ts"]); + }); + + test("handles pattern with no prefix before braces", () => { + const result = expandBracePattern("{src/a,lib/b}.ts"); + expect(result).toEqual(["src/a.ts", "lib/b.ts"]); + }); + + test("expands later brace group when first group has no path separators", () => { + const result = expandBracePattern("a/{b,c}/{d/e,f}"); + expect(result).toEqual(["a/{b,c}/d/e", "a/{b,c}/f"]); + }); +}); + +// Regression: oven-sh/bun#32596 — Bun.Glob.scan() silently returns empty +// results for brace patterns whose alternatives contain path separators. +// ctx.glob() and ctx.grepFiles() pre-expand such patterns to work around it. +describe("brace expansion in rule context (regression oven-sh/bun#32596)", () => { + let tempDir: string; + + const EMPTY_RULE_SET: RuleSet = { rules: {} }; + + function makeLoadedAdr( + overrides: Partial = {}, + ruleSet: RuleSet = EMPTY_RULE_SET + ): LoadResult { + return { + type: "loaded", + value: { + adr: { + frontmatter: { + id: "TEST-001", + title: "Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }, + }; + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-brace-test-")); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + safeRmSync(tempDir); + }); + + test("ctx.glob handles brace patterns with path separators", async () => { + mkdirSync(join(tempDir, "svc", "src"), { recursive: true }); + writeFileSync(join(tempDir, "svc", "src", "env.ts"), ""); + writeFileSync(join(tempDir, "svc", "env.ts"), ""); + + let foundFiles: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "brace-glob-test": { + description: "Test brace expansion with path seps", + async check(ctx) { + foundFiles = await ctx.glob("svc/{src/env.ts,env.ts}"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(foundFiles).toContain("svc/src/env.ts"); + expect(foundFiles).toContain("svc/env.ts"); + expect(foundFiles).toHaveLength(2); + }); + + test("ctx.grepFiles handles brace patterns with path separators", async () => { + mkdirSync(join(tempDir, "svc", "src"), { recursive: true }); + writeFileSync( + join(tempDir, "svc", "src", "env.ts"), + "export const A = 1;\n" + ); + writeFileSync(join(tempDir, "svc", "env.ts"), "export const B = 2;\n"); + + let matches: Array<{ file: string }> = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "brace-grep-test": { + description: "Test grepFiles brace expansion with path seps", + async check(ctx) { + matches = await ctx.grepFiles( + /export/u, + "svc/{src/env.ts,env.ts}" + ); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(matches).toHaveLength(2); + const files = matches.map((m) => m.file); + expect(files).toContain("svc/src/env.ts"); + expect(files).toContain("svc/env.ts"); + }); + + test("ctx.glob still works with simple braces (no path separators)", async () => { + writeFileSync(join(tempDir, "src", "a.ts"), ""); + writeFileSync(join(tempDir, "src", "b.ts"), ""); + + let foundFiles: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "simple-brace-test": { + description: "Test simple braces still work", + async check(ctx) { + foundFiles = await ctx.glob("src/{a,b}.ts"); + }, + }, + }, + } + ); + + await runChecks(tempDir, [loaded]); + expect(foundFiles).toContain("src/a.ts"); + expect(foundFiles).toContain("src/b.ts"); + expect(foundFiles).toHaveLength(2); + }); +}); diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 97a9b0db..287a20a7 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -493,5 +493,25 @@ describe("git-files", () => { warnSpy.mockRestore(); } }); + + // Regression: oven-sh/bun#32596 — Bun.Glob.scan() silently returns empty + // results for brace patterns whose alternatives contain path separators. + test("resolves brace patterns with path separators (regression oven-sh/bun#32596)", async () => { + await git(["init"], tempDir); + mkdirSync(join(tempDir, "svc", "src"), { recursive: true }); + writeFileSync( + join(tempDir, "svc", "src", "env.ts"), + "export const A = 1;" + ); + writeFileSync(join(tempDir, "svc", "env.ts"), "export const B = 2;"); + await git(["add", "."], tempDir); + + const files = await resolveScopedFiles(tempDir, [ + "svc/{src/env.ts,env.ts}", + ]); + expect(files).toContain("svc/src/env.ts"); + expect(files).toContain("svc/env.ts"); + expect(files).toHaveLength(2); + }); }); });