1f4c3031be
* ci: filter test matrices by per-test coverage globs to cut LLM spend every test in `crossagent/`, `agnostic/`, and every provider entry now declares a `coverage: string[]` of repo-relative globs. the new `changes` job runs `paths-filter` for a docs-only short-circuit, then pipes the changed-file list into `action/test/matrix.ts`, which intersects each entry's coverage against the diff and emits filtered `agents`, `agnostic`, `flagships`, and `aliases` matrices. main pushes and `workflow_dispatch` set `FULL=1` to run everything as a stale-glob safety net. retires `changed-agents.sh` and the `MODE=flagships` branch in `list-aliases.ts` in favor of one consistent model. * ci(matrix): switch test discovery to dep-free static parsing the GHA `changes` job has no `node_modules` installed. the previous dynamic-import path pulled the test files transitively through `utils.ts` -> `agents/index.ts` -> `@actions/core`, which exploded with ERR_MODULE_NOT_FOUND. parse the test files via regex instead so matrix.ts stays zero-dep — the chain (matrix -> coverage / providers / list-aliases / models) imports only node builtins and relative TS files. * ci(matrix): address PR #730 review feedback - drop dangling `action/mcp/toolFiltering.ts` glob from `nobash`, `restricted`, `tokenExfil` (file doesn't exist; `.test.ts` does, but the runtime tooling lives in `mcp/shell.ts` and `agents/{claude,opencode}.ts`, both already covered). - drop unused `coverageForProvider` export and its `byName` map from `providers.ts` (matrix.ts builds its own lookup inline). - derive the active agent list from `agents/index.ts` via the same dep-free regex tactic as `parseTestFile` instead of hardcoding `["claude", "opencode"]` — adding a new harness file now wires it into the dynamic matrix automatically. - treat `coverage: []` as `coverage: undefined` in `shouldRun` so an accidentally-empty array doesn't silently skip CI on every PR. - add `action/utils/activity.ts` and `action/mcp/selectMode.ts` to the `timeout` test's coverage — the activity-timeout enforcement path was the original reason the test exists. - ungate the `root` job (lint/format/typecheck/vitest). it's a required status check on `main`, so gating it on `code == 'true'` would make docs-only PRs unmergeable (skipped jobs don't satisfy required-check rules). the real LLM savings come from skipping the four matrices, not from skipping `root`. - harden the four matrix-job `if:` guards from `outputs.matrix && ...` to `outputs.matrix != '' && ...` — explicit > implicit short-circuit. - document `expandBraces`'s flat-only support so a future author isn't surprised by `{a,{b,c}}` not expanding. - fix awkward sentence in `wiki/action-tests.md` "CI Cost Filtering".
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
/**
|
|
* shared coverage / glob plumbing for the matrix builder.
|
|
*
|
|
* every test (`crossagent/`, `agnostic/`) and every provider entry
|
|
* (`providers.ts`) declares a `coverage` array of repo-relative globs. on a PR
|
|
* push, the `changes` job feeds the changed-file list into `matrix.ts`, which
|
|
* intersects each entry's globs against the diff and emits only the entries
|
|
* that need to run.
|
|
*
|
|
* `ALWAYS_RUN_ALL` is the escape hatch: any change to a file matched here
|
|
* forces the full matrix (every test, every flagship, every alias). it
|
|
* captures cross-cutting infrastructure where fan-out is unpredictable —
|
|
* agent loader, MCP server boot, test runner itself. if a per-test glob
|
|
* goes stale, this list and the on-`main`-full-matrix policy are the safety
|
|
* nets — there's no completeness lint.
|
|
*
|
|
* `coverage` is optional on tests/providers; missing = always run (treat as
|
|
* "any code change touches me"). default to defensive — opt into precision
|
|
* by adding globs.
|
|
*/
|
|
|
|
/** patterns that, when matched by any changed file, force the full matrix. */
|
|
export const ALWAYS_RUN_ALL: string[] = [
|
|
// agent loader + cross-agent shared code
|
|
"action/agents/shared.ts",
|
|
"action/agents/index.ts",
|
|
"action/agents/postRun.ts",
|
|
// test harness — changing these can affect every test
|
|
"action/test/run.ts",
|
|
"action/test/utils.ts",
|
|
"action/test/matrix.ts",
|
|
"action/test/coverage.ts",
|
|
"action/test/providers.ts",
|
|
// boot + lifecycle
|
|
"action/main.ts",
|
|
"action/index.ts",
|
|
"action/cli.ts",
|
|
"action/utils/setup.ts",
|
|
"action/utils/lifecycle.ts",
|
|
"action/utils/install.ts",
|
|
"action/utils/docker.ts",
|
|
"action/utils/globals.ts",
|
|
// MCP orchestrator (every test runs through it)
|
|
"action/mcp/server.ts",
|
|
"action/mcp/shared.ts",
|
|
// dependency graph
|
|
"action/package.json",
|
|
"action/pnpm-lock.yaml",
|
|
// workflow itself
|
|
".github/workflows/test.yml",
|
|
];
|
|
|
|
/**
|
|
* expand a single brace group like `{a,b,c}` into an array of patterns.
|
|
*
|
|
* intentionally minimal: nested braces (`{a,{b,c}}`) and escaped braces are
|
|
* NOT supported — coverage globs in this repo only need flat brace groups
|
|
* (`{claude,opencode}.ts`). add complexity if a real use case emerges.
|
|
*/
|
|
function expandBraces(pattern: string): string[] {
|
|
const m = pattern.match(/\{([^{}]+)\}/);
|
|
if (!m || m.index === undefined) return [pattern];
|
|
const before = pattern.slice(0, m.index);
|
|
const after = pattern.slice(m.index + m[0].length);
|
|
const opts = m[1].split(",");
|
|
return opts.flatMap((opt) => expandBraces(`${before}${opt}${after}`));
|
|
}
|
|
|
|
/** convert a glob pattern to a regex anchored at start + end. */
|
|
function globToRegex(pattern: string): RegExp {
|
|
const DSTAR = "\u0000DSTAR\u0000";
|
|
let s = pattern.replace(/\*\*/g, DSTAR);
|
|
s = s.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
s = s.replace(/\*/g, "[^/]*");
|
|
s = s.replace(/\?/g, "[^/]");
|
|
s = s.replaceAll(DSTAR, ".*");
|
|
return new RegExp(`^${s}$`);
|
|
}
|
|
|
|
/** does any path in `paths` match any glob in `patterns`? */
|
|
export function anyMatch(paths: string[], patterns: string[]): boolean {
|
|
if (patterns.length === 0) return false;
|
|
const regexes = patterns.flatMap((p) => expandBraces(p)).map(globToRegex);
|
|
return paths.some((path) => regexes.some((r) => r.test(path)));
|
|
}
|
|
|
|
/**
|
|
* decide whether an entry runs given changed files + its coverage globs.
|
|
*
|
|
* three short-circuits:
|
|
* 1. `full` flag (e.g. main pushes, workflow_dispatch) → always run
|
|
* 2. any changed file matches `ALWAYS_RUN_ALL` → run everything
|
|
* 3. coverage missing or empty on the entry → run (defensive default)
|
|
*
|
|
* otherwise: run iff any changed file matches the entry's coverage globs.
|
|
*
|
|
* `coverage: []` is treated identically to `coverage: undefined` to avoid the
|
|
* footgun where a future test author intends "skip on PRs" by passing an
|
|
* empty array — silently skipping CI on every PR is worse than always running.
|
|
*/
|
|
export type ShouldRunInput = {
|
|
changedFiles: string[];
|
|
coverage: string[] | undefined;
|
|
full: boolean;
|
|
};
|
|
|
|
export function shouldRun(input: ShouldRunInput): boolean {
|
|
if (input.full) return true;
|
|
if (anyMatch(input.changedFiles, ALWAYS_RUN_ALL)) return true;
|
|
if (input.coverage === undefined || input.coverage.length === 0) return true;
|
|
return anyMatch(input.changedFiles, input.coverage);
|
|
}
|