Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/engine/git-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
78 changes: 62 additions & 16 deletions src/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const RULE_TIMEOUT_MS = 30_000;

export interface RuleResult {
Expand Down Expand Up @@ -121,17 +154,23 @@ function createRuleContext(

async glob(pattern: string): Promise<string[]> {
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<string>();
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<GrepMatch[]> {
Expand All @@ -157,17 +196,24 @@ function createRuleContext(

async grepFiles(pattern: RegExp, fileGlob: string): Promise<GrepMatch[]> {
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<string>();
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[] = [];
Expand Down
179 changes: 179 additions & 0 deletions tests/engine/expand-brace-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<AdrDocument["frontmatter"]> = {},
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);
});
});
20 changes: 20 additions & 0 deletions tests/engine/git-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});