diff --git a/src/core/push.test.ts b/src/core/push.test.ts index 2321358..ce88062 100644 --- a/src/core/push.test.ts +++ b/src/core/push.test.ts @@ -777,6 +777,238 @@ describe("push planning", () => { "alpha", ]); }); + + test("scanPushRepo skips extended build-output directories like target/, build/, .next/", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: product-a\ncomponents:\n MAIN:\n requirements:\n 1: Alpha requirement\n`, + "src/main.ts": `// alpha.MAIN.1\n`, + // These should all be skipped during ref scanning. + "target/release/foo.rs": `// alpha.MAIN.1 leak from target\n`, + "build/output.js": `// alpha.MAIN.1 leak from build\n`, + ".next/cache/x.js": `// alpha.MAIN.1 leak from .next\n`, + "vendor/pkg/lib.go": `// alpha.MAIN.1 leak from vendor\n`, + "__pycache__/m.pyc": `// alpha.MAIN.1 leak from __pycache__\n`, + }); + + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + + // Only the legitimate src/main.ts reference should be discovered. + expect(scan.references.map((entry) => entry.path)).toEqual([ + "src/main.ts:1", + ]); + }); + + test("scanPushRepo descends into src-tauri/src so Rust source ACIDs are discovered, while src-tauri/target is still skipped via the target rule", async () => { + const root = await createRepoFixture({ + "features/tauri.feature.yaml": `feature:\n name: tauri\n product: app\ncomponents:\n RUST:\n requirements:\n 1: Native handler\n`, + // Real Rust source — should be scanned. + "src-tauri/src/main.rs": `// tauri.RUST.1\n`, + // Cargo build artifacts — must be skipped. + "src-tauri/target/release/app": `// tauri.RUST.1 leak\n`, + }); + + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/tauri.feature.yaml": "t1", + }); + + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + + expect(scan.references.map((entry) => entry.path)).toEqual([ + "src-tauri/src/main.rs:1", + ]); + }); + + test("scanPushRepo descends into features// even when matches a build-output directory (e.g. features/build/)", async () => { + const root = await createRepoFixture({ + // Spec located under features/build/ — should be discovered despite + // "build" being in IGNORED_REF_DIRS. + "features/build/login.feature.yaml": `feature:\n name: login\n product: build\ncomponents:\n FORM:\n requirements:\n 1: Login form\n`, + "features/runtime/checkout.feature.yaml": `feature:\n name: checkout\n product: runtime\ncomponents:\n CART:\n requirements:\n 1: Cart flow\n`, + }); + + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/build/login.feature.yaml": "b1", + "log -1 --format=%H -- features/runtime/checkout.feature.yaml": "r1", + }); + + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + + expect(scan.specs.map((entry) => entry.featureName).sort()).toEqual([ + "checkout", + "login", + ]); + }); + + describe(".acaiignore support", () => { + test("missing .acaiignore behaves as a no-op", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + "src/a.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual(["src/a.ts:1"]); + }); + + test("segment patterns ignore matching directories anywhere in the tree", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "large-data\n", + "large-data/dump.ts": `// alpha.M.1\n`, + "src/large-data/x.ts": `// alpha.M.1\n`, + "src/main.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual(["src/main.ts:1"]); + }); + + test("prefix patterns (containing /) match relative path prefixes only", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "src/legacy/\n", + "src/legacy/old.ts": `// alpha.M.1\n`, + "src/new.ts": `// alpha.M.1\n`, + "other/legacy/keep.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path).sort()).toEqual([ + "other/legacy/keep.ts:1", + "src/new.ts:1", + ]); + }); + + test("leading slash anchors a pattern to the repo root", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "/build-cache\n", + "build-cache/x.ts": `// alpha.M.1\n`, + "src/build-cache/y.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual([ + "src/build-cache/y.ts:1", + ]); + }); + + test("single-segment * glob matches any single segment", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "*.egg-info\n", + "foo.egg-info/PKG-INFO": `// alpha.M.1\n`, + "bar.egg-info/PKG-INFO": `// alpha.M.1\n`, + "src/main.py": `# alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual(["src/main.py:1"]); + }); + + test("comments and blank lines are skipped", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "\n# this is a comment\n\nlegacy\n # leading-spaces also a comment\n", + "legacy/old.ts": `// alpha.M.1\n`, + "src/main.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual(["src/main.ts:1"]); + }); + + test("negation lines (!pattern) are skipped (unsupported, no crash)", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + // `legacy` ignores legacy/, the negation line is ignored as "unsupported". + ".acaiignore": "legacy\n!legacy/keep.ts\n", + "legacy/old.ts": `// alpha.M.1\n`, + "legacy/keep.ts": `// alpha.M.1\n`, + "src/main.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + // Negation is unsupported, so legacy/keep.ts stays excluded. + expect(scan.references.map((r) => r.path)).toEqual(["src/main.ts:1"]); + }); + + test("file-level patterns (e.g. snapshot.json) ignore matching files", async () => { + const root = await createRepoFixture({ + "features/alpha.feature.yaml": `feature:\n name: alpha\n product: a\ncomponents:\n M:\n requirements:\n 1: r\n`, + ".acaiignore": "snapshot.json\n", + "snapshot.json": `{ "ref": "alpha.M.1" }\n`, + "src/main.ts": `// alpha.M.1\n`, + }); + const runner = createGitRunner({ + "rev-parse --show-toplevel": root, + "log -1 --format=%H -- features/alpha.feature.yaml": "a1", + }); + const scan = await scanPushRepo({ + cwd: root, + runner: runner as never, + }); + expect(scan.references.map((r) => r.path)).toEqual(["src/main.ts:1"]); + }); + }); }); describe("push option normalization", () => { diff --git a/src/core/push.ts b/src/core/push.ts index e9930e0..73a2267 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -104,6 +104,7 @@ const FEATURE_SPEC_SUFFIX = ".feature.yaml"; const FEATURE_SPEC_PREFIX = "features/"; const UNSCOPED_REFS_BUCKET = ""; const IGNORED_REF_DIRS = new Set([ + // Existing entries ".git", "node_modules", "coverage", @@ -111,6 +112,41 @@ const IGNORED_REF_DIRS = new Set([ "tmp", ".agents", "states", + // Build outputs (added: prevent V8 string-length overflow on large repos) + "target", // Rust/Cargo, Java/Maven, Scala/sbt + "build", // CMake, Gradle, generic + "out", // Next.js export, generic + "bin", // .NET, generic + "obj", // .NET + ".next", // Next.js + ".nuxt", // Nuxt + ".svelte-kit", // SvelteKit + ".turbo", // Turborepo + ".cache", // Many tools + ".parcel-cache", // Parcel + ".vite", // Vite + ".astro", // Astro + "DerivedData", // Xcode + "Pods", // iOS / CocoaPods + // Note: do NOT add `src-tauri` here. It contains real Rust source under + // `src-tauri/src/**` that should be scanned. The heavy artifact is + // `src-tauri/target/`, which is already excluded by the `target` entry above. + // Python + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + // Note: this set uses exact-name matching. Glob patterns like `*.egg-info` + // must be expressed via `.acaiignore` (not yet supported in this PR). + // Go / PHP + "vendor", + // IDE / OS junk + ".idea", + ".vscode", + ".DS_Store", ]); const TEST_PATH_SEGMENTS = new Set(["test", "tests", "__tests__"]); const REF_SCAN_EXCLUDED_SUFFIXES = new Set([ @@ -760,10 +796,103 @@ export function buildPushPayloads( } async function listRepoFiles(cwd: string): Promise { - return walkFiles(cwd, cwd); + const ignoreMatcher = await loadAcaiIgnore(cwd); + return walkFiles(cwd, cwd, ignoreMatcher); } -async function walkFiles(root: string, directory: string): Promise { +/** + * Loads `.acaiignore` from the repo root and returns a matcher function. + * Falls back to a no-op matcher if the file does not exist. + * + * Supports a subset of gitignore syntax: + * - blank lines and lines starting with `#` are ignored + * - lines without `/` match any path segment (e.g. `target` matches `foo/target/bar`) + * - lines with `/` match path prefixes relative to the repo root (e.g. `src/legacy/`) + * - trailing `/` is stripped (we always match against directory entries) + * - leading `/` anchors the pattern to the repo root + * - `*` is treated as `[^/]*` (single-segment glob) + * + * Negation (`!pattern`) and `**` are intentionally not supported in this minimal version. + */ +async function loadAcaiIgnore( + cwd: string, +): Promise<(relativePath: string, isDirectory: boolean) => boolean> { + const { readFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + + let raw: string; + try { + raw = await readFile(join(cwd, ".acaiignore"), "utf8"); + } catch { + return () => false; + } + + const segmentPatterns: RegExp[] = []; // match any single path segment + const prefixPatterns: RegExp[] = []; // match relative path prefix + + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("!")) continue; // negation not supported + + const stripped = line.replace(/\/+$/u, ""); // remove trailing slash + const anchored = stripped.startsWith("/"); + const body = anchored ? stripped.slice(1) : stripped; + if (!body) continue; + + const regex = globToRegExp(body); + + if (anchored || body.includes("/")) { + prefixPatterns.push(regex); + } else { + segmentPatterns.push(regex); + } + } + + if (segmentPatterns.length === 0 && prefixPatterns.length === 0) { + return () => false; + } + + return (relativePath, _isDirectory) => { + const segments = relativePath.split("/"); + for (const segment of segments) { + if (!segment) continue; + for (const pattern of segmentPatterns) { + if (pattern.test(segment)) return true; + } + } + for (const pattern of prefixPatterns) { + if (pattern.test(relativePath)) return true; + } + return false; + }; +} + +/** + * Converts a gitignore-style pattern to a RegExp anchored to the start of the input. + * Only `*` is treated as a glob (single segment, `[^/]*`). + */ +function globToRegExp(pattern: string): RegExp { + let regex = "^"; + for (const ch of pattern) { + if (ch === "*") { + regex += "[^/]*"; + } else if (/[.+?^${}()|[\]\\]/u.test(ch)) { + regex += `\\${ch}`; + } else { + regex += ch; + } + } + regex += "(?:/.*)?$"; + return new RegExp(regex); +} + +async function walkFiles( + root: string, + directory: string, + ignoreMatcher: (relativePath: string, isDirectory: boolean) => boolean = () => + false, +): Promise { const { readdir } = await import("node:fs/promises"); const { join, relative } = await import("node:path"); const collected: string[] = []; @@ -780,16 +909,24 @@ async function walkFiles(root: string, directory: string): Promise { dirEntries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of dirEntries) { + const fullPath = join(current, entry.name); + const relativePath = relative(root, fullPath).split("\\").join("/"); + if (entry.isDirectory()) { - if (IGNORED_REF_DIRS.has(entry.name)) continue; - queue.push(join(current, entry.name)); + // Always descend into the canonical features/ tree, even if a + // subdirectory shares a name with a build-output directory + // (e.g. `features/build/*.feature.yaml` for a product named "build"). + const isUnderFeaturesRoot = + relativePath === FEATURE_SPEC_PREFIX.replace(/\/$/u, "") || + relativePath.startsWith(FEATURE_SPEC_PREFIX); + if (!isUnderFeaturesRoot && IGNORED_REF_DIRS.has(entry.name)) continue; + if (ignoreMatcher(relativePath, true)) continue; + queue.push(fullPath); continue; } if (!entry.isFile()) continue; - const relativePath = relative(root, join(current, entry.name)) - .split("\\") - .join("/"); + if (ignoreMatcher(relativePath, false)) continue; collected.push(relativePath); } }