fix(ci): own mock isolation and normalize Git patch directories

Convert native directory separators only at Git patch argument production. Exercise fresh, applied and invalid dependency patches in private copies, preserving protectNTFS, and select the regression for Windows CI.

Replace the spelling-based mock inventory with one binding-aware Oxlint rule. Canonical test projects own isolation for both Vitest and lint; external temporary CLI probes avoid concurrent test discovery.

Validated actual local Git and Oxlint execution, resolved-policy parity, CI selection and report tests. Native Windows verification remains with CI. Refs: #7050
This commit is contained in:
morluto committed 2026-10-25 09:49:26 +08:00
1 parent cb26e8ca63
commit c8269ca497
16 files changed
+463 -48

No files matched your search

+3
View File
@@ -144,6 +144,9 @@ export const SCOPES = {
"scripts/windows-capabilities.mjs",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
"scripts/lib/release-proposal.mjs",
"patches/release-please+17.3.0.patch",
"tests/boundary/filesystem/releaseProposal.test.ts",
"scripts/rea.mjs",
"scripts/lib/cli-process-environment.mjs",
"install.sh",
+8 -2
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, relative } from "node:path";
import { dirname, relative, sep } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { requireCompleteReleaseHistory } from "./release-notes.mjs";
@@ -24,7 +24,13 @@ export async function applyReleasePleasePatch(
"The release proposal patch requires release-please 17.3.0",
);
}
const args = ["--directory", relative(project, directory), patch];
// Git prefixes patch filenames with a slash-separated path, even on Windows.
// Split only native separators so POSIX backslashes remain filename bytes.
const args = [
"--directory",
relative(project, directory).split(sep).join("/"),
patch,
];
const check = async (reverse) => {
try {
await execute(
+1
View File
@@ -1,6 +1,7 @@
export interface TestProjectDefinition {
readonly name: string;
readonly group: "source" | "built";
readonly isolate?: boolean;
readonly include: readonly string[];
readonly exclude?: readonly string[];
}
+7
View File
@@ -5,11 +5,13 @@ export const SOURCE_TEST_PROJECTS = [
{
name: "domain",
group: "source",
isolate: false,
include: ["src/{contracts,domain}/**/*.test.ts"],
},
{
name: "services",
group: "source",
isolate: false,
include: ["src/application/**/*.test.ts"],
},
{
@@ -23,17 +25,20 @@ export const SOURCE_TEST_PROJECTS = [
{
name: "composition",
group: "source",
isolate: false,
include: ["tests/composition/**/*.test.ts"],
},
{
name: "conformance",
group: "source",
isolate: false,
include: ["tests/conformance/**/*.test.ts"],
exclude: ["tests/conformance/providers/hopper/realHopperSearch.test.ts"],
},
{
name: "evaluation",
group: "source",
isolate: false,
include: ["tests/evaluation/**/*.test.ts"],
},
];
@@ -55,6 +60,7 @@ export const BUILT_TEST_PROJECTS = [
{
name: "cli-boundary",
group: "built",
isolate: false,
include: ["tests/boundary/cli/**/*.test.ts"],
},
{
@@ -65,6 +71,7 @@ export const BUILT_TEST_PROJECTS = [
{
name: "mcp-boundary",
group: "built",
isolate: false,
include: ["tests/boundary/mcp/**/*.test.ts"],
},
{
+2
View File
@@ -4,6 +4,7 @@ import cancellationCause from "./cancellation-cause.mjs";
import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mjs";
import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs";
import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs";
import noSharedModuleMock from "./rules/no-shared-module-mock.mjs";
export default {
meta: { name: "rea" },
@@ -13,5 +14,6 @@ export default {
"no-evidence-json-preadmission": noEvidenceJsonPreadmission,
"no-fixture-root-cleanup": noFixtureRootCleanup,
"no-positional-tool-contract": noPositionalToolContract,
"no-shared-module-mock": noSharedModuleMock,
},
};
@@ -0,0 +1,4 @@
import type { RuleTester } from "oxlint/plugins-dev";
declare const rule: Parameters<RuleTester["run"]>[1];
export default rule;
@@ -0,0 +1,116 @@
import { relative, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { findBinding } from "../bindings.mjs";
import { projectForTest } from "../../lib/test-projects.mjs";
const repository = fileURLToPath(new URL("../../../", import.meta.url));
const mockMethods = new Set(["mock", "doMock"]);
const unwrap = (node) =>
node?.type === "ChainExpression" ? node.expression : node;
const propertyName = (member) => {
if (!member?.computed && member?.property?.type === "Identifier")
return member.property.name;
if (member?.computed && member.property?.type === "Literal")
return member.property.value;
return undefined;
};
const importedFromVitest = (sourceCode, identifier) => {
const binding = findBinding(sourceCode, identifier);
const definition = binding?.defs[0];
if (definition?.type !== "ImportBinding") return undefined;
const declaration = definition.parent ?? definition.node.parent;
if (declaration?.source?.value !== "vitest") return undefined;
return definition.node;
};
const isViImport = (sourceCode, identifier) => {
const specifier = importedFromVitest(sourceCode, identifier);
return (
specifier?.type === "ImportSpecifier" &&
(specifier.imported.name ?? specifier.imported.value) === "vi"
);
};
const isVitestNamespace = (sourceCode, identifier) =>
importedFromVitest(sourceCode, identifier)?.type ===
"ImportNamespaceSpecifier";
const isVitestViMember = (sourceCode, node) => {
const member = unwrap(node);
if (
member?.type !== "MemberExpression" ||
propertyName(member) !== "vi" ||
member.object?.type !== "Identifier"
)
return false;
return isVitestNamespace(sourceCode, member.object);
};
const isModuleMockCall = (sourceCode, callee) => {
const method = unwrap(callee);
if (
method?.type !== "MemberExpression" ||
!mockMethods.has(propertyName(method))
)
return false;
const receiver = unwrap(method.object);
return (
(receiver?.type === "Identifier" && isViImport(sourceCode, receiver)) ||
isVitestViMember(sourceCode, receiver)
);
};
const isolationForFile = (filename, cwd) => {
if (typeof filename !== "string" || !filename.endsWith(".test.ts"))
return undefined;
const testPath = relative(cwd, filename).split("\\").join("/");
if (
testPath === "" ||
isAbsolute(testPath) ||
testPath === ".." ||
testPath.startsWith("../")
)
return undefined;
try {
return projectForTest(testPath).isolate !== false;
} catch (error) {
if (
error instanceof Error &&
error.message.startsWith("No test project owns ")
)
return undefined;
throw error;
}
};
export default {
meta: {
type: "problem",
docs: {
description:
"Keep Vitest module mocks in projects with per-file module isolation.",
},
schema: [],
messages: {
shared:
"Vitest module mocks need an isolated test project; move the test to an isolated project or replace the module mock with a scoped spy or injected dependency.",
},
},
create(context) {
const filename = context.physicalFilename ?? context.filename;
const sourceCode = context.sourceCode;
const cwd =
context.cwd ?? context.getCwd?.() ?? sourceCode.getCwd?.() ?? repository;
if (isolationForFile(filename, cwd) !== false) return {};
return {
CallExpression(node) {
if (isModuleMockCall(sourceCode, node.callee))
context.report({ node, messageId: "shared" });
},
};
},
};