@diffci.com/diffci 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import { repositoryLayout, UNKNOWN_REPOSITORY_LAYOUT } from "../repo/layout.js";
2
+ function toPosix(p) {
3
+ return p.replace(/\\/g, "/");
4
+ }
5
+ function directoryOf(path) {
6
+ const index = path.lastIndexOf("/");
7
+ return index <= 0 ? "" : path.slice(0, index);
8
+ }
9
+ /** Every directory that contains at least one test at any depth below it. */
10
+ function directoriesContainingTests(allTestPaths) {
11
+ const dirs = new Set();
12
+ for (const test of allTestPaths) {
13
+ let dir = directoryOf(toPosix(test));
14
+ for (;;) {
15
+ dirs.add(dir);
16
+ if (dir === "")
17
+ break;
18
+ dir = directoryOf(dir);
19
+ }
20
+ }
21
+ return dirs;
22
+ }
23
+ function isUnder(path, dir) {
24
+ return dir === "" || path === dir || path.startsWith(`${dir}/`);
25
+ }
26
+ /** The deepest ancestor directory of `changedPath` that contains any test, or undefined when only the
27
+ * repository root does - which is not a scoping, it is "run everything". */
28
+ function nearestTestScope(changedPath, testDirs) {
29
+ let dir = directoryOf(changedPath);
30
+ while (dir !== "") {
31
+ if (testDirs.has(dir))
32
+ return dir;
33
+ dir = directoryOf(dir);
34
+ }
35
+ return undefined;
36
+ }
37
+ /** For a repository whose tests live in their own root mirroring a source root, the directory under
38
+ * that test root corresponding to the changed file. */
39
+ function mirroredTestScopes(changedPath, profile, testDirs) {
40
+ if (!profile)
41
+ return [];
42
+ const sourceRoots = profile.sourceRoots.filter((r) => r.kind !== "tests").map((r) => toPosix(r.path));
43
+ const testRoots = profile.sourceRoots.filter((r) => r.kind === "tests").map((r) => toPosix(r.path));
44
+ if (testRoots.length === 0)
45
+ return [];
46
+ const containing = sourceRoots.find((root) => isUnder(changedPath, root));
47
+ if (containing === undefined)
48
+ return [];
49
+ const relativeDir = directoryOf(changedPath.slice(containing.length + 1));
50
+ const scopes = [];
51
+ for (const testRoot of testRoots) {
52
+ // Walk the mirrored directory upward until one that actually holds tests is found, so a change in
53
+ // `src/a/b/c.ts` still scopes to `tests/a` when the mirror is only that deep.
54
+ let candidate = relativeDir === "" ? testRoot : `${testRoot}/${relativeDir}`;
55
+ for (;;) {
56
+ if (testDirs.has(candidate)) {
57
+ scopes.push(candidate);
58
+ break;
59
+ }
60
+ if (!isUnder(candidate, testRoot) || candidate === testRoot)
61
+ break;
62
+ candidate = directoryOf(candidate);
63
+ }
64
+ }
65
+ return scopes;
66
+ }
67
+ const INFRASTRUCTURE_DIRECTORIES = new Set([
68
+ "ops", "terraform", "cloudformation", "pulumi", "cdktf", "deploy", "deployments", "kubernetes", "k8s", "helm", "docker",
69
+ ]);
70
+ const DATABASE_DIRECTORIES = new Set(["database", "migrations", "prisma", "drizzle", "supabase", "schema"]);
71
+ function firstSegment(path) {
72
+ const index = path.indexOf("/");
73
+ return index === -1 ? path : path.slice(0, index);
74
+ }
75
+ export function runPathBaseline(allTestPaths, changedFiles, profile) {
76
+ const changedPaths = changedFiles.map((f) => toPosix(f.path));
77
+ const matchedRules = [];
78
+ const layout = profile ? repositoryLayout(profile) : UNKNOWN_REPOSITORY_LAYOUT;
79
+ const docsOnly = changedPaths.every((p) => layout.isDocumentationPath(p) || p.startsWith("README"));
80
+ if (docsOnly) {
81
+ matchedRules.push("docs-only -> skip tests");
82
+ return { strategy: "PATH_BASELINE", selectedTests: [], fallbackRequired: false, fallbackReasons: [], matchedRules };
83
+ }
84
+ const runEverything = (rule, reason) => {
85
+ matchedRules.push(rule);
86
+ return { strategy: "PATH_BASELINE", selectedTests: allTestPaths, fallbackRequired: true, fallbackReasons: [reason], matchedRules };
87
+ };
88
+ if (changedPaths.some((p) => p === "package.json" || p === "package-lock.json" || /^(yarn\.lock|pnpm-lock\.yaml|bun\.lockb?)$/.test(p))) {
89
+ return runEverything("config/dependency -> full fallback", "config/dependency change triggers full fallback");
90
+ }
91
+ if (changedPaths.some((p) => p.startsWith(".github/workflows/"))) {
92
+ return runEverything("workflow change -> full fallback", "workflow change triggers full fallback");
93
+ }
94
+ if (changedPaths.some((p) => DATABASE_DIRECTORIES.has(firstSegment(p)))) {
95
+ return runEverything("database -> full fallback", "database change triggers full fallback");
96
+ }
97
+ if (changedPaths.some((p) => INFRASTRUCTURE_DIRECTORIES.has(firstSegment(p)) || p.startsWith("docker") || p.includes("Dockerfile"))) {
98
+ return runEverything("infrastructure -> full fallback", "infrastructure change triggers full fallback");
99
+ }
100
+ const testDirs = directoriesContainingTests(allTestPaths);
101
+ const scopes = new Set();
102
+ let unscopable = false;
103
+ for (const changedPath of changedPaths) {
104
+ const mirrored = mirroredTestScopes(changedPath, profile, testDirs);
105
+ if (mirrored.length > 0) {
106
+ for (const scope of mirrored)
107
+ scopes.add(scope);
108
+ continue;
109
+ }
110
+ const nearest = nearestTestScope(changedPath, testDirs);
111
+ if (nearest !== undefined) {
112
+ scopes.add(nearest);
113
+ continue;
114
+ }
115
+ // A change no directory scoping can localise makes the whole baseline unscoped - a path-rule CI
116
+ // that cannot place ONE changed file has to run everything, regardless of how well it placed the
117
+ // rest. Recording it per-file and then ignoring it would flatter the baseline.
118
+ unscopable = true;
119
+ }
120
+ if (unscopable || scopes.size === 0) {
121
+ return runEverything("no directory scoping applies -> run all tests", "unscopable changed paths");
122
+ }
123
+ const sortedScopes = Array.from(scopes).sort();
124
+ matchedRules.push(`directory scoping -> tests under ${sortedScopes.join(", ")}`);
125
+ // Sorted, so a selection is comparable across runs rather than inheriting the caller's input order.
126
+ const selected = allTestPaths.filter((test) => sortedScopes.some((scope) => isUnder(toPosix(test), scope))).sort();
127
+ if (selected.length === 0) {
128
+ return runEverything("directory scoping selected nothing -> run all tests", "scoping produced an empty selection");
129
+ }
130
+ return { strategy: "PATH_BASELINE", selectedTests: selected, fallbackRequired: false, fallbackReasons: [], matchedRules };
131
+ }
@@ -0,0 +1,124 @@
1
+ import { matchesGlob } from "../repo/test-discovery.js";
2
+ import { END_TO_END_FRAMEWORKS } from "../repo/test-framework.js";
3
+ /** How each runner takes a list of test files, and whether it accepts a config file. */
4
+ const RUNNER_INVOCATION = {
5
+ vitest: { binary: "vitest", leadingArgs: ["run"], configFlag: "--config" },
6
+ jest: { binary: "jest", leadingArgs: [], configFlag: "--config" },
7
+ mocha: { binary: "mocha", leadingArgs: [], configFlag: "--config" },
8
+ ava: { binary: "ava", leadingArgs: [] },
9
+ tap: { binary: "tap", leadingArgs: [] },
10
+ "node:test": { binary: "node", leadingArgs: ["--test"] },
11
+ jasmine: { binary: "jasmine", leadingArgs: [] },
12
+ "bun:test": { binary: "bun", leadingArgs: ["test"] },
13
+ playwright: { binary: "playwright", leadingArgs: ["test"], configFlag: "--config" },
14
+ cypress: { binary: "cypress", leadingArgs: ["run"], configFlag: "--config-file", pathStyle: "comma-separated-spec-flag" },
15
+ };
16
+ /**
17
+ * How to reach a locally-installed binary with each package manager. Every one of these resolves to
18
+ * the same `node_modules/.bin` entry; the package-manager-native form is used because it is what the
19
+ * repository's own contributors and CI would type, and because pnpm's default layout makes the raw
20
+ * path non-obvious.
21
+ */
22
+ function execPrefix(packageManager, binary) {
23
+ switch (packageManager) {
24
+ case "pnpm":
25
+ return { executable: "pnpm", args: ["exec", binary] };
26
+ case "yarn":
27
+ return { executable: "yarn", args: ["run", binary] };
28
+ case "bun":
29
+ return { executable: "bunx", args: [binary] };
30
+ case "npm":
31
+ case "unknown":
32
+ default:
33
+ return { executable: "npx", args: ["--no-install", binary] };
34
+ }
35
+ }
36
+ /** Runners that are the runtime itself rather than an installed dependency, so they are invoked
37
+ * directly rather than through a package manager's binary resolution. */
38
+ function isDirectlyInvoked(framework) {
39
+ return framework === "node:test" || framework === "bun:test";
40
+ }
41
+ function frameworkOfConfig(config) {
42
+ return config.runner;
43
+ }
44
+ function buildCommand(framework, packageManager, configFile, paths) {
45
+ const invocation = RUNNER_INVOCATION[framework];
46
+ const configArgs = configFile && invocation.configFlag ? [invocation.configFlag, configFile] : [];
47
+ const pathArgs = invocation.pathStyle === "comma-separated-spec-flag" ? ["--spec", paths.join(",")] : [...paths];
48
+ if (isDirectlyInvoked(framework)) {
49
+ return { executable: invocation.binary, args: [...invocation.leadingArgs, ...configArgs, ...pathArgs] };
50
+ }
51
+ const prefix = execPrefix(packageManager, invocation.binary);
52
+ return { executable: prefix.executable, args: [...prefix.args, ...invocation.leadingArgs, ...configArgs, ...pathArgs] };
53
+ }
54
+ /**
55
+ * Routes each selected test to the runner configuration that claims it, then emits one command per
56
+ * group. A repository with `vitest.config.ts` and `vitest.e2e.config.ts` gets two commands, because
57
+ * running an e2e suite under the unit config is not the same job - the family distinction that
58
+ * test-discovery.ts already models is carried through to execution rather than flattened.
59
+ */
60
+ export function planSelectiveTestCommands(profile, selectedPaths) {
61
+ const paths = [...selectedPaths].sort();
62
+ if (paths.length === 0)
63
+ return { commands: [], groups: [], unroutedPaths: [] };
64
+ const frameworks = profile.testUniverse?.declaredFrameworks ?? [];
65
+ const configs = profile.testRunnerConfigs ?? [];
66
+ if (frameworks.length === 0 && configs.length === 0) {
67
+ return {
68
+ commands: [],
69
+ groups: [],
70
+ unroutedPaths: paths,
71
+ refusalReason: "Repository declares no recognised test framework; DiffCI cannot construct a command that would run a subset of its tests",
72
+ };
73
+ }
74
+ const remaining = new Set(paths);
75
+ const groups = [];
76
+ // A config with explicit include globs is the strongest statement a repository makes about which
77
+ // command runs which files, so those claims are honoured first.
78
+ for (const config of configs) {
79
+ if (config.includes.length === 0)
80
+ continue;
81
+ const claimed = paths.filter((p) => remaining.has(p) && config.includes.some((glob) => matchesGlob(p, glob)));
82
+ if (claimed.length === 0)
83
+ continue;
84
+ for (const p of claimed)
85
+ remaining.delete(p);
86
+ const framework = frameworkOfConfig(config);
87
+ groups.push({
88
+ runnerId: `${framework}:${config.file}`,
89
+ label: `${framework} (${config.file})`,
90
+ commandSpec: buildCommand(framework, profile.packageManager, config.file, claimed),
91
+ paths: claimed,
92
+ });
93
+ }
94
+ // Everything else goes to the repository's primary framework under its default configuration. A
95
+ // repository with both vitest and playwright has two real frameworks; routing an unclaimed unit
96
+ // test to the e2e runner would produce a command that runs nothing, so e2e runners are only ever
97
+ // chosen when the repository declares nothing else.
98
+ if (remaining.size > 0) {
99
+ const unitFrameworks = frameworks.filter((f) => !END_TO_END_FRAMEWORKS.has(f));
100
+ const primary = unitFrameworks[0] ?? frameworks[0] ?? (configs[0] ? frameworkOfConfig(configs[0]) : undefined);
101
+ if (primary === undefined) {
102
+ return {
103
+ commands: groups.map((g) => g.commandSpec),
104
+ groups,
105
+ unroutedPaths: Array.from(remaining).sort(),
106
+ refusalReason: "No framework claims the remaining selected tests",
107
+ };
108
+ }
109
+ const rest = Array.from(remaining).sort();
110
+ groups.push({
111
+ runnerId: primary,
112
+ label: `${primary} (default configuration)`,
113
+ commandSpec: buildCommand(primary, profile.packageManager, undefined, rest),
114
+ paths: rest,
115
+ });
116
+ }
117
+ return { commands: groups.map((g) => g.commandSpec), groups, unroutedPaths: [] };
118
+ }
119
+ function shellEscape(arg) {
120
+ return arg.replace(/([\s'"\\$|&;<>(){}\[\]*?#~`])/g, "\\$1");
121
+ }
122
+ export function commandSpecToString(spec) {
123
+ return [spec.executable, ...spec.args.map(shellEscape)].join(" ");
124
+ }
@@ -0,0 +1 @@
1
+ export {};