Files
shockbot/test/coverage.ts
T
Colin McDonnell cb0dbcd371 feat(action): make prepush hook non-blocking after one failure (#777)
* feat(action): make prepush hook non-blocking after one failure

push_branch now treats the repository's prepush hook as best-effort: it
runs at most once per run, surfaces the failure output if the script
exits non-zero, and every subsequent push_branch call this run skips
the hook so the agent isn't blocked by failures unrelated to its
change. The agent can iterate by running the hook command itself via
the shell tool when shell access is available; push_branch will not
re-run the hook automatically after a failure.

Why: a one-line OSS-allowlist change took 9 minutes (#776) because the
agent retried push_branch six times against a prepush hook that was
failing for env-leak and missing-build-artifact reasons unrelated to
the change. CI catches the same checks on the GitHub side; the local
prepush gate was duplicating work and blocking unrelated fixes.

- ToolState: new prepushFailureCount counter (per-run, never resets)
- executeLifecycleHook: returns structured failure (kind/output/exitCode)
  so prepush can compose its own agent-facing message instead of
  inheriting the generic retry/no-retry advice meant for setup
- push_branch: composes a shell-mode-aware error message; surfaces
  prepushSkipped on the success payload + appends a note to the message
- instructions.ts + wiki/prompt.md + docs/comparisons.mdx: updated to
  reflect best-effort semantics

* fix(action): clarify prepush latch semantics + soften static guidance

review fixes from PR #777:

- toolState comment, instructions, success message, tool description:
  replace "runs at most once per run" / "first call only" wording with
  the actual semantic — successful prepush keeps running on later
  push_branch calls; only a hook FAILURE latches the bypass.
- tool description: drop hardcoded "via the shell tool" guidance so
  the static description doesn't mislead in shell:disabled runs (the
  dynamic agent prompt in instructions.ts already does shell-conditional
  messaging).
- LifecycleHookFailure.output JSDoc: match the implementation
  (stderr-preferred fallback to stdout, empty for timeout/spawn).

* fix(action): shorten prepush-skip log to terse operator telemetry

the previous log line tried to address the agent ("re-run the hook
command yourself via shell"), but log.info writes to the action
runtime's stdout — the agent never sees it. agent-facing skip
guidance already lives in the error message from
buildPrepushFailureMessage, the success message when bypassed, and
the system prompt in instructions.ts. log line is now just operator
telemetry.

* refactor(action): drop slop from prepush soft-fail

self-audit pass after the previous review-fix round. removed
duplication between code-level comment and the five other places that
already explain the same behavior, tightened verbose JSDoc, and
collapsed redundant clauses in agent-facing strings.

- LifecycleHookFailure → discriminated union. drops the optional
  exitCode/spawnError fields (and the empty-output sentinel for
  timeout/spawn) plus the corresponding ?? fallbacks in the helper.
- PushBranchTool: 7-line code comment above the latch removed
  (toolState field comment + tool description + error message +
  success message + system prompt all already cover it). tool
  description third sentence dropped (restated the second). success
  message tightened to a parenthetical.
- buildPrepushFailureMessage: 4-line JSDoc → 1 line. shared "if you
  think the failure could indicate a real bug in your code" prefix
  factored out across the shell-conditional branches.
- ToolState.prepushFailureCount comment: 8 lines → 3. the "what" is
  in git.ts; comment now only documents the invariant (never
  decremented within a run).
- instructions.ts prepush guidance: collapsed nested bullets + ternary
  into one paragraph; dropped the "so re-running via shell is the only
  way…" tail that restated "push_branch will NOT re-run it".

* fix(action): hint prepush bypass on dirty tree after hook failure

When push_branch blocks on a dirty working tree and the prepush latch
is already set, tell the agent the hook will be skipped once the tree is clean.

* fix(test): narrow CI matrix for lifecycle and toolState changes

Remove lifecycle.ts from ALWAYS_RUN_ALL and add lifecycle.ts + toolState.ts
to push/git agnostic test coverage so PRs touching prepush latch logic run
targeted tests instead of the full matrix.
2026-05-20 02:43:23 +00:00

117 lines
4.4 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/list-aliases.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/install.ts",
"action/utils/runFixture.ts",
"action/utils/globals.ts",
// GHA-like container plumbing (changes invalidate every test's environment)
"action/Dockerfile",
"action/docker-entrypoint.sh",
"action/gha.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);
}