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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**An open-source engine for understanding changes and planning less CI work conservatively.** Licensed under AGPL-3.0-only.

This is the standalone Core engine. It analyzes local JavaScript/TypeScript repositories, supported Vue components and root Go modules, traces dependency impact, infers GitHub Actions structure and proposes test selections with evidence and full-run fallbacks. It works without a DiffCI account, hosted service or API key. See [the release boundary](docs/release-boundary.md) for support limits.
This is the standalone Core engine. It analyzes local JavaScript/TypeScript repositories, supported Vue components, root Go modules and conventional Maven reactors, traces dependency impact, infers GitHub Actions structure and proposes test selections with evidence and full-run fallbacks. It works without a DiffCI account, hosted service or API key. See [the release boundary](docs/release-boundary.md) for support limits.

## Run locally

Expand All @@ -21,6 +21,14 @@ The target must be a clean Git repository root checked out at `--head`. Fetch th

**Plans are advisory.** This release does not execute, skip or cancel CI jobs. `SKIP_CANDIDATE` is a candidate, not permission to bypass a check. Keep full CI authoritative while evaluating Core. Missing command synthesis, unsupported configuration and incomplete evidence must not be treated as an empty test suite. Static analysis is not proof that a test can safely be omitted.

For Maven jobs whose CI runs a lifecycle goal or profiles other than `test`, declare them in `diffci.json` (or the `diffci` key in `package.json`):

```json
{ "maven": { "goal": "verify", "profiles": ["run-its"] } }
```

This changes the proposed command, such as `mvn -pl tools -am verify -P run-its`; Core still does not run it. Confirm the command against the repository's CI workflow before using it for execution.

## What is included

| Area | Implementation |
Expand Down
15 changes: 10 additions & 5 deletions release-files.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@
".github/workflows/ci.yml",
".gitignore",
"CONTRIBUTING.md",
"LICENSE",
"NOTICE",
"README.md",
"docs/release-boundary.md",
"funding.json",
"LICENSE",
"NOTICE",
"package-lock.json",
"package.json",
"README.md",
"release-files.json",
"scripts/audit-boundary.mjs",
"scripts/benchmark.ts",
"scripts/run-tests.mjs",
"src/analyze.ts",
"src/cache/economics-context.ts",
"src/cache/graph-cache.ts",
"src/cache/vue-analysis-cache.ts",
"src/ci-inference/causal.ts",
"src/ci-inference/evidence.ts",
"src/ci-inference/expression.ts",
Expand All @@ -38,13 +40,13 @@
"src/planner/task-registry.ts",
"src/planner/test-command.ts",
"src/planner/types.ts",
"src/repo/analyzer.ts",
"src/repo/adapters/go-test.ts",
"src/repo/adapters/go.ts",
"src/repo/adapters/maven.ts",
"src/repo/adapters/index.ts",
"src/repo/adapters/maven.ts",
"src/repo/adapters/types.ts",
"src/repo/adapters/vue.ts",
"src/repo/analyzer.ts",
"src/repo/graph.ts",
"src/repo/impact-types.ts",
"src/repo/impact.ts",
Expand All @@ -55,13 +57,15 @@
"src/repo/test-fixture-ownership.ts",
"src/repo/test-framework.ts",
"src/repo/types.ts",
"src/repo/vue-scope.ts",
"src/research/baseline/matcher.ts",
"src/research/baseline/path-baseline.ts",
"src/research/baseline/registry.ts",
"src/research/baseline/test-activity.ts",
"src/research/baseline/workflow-parser.ts",
"tests/analyze.test.ts",
"tests/cache/graph-cache.test.ts",
"tests/cache/vue-analysis-cache.test.ts",
"tests/ci-inference/causal.test.ts",
"tests/ci-inference/dependency-time-boxing.test.ts",
"tests/ci-inference/executability.test.ts",
Expand All @@ -86,6 +90,7 @@
"tests/repo/test-discovery.test.ts",
"tests/repo/test-fixture-ownership.test.ts",
"tests/repo/test-framework.test.ts",
"tests/repo/vue-scope.test.ts",
"tests/research/baseline/matcher.test.ts",
"tests/research/baseline/test-activity.test.ts",
"tests/research/baseline/workflow-parser.test.ts",
Expand Down
19 changes: 19 additions & 0 deletions src/cache/economics-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { readRepositoryConfig } from "../repo/repo-config.js";

