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
17 changes: 15 additions & 2 deletions packages/loopover-miner/lib/stack-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,24 @@ function parseJson(text: any) {
}
}

/** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */
/** Script-name segments that denote a watch/non-terminating or write-mode variant (#10006). Matched
* case-insensitively against whole `:`-delimited segments only, so `test:fixtures` is kept while
* `test:watch` / `lint:fix` / `test:u` are not. */
const EXCLUDED_SCRIPT_SEGMENTS = new Set(["watch", "dev", "serve", "fix", "write", "update", "u"]);

function isExcludedScriptName(name: string): boolean {
return name.split(":").some((segment) => EXCLUDED_SCRIPT_SEGMENTS.has(segment.toLowerCase()));
}

/** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts.
* Pattern fallback skips watch/write variants and picks the lexicographically smallest survivor (#10006). */
function pickScript(scripts: any, exactName: any, pattern: any) {
const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string");
if (names.includes(exactName)) return exactName;
return names.find((name) => pattern.test(name)) ?? null;
const candidates = names
.filter((name) => pattern.test(name) && !isExcludedScriptName(name))
.sort();
return candidates[0] ?? null;
}

function nodeLockfile(exists: any) {
Expand Down
53 changes: 51 additions & 2 deletions test/unit/miner-stack-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,14 @@ describe("detectRepoStack — Node (#4785)", () => {
it("matches script name variants and ignores non-string script values", () => {
const result = detect({
"package.json": pkg({
scripts: { build: 123, "compile:prod": "tsc -p .", "test:ci": "vitest run", "lint:fix": "eslint --fix", fmt: "biome format" },
// #10006: use lint:ci (not lint:fix) — write-mode :fix segments are excluded from pattern fallback.
scripts: { build: 123, "compile:prod": "tsc -p .", "test:ci": "vitest run", "lint:ci": "eslint .", fmt: "biome format" },
}),
});
expect(result).toMatchObject({
buildCommand: "npm run compile:prod",
testCommand: "npm run test:ci",
lintCommand: "npm run lint:fix",
lintCommand: "npm run lint:ci",
formatCommand: "npm run fmt",
});
});
Expand Down Expand Up @@ -146,6 +147,54 @@ describe("detectRepoStack — Node (#4785)", () => {
it("ignores a non-object scripts field", () => {
expect(detect({ "package.json": pkg({ scripts: ["build"] }) })).toMatchObject({ buildCommand: null, testCommand: null });
});

it("REGRESSION: a watch-only or fix-only script is never selected as a validation command (#10006)", () => {
expect(detect({ "package.json": pkg({ scripts: { "test:watch": "vitest" } }) })).toMatchObject({
testCommand: null,
});
expect(detect({ "package.json": pkg({ scripts: { "build:watch": "tsc -w" } }) })).toMatchObject({
buildCommand: null,
});
expect(detect({ "package.json": pkg({ scripts: { "lint:fix": "eslint --fix ." } }) })).toMatchObject({
lintCommand: null,
});
expect(detect({ "package.json": pkg({ scripts: { "format:write": "prettier -w ." } }) })).toMatchObject({
formatCommand: null,
});
// Other excluded segments (dev/serve/update/u) — whole-segment match only.
expect(detect({ "package.json": pkg({ scripts: { "test:dev": "vitest" } }) })).toMatchObject({ testCommand: null });
expect(detect({ "package.json": pkg({ scripts: { "test:serve": "vitest" } }) })).toMatchObject({ testCommand: null });
expect(detect({ "package.json": pkg({ scripts: { "test:update": "vitest -u" } }) })).toMatchObject({ testCommand: null });
expect(detect({ "package.json": pkg({ scripts: { "test:u": "vitest -u" } }) })).toMatchObject({ testCommand: null });
// Near-miss: fixtures is not an excluded segment.
expect(detect({ "package.json": pkg({ scripts: { "test:fixtures": "node gen.js" } }) })).toMatchObject({
testCommand: "npm run test:fixtures",
});
});

it("exact-name scripts still win over watch/fix siblings (#10006)", () => {
expect(
detect({
"package.json": pkg({ scripts: { test: "vitest run", "test:watch": "vitest" } }),
}),
).toMatchObject({ testCommand: "npm test" });
expect(
detect({
"package.json": pkg({ scripts: { lint: "eslint .", "lint:fix": "eslint --fix ." } }),
}),
).toMatchObject({ lintCommand: "npm run lint" });
});

it("picks the lexicographically smallest non-excluded pattern candidate regardless of key order (#10006)", () => {
const unitFirst = detect({
"package.json": pkg({ scripts: { "test:unit": "a", "test:e2e": "b" } }),
});
const e2eFirst = detect({
"package.json": pkg({ scripts: { "test:e2e": "b", "test:unit": "a" } }),
});
expect(unitFirst).toMatchObject({ testCommand: "npm run test:e2e" });
expect(e2eFirst).toMatchObject({ testCommand: "npm run test:e2e" });
});
});

describe("detectRepoStack — Python (#4785)", () => {
Expand Down
37 changes: 37 additions & 0 deletions test/unit/miner-target-repo-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,43 @@ describe("runTargetRepoVerification (#8807)", () => {
expect(spawn).not.toHaveBeenCalled();
});

it("SKIPS when detectRepoStack excluded every watch/fix-only script (#10006)", async () => {
const { detectRepoStack } = await import("../../packages/loopover-miner/lib/stack-detection");
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const dir = mkdtempSync(join(tmpdir(), "miner-verify-excluded-scripts-"));
try {
writeFileSync(
join(dir, "package.json"),
JSON.stringify({
scripts: {
"test:watch": "vitest",
"lint:fix": "eslint --fix .",
"build:watch": "tsc -w",
},
}),
);
const stack = detectRepoStack(dir);
expect(stack).toMatchObject({
detected: true,
testCommand: null,
lintCommand: null,
buildCommand: null,
});
const spawn = vi.fn();
const result = await runTargetRepoVerification({
worktreeDir: dir,
stack,
spawn: spawn as never,
});
expect(result).toEqual({ status: "skipped", reason: "no_commands_detected" });
expect(spawn).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("the default spawn runs shell commands from the cwd, merges output, and enforces the timeout", async () => {
const ok = await defaultVerificationSpawn("echo hello && echo err 1>&2", { cwd: process.cwd(), timeoutMs: DEFAULT_VERIFICATION_TIMEOUT_MS });
expect(ok.code).toBe(0);
Expand Down