Skip to content
Open
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
77 changes: 77 additions & 0 deletions src/core/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,83 @@ 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/<name>/ even when <name> 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("push option normalization", () => {
Expand Down
47 changes: 46 additions & 1 deletion src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,49 @@ 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",
"dist",
"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([
Expand Down Expand Up @@ -781,7 +817,16 @@ async function walkFiles(root: string, directory: string): Promise<string[]> {

for (const entry of dirEntries) {
if (entry.isDirectory()) {
if (IGNORED_REF_DIRS.has(entry.name)) continue;
// 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 relativePath = relative(root, join(current, entry.name))
.split("\\")
.join("/");
const isUnderFeaturesRoot =
relativePath === FEATURE_SPEC_PREFIX.replace(/\/$/u, "") ||
relativePath.startsWith(FEATURE_SPEC_PREFIX);
if (!isUnderFeaturesRoot && IGNORED_REF_DIRS.has(entry.name)) continue;
queue.push(join(current, entry.name));
continue;
}
Expand Down