/** Files that determine whether a cached Vue analysis still matches the checkout configuration. */
export function economicsContext(repoPath: string): string {
const scope = readRepositoryConfig(repoPath).vue;
const paths = new Set(["diffci.json", "package.json", "tsconfig.json", "go.mod", "go.sum", "go.work", "go.work.sum", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", ...["ts", "js", "mts", "mjs", "cts", "cjs"].map(ext => `vitest.config.${ext}`)]);
if (scope) for (const path of ["package.json", "tsconfig.json", scope.testConfig]) paths.add(`${scope.packageRoot}/${path}`);
const hash = createHash("sha256").update(JSON.stringify([process.platform, process.arch, process.version]));
for (const path of [...paths].sort()) {
hash.update(JSON.stringify(path));
const file = join(repoPath, path);
hash.update(existsSync(file) ? readFileSync(file) : "<absent>");
hash.update("\0");
}
return hash.digest("hex");
}
80 changes: 80 additions & 0 deletions src/cache/vue-analysis-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { economicsContext } from "./economics-context.js";
import { vueAdapter } from "../repo/adapters/vue.js";
import { contribution, type AdapterContext, type AdapterContribution } from "../repo/adapters/types.js";

const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
function physical(path: string): string {
if (existsSync(path)) return realpathSync(path);
return resolve(physical(dirname(path)), relative(dirname(path), path));
}
function fingerprint(path: string): string {
if (!existsSync(path)) return "absent";
return JSON.stringify([realpathSync(path), statSync(path).isFile() ? digest(readFileSync(path)) : "directory"]);
}

/** Trusted runner-local cache, optional and never a source of test-selection authority.
* Every hit validates source, context and all compiler filesystem probes. Graph resolution
* and selection are rebuilt. Any unreadable/corrupt cache is an ordinary analysis miss. */
export function analyzeVueCached(context: AdapterContext, directory: string, version: string): AdapterContribution {
let cacheDir: string;
let namespace: string;
try {
cacheDir = physical(resolve(directory));
const rel = relative(realpathSync(context.repoPath), cacheDir);
if (!rel || (!(rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) && !isAbsolute(rel))) return vueAdapter.analyze(context);
namespace = digest(JSON.stringify(["vue-analysis-v1", version, vueAdapter.version, realpathSync(context.repoPath), economicsContext(context.repoPath), context.files]));
} catch { return vueAdapter.analyze(context); }
const result = contribution(vueAdapter);
const phasesMs: Record<string, number> = {};
const counts: Record<string, number> = { cacheHits: 0, cacheMisses: 0, cacheWriteFailures: 0 };
result.performance = { phasesMs, counts };
for (const path of context.files.filter(file => file.endsWith(".vue"))) {
const started = performance.now();
const file = join(cacheDir, `${digest(path)}.json`);
let key = "";
let item: AdapterContribution | undefined;
try {
key = digest(JSON.stringify([namespace, path, fingerprint(join(context.repoPath, path))]));
const envelope = JSON.parse(readFileSync(file, "utf8"));
if (envelope.key === key && typeof envelope.payload === "string" && digest(envelope.payload) === envelope.sha256) {
const data = JSON.parse(envelope.payload);
if (Array.isArray(data.probes) && data.probes.every((probe: [string, string]) => Array.isArray(probe) && probe.length === 2 && typeof probe[0] === "string" && fingerprint(probe[0]) === probe[1])) item = data.result;
}
} catch { /* unreadable or stale entry: rebuild */ }
phasesMs.cacheReadValidate = (phasesMs.cacheReadValidate ?? 0) + performance.now() - started;
if (item) counts.cacheHits++;
else {
counts.cacheMisses++;
const probes = new Map<string, string>();
let cacheable = true;
item = vueAdapter.analyze({ ...context, vueComponentPaths: [path], recordVueRead(file) {
// TS config expansion can read extended configs through its own system host.
// Until every such input is recorded, rebuild compiler-assisted components.
if (file.endsWith(".json")) cacheable = false;
try { probes.set(resolve(file), fingerprint(file)); } catch { cacheable = false; }
} });
if (key && cacheable) {
const writeStart = performance.now();
try {
const { performance: _performance, ...cachedResult } = item;
const payload = JSON.stringify({ probes: [...probes], result: cachedResult });
mkdirSync(cacheDir, { recursive: true });
const temporary = `${file}.${randomUUID()}.tmp`;
writeFileSync(temporary, JSON.stringify({ key, payload, sha256: digest(payload) }));
renameSync(temporary, file);
} catch { counts.cacheWriteFailures++; }
phasesMs.cacheWrite = (phasesMs.cacheWrite ?? 0) + performance.now() - writeStart;
}
}
for (const field of ["sourcePaths", "assetPaths", "edges", "virtualSources", "testFiles"] as const) (result[field] as unknown[]).push(...item[field]);
result.blockers.push(...item.blockers.filter(blocker => !result.blockers.includes(blocker)));
(result.fileBlockers ??= []).push(...(item.fileBlockers ?? []));
Object.assign(result.testPackages, item.testPackages);
for (const [name, value] of Object.entries(item.performance?.phasesMs ?? {})) phasesMs[name] = (phasesMs[name] ?? 0) + value;
for (const [name, value] of Object.entries(item.performance?.counts ?? {})) counts[name] = (counts[name] ?? 0) + value;
}
return result;
}
15 changes: 14 additions & 1 deletion src/planner/test-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ export function planSelectiveTestCommands(
): SelectiveTestCommandPlan {
const blockers = profile.adapterBlockers ?? profile.adapters?.flatMap((adapter) => adapter.blockers) ?? [];
if (blockers.length) return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: blockers.join("; ") };
if (profile.vueScope) {
const scope = profile.vueScope;
const known = new Set(profile.testFilePaths);
const unclaimed = selectedPaths.filter(path => !known.has(path));
if (unclaimed.length || profile.packageManager !== "pnpm") return { commands: [], groups: [], unroutedPaths: [...unclaimed], refusalReason: "Scoped Vitest execution requires pnpm and verified package test paths" };
if (!selectedPaths.length) return { commands: [], groups: [], unroutedPaths: [] };
const paths = [...selectedPaths].sort();
const local = paths.map(path => scope.packageRoot === "." ? path : path.slice(scope.packageRoot.length + 1));
const commandSpec: CommandSpec = { executable: "pnpm", args: ["--dir", scope.packageRoot, "exec", "vitest", "run", "--config", scope.testConfig, ...local] };
return { commands: [commandSpec], groups: [{ runnerId: `vitest:${scope.packageRoot}/${scope.testConfig}`, label: "Declared Vue package suite", paths, commandSpec }], unroutedPaths: [] };
}
const goPaths = selectedPaths.filter((path) => path.endsWith(".go"));
if (goPaths.length) {
const packages = profile.goTestPackages ?? {};
Expand Down Expand Up @@ -161,7 +172,9 @@ export function planSelectiveTestCommands(
if (targets.some((target) => target !== "." && (target.startsWith("/") || target.split("/").includes("..") || /[\\\r\n]/.test(target)))) {
return { commands: [], groups: [], unroutedPaths: javaPaths, refusalReason: "Invalid Maven reactor module target" };
}
const args = targets.length === 1 && targets[0] === "." ? ["test"] : ["-pl", targets.join(","), "-am", "test"];
const maven = profile.diffciConfig?.maven;
const goal = maven?.goal ?? "test";
const args = [...(targets.length === 1 && targets[0] === "." ? [] : ["-pl", targets.join(","), "-am"]), goal, ...(maven?.profiles?.length ? ["-P", maven.profiles.join(",")] : [])];
const commandSpec: CommandSpec = { executable: "mvn", args };
const group: SelectiveTestCommandGroup = { runnerId: "maven:surefire", label: "Maven reactor tests", paths: [...javaPaths].sort(), commandSpec };
return { commands: [commandSpec], groups: [group], unroutedPaths: [] };
Expand Down
29 changes: 24 additions & 5 deletions src/repo/adapters/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface GoPackage {
DepsErrors?: unknown[];
Incomplete?: boolean;
GoFiles?: string[];
IgnoredGoFiles?: string[];
CgoFiles?: string[];
TestGoFiles?: string[];
XTestGoFiles?: string[];
Expand Down Expand Up @@ -59,6 +60,11 @@ function internalPath(root: string, path: string): string | undefined {
return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel;
}

/** go ./... excludes these names; edits still require full CI (for example testdata reads). */
export function isGoDiscoveryIgnoredPath(path: string): boolean {
return path.split("/").some(part => part === "testdata" || part.startsWith("_") || part.startsWith("."));
}

export function analyzeGoMetadata(context: AdapterContext, output: string): AdapterContribution {
const result = contribution(goAdapter);
const all = parseGoList(output);
Expand All @@ -68,7 +74,10 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap
const anchors = new Map<string, string>();
const members = new Map<string, string[]>();
for (const pkg of packages) {
const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? [])];
// Associate inactive files with their package conservatively. They are not runnable
// tests, but edits (including build constraints) must still select that package and
// its dependents. Unaccounted files remain a global blocker below.
const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? []), ...(pkg.IgnoredGoFiles ?? [])];
const paths = files.map((file) => internalPath(context.repoPath, resolve(pkg.Dir, file)));
if (paths.some((p) => p === undefined)) { result.blockers.push("Go package contains files outside the repository"); continue; }
const sources = paths as string[];
Expand Down Expand Up @@ -110,16 +119,19 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap
if (!result.sourcePaths.length) result.blockers.push("Go analysis found no local packages");
// Ignored files/build tags, generation and testdata can change the runnable universe.
const modeled = new Set([...result.sourcePaths, ...result.assetPaths]);
if (context.files.some((file) => file.endsWith(".go") && !modeled.has(file))) result.blockers.push("Go files outside the active build context require full validation");
const unmodeled = context.files.filter(file => file.endsWith(".go") && !modeled.has(file) && !isGoDiscoveryIgnoredPath(file));
if (unmodeled.length) result.blockers.push(`Go files outside the active build context require full validation: ${unmodeled.slice(0, 20).join(", ")}`);
return result;
}

export const goAdapter: RepositoryAdapter = {
id: "go", version: "1", kind: "language",
id: "go", version: "3", kind: "language",
detect: ({ files }) => files.some((file) => file === "go.mod" || file.endsWith(".go")),
analyze(context) {
const failure = contribution(this);
if (!context.files.includes("go.mod") || context.files.some((f) => f === "go.work" || f.endsWith("/go.mod"))) {
const nestedRoots = context.files.filter(f => f.endsWith("/go.mod")).map(f => f.slice(0, -"go.mod".length));
const scoped = context.profile.diffciConfig?.go?.scope === "root-module";
if (!context.files.includes("go.mod") || context.files.includes("go.work") || (nestedRoots.length && !scoped)) {
failure.blockers.push("Go support requires one root go.mod; workspaces and nested modules require full validation");
return failure;
}
Expand All @@ -140,7 +152,14 @@ export const goAdapter: RepositoryAdapter = {
env,
stdio: ["ignore", "pipe", "pipe"],
});
const result = analyzeGoMetadata(context, output);
const excluded = (file: string) => nestedRoots.some(root => file.startsWith(root));
const scopedContext = scoped ? { ...context, files: context.files.filter(file => !excluded(file)) } : context;
const result = analyzeGoMetadata(scopedContext, output);
if (scoped) {
context.profile.goExcludedModuleRoots = nestedRoots;
// A local replacement can make an excluded module part of the root module's build.
if (parseGoList(output).some(pkg => pkg.Module?.Replace?.Dir)) result.blockers.push("Go root-module scope with local replacements requires full validation");
}
result.executionEnv = { GOOS: buildEnv.GOOS, GOARCH: buildEnv.GOARCH, CGO_ENABLED: buildEnv.CGO_ENABLED, GOFLAGS: "" };
return result;
} catch {
Expand Down
5 changes: 5 additions & 0 deletions src/repo/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export interface AdapterContext {
repoPath: string;
files: readonly string[];
profile: RepositoryProfile;
vueComponentPaths?: readonly string[];
recordVueRead?: (path: string) => void;
vueAnalysisSession?: object;
}

export interface AdapterContribution {
Expand All @@ -20,6 +23,8 @@ export interface AdapterContribution {
executionEnv?: Record<string, string>;
/** Global blockers: missing edges cannot be dismissed using graph reachability. */
blockers: string[];
fileBlockers?: Array<{ path: string; reason: string }>;
performance?: { phasesMs: Record<string, number>; counts: Record<string, number> };
}

export interface RepositoryAdapter {
Expand Down
Loading
Loading