perf(ci): start source feedback early and diagnose stalled runs (#3013)

* perf(ci): start source feedback early and diagnose stalled runs

* fix(ci): verify workflow identities before main comparison limits

* fix(ci): preserve diagnostics across passing checks and install failures

* fix(ci): preserve ambiguous workflow run ordering

* fix(ci): preserve failure-report candidate and request budgets

* fix(ci): retain candidate errors while checking later main evidence

* fix(ci): retain partial reports and require complete health scans

* test(ci): prune stale fixtures and duplicated assumptions
This commit is contained in:
morluto 2026-10-14 06:07:08 +08:00 committed by GitHub
parent aa6f74af04
commit 6510d0d57f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 4661 additions and 192 deletions

View File

@ -12,12 +12,13 @@ concurrency:
permissions:
contents: read
actions: read
pull-requests: read
jobs:
report:
name: Collect recent completed workflow attempts
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
# Consume only API data and execute batch code from the trusted default branch.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@ -27,6 +28,18 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
continue-on-error: true
uses: ./.github/actions/npm-cache
- name: Diagnose missing and startup-failed PR CI
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/pr-ci-health.mjs
- name: Record queue, execution, and matched step timing
continue-on-error: true
env:
@ -44,3 +57,28 @@ jobs:
if-no-files-found: error
overwrite: true
retention-days: 14
- name: Install dependencies for static failure attribution
id: failure-deps
if: ${{ !cancelled() && hashFiles('.cache/ci-job-timings/batch-manifest.json') != '' }}
continue-on-error: true
run: npm ci
- name: Attribute supported static failures against main
if: ${{ !cancelled() && steps.failure-deps.outcome == 'success' && hashFiles('.cache/ci-job-timings/batch-manifest.json') != '' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/failure-report.mjs
- name: Upload CI health and failure diagnostics
if: ${{ !cancelled() }}
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ci-health-diagnostics
path: |
.cache/ci-pr-health/
.cache/ci-failures/
include-hidden-files: true
if-no-files-found: warn
retention-days: 14

View File

@ -139,6 +139,12 @@ jobs:
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
- name: Record static task source and execution identity
continue-on-error: true
if: needs.changes.outputs.code == 'true'
env:
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: npm run ci:static-context
- if: needs.changes.outputs.code == 'true'
run: npm run check:ci
- if: needs.changes.outputs.code == 'true'
@ -167,6 +173,34 @@ jobs:
if-no-files-found: ignore
retention-days: 7
test-source:
name: Source tests
needs: changes
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run source tests without waiting for build
run: npm run test:fast -- --reporter=default --reporter=blob
env:
VITEST_BLOB_LABEL: source
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: vitest-report-source
path: .vitest/blob/*
include-hidden-files: true
if-no-files-found: error
retention-days: 7
test-shard:
name: Test shard (${{ matrix.shard }})
needs: [changes, build]
@ -178,7 +212,9 @@ jobs:
# every shard's diagnostics and coverage. Passing PRs run the full suite.
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
matrix:
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
# Fast feedback spends the same six test jobs on one source group and
# five built groups; full qualification retains six complete shards.
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/5","2/5","3/5","4/5","5/5"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@ -197,15 +233,16 @@ jobs:
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- name: Run complete portable suite for fast feedback
- name: Run built-dependent tests for fast feedback
if: needs.changes.outputs.tier == 'fast'
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
run: npm run test:built -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env:
VITEST_BLOB_LABEL: built
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: vitest-report-${{ strategy.job-index }}
name: vitest-report-built-${{ strategy.job-index }}
path: .vitest/blob/*
include-hidden-files: true
if-no-files-found: error
@ -219,11 +256,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run reverse tools tests with coverage
run: npm run test:coverage:reverse-tools
@ -316,7 +355,7 @@ jobs:
test:
name: Portable tests
needs: [changes, test-shard]
needs: [changes, test-source, test-shard]
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
@ -340,7 +379,9 @@ jobs:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
(needs.changes.outputs.tier == 'fast' &&
(needs.test-source.result == 'success' || needs.test-source.result == 'failure')))
with:
path: .vitest/blob
pattern: vitest-report-*
@ -368,7 +409,8 @@ jobs:
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
needs.changes.outputs.tier == 'fast' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
needs.test-source.result == 'success' || needs.test-source.result == 'failure')
run: >-
npm exec -- vitest --merge-reports
--reporter=default
@ -381,6 +423,8 @@ jobs:
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-timings.json') != ''
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
SOURCE_RESULT: ${{ needs.test-source.result }}
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
@ -413,7 +457,13 @@ jobs:
env:
CODE_REQUIRED: ${{ needs.changes.outputs.code }}
SHARD_RESULT: ${{ needs.test-shard.result }}
run: test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success"
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
SOURCE_RESULT: ${{ needs.test-source.result }}
run: |
if [ "${CODE_REQUIRED}" = "true" ]; then
test "${SHARD_RESULT}" = "success" || exit 1
test "${SOURCE_REQUIRED}" != "true" || test "${SOURCE_RESULT}" = "success"
fi
conformance-fixtures:
name: Source-built conformance fixtures
@ -847,6 +897,7 @@ jobs:
build,
reverse-tools-quality,
test-shard,
test-source,
sqlite-runtime,
test,
conformance-fixtures,

View File

@ -13,8 +13,8 @@ jobs:
name: Build Windows bridge security boundary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Install the cross compiler used by the Windows native lane
@ -44,11 +44,13 @@ jobs:
- platform: Windows
runner: [self-hosted, cutter-upstream, Windows]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- if: matrix.platform == 'Windows'
uses: actions/download-artifact@v4

View File

@ -280,8 +280,15 @@ focused suite also exercises affected MCP routes and the Python Cutter bridge.
Contract, integration, and real-provider lanes complement these focused tests;
simulated providers do not establish real-provider behavior.
CI runs six native Vitest shards without retries. Each shard emits a blob
report; the merge job produces JUnit and JSON diagnostics, a module timing
Fast CI starts the source-only projects after classification, independently of
build and static checks, and runs five built-dependent shards after the build.
The six test jobs still select every portable test. Full qualification keeps six
complete Vitest shards with coverage. Neither tier retries tests. Canonical
project ownership lives in `scripts/lib/test-projects.mjs`, shared by Vitest,
CI group execution, and development-test preparation. A test importing compiled
verifier code belongs to the built group even if its directory is conformance.
Source and built blobs have separate labels to prevent merge collisions.
Each test job emits a blob report; the merge job produces JUnit and JSON diagnostics, a module timing
report, plus aggregate coverage during full qualification. It retains available
reports even when a shard fails, uploads available diagnostics, and writes the
slowest files to the workflow summary. Partial reports are marked diagnostic
@ -316,6 +323,30 @@ portable test shard. These limits leave headroom for cold setup and full coverag
while preventing a hung job from holding the active main run for hours. They do
not bound runner queue waiting. Timeouts still fail the required gate.
The separate `CI timings` workflow also diagnoses missing, startup-failed,
queued/running, approval-required, canceled, and failed PR CI through bounded,
read-only API scans. Incomplete pagination or changing identity stays unknown.
It never closes/reopens PRs, reruns workflows, posts comments, or changes gates.
The timing manifest retains each run's event and head branch, including explicit
unknowns, so PR runs cannot consume the eligible-main comparison budget.
Its static failure report compares supported lint/duplication diagnostics with
failed main runs using exact source, ancestry, task hashes, and toolchain proof.
A match describes those diagnostics only; it does not establish that every
failure in a job came from main. Duplication reports retain the actual checker
exit result, so passing duplication checks preserve lint failures and caller
thresholds remain owned by the producer. Missing checker outcomes leave
those duplication diagnostics unclassified. Missing historical receipts remain unknown.
The reporter executes only trusted default-branch code and parses whitelisted
JSON artifact members without executing artifact content. Reports and limits
are retained in `ci-health-diagnostics`; its ten-minute job limit covers the
existing four-minute timing collection plus the bounded diagnostic stages.
Shard-count reduction remains deferred: the available successful timing sample
covers an older inventory, and file phase weights are not job wall time. The
one-source/five-built split retains six test jobs; it makes no hosted speedup
claim. Current complete timings and completed candidate hosted runs are needed
before enabling a smaller adaptive matrix.
The separate `CI timings` workflow batches recent completed CI and Release runs
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
a normalized point-in-time report for each collected workflow attempt, plus a

View File

@ -118,7 +118,9 @@
"precheck:pr": "npm run deps:check",
"check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos",
"precheck:ci": "npm run deps:check",
"check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards",
"check:ci": "node scripts/run-exclusive.mjs artifacts npm run check:ci:tasks && npm run verify:architecture-guards",
"check:ci:tasks": "turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos",
"ci:static-context": "node scripts/ci/static-task-context.mjs",
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
"build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run",
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
@ -138,7 +140,8 @@
"test:run": "vitest run",
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v && python3 -m unittest discover -s experiments/analysis-efficiency -p 'test_*.py' -v",
"test:local": "node scripts/run-development-tests.mjs local",
"test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation",
"test:fast": "node scripts/ci/test-group.mjs source",
"test:built": "node scripts/ci/test-group.mjs built",
"pretest:boundary": "npm run test:prepare",
"test:boundary": "vitest run --project boundary --project cli-boundary --project process-boundary --project mcp-boundary --project process-global",
"pretest:mcp": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills artifacts:mcp-catalog",

View File

@ -0,0 +1,67 @@
import type { NormalizedWorkflowRun } from "./workflow-run.mjs";
import type { StaticTaskContext } from "./static-task-context.mjs";
export type { StaticTaskContext } from "./static-task-context.mjs";
export interface StaticDiagnostic {
readonly kind: "lint" | "jscpd";
readonly path?: string;
readonly paths?: readonly string[];
readonly code?: string;
readonly message?: string;
readonly severity?: string;
}
export function classifyFailure(input: {
repository: string;
defaultBranch: string;
run: Record<string, unknown> & NormalizedWorkflowRun;
job: Record<string, unknown>;
diagnostics: readonly StaticDiagnostic[];
context: StaticTaskContext;
mainRun: Record<string, unknown> & NormalizedWorkflowRun;
mainJob: Record<string, unknown>;
mainDiagnostics: readonly StaticDiagnostic[];
mainContext: StaticTaskContext;
executionSourcesVerified: boolean;
ancestry: { status: string; mergeBaseSha: string | null };
blobs: Readonly<Record<string, boolean>>;
}): {
classification: "matching-main-diagnostics" | "unknown";
reason: string;
};
export function diagnosticPaths(item: StaticDiagnostic): readonly string[];
export function isFailedConclusion(
value: unknown,
): value is "failure" | "timed_out";
export function isEligibleMainRun(
run: Record<string, unknown>,
defaultBranch: string,
repository: string,
): boolean;
export function pullRequestBaseSha(
run: Record<string, unknown>,
repository: string,
): string | null;
export function safeSha(value: unknown): string | null;
export function validRepoPath(value: unknown): value is string;
export function selectStaticCheckArtifact(
page: unknown,
run: { id?: string | number; head_sha?: string },
runAttempt: number,
): unknown | null;
export function readStaticCheckArtifact(options: {
repository: string;
artifactId: number | string;
token: string;
fetchImpl?: typeof fetch;
}): Promise<{
context: StaticTaskContext;
diagnostics: readonly StaticDiagnostic[];
}>;
export function parseStaticArchive(archivePath: string): Promise<{
context: StaticTaskContext;
diagnostics: readonly StaticDiagnostic[];
}>;

436
scripts/ci/artifacts.mjs Normal file
View File

@ -0,0 +1,436 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { parseStaticTaskContext } from "./static-task-context.mjs";
const execute = promisify(execFile);
const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024;
const MAX_MEMBER_BYTES = 4 * 1024 * 1024;
const MAX_DIAGNOSTICS = 10_000;
const API_ROOT = "https://api.github.com/repos";
const SHA = /^[0-9a-f]{40}$/iu;
const TASK_HASH = /^[a-f0-9]{16}$/u;
/** Compare parsed static diagnostics only after source, task, host, and ancestry proof. */
export function classifyFailure({
repository,
defaultBranch,
run,
job,
diagnostics,
context,
mainRun,
mainJob,
mainDiagnostics,
mainContext,
executionSourcesVerified,
ancestry,
blobs,
}) {
const failedGate = firstFailure([
() => [
!isCompletedFailure(run) ||
job?.status !== "completed" ||
!isFailedConclusion(job?.conclusion),
"run_or_job_not_a_completed_failure",
],
() => [
!isEligiblePullRun(run, repository) ||
!isEligibleMainRun(mainRun, defaultBranch, repository),
"run_event_or_repository_identity_unverified",
],
() => [
job?.name !== mainJob?.name || job?.name !== "Static checks",
"failed_job_not_the_same_static_gate",
],
() => [
!Array.isArray(diagnostics) ||
diagnostics.length === 0 ||
!Array.isArray(mainDiagnostics),
"structured_diagnostics_missing_or_invalid",
],
() => [
!validContext(context) || !validContext(mainContext),
"static_task_context_missing_or_invalid",
],
() => [
executionSourcesVerified !== true,
"checkout_sources_not_verified_against_api_commits",
],
() => [
!sameExecutionContext(context, mainContext),
"task_or_toolchain_identity_differs",
],
() => [
ancestry?.status !== "ahead" && ancestry?.status !== "identical",
"main_head_not_proven_ancestor_of_tested_base",
],
() => [
ancestry?.mergeBaseSha !== mainRun?.head_sha,
"main_head_is_not_the_verified_merge_base",
],
() => [
!sameTaskHashes(diagnostics, context, mainContext),
"owning_task_hash_differs",
],
]);
if (failedGate) return outcome("unknown", failedGate.reason);
const keys = diagnostics.map(diagnosticKey);
const mainKeys = new Set(mainDiagnostics.map(diagnosticKey).filter(Boolean));
if (keys.some((key) => key === null))
return outcome("unknown", "diagnostic_shape_not_supported");
if (!keys.every((key) => mainKeys.has(key)))
return outcome(
"unknown",
"main_run_does_not_contain_the_same_structured_diagnostic",
);
if (!sameBlobEvidence(diagnostics, blobs))
return outcome(
"unknown",
"diagnostic_source_content_not_identical_at_main_and_tested_base",
);
return outcome(
"matching-main-diagnostics",
"supported_diagnostics_match_failed_task_and_source_on_verified_main_ancestor",
);
}
export function diagnosticPaths(item) {
return item.kind === "jscpd" ? item.paths : [item.path];
}
export function isFailedConclusion(value) {
return value === "failure" || value === "timed_out";
}
export function isEligibleMainRun(run, defaultBranch, repository) {
return (
isEligibleStaticRun(run) &&
run.repository.full_name === repository &&
run.head_branch === defaultBranch &&
["push", "schedule", "workflow_dispatch"].includes(run.event)
);
}
export function pullRequestBaseSha(run, repository) {
if (
run?.event !== "pull_request" ||
run.repository?.full_name !== repository ||
!Array.isArray(run.pull_requests) ||
run.pull_requests.length !== 1
)
return null;
const base = run.pull_requests[0].base;
return base?.repo?.full_name === repository && SHA.test(base?.sha ?? "")
? base.sha
: null;
}
export function safeSha(value) {
return typeof value === "string" && SHA.test(value)
? value.toLowerCase()
: null;
}
export function selectStaticCheckArtifact(page, run, runAttempt) {
if (runAttempt !== 1) return null;
if (
!Array.isArray(page?.artifacts) ||
!Number.isSafeInteger(page.total_count) ||
page.total_count !== page.artifacts.length ||
page.artifacts.length >= 100
)
return null;
const matches = page.artifacts.filter(
(artifact) => artifact.name === "static-check-reports",
);
if (matches.length !== 1) return null;
const [artifact] = matches;
if (
artifact.expired !== false ||
String(artifact.workflow_run?.id) !== String(run.id) ||
artifact.workflow_run?.head_sha !== run.head_sha
)
return null;
return artifact;
}
export function validRepoPath(value) {
return (
typeof value === "string" &&
value.length > 0 &&
!isAbsolute(value) &&
!value.includes("\\") &&
value
.split("/")
.every((part) => part !== "" && part !== "." && part !== "..") &&
!value.startsWith("/")
);
}
/** Download one named static report archive and parse only whitelisted JSON members. */
export async function readStaticCheckArtifact({
repository,
artifactId,
token,
fetchImpl = fetch,
}) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository ?? ""))
throw new Error("artifact repository identity is invalid");
if (!token) throw new Error("artifact download requires a GitHub token");
const url = `${API_ROOT}/${repository}/actions/artifacts/${positiveId(artifactId)}/zip`;
let response = await fetchImpl(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(10_000),
redirect: "manual",
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location || new URL(location).protocol !== "https:")
throw new Error("static_artifact_redirect_invalid");
response = await fetchImpl(location, {
signal: AbortSignal.timeout(10_000),
redirect: "error",
});
}
if (!response.ok) throw new Error(`github_http_${response.status}`);
const bytes = await readLimitedBytes(response, MAX_ARCHIVE_BYTES);
const directory = await mkdtemp(join(tmpdir(), "rea-ci-static-artifact-"));
const archive = join(directory, "artifact.zip");
try {
await writeFile(archive, bytes, { flag: "wx" });
return await parseStaticArchive(archive);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
/** Accept only the three named producer outputs; no archive paths are written to disk. */
export async function parseStaticArchive(archivePath) {
const { stdout: listing } = await execute("unzip", ["-Z1", archivePath], {
encoding: "utf8",
maxBuffer: 128 * 1024,
timeout: 5_000,
});
const allowed = new Set([
"oxlint.json",
"jscpd/jscpd-report.json",
"static-task-context.json",
]);
const members = new Map();
for (const name of listing.split(/\r?\n/u).filter(Boolean)) {
const prefix = ".cache/static-checks/";
const member = name.startsWith(prefix) ? name.slice(prefix.length) : name;
if (!allowed.has(member)) continue;
if (members.has(member))
throw new Error("static artifact contains duplicate report members");
const { stdout } = await execute("unzip", ["-p", archivePath, name], {
encoding: "buffer",
maxBuffer: MAX_MEMBER_BYTES,
timeout: 5_000,
});
members.set(member, JSON.parse(stdout.toString("utf8")));
}
const context = parseStaticTaskContext(
members.get("static-task-context.json"),
);
const diagnostics = normalizeDiagnostics(
members.get("oxlint.json"),
members.get("jscpd/jscpd-report.json"),
);
if (diagnostics.length > MAX_DIAGNOSTICS)
throw new Error("static diagnostic count exceeds the reporting limit");
return { context, diagnostics };
}
function normalizeDiagnostics(lint, duplicate) {
return [...normalizeLint(lint), ...normalizeDuplicates(duplicate)];
}
function normalizeLint(report) {
if (report === undefined) return [];
if (!Array.isArray(report?.diagnostics))
throw new Error("oxlint diagnostics are malformed");
return report.diagnostics
.filter((item) => item.severity === "error")
.map((item) => {
if (
typeof item.filename !== "string" ||
!repoPath(item.filename) ||
typeof item.code !== "string" ||
typeof item.message !== "string"
)
throw new Error("oxlint error diagnostic is malformed");
return {
kind: "lint",
path: item.filename,
code: item.code,
message: item.message,
severity: "error",
};
});
}
function normalizeDuplicates(report) {
if (report === undefined) return [];
if (
!Array.isArray(report?.duplicates) ||
!Number.isFinite(report.statistics?.total?.percentage) ||
report.statistics.total.percentage < 0
)
throw new Error("jscpd report is malformed");
// The producer owns the checker outcome; do not copy or infer its threshold.
const exitCode = report.reaCheck?.exitCode;
if (exitCode === undefined || exitCode === 0) return [];
if (!Number.isSafeInteger(exitCode) || exitCode < 0)
throw new Error("jscpd checker outcome is malformed");
if (exitCode !== 1) return [];
return report.duplicates.map((item) => {
const paths = [item.firstFile?.name, item.secondFile?.name].sort();
if (paths.some((path) => !repoPath(path)))
throw new Error("jscpd duplicate paths are malformed");
return { kind: "jscpd", paths };
});
}
async function readLimitedBytes(response, limit) {
const reader = response.body?.getReader();
if (!reader) throw new Error("static artifact response has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel();
throw new Error("static artifact exceeds the download limit");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function repoPath(value) {
return (
typeof value === "string" &&
value.length > 0 &&
!value.startsWith("/") &&
!value.includes("\\") &&
value
.split("/")
.every((part) => part !== "" && part !== "." && part !== "..")
);
}
function positiveId(value) {
const id = Number(value);
if (!Number.isSafeInteger(id) || id < 1)
throw new Error("artifact ID must be a positive integer");
return String(id);
}
function diagnosticKey(item) {
if (
item?.kind === "lint" &&
validRepoPath(item.path) &&
typeof item.code === "string" &&
typeof item.message === "string" &&
item.severity === "error"
)
return JSON.stringify([
item.kind,
item.path,
item.code,
item.message,
item.severity,
]);
if (
item?.kind === "jscpd" &&
Array.isArray(item.paths) &&
item.paths.length === 2 &&
item.paths.every(validRepoPath)
)
return JSON.stringify([item.kind, ...[...item.paths].sort()]);
return null;
}
function sameBlobEvidence(diagnostics, blobs) {
const paths = [...new Set(diagnostics.flatMap(diagnosticPaths))];
return paths.length > 0 && paths.every((path) => blobs?.[path] === true);
}
function sameTaskHashes(diagnostics, left, right) {
return diagnostics.every((diagnostic) => {
const task = diagnostic.kind === "jscpd" ? "jscpd" : "lint:built";
return (
TASK_HASH.test(left.taskHashes[task] ?? "") &&
left.taskHashes[task] === right.taskHashes[task]
);
});
}
function validContext(value) {
return (
value?.version === 1 &&
SHA.test(value.sourceSha ?? "") &&
value.sourceClean === true &&
typeof value.nodeVersion === "string" &&
typeof value.npmVersion === "string" &&
typeof value.turboVersion === "string" &&
typeof value.platform === "string" &&
typeof value.architecture === "string" &&
value.taskHashes &&
typeof value.taskHashes === "object" &&
!Array.isArray(value.taskHashes)
);
}
function sameExecutionContext(left, right) {
return [
"nodeVersion",
"npmVersion",
"turboVersion",
"platform",
"architecture",
].every((key) => left[key] === right[key]);
}
function isEligiblePullRun(run, repository) {
return (
isEligibleStaticRun(run) && pullRequestBaseSha(run, repository) !== null
);
}
function isEligibleStaticRun(run) {
return (
isRecord(run) &&
run.status === "completed" &&
isFailedConclusion(run.conclusion) &&
SHA.test(run.head_sha ?? "") &&
typeof run.repository?.full_name === "string" &&
run.workflowPath?.file === ".github/workflows/ci.yml"
);
}
function isCompletedFailure(run) {
return run?.status === "completed" && isFailedConclusion(run.conclusion);
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function firstFailure(checks) {
for (const check of checks) {
const [failed, reason] = check();
if (failed) return { reason };
}
return null;
}
function outcome(classification, reason) {
return { classification, reason };
}

View File

@ -0,0 +1,159 @@
import type { NormalizedWorkflowRun } from "./workflow-run.mjs";
import type { StaticDiagnostic } from "./artifacts.mjs";
export type { StaticDiagnostic } from "./artifacts.mjs";
import type { StaticTaskContext } from "./static-task-context.mjs";
export type { StaticTaskContext } from "./static-task-context.mjs";
export interface CiRun {
readonly id?: string | number;
readonly run_attempt?: number;
readonly status?: string;
readonly conclusion?: string | null;
readonly event?: string;
readonly path?: string;
readonly head_sha?: string;
readonly head_branch?: string;
readonly repository?: { readonly full_name?: string };
readonly pull_requests?: readonly {
readonly base?: {
readonly sha?: string;
readonly repo?: { readonly full_name?: string };
};
}[];
}
export interface CiJob {
readonly name?: string;
readonly status?: string;
readonly conclusion?: string | null;
readonly run_id?: number;
readonly run_attempt?: number;
}
export interface StaticEvidence {
readonly context: StaticTaskContext;
readonly diagnostics: readonly StaticDiagnostic[];
}
export interface CiFailureReport {
readonly version: 1;
readonly observedAt: string;
readonly repository: string;
readonly defaultBranch: string;
readonly informationalOnly: true;
readonly gateEffect: string;
readonly boundedInput: {
readonly candidateRuns: number;
readonly limit: number;
readonly mainCandidateLimit: number;
readonly mainCandidateRuns: number;
readonly mainRunLookupLimit: number;
readonly manifestTruncated: boolean;
};
readonly counts: Readonly<
Record<"matching-main-diagnostics" | "unknown", number>
>;
readonly failures: readonly {
readonly runId: number | string;
readonly runAttempt: number;
readonly classification: "matching-main-diagnostics" | "unknown";
readonly reason: string;
readonly candidateFailures?: readonly {
readonly runId: number | string;
readonly runAttempt: number;
readonly reason: string;
}[];
readonly runHead?: string | null;
readonly event?: string | null;
readonly job?: string;
readonly conclusion?: string | null;
}[];
}
export interface FailureReportApi {
getRun(repository: string, id: string | number): Promise<CiRun>;
getJobs(
repository: string,
id: string | number,
attempt: number,
): Promise<readonly CiJob[]>;
getStaticEvidence(
repository: string,
run: CiRun,
job: CiJob,
): Promise<StaticEvidence | null>;
getAncestry(
repository: string,
ancestor: string,
descendant: string,
): Promise<{ status: string; mergeBaseSha: string | null }>;
verifyExecutionSource(
repository: string,
run: CiRun,
context: StaticTaskContext,
): Promise<boolean>;
getBlobIdentity(
repository: string,
ancestor: string,
descendant: string,
paths: readonly string[],
): Promise<Readonly<Record<string, boolean>>>;
}
export function classifyFailure(input: {
repository: string;
defaultBranch: string;
run: CiRun & NormalizedWorkflowRun;
job: CiJob;
diagnostics: readonly StaticDiagnostic[];
context: StaticTaskContext;
mainRun: CiRun & NormalizedWorkflowRun;
mainJob: CiJob;
mainDiagnostics: readonly StaticDiagnostic[];
mainContext: StaticTaskContext;
executionSourcesVerified: boolean;
ancestry: { status: string; mergeBaseSha: string | null };
blobs: Readonly<Record<string, boolean>>;
}): {
classification: "matching-main-diagnostics" | "unknown";
reason: string;
};
export function reportCiFailures(options: {
repository: string;
defaultBranch: string;
batchManifest: {
readonly repository: string;
readonly runs: readonly {
readonly id: number | string;
readonly runAttempt: number;
readonly workflow: string;
readonly event?: string | null;
readonly headBranch?: string | null;
readonly conclusion: string | null;
readonly status: string;
}[];
readonly truncation?: readonly unknown[];
readonly coverage?: { readonly exhaustive?: boolean };
readonly selection?: { readonly exhaustive?: boolean };
};
api: FailureReportApi;
outputDirectory?: string;
summaryPath?: string;
observedAt?: string;
}): Promise<CiFailureReport>;
export function createGitHubApi(options?: {
token?: string;
fetchImpl?: typeof fetch;
apiRequest?: (
method: string,
endpoint: string,
budget: { requests: number; startedAt: number; limitMs: number },
) => Promise<unknown>;
}): FailureReportApi;
export function writeFailureReport(
report: CiFailureReport,
outputDirectory?: string,
summaryPath?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;

View File

@ -0,0 +1,529 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
classifyFailure,
diagnosticPaths,
isEligibleMainRun,
isFailedConclusion,
pullRequestBaseSha,
readStaticCheckArtifact,
safeSha,
selectStaticCheckArtifact,
validRepoPath,
} from "./artifacts.mjs";
import { createGhApiRequest } from "./github-api.mjs";
import { normalizeWorkflowRun } from "./workflow-run.mjs";
const OUTPUT_DIRECTORY = join(".cache", "ci-failures");
const BATCH_DIRECTORY = join(".cache", "ci-job-timings");
const MAX_JSON_BYTES = 8 * 1024 * 1024;
const MAX_FAILED_RUNS = 20;
const MAX_MAIN_CANDIDATES = 5;
const MAX_MAIN_RUN_LOOKUPS = 100;
const MAX_PATHS_PER_COMPARISON = 50;
const MAX_API_REQUESTS = 250;
export { classifyFailure } from "./artifacts.mjs";
/** Uses only failed CI candidates present in the supplied bounded timing manifest. */
export async function reportCiFailures({
repository,
defaultBranch,
batchManifest,
api,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
observedAt = new Date().toISOString(),
}) {
validateRepository(repository);
if (typeof defaultBranch !== "string" || defaultBranch.length === 0)
throw new Error("defaultBranch is required");
if (
!batchManifest ||
batchManifest.repository !== repository ||
!Array.isArray(batchManifest.runs)
)
throw new Error("timing manifest repository or runs are invalid");
const failedRuns = batchManifest.runs.filter(
(entry) =>
entry.workflow === "CI" &&
entry.status === "collected" &&
isFailedConclusion(entry.conclusion),
);
const prRuns = failedRuns.filter((entry) => entry.event === "pull_request");
const candidates = prRuns.slice(0, MAX_FAILED_RUNS);
const mainEntries = failedRuns.filter(
(entry) =>
entry.headBranch === defaultBranch &&
["push", "schedule", "workflow_dispatch"].includes(entry.event),
);
const lookupRun = verifiedRunLookup(repository, api);
const mainCandidates = [];
for (const entry of mainEntries.slice(0, MAX_MAIN_RUN_LOOKUPS)) {
try {
const run = await lookupRun(entry);
if (isEligibleMainRun(run, defaultBranch, repository))
mainCandidates.push({
run,
getEvidence: mainEvidenceLookup(repository, api, entry, run),
});
if (mainCandidates.length === MAX_MAIN_CANDIDATES) break;
} catch {
// Unverified metadata cannot consume the eligible-main candidate budget.
}
}
const entries = [];
for (const entry of candidates) {
try {
entries.push(
...(await analyzeFailedRun({
repository,
defaultBranch,
entry,
run: await lookupRun(entry),
mainCandidates,
api,
})),
);
} catch (error) {
entries.push({
runId: entry.id,
runAttempt: entry.runAttempt,
classification: "unknown",
reason: errorCode(error),
});
}
}
const report = {
version: 1,
observedAt,
repository,
defaultBranch,
informationalOnly: true,
gateEffect: "none; this report never changes CI conclusions",
boundedInput: {
candidateRuns: candidates.length,
limit: MAX_FAILED_RUNS,
mainCandidateLimit: MAX_MAIN_CANDIDATES,
mainCandidateRuns: mainCandidates.length,
mainRunLookupLimit: MAX_MAIN_RUN_LOOKUPS,
manifestTruncated:
prRuns.length > MAX_FAILED_RUNS ||
batchManifest.truncation?.length > 0 ||
batchManifest.selection?.exhaustive === false ||
batchManifest.coverage?.exhaustive === false,
},
counts: Object.fromEntries(
["matching-main-diagnostics", "unknown"].map((category) => [
category,
entries.filter((item) => item.classification === category).length,
]),
),
failures: entries,
};
await writeFailureReport(report, outputDirectory, summaryPath);
return report;
}
async function analyzeFailedRun({
repository,
defaultBranch,
entry,
run,
mainCandidates,
api,
}) {
const jobs = await api.getJobs(repository, entry.id, entry.runAttempt);
if (jobs.some((job) => !hasJobIdentity(job, entry)))
throw new Error("workflow_job_identity_mismatch");
const failedJobs = jobs.filter(
(job) => job.status === "completed" && isFailedConclusion(job.conclusion),
);
if (failedJobs.length === 0)
return [
{
runId: entry.id,
runAttempt: entry.runAttempt,
classification: "unknown",
reason: "failed_jobs_not_present_in_api_snapshot",
},
];
const failures = [];
for (const job of failedJobs) {
const evidence = await api.getStaticEvidence(repository, run, job);
const classification = await attributeToMain({
repository,
defaultBranch,
run,
job,
evidence,
mainCandidates,
api,
});
failures.push({
runId: entry.id,
runAttempt: entry.runAttempt,
runHead: safeSha(run.head_sha),
event: run.event ?? null,
job: job.name,
conclusion: job.conclusion,
...classification,
});
}
return failures;
}
async function attributeToMain({
repository,
defaultBranch,
run,
job,
evidence,
mainCandidates,
api,
}) {
const baseSha = pullRequestBaseSha(run, repository);
if (baseSha === null || evidence === null)
return outcome("unknown", "no_matching_verified_main_failure");
let result = outcome("unknown", "no_matching_verified_main_failure");
const candidateFailures = [];
for (const candidate of mainCandidates) {
try {
const main = await compareCandidate({
repository,
defaultBranch,
run,
job,
evidence,
baseSha,
candidate,
api,
});
if (main !== null) result = main;
if (main?.classification === "matching-main-diagnostics") break;
} catch (error) {
candidateFailures.push({
runId: candidate.run.id,
runAttempt: candidate.run.run_attempt,
reason: errorCode(error),
});
}
}
return {
...result,
...(candidateFailures.length > 0 ? { candidateFailures } : {}),
};
}
async function compareCandidate({
repository,
defaultBranch,
run,
job,
evidence,
baseSha,
candidate,
api,
}) {
const mainRun = candidate.run;
const main = await candidate.getEvidence(job.name);
if (!main?.evidence) return null;
const { job: mainJob, evidence: mainEvidence } = main;
const ancestry = await api.getAncestry(repository, mainRun.head_sha, baseSha);
const paths = [
...new Set(evidence.diagnostics.flatMap(diagnosticPaths)),
].slice(0, MAX_PATHS_PER_COMPARISON);
const blobs = await api.getBlobIdentity(
repository,
mainRun.head_sha,
baseSha,
paths,
);
const executionSourcesVerified =
(await api.verifyExecutionSource(repository, run, evidence.context)) &&
(await api.verifyExecutionSource(
repository,
mainRun,
mainEvidence.context,
));
return classifyFailure({
repository,
defaultBranch,
run,
job,
diagnostics: evidence.diagnostics,
context: evidence.context,
mainRun,
mainJob,
mainDiagnostics: mainEvidence.diagnostics,
mainContext: mainEvidence.context,
executionSourcesVerified,
ancestry,
blobs,
});
}
// Each verified main run/attempt owns its lazy jobs and parsed evidence for this
// report only. Cache nulls and rejected reads too; retries cannot spend the budget
// repeatedly on an immutable observation that was unavailable or invalid.
function mainEvidenceLookup(repository, api, entry, run) {
let jobs;
const evidence = new Map();
return (name) => {
jobs ??= Promise.resolve().then(async () => {
const items = await api.getJobs(repository, entry.id, entry.runAttempt);
return items.some((item) => !hasJobIdentity(item, entry)) ? [] : items;
});
if (!evidence.has(name))
evidence.set(
name,
jobs.then(async (items) => {
const job = items.find(
(item) =>
item.name === name &&
item.status === "completed" &&
isFailedConclusion(item.conclusion),
);
if (!job) return null;
return {
job,
evidence: await api.getStaticEvidence(repository, run, job),
};
}),
);
return evidence.get(name);
};
}
// Selection and attribution share one immutable API observation per run/attempt.
function verifiedRunLookup(repository, api) {
const runs = new Map();
return (entry) => {
const key = `${positiveId(entry.id)}:${positiveId(entry.runAttempt)}`;
if (!runs.has(key))
runs.set(
key,
Promise.resolve().then(async () => {
const run = normalizeWorkflowRun(
await api.getRun(repository, entry.id),
);
if (!hasRunIdentity(run, entry))
throw new Error("workflow_run_identity_mismatch");
return run;
}),
);
return runs.get(key);
};
}
function hasRunIdentity(run, entry) {
return (
String(run?.id) === String(entry.id) &&
Number(run.run_attempt) === Number(entry.runAttempt) &&
run.workflowPath?.file === ".github/workflows/ci.yml"
);
}
function hasJobIdentity(job, entry) {
return (
String(job.run_id) === String(entry.id) &&
Number(job.run_attempt) === Number(entry.runAttempt)
);
}
export function createGitHubApi({
token = process.env.GH_TOKEN,
fetchImpl = fetch,
apiRequest = createGhApiRequest(),
} = {}) {
if (!token) throw new Error("GH_TOKEN is required");
const budget = { requests: 0, startedAt: Date.now(), limitMs: 60_000 };
const request = async (endpoint) => {
if (budget.requests >= MAX_API_REQUESTS)
throw new Error("github_api_request_budget_exhausted");
budget.requests += 1;
return await apiRequest("GET", endpoint, budget);
};
return {
async getRun(repository, id) {
return await request(
`repos/${repository}/actions/runs/${positiveId(id)}`,
);
},
async getJobs(repository, id, attempt) {
const page = await request(
`repos/${repository}/actions/runs/${positiveId(id)}/attempts/${positiveId(attempt)}/jobs?per_page=100&page=1`,
);
if (!Array.isArray(page.jobs) || page.jobs.length >= 100)
throw new Error("job_list_incomplete_or_malformed");
return page.jobs;
},
async getStaticEvidence(repository, run, job) {
if (job.name !== "Static checks") return null;
if (run.run_attempt !== 1) return null;
const page = await request(
`repos/${repository}/actions/runs/${positiveId(run.id)}/artifacts?per_page=100&page=1`,
);
const artifact = selectStaticCheckArtifact(page, run, run.run_attempt);
if (!artifact) return null;
return await readStaticCheckArtifact({
repository,
artifactId: artifact.id,
token,
fetchImpl,
});
},
async getAncestry(repository, ancestor, descendant) {
const comparison = await request(
`repos/${repository}/compare/${safeSha(ancestor)}...${safeSha(descendant)}`,
);
return {
status: comparison.status,
mergeBaseSha: safeSha(comparison.merge_base_commit?.sha),
};
},
async verifyExecutionSource(repository, run, context) {
const sourceSha = safeSha(context.sourceSha);
if (!sourceSha) return false;
const pr =
run.event === "pull_request"
? pullRequestBaseSha(run, repository)
: null;
if (
run.event === "pull_request" &&
(pr === null || context.prHeadSha !== run.head_sha)
)
return false;
const commit = await request(`repos/${repository}/commits/${sourceSha}`);
if (safeSha(commit.sha) !== sourceSha) return false;
if (sourceSha === run.head_sha) return true;
if (
pr === null ||
!Array.isArray(commit.parents) ||
commit.parents.length !== 2
)
return false;
return (
safeSha(commit.parents[0]?.sha) === pr &&
safeSha(commit.parents[1]?.sha) === run.head_sha
);
},
async getBlobIdentity(repository, ancestor, descendant, paths) {
const result = {};
for (const path of paths) {
if (!validRepoPath(path)) continue;
const [left, right] = await Promise.all([
getContent(repository, ancestor, path, request),
getContent(repository, descendant, path, request),
]);
result[path] = left !== null && left === right;
}
return result;
},
};
}
async function getContent(repository, ref, path, request) {
try {
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
const value = await request(
`repos/${repository}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`,
);
return typeof value.sha === "string" ? value.sha : null;
} catch {
return null;
}
}
function outcome(classification, reason) {
return { classification, reason };
}
function validateRepository(value) {
if (
typeof value !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)
)
throw new Error("repository must be owner/name");
}
function positiveId(value) {
if (!Number.isSafeInteger(Number(value)) || Number(value) < 1)
throw new Error(
"GitHub run and attempt IDs must be positive safe integers",
);
return String(Number(value));
}
function errorCode(error) {
return error instanceof Error ? error.message.slice(0, 160) : "unknown_error";
}
export async function writeFailureReport(
report,
outputDirectory = OUTPUT_DIRECTORY,
summaryPath,
) {
await mkdir(outputDirectory, { recursive: true });
const jsonPath = join(outputDirectory, "report.json");
const markdownPath = join(outputDirectory, "report.md");
const matchedDiagnostics = report.failures.filter(
(item) => item.classification === "matching-main-diagnostics",
);
const lines = [
"# CI failure attribution",
"",
"Informational only. This report never changes a CI conclusion or required gate.",
"",
`Observed ${report.observedAt}; ${String(matchedDiagnostics.length)} of ${String(report.failures.length)} failure reports contain supported diagnostics matching a failed static task on an ancestor main revision. This does not attribute other failures in the same job. Unknown means evidence was incomplete or did not match.`,
"",
];
for (const item of report.failures)
lines.push(
`- Run ${item.runId} attempt ${item.runAttempt}${item.job ? `, ${item.job}` : ""}: **${item.classification}** — ${item.reason}.`,
);
if (report.failures.length === 0)
lines.push(
"- No completed failed CI attempts were present in the supplied timing snapshot.",
);
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeFile(markdownPath, `${lines.join("\n")}\n`, "utf8");
if (summaryPath)
await appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
return { jsonPath, markdownPath };
}
async function main() {
const repository = process.env.CI_REPOSITORY;
const defaultBranch = process.env.CI_DEFAULT_BRANCH;
const batchPath =
process.env.CI_TIMING_MANIFEST ??
join(BATCH_DIRECTORY, "batch-manifest.json");
const batchManifest = JSON.parse(await readBoundedFile(batchPath));
const api = createGitHubApi();
await reportCiFailures({
repository,
defaultBranch,
batchManifest,
api,
outputDirectory: process.env.CI_FAILURES_DIRECTORY ?? OUTPUT_DIRECTORY,
summaryPath: process.env.GITHUB_STEP_SUMMARY,
});
}
async function readBoundedFile(path) {
const bytes = await readFile(path);
if (bytes.byteLength > MAX_JSON_BYTES)
throw new Error("timing manifest exceeds size limit");
return bytes.toString("utf8");
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@ -0,0 +1,16 @@
export interface GitHubApiBudget {
requests: number;
startedAt: number;
limitMs: number;
}
export type GitHubApiRequest = (
method: string,
endpoint: string,
budget: GitHubApiBudget,
) => Promise<unknown>;
export function createGhApiRequest(options?: {
env?: NodeJS.ProcessEnv;
now?: () => number;
}): GitHubApiRequest;

33
scripts/ci/github-api.mjs Normal file
View File

@ -0,0 +1,33 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 8 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
/** Create a bounded GitHub CLI JSON transport with GET-only authority. */
export function createGhApiRequest({ env = process.env, now = Date.now } = {}) {
return async (method, endpoint, budget) => {
if (method !== "GET")
throw new Error(
"GitHub API transport supports read-only GET requests only",
);
const remaining = budget.limitMs - (now() - budget.startedAt);
if (remaining <= 0) throw new Error("lookup_time_budget_exhausted");
const { stdout } = await execFileAsync(
"gh",
["api", "--method", "GET", endpoint],
{
env,
encoding: "utf8",
maxBuffer: MAX_BUFFER,
timeout: Math.min(REQUEST_TIMEOUT_MS, remaining),
},
);
try {
return JSON.parse(stdout);
} catch (cause) {
throw new Error("github_api_malformed_json", { cause });
}
};
}

View File

@ -103,6 +103,9 @@ export async function collectJobTimingsBatch({
id: run.id,
runAttempt: run.run_attempt,
workflow: workflow.name,
event: typeof run.event === "string" ? run.event : null,
headBranch:
typeof run.head_branch === "string" ? run.head_branch : null,
conclusion: run.conclusion ?? null,
createdAt: run.created_at ?? null,
});

View File

@ -0,0 +1,95 @@
export interface PrCiHead {
repositoryId: number | null;
repository?: string | null;
ref: string | null;
sha: string | null;
}
export interface PrCiPullRequest {
number: number | null;
draft?: boolean;
identityValid: boolean;
mergeable?: boolean;
head: PrCiHead;
[key: string]: unknown;
}
export interface PrCiWorkflowRun {
id?: number;
run_attempt?: number;
event?: string;
head_branch?: string;
head_repository?: { id?: number | null } | null;
head_sha?: string;
status?: string;
conclusion?: string | null;
created_at?: string;
html_url?: string;
pull_requests?: Array<{
number?: number;
head?: { sha?: string; repo?: { id?: number | null } } | null;
}>;
[key: string]: unknown;
}
export interface PrCiClassification {
category: string;
reason: string;
runs: Array<Record<string, unknown>>;
}
export interface PrCiHealthReport {
schemaVersion: 1;
observedAt: string;
repository: string;
repositoryId: number | null;
defaultBranch: string;
lookbackDays: number;
scan: {
status: "complete" | "partial";
cutoff: string;
pullRequestPages: number;
workflowRunPages: number;
pullRequestInventoryComplete: boolean;
workflowRunInventoryComplete: boolean;
pullRequestDetailsFetched: number;
pullRequestDetailsLimit: number;
apiRequests: number;
apiRequestLimit: number;
errors: string[];
};
counts: Record<string, number>;
pullRequests: Array<PrCiPullRequest & { ci: PrCiClassification }>;
}
export function collectPrCiHealth(options: {
repository: string;
repositoryId?: number | string | null;
defaultBranch: string;
lookbackDays?: number;
now?: number;
apiRequest?: GitHubApiRequest;
}): Promise<PrCiHealthReport>;
export function classifyPrCi(input: {
pull: PrCiPullRequest;
runs: Array<{ run: PrCiWorkflowRun; basis: string }>;
runsComplete: boolean;
pullRequestInventoryComplete: boolean;
ambiguousRuns: Array<{ run: PrCiWorkflowRun; basis: string }>;
}): PrCiClassification;
export function matchRunToPullRequest(input: {
run: PrCiWorkflowRun;
pull: PrCiPullRequest;
identityCounts: Map<string, number>;
inventoryComplete?: boolean;
}): { kind: "none" } | { kind: "ambiguous" } | { kind: "match"; basis: string };
export function writePrCiHealthReport(
report: PrCiHealthReport,
outputDirectory?: string,
): Promise<{ jsonPath: string; markdownPath: string }>;
export function renderMarkdownSummary(report: PrCiHealthReport): string;
import type { GitHubApiRequest } from "./github-api.mjs";

718
scripts/ci/pr-ci-health.mjs Normal file
View File

@ -0,0 +1,718 @@
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createGhApiRequest } from "./github-api.mjs";
const OUTPUT_DIRECTORY = join(".cache", "ci-pr-health");
const PAGE_SIZE = 100;
const MAX_PULL_REQUEST_PAGES = 3;
const MAX_WORKFLOW_RUN_PAGES = 5;
const MAX_PULL_REQUEST_DETAILS = 40;
const MAX_API_REQUESTS =
MAX_PULL_REQUEST_PAGES + MAX_WORKFLOW_RUN_PAGES + MAX_PULL_REQUEST_DETAILS;
const LOOKBACK_DAYS = 14;
const MAX_LOOKBACK_DAYS = 30;
const MAX_TOTAL_TIME_MS = 60_000;
const SHA_PATTERN = /^[0-9a-f]{40}$/iu;
const COMPLETED_CATEGORIES = new Map([
["failure", "completed_failure"],
["timed_out", "completed_failure"],
["cancelled", "completed_canceled"],
["success", "completed_success"],
]);
/** Read workflow/PR state and conservatively distinguish absent CI from unknown. */
export async function collectPrCiHealth({
repository,
repositoryId,
defaultBranch,
lookbackDays = LOOKBACK_DAYS,
now = Date.now(),
apiRequest = createGhApiRequest(),
}) {
const identity = validateInputs({
repository,
repositoryId,
defaultBranch,
lookbackDays,
now,
});
const observedAt = new Date(now).toISOString();
const cutoff = now - identity.lookbackDays * 24 * 60 * 60 * 1_000;
const snapshot = await collectApiSnapshot({ apiRequest, identity, cutoff });
const classified = classifyPullRequests(snapshot);
const { pulls, runs, budget } = snapshot;
const errors = [pulls.error, runs.error, ...snapshot.detailsErrors].filter(
Boolean,
);
return {
schemaVersion: 1,
observedAt,
repository: identity.repository,
repositoryId: identity.repositoryId,
defaultBranch: identity.defaultBranch,
lookbackDays: identity.lookbackDays,
scan: {
status:
errors.length === 0 && pulls.complete && runs.complete
? "complete"
: "partial",
cutoff: new Date(cutoff).toISOString(),
pullRequestPages: pulls.pages,
workflowRunPages: runs.pages,
pullRequestInventoryComplete: pulls.complete,
workflowRunInventoryComplete: runs.complete,
pullRequestDetailsFetched: snapshot.detailsFetched,
pullRequestDetailsLimit: MAX_PULL_REQUEST_DETAILS,
apiRequests: budget.requests,
apiRequestLimit: MAX_API_REQUESTS,
errors,
},
counts: countCategories(classified),
pullRequests: classified,
};
}
async function collectApiSnapshot({ apiRequest, identity, cutoff }) {
const budget = {
requests: 0,
startedAt: Date.now(),
limitMs: MAX_TOTAL_TIME_MS,
};
const requestGet = async (endpoint) => {
if (budget.requests >= MAX_API_REQUESTS)
throw new Error("lookup_request_budget_exhausted");
if (Date.now() - budget.startedAt >= budget.limitMs)
throw new Error("lookup_time_budget_exhausted");
budget.requests += 1;
return await apiRequest("GET", endpoint, budget);
};
const pulls = await listOpenPullRequests({
requestGet,
identity,
});
const candidates = pulls.items.filter(
(pull) =>
!pull.draft &&
Number.isFinite(Date.parse(pull.createdAt ?? "")) &&
Date.parse(pull.createdAt ?? "") >= cutoff &&
pull.base.ref === identity.defaultBranch &&
(identity.repositoryId === null ||
pull.base.repositoryId === identity.repositoryId),
);
const runs = await listRecentWorkflowRuns({
requestGet,
identity,
cutoff,
});
const detailResult = await resolveMergeability({
candidates,
requestGet,
identity,
});
return {
budget,
pulls,
runs,
candidates,
detailsFetched: detailResult.fetched,
detailsErrors: detailResult.errors,
};
}
function classifyPullRequests(snapshot) {
const { candidates, pulls, runs } = snapshot;
const identityCounts = countHeadIdentities(pulls.items);
return candidates.map((pull) => {
const relevant = [];
const ambiguousRuns = [];
for (const run of runs.items) {
const match = matchRunToPullRequest({
run,
pull,
identityCounts,
inventoryComplete: pulls.complete,
});
if (match.kind === "match") relevant.push({ run, basis: match.basis });
if (match.kind === "ambiguous")
ambiguousRuns.push({ run, basis: "ambiguous_head_identity" });
}
return {
...pull,
ci: classifyPrCi({
pull,
runs: relevant,
runsComplete: runs.complete,
pullRequestInventoryComplete: pulls.complete,
ambiguousRuns,
}),
};
});
}
async function resolveMergeability({ candidates, requestGet, identity }) {
let detailsFetched = 0;
const errors = new Set();
for (const pull of candidates) {
if (pull.mergeable !== undefined) continue;
if (detailsFetched >= MAX_PULL_REQUEST_DETAILS) {
errors.add("pull_request_mergeability_lookup_budget_exhausted");
continue;
}
const result = await getPullRequestDetails({ requestGet, identity, pull });
detailsFetched += 1;
if (result.error !== null) {
errors.add(result.error);
continue;
}
const { detail } = result;
const identityChanged =
detail.headSha !== pull.head.sha ||
detail.headRef !== pull.head.ref ||
detail.headRepositoryId !== pull.head.repositoryId ||
detail.baseRef !== pull.base.ref ||
detail.baseRepositoryId !== pull.base.repositoryId;
if (identityChanged) {
pull.identityValid = false;
pull.identityChangedDuringScan = true;
errors.add("pull_request_identity_changed_during_scan");
} else {
pull.mergeable = detail.mergeable;
pull.detailFetched = true;
if (detail.mergeable === undefined)
errors.add("pull_request_mergeability_unknown");
}
}
return { fetched: detailsFetched, errors: [...errors] };
}
function countCategories(pulls) {
const categories = [
"missing",
"startup_failure",
"queued_pending_executing",
"approval_required",
"completed_success",
"completed_failure",
"completed_canceled",
"completed_other",
"merge_conflict",
"unknown",
"deferred",
];
return Object.fromEntries(
categories.map((category) => [
category,
pulls.filter(({ ci }) => ci.category === category).length,
]),
);
}
/** Pure status classifier; missing requires complete, identity-safe inventories. */
export function classifyPrCi({
pull,
runs,
runsComplete,
pullRequestInventoryComplete,
ambiguousRuns,
}) {
if (!pull.identityValid)
return result("unknown", "pull_request_head_identity_invalid", []);
const evidence = [...runs, ...ambiguousRuns];
if (pull.mergeable === false)
return result("merge_conflict", "github_reports_merge_conflict", evidence);
// The newest collected run is not proven current until pagination completes:
// an older-created run outside this snapshot may have been retried recently.
if (!runsComplete)
return result(
evidence.length > 0 ? "unknown" : "deferred",
evidence.length > 0
? "workflow_run_inventory_incomplete"
: "lookup_inventory_incomplete",
evidence,
);
const selection = newestRun(runs);
if (!selection.known)
return result("unknown", "workflow_run_order_unknown", evidence);
const latest = selection.latest;
if (
ambiguousRuns.some((candidate) => {
const order = latest ? compareRunEvidence(candidate, latest) : null;
return order === null || order >= 0;
})
)
return result("unknown", "workflow_run_identity_ambiguous", evidence);
if (latest !== undefined) {
const run = latest.run;
if (run.conclusion === "startup_failure")
return result("startup_failure", "workflow_startup_failure", evidence);
if (run.status === "waiting" || run.conclusion === "action_required")
return result(
"approval_required",
"workflow_waiting_for_approval_or_gate",
evidence,
);
if (["queued", "requested", "pending", "in_progress"].includes(run.status))
return result(
"queued_pending_executing",
`workflow_${run.status}`,
evidence,
);
if (run.status === "completed")
return result(
COMPLETED_CATEGORIES.get(run.conclusion) ?? "completed_other",
run.conclusion === "success"
? "workflow_succeeded"
: `workflow_${run.conclusion ?? "unknown_conclusion"}`,
evidence,
);
return result("unknown", "workflow_status_unrecognized", evidence);
}
if (!pullRequestInventoryComplete)
return result("deferred", "lookup_inventory_incomplete", []);
if (pull.mergeable !== true)
return result("unknown", "pull_request_mergeability_unknown", []);
return result("missing", "no_pull_request_event_run_for_current_head", []);
}
/** Match only a pull_request event proven to belong to this immutable PR head. */
export function matchRunToPullRequest({
run,
pull,
identityCounts,
inventoryComplete = true,
}) {
if (run?.event !== "pull_request") return { kind: "none" };
if (!pull.identityValid || !isRecord(run)) return { kind: "none" };
const association = matchExplicitPullAssociation(run, pull);
if (association === "match")
return { kind: "match", basis: "event_pr_number_and_head_sha" };
if (association === "ambiguous") return { kind: "ambiguous" };
if (association === "other" || !sameHeadRepositoryAndRef(run, pull))
return { kind: "none" };
if (!uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete))
return { kind: "ambiguous" };
if (isSha(run.head_sha) && run.head_sha.toLowerCase() === pull.head.sha)
return { kind: "match", basis: "event_unique_head_ref_and_repository_id" };
return { kind: "none" };
}
function matchExplicitPullAssociation(run, pull) {
const associations = Array.isArray(run.pull_requests)
? run.pull_requests
: [];
if (associations.length === 0) return "absent";
const matchingNumber = associations.filter(
(item) => item?.number === pull.number,
);
if (matchingNumber.length === 0) return "other";
const exactHead = matchingNumber.find(
(item) => item?.head?.sha === pull.head.sha,
);
if (exactHead === undefined) {
return matchingNumber.some(
(item) =>
typeof item?.head?.sha === "string" && item.head.sha !== pull.head.sha,
)
? "other"
: "absent";
}
const associatedHeadRepositoryId = positiveInteger(exactHead.head?.repo?.id);
const runHeadRepositoryId = positiveInteger(run.head_repository?.id);
if (associatedHeadRepositoryId === null || runHeadRepositoryId === null)
return "ambiguous";
if (
associatedHeadRepositoryId !== pull.head.repositoryId ||
runHeadRepositoryId !== pull.head.repositoryId
)
return "other";
const baseRefMatches =
typeof exactHead.base?.ref !== "string" ||
exactHead.base.ref === pull.base.ref;
const baseRepositoryId = positiveInteger(exactHead.base?.repo?.id);
const baseRepositoryMatches =
baseRepositoryId === null || baseRepositoryId === pull.base.repositoryId;
return baseRefMatches && baseRepositoryMatches ? "match" : "other";
}
function sameHeadRepositoryAndRef(run, pull) {
return (
run.head_branch === pull.head.ref &&
run.head_repository?.id === pull.head.repositoryId
);
}
function uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete) {
if (!inventoryComplete) return false;
const key = headIdentityKey(pull.head.repositoryId, pull.head.ref);
const unknownRepositoryKey = headIdentityKey(null, pull.head.ref);
return (
identityCounts.get(key) === 1 &&
(identityCounts.get(unknownRepositoryKey) ?? 0) === 0
);
}
function normalizePullRequest(raw, identity) {
const number = positiveInteger(raw?.number);
const base = normalizeBase(raw?.base);
const head = normalizeHead(raw?.head);
const draft = raw?.draft === true;
return {
number,
draft,
createdAt: typeof raw?.created_at === "string" ? raw.created_at : null,
url: typeof raw?.html_url === "string" ? raw.html_url : null,
base,
head,
mergeable: typeof raw?.mergeable === "boolean" ? raw.mergeable : undefined,
identityValid: hasValidPullIdentity({ raw, identity, number, base, head }),
};
}
function normalizeBase(raw) {
return {
ref: typeof raw?.ref === "string" ? raw.ref : null,
repositoryId: positiveInteger(raw?.repo?.id),
};
}
function normalizeHead(raw) {
return {
repositoryId: positiveInteger(raw?.repo?.id),
repository:
typeof raw?.repo?.full_name === "string" ? raw.repo.full_name : null,
ref: typeof raw?.ref === "string" ? raw.ref : null,
sha:
typeof raw?.sha === "string" && isSha(raw.sha)
? raw.sha.toLowerCase()
: null,
};
}
function hasValidPullIdentity({ raw, identity, number, base, head }) {
return (
number !== null &&
typeof raw?.draft === "boolean" &&
base.ref === identity.defaultBranch &&
(identity.repositoryId === null ||
base.repositoryId === identity.repositoryId) &&
head.repositoryId !== null &&
head.sha !== null &&
head.ref !== null
);
}
function countHeadIdentities(pulls) {
const counts = new Map();
for (const pull of pulls) {
if (typeof pull.head.ref !== "string") continue;
const key = headIdentityKey(pull.head.repositoryId, pull.head.ref);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
function headIdentityKey(repositoryId, ref) {
return `${String(repositoryId)}\u0000${ref}`;
}
function newestRun(runs) {
let latest = runs[0];
for (const candidate of runs) {
if (latest && (compareRunEvidence(candidate, latest) ?? 0) > 0)
latest = candidate;
}
const known = runs.every((candidate) => {
if (candidate === latest) return true;
const order = compareRunEvidence(candidate, latest);
return order !== null && order <= 0;
});
return { known, latest };
}
// Attempt ordering is meaningful within one run; different run IDs are not clocks.
function compareRunEvidence(left, right) {
const leftRun = left.run;
const rightRun = right.run;
const id = positiveInteger(leftRun.id);
if (id !== null && id === positiveInteger(rightRun.id)) {
const leftAttempt = positiveInteger(leftRun.run_attempt);
const rightAttempt = positiveInteger(rightRun.run_attempt);
if (leftAttempt === null || rightAttempt === null) return null;
if (leftAttempt !== rightAttempt) return leftAttempt - rightAttempt;
return leftRun.status === rightRun.status &&
leftRun.conclusion === rightRun.conclusion
? 0
: null;
}
// A retry may start long after its original creation; without an attempt
// clock, creation dates only order first attempts from different runs.
if (
positiveInteger(leftRun.run_attempt) !== 1 ||
positiveInteger(rightRun.run_attempt) !== 1
)
return null;
const time =
Date.parse(leftRun.created_at ?? "") -
Date.parse(rightRun.created_at ?? "");
return Number.isFinite(time) && time !== 0 ? time : null;
}
function result(category, reason, runs) {
return {
category,
reason,
runs: runs.map(({ run, basis }) => ({
id: positiveInteger(run.id),
attempt: positiveInteger(run.run_attempt),
status: typeof run.status === "string" ? run.status : null,
conclusion: typeof run.conclusion === "string" ? run.conclusion : null,
headSha: isSha(run.head_sha) ? run.head_sha.toLowerCase() : null,
createdAt: typeof run.created_at === "string" ? run.created_at : null,
url: typeof run.html_url === "string" ? run.html_url : null,
identityBasis: basis,
})),
};
}
async function listOpenPullRequests({ requestGet, identity }) {
const items = [];
let complete = false;
let pages = 0;
let error = null;
for (let page = 1; page <= MAX_PULL_REQUEST_PAGES; page += 1) {
try {
const endpoint = `repos/${identity.repository}/pulls?state=open&sort=created&direction=desc&per_page=${PAGE_SIZE}&page=${page}`;
const response = await requestGet(endpoint);
if (!Array.isArray(response))
throw new Error("pull_request_page_malformed");
pages += 1;
const normalized = response.map((raw) =>
normalizePullRequest(raw, identity),
);
if (
normalized.some(
(pull) => !Number.isFinite(Date.parse(pull.createdAt ?? "")),
)
)
throw new Error("pull_request_page_malformed");
items.push(...normalized);
if (response.length < PAGE_SIZE) {
complete = true;
break;
}
} catch (cause) {
error = safeErrorCode(cause);
break;
}
}
return { items, pages, complete, error };
}
async function listRecentWorkflowRuns({ requestGet, identity, cutoff }) {
const items = [];
let complete = false;
let pages = 0;
let error = null;
for (let page = 1; page <= MAX_WORKFLOW_RUN_PAGES; page += 1) {
try {
const cutoffDate = new Date(cutoff).toISOString().slice(0, 10);
const endpoint = `repos/${identity.repository}/actions/workflows/ci.yml/runs?event=pull_request&created=%3E%3D${cutoffDate}&per_page=${PAGE_SIZE}&page=${page}`;
const response = await requestGet(endpoint);
if (!isRecord(response) || !Array.isArray(response.workflow_runs))
throw new Error("workflow_run_page_malformed");
pages += 1;
if (!response.workflow_runs.every(isRecord))
throw new Error("workflow_run_page_malformed");
items.push(...response.workflow_runs);
if (response.workflow_runs.length < PAGE_SIZE) {
complete = true;
break;
}
} catch (cause) {
error = safeErrorCode(cause);
break;
}
}
return { items, pages, complete, error };
}
async function getPullRequestDetails({ requestGet, identity, pull }) {
if (pull.number === null)
return { detail: null, error: "pull_request_detail_identity_missing" };
try {
const response = await requestGet(
`repos/${identity.repository}/pulls/${pull.number}`,
);
if (!isRecord(response))
return { detail: null, error: "pull_request_detail_malformed" };
return {
detail: {
mergeable:
typeof response.mergeable === "boolean"
? response.mergeable
: undefined,
headSha: isSha(response.head?.sha)
? response.head.sha.toLowerCase()
: null,
headRef:
typeof response.head?.ref === "string" ? response.head.ref : null,
headRepositoryId: positiveInteger(response.head?.repo?.id),
baseRef:
typeof response.base?.ref === "string" ? response.base.ref : null,
baseRepositoryId: positiveInteger(response.base?.repo?.id),
},
error: null,
};
} catch (cause) {
return { detail: null, error: safeErrorCode(cause) };
}
}
function validateInputs({
repository,
repositoryId,
defaultBranch,
lookbackDays,
now,
}) {
if (
typeof repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
)
throw new Error(
"CI_REPOSITORY must be an owner/name repository identifier",
);
if (
typeof defaultBranch !== "string" ||
defaultBranch.length === 0 ||
/[\r\n]/u.test(defaultBranch)
)
throw new Error("CI_DEFAULT_BRANCH must be a nonempty branch name");
const parsedRepositoryId =
repositoryId === undefined || repositoryId === null || repositoryId === ""
? null
: positiveInteger(repositoryId);
if (
repositoryId !== undefined &&
repositoryId !== null &&
repositoryId !== "" &&
parsedRepositoryId === null
)
throw new Error("CI_REPOSITORY_ID must be a positive safe integer");
if (
!Number.isInteger(lookbackDays) ||
lookbackDays < 1 ||
lookbackDays > MAX_LOOKBACK_DAYS
)
throw new Error(
`lookbackDays must be an integer from 1 to ${String(MAX_LOOKBACK_DAYS)}`,
);
if (!Number.isFinite(now)) throw new Error("now must be a finite timestamp");
return {
repository,
repositoryId: parsedRepositoryId,
defaultBranch,
lookbackDays,
};
}
function positiveInteger(value) {
const number = typeof value === "number" ? value : Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : null;
}
function isSha(value) {
return typeof value === "string" && SHA_PATTERN.test(value);
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeErrorCode(cause) {
if (cause?.message === "lookup_time_budget_exhausted")
return "lookup_time_budget_exhausted";
if (cause?.message === "lookup_request_budget_exhausted")
return "lookup_request_budget_exhausted";
if (cause?.message === "github_api_malformed_json")
return "github_api_malformed_json";
if (cause?.message?.endsWith("_malformed")) return cause.message;
if (cause?.code === "ETIMEDOUT" || cause?.killed) return "github_api_timeout";
return "github_api_request_failed";
}
export async function writePrCiHealthReport(
report,
outputDirectory = OUTPUT_DIRECTORY,
) {
const jsonPath = join(outputDirectory, "report.json");
const markdownPath = join(outputDirectory, "summary.md");
await mkdir(outputDirectory, { recursive: true });
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeFile(markdownPath, renderMarkdownSummary(report), "utf8");
return { jsonPath, markdownPath };
}
export function renderMarkdownSummary(report) {
const rows = report.pullRequests.map(
(pull) =>
`| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`,
);
return [
"# Pull request CI health snapshot",
"",
`Observed ${report.observedAt} for \`${report.repository}\`, target branch \`${escapeCell(report.defaultBranch)}\`; recent window ${String(report.lookbackDays)} days. Scan: **${report.scan.status}**.`,
"",
"This is read-only point-in-time telemetry. Missing is reported only when PR and workflow-run inventories are complete and the current immutable PR head identity is unambiguous. Incomplete lookups remain deferred or unknown.",
"",
`API pages: ${String(report.scan.pullRequestPages)} PR, ${String(report.scan.workflowRunPages)} workflow runs; ${String(report.scan.apiRequests)} bounded requests. Errors: ${report.scan.errors.length === 0 ? "none" : report.scan.errors.join(", ")}.`,
"",
"| PR | Head repository | Head ref | Head SHA | CI classification | Evidence |",
"| ---: | --- | --- | --- | --- | --- |",
...rows,
"",
"Categories: missing, startup failure, queued/pending/executing, approval required, completed outcomes, merge conflict, unknown, and deferred.",
"",
].join("\n");
}
function escapeCell(value) {
return String(value)
.replaceAll("|", "\\|")
.replaceAll("\n", " ")
.replaceAll("\r", " ");
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
const report = await collectPrCiHealth({
repository: process.env.CI_REPOSITORY,
repositoryId: process.env.CI_REPOSITORY_ID,
defaultBranch: process.env.CI_DEFAULT_BRANCH,
});
const paths = await writePrCiHealthReport(
report,
process.env.CI_PR_HEALTH_DIRECTORY ?? OUTPUT_DIRECTORY,
);
if (process.env.GITHUB_STEP_SUMMARY)
await appendFile(
process.env.GITHUB_STEP_SUMMARY,
renderMarkdownSummary(report),
"utf8",
);
process.stdout.write(
`Wrote ${report.pullRequests.length} recent PR CI records to ${paths.jsonPath}\n`,
);
} catch (cause) {
process.stderr.write(
`PR CI health telemetry failed: ${cause instanceof Error ? cause.message : "unknown_error"}\n`,
);
process.exitCode = 1;
}
}

View File

@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeWorkflowRun } from "./workflow-run.mjs";
const ARTIFACT_NAME = "ci-full-qualification-v1";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
@ -119,8 +120,8 @@ function admit() {
// Artifact metadata narrows the search before run lookups. Never scan an
// unbounded history or treat a missing receipt as a successful qualification.
for (const artifact of candidates.slice(0, 3)) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
const run = normalizeWorkflowRun(
api(`repos/${repository}/actions/runs/${artifact.workflow_run.id}`),
);
// An old run can upload a new artifact when only its gate is rerun.
// Bound the original run age as well so retries cannot renew stale proof.
@ -133,7 +134,7 @@ function admit() {
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
run.workflowPath?.file === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_sha === head &&
run.head_branch === branch &&

View File

@ -30,7 +30,11 @@ if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
throw new Error("Full qualification requires every canonical CI scope");
const required = reused ? ["changes"] : ["changes", "static", "test"];
const lanes = {
code: ["build", "test-shard"],
code: [
"build",
"test-shard",
...(outputs.tier === "fast" ? ["test-source"] : []),
],
reverse_tools: ["reverse-tools-quality"],
runtime: ["windows-native-build", "package-e2e"],
package: ["windows-curated"],

View File

@ -133,6 +133,8 @@ try {
for (const clone of report.duplicates)
for (const file of [clone.firstFile, clone.secondFile])
file.name = relative(process.cwd(), file.name).split(sep).join("/");
// Record the actual checker result, including caller-selected thresholds.
report.reaCheck = { exitCode: result.status };
await writeFile(reportPath, JSON.stringify(report, null, 2));
const { sources, clones, percentage } = report.statistics.total;
console.log(

View File

@ -0,0 +1,18 @@
export interface StaticTaskContext {
readonly version: 1;
readonly sourceSha: string;
readonly sourceClean: boolean;
readonly prHeadSha?: string;
readonly nodeVersion: string;
readonly npmVersion: string;
readonly turboVersion: string;
readonly platform: string;
readonly architecture: string;
readonly taskHashes: Readonly<Record<string, string>>;
}
export function parseStaticTaskContext(value: unknown): StaticTaskContext;
export function recordStaticTaskContext(options?: {
root?: string;
npmCli?: string;
outputDirectory?: string;
}): Promise<StaticTaskContext>;

View File

@ -0,0 +1,104 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { z } from "zod";
const execute = promisify(execFile);
const contextSchema = z.object({
version: z.literal(1),
sourceSha: z.string().regex(/^[a-f0-9]{40}$/u),
sourceClean: z.boolean(),
prHeadSha: z
.string()
.regex(/^[a-f0-9]{40}$/u)
.optional(),
nodeVersion: z.string().min(1),
npmVersion: z.string().min(1),
turboVersion: z.string().min(1),
platform: z.string().min(1),
architecture: z.string().min(1),
taskHashes: z.record(z.string(), z.string().regex(/^[a-f0-9]{16}$/u)),
});
/** Parse the producing check graph's receipt; incomplete provenance is unknown. */
export const parseStaticTaskContext = (value) => contextSchema.parse(value);
/** Retain only named task hashes and tool/source identity, never the environment. */
export async function recordStaticTaskContext({
root = process.cwd(),
npmCli = process.env.npm_execpath,
outputDirectory = join(root, ".cache/static-checks"),
} = {}) {
if (typeof npmCli !== "string" || !npmCli)
throw new Error(
"Run static context recording through npm run ci:static-context",
);
const options = {
cwd: root,
encoding: "utf8",
timeout: 30_000,
maxBuffer: 32 * 1024 * 1024,
};
const [graph, source, status, npmVersion, turbo] = await Promise.all([
execute(
process.execPath,
[npmCli, "run", "--silent", "check:ci:tasks", "--", "--dry=json"],
options,
),
execute("git", ["rev-parse", "HEAD"], options),
execute(
"git",
["status", "--porcelain", "--untracked-files=normal"],
options,
),
execute(process.execPath, [npmCli, "--version"], options),
readFile(join(root, "node_modules/turbo/package.json"), "utf8"),
]);
const tasks = z
.object({
tasks: z
.array(
z.object({
task: z.string().min(1),
hash: z.string().regex(/^[a-f0-9]{16}$/u),
}),
)
.min(1),
})
.parse(JSON.parse(graph.stdout)).tasks;
if (new Set(tasks.map(({ task }) => task)).size !== tasks.length)
throw new Error("Static graph contains duplicate task identities");
const context = parseStaticTaskContext({
version: 1,
sourceSha: source.stdout.trim(),
sourceClean: status.stdout.length === 0,
...(process.env.CI_PR_HEAD_SHA
? { prHeadSha: process.env.CI_PR_HEAD_SHA }
: {}),
nodeVersion: process.version,
npmVersion: npmVersion.stdout.trim(),
turboVersion: z.object({ version: z.string() }).parse(JSON.parse(turbo))
.version,
platform: process.platform,
architecture: process.arch,
taskHashes: Object.fromEntries(tasks.map(({ task, hash }) => [task, hash])),
});
if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== context.sourceSha)
throw new Error(
"Static context source does not match the workflow revision",
);
await mkdir(outputDirectory, { recursive: true });
await writeFile(
join(outputDirectory, "static-task-context.json"),
JSON.stringify(context, null, 2) + "\n",
);
return context;
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
await recordStaticTaskContext();

46
scripts/ci/test-group.mjs Normal file
View File

@ -0,0 +1,46 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import {
BUILT_TEST_PROJECTS,
SOURCE_TEST_PROJECTS,
} from "../lib/test-projects.mjs";
const [group, ...arguments_] = process.argv.slice(2);
const projects = { source: SOURCE_TEST_PROJECTS, built: BUILT_TEST_PROJECTS }[
group
];
if (!projects) throw new Error("Expected source or built test group");
// The group owns project selection; callers can request reporters or shards.
if (
arguments_.some((argument) => /^--(?:project|exclude)(?:=|$)/u.test(argument))
)
throw new Error("Test groups cannot override their project inventory");
const child = spawn(
process.execPath,
[
resolve("node_modules/vitest/vitest.mjs"),
"run",
...projects.flatMap(({ name }) => ["--project", name]),
...arguments_,
],
{ stdio: "inherit" },
);
let shutdownSignal;
for (const signal of ["SIGINT", "SIGTERM"])
process.on(signal, () => {
shutdownSignal ??= signal;
child.kill(signal);
});
child.once("error", (error) => {
console.error(error.message);
process.exitCode = 1;
});
child.once("close", (code) => {
process.exitCode =
shutdownSignal === "SIGINT"
? 130
: shutdownSignal === "SIGTERM"
? 143
: (code ?? 1);
});

View File

@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeWorkflowRun } from "./workflow-run.mjs";
const ARTIFACT_NAME = "test-timings";
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@ -68,8 +69,8 @@ function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
.slice(0, MAX_CANDIDATES);
for (const artifact of candidates) {
const run = api(
`repos/${repository}/actions/runs/${artifact.workflow_run.id}`,
const run = normalizeWorkflowRun(
api(`repos/${repository}/actions/runs/${artifact.workflow_run.id}`),
);
const runAge = now - Date.parse(run?.created_at);
if (
@ -80,7 +81,7 @@ function findTimingRun({ repository, branch, repositoryId, now = Date.now() }) {
run.id === artifact.workflow_run.id &&
run.status === "completed" &&
run.conclusion === "success" &&
run.path === ".github/workflows/ci.yml" &&
run.workflowPath?.file === ".github/workflows/ci.yml" &&
["schedule", "workflow_dispatch"].includes(run.event) &&
run.head_branch === branch &&
run.head_repository?.full_name === repository

View File

@ -41,9 +41,20 @@ const files = report.files.map(({ name, durationMs, state }) => {
throw new Error(`Test report path is outside the checkout: ${name}`);
return { name: path.split(sep).join("/"), durationMs, state };
});
const complete = process.env.SHARD_RESULT === "success";
// Keep group outcomes intact: an unavailable group is not a completed failure.
const groupResults = [
process.env.SHARD_RESULT,
...(process.env.SOURCE_REQUIRED === "true"
? [process.env.SOURCE_RESULT]
: []),
];
const groupsCompleted = groupResults.every((state) =>
["success", "failure"].includes(state),
);
const passed =
complete && process.env.MERGE_RESULT === "success" && report.success;
groupResults.every((state) => state === "success") &&
process.env.MERGE_RESULT === "success" &&
report.success;
// Successful shards and merged reports must account for the complete portable
// inventory. Coverage thresholds alone cannot prove that every file ran.
const verifyInventory = process.env.CI_VERIFY_TEST_INVENTORY === "true";
@ -69,7 +80,7 @@ if (process.env.CI_TEST_SELECTION) {
comparison.complete &&
report.unhandledErrors === 0 &&
["passed", "failed"].includes(report.reason) &&
["success", "failure"].includes(process.env.SHARD_RESULT) &&
groupsCompleted &&
["success", "failure"].includes(process.env.MERGE_RESULT),
executionSha: process.env.GITHUB_SHA ?? null,
prHeadSha: process.env.CI_PR_HEAD_SHA ?? null,
@ -91,7 +102,9 @@ if (passed && verifyInventory) {
);
}
const summary = [
complete ? "## Merged test diagnostics" : "## Partial test diagnostics",
groupsCompleted
? "## Merged test diagnostics"
: "## Partial test diagnostics",
"",
...(selection
? [
@ -99,11 +112,14 @@ const summary = [
"",
`Plan: ${selection.plan.mode}. Proposed files: ${selection.selected.length}; omitted: ${selection.omitted.length}.`,
`Full-run comparison: ${selection.complete ? "complete" : "incomplete"}; failures outside proposed selection: ${selection.missedFailures.length}.`,
"The complete suite still ran. This report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
"This observational report cannot enable omissions or qualify a revision. Skipped, missing, and interrupted observations do not establish parity.",
"",
]
: []),
`Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`,
...(process.env.SOURCE_REQUIRED === "true"
? [`Source result: ${process.env.SOURCE_RESULT ?? "unknown"}.`]
: []),
passed
? "Successful timing history is available."
: "This report is diagnostic only and is not a successful timing baseline.",

View File

@ -0,0 +1,10 @@
export interface WorkflowPath {
readonly file: string;
readonly ref: string | null;
}
export interface NormalizedWorkflowRun {
readonly workflowPath: WorkflowPath | null;
}
export function normalizeWorkflowRun<T extends Record<string, unknown>>(
value: T,
): Omit<T, "workflowPath"> & NormalizedWorkflowRun;

View File

@ -0,0 +1,15 @@
/** Keep the API path as evidence and parse its optional ref at the boundary. */
export function normalizeWorkflowRun(value) {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error("workflow_run_malformed");
const match =
typeof value.path === "string" && !/[\u0000-\u001f\u007f]/u.test(value.path)
? /^(\.github\/workflows\/[^/\\@\s]+\.ya?ml)(?:@([^\s]+))?$/u.exec(
value.path,
)
: null;
return {
...value,
workflowPath: match ? { file: match[1], ref: match[2] ?? null } : null,
};
}

View File

@ -1,17 +1,4 @@
const sourceProjects = [
"domain",
"services",
"adapters",
"composition",
"conformance",
"evaluation",
];
const sourceTest = (path) =>
path.startsWith("src/") ||
["composition", "conformance", "evaluation"].some((project) =>
path.startsWith(`tests/${project}/`),
);
import { SOURCE_TEST_PROJECTS, projectForTest } from "./test-projects.mjs";
/** Parse explicit test paths and an optional branch-comparison base. */
export const parseDevelopmentTestRequest = (mode, arguments_) => {
@ -35,10 +22,9 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => {
path.split("/").some((part) => part === ".." || part === ".")
)
throw new Error(`Expected an exact repository test path: ${argument}`);
if (mode !== "focused" && !sourceTest(path))
throw new Error(
`Use test:focused for compiled boundary tests: ${path}`,
);
const project = projectForTest(path);
if (mode !== "focused" && project.group !== "source")
throw new Error(`Use test:focused for built test projects: ${path}`);
paths.push(path);
}
}
@ -72,9 +58,14 @@ export const developmentTestPlan = (request, baseCommit) => {
!/^[a-f0-9]{40,64}$/u.test(baseCommit ?? "")
)
throw new Error("Changed tests require a resolved Git merge base");
const projects = request.mode === "focused" ? [] : sourceProjects;
const projects =
request.mode === "focused"
? []
: SOURCE_TEST_PROJECTS.map(({ name }) => name);
return {
needsBuild: request.paths.some((path) => !sourceTest(path)),
needsBuild: request.paths.some(
(path) => projectForTest(path).group === "built",
),
artifactTasks: Object.entries(artifactConsumers).flatMap(
([task, consumers]) =>
request.paths.some((path) => consumers.includes(path)) ? [task] : [],

View File

@ -0,0 +1,10 @@
export interface TestProjectDefinition {
readonly name: string;
readonly group: "source" | "built";
readonly include: readonly string[];
readonly exclude?: readonly string[];
}
export const SOURCE_TEST_PROJECTS: readonly TestProjectDefinition[];
export const BUILT_TEST_PROJECTS: readonly TestProjectDefinition[];
export function projectForTest(testPath: string): TestProjectDefinition;

View File

@ -0,0 +1,107 @@
import { matchesGlob } from "node:path";
/** Canonical file ownership shared by Vitest and development-test planning. */
export const SOURCE_TEST_PROJECTS = [
{
name: "domain",
group: "source",
include: ["src/{contracts,domain}/**/*.test.ts"],
},
{
name: "services",
group: "source",
include: ["src/application/**/*.test.ts"],
},
{
name: "adapters",
group: "source",
include: [
"src/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts",
],
},
{
name: "composition",
group: "source",
include: ["tests/composition/**/*.test.ts"],
},
{
name: "conformance",
group: "source",
include: ["tests/conformance/**/*.test.ts"],
exclude: ["tests/conformance/providers/hopper/realHopperSearch.test.ts"],
},
{
name: "evaluation",
group: "source",
include: ["tests/evaluation/**/*.test.ts"],
},
];
export const BUILT_TEST_PROJECTS = [
{
name: "boundary",
group: "built",
include: [
"tests/boundary/**/*.test.ts",
"tests/conformance/providers/hopper/realHopperSearch.test.ts",
],
exclude: [
"tests/boundary/cli/**/*.test.ts",
"tests/boundary/mcp/**/*.test.ts",
"tests/boundary/process/**/*.test.ts",
],
},
{
name: "cli-boundary",
group: "built",
include: ["tests/boundary/cli/**/*.test.ts"],
},
{
name: "process-boundary",
group: "built",
include: ["tests/boundary/process/**/*.test.ts"],
},
{
name: "mcp-boundary",
group: "built",
include: ["tests/boundary/mcp/**/*.test.ts"],
},
{
name: "acceptance",
group: "built",
include: ["tests/acceptance/**/*.test.ts"],
},
{
name: "process-global",
group: "built",
include: ["tests/process-global/**/*.test.ts"],
},
];
const ALL_TEST_PROJECTS = [...SOURCE_TEST_PROJECTS, ...BUILT_TEST_PROJECTS];
/** Return the unique Vitest owner for an exact repository-relative test path. */
export const projectForTest = (testPath) => {
if (
typeof testPath !== "string" ||
testPath.startsWith("/") ||
testPath.includes("\\") ||
testPath.split("/").some((part) => part === ".." || part === ".") ||
!testPath.endsWith(".test.ts")
)
throw new Error(`Expected a normalized repository test path: ${testPath}`);
const owners = ALL_TEST_PROJECTS.filter(
({ include, exclude = [] }) =>
include.some((pattern) => matchesGlob(testPath, pattern)) &&
!exclude.some((pattern) => matchesGlob(testPath, pattern)),
);
if (owners.length !== 1)
throw new Error(
owners.length === 0
? `No test project owns ${testPath}`
: `Multiple test projects own ${testPath}: ${owners.map(({ name }) => name).join(", ")}`,
);
return owners[0];
};

View File

@ -961,7 +961,7 @@ it("does not impose a message-count cutoff below the byte budget", () => {
);
expect(
boundedViewMessages(messages, `ev_${"a".repeat(64)}`, "limitations"),
).toEqual(messages);
).toEqual(messages.toSorted());
});
it("bounds repeated view messages even when fewer than 64 source strings are huge", () => {

View File

@ -45,6 +45,7 @@ const fixture = async () => {
for (const path of [
"scripts/run-development-tests.mjs",
"scripts/lib/development-tests.mjs",
"scripts/lib/test-projects.mjs",
])
await copyFile(join(process.cwd(), path), join(root, path));
await symlink(

View File

@ -0,0 +1,419 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { normalizeWorkflowRun } from "../../../scripts/ci/workflow-run.mjs";
import {
readStaticCheckArtifact,
selectStaticCheckArtifact,
} from "../../../scripts/ci/artifacts.mjs";
import {
classifyFailure,
createGitHubApi,
reportCiFailures,
type CiRun,
type CiJob,
type FailureReportApi,
} from "../../../scripts/ci/failure-report.mjs";
const sha = (digit: string) => digit.repeat(40);
const diagnostic = {
kind: "lint",
path: "src/example.ts",
code: "no-debugger",
message: "Unexpected debugger statement.",
severity: "error",
} as const;
const context = (
sourceSha: string,
taskHash = "a".repeat(16),
prHeadSha?: string,
) => ({
version: 1 as const,
sourceSha,
...(prHeadSha ? { prHeadSha } : {}),
sourceClean: true,
nodeVersion: "v24.18.0",
npmVersion: "11.16.0",
turboVersion: "2.11.7",
platform: "linux",
architecture: "x64",
taskHashes: { "lint:built": taskHash },
});
const run = (overrides: Partial<CiRun> = {}) =>
normalizeWorkflowRun({
id: 10,
run_attempt: 1,
status: "completed",
conclusion: "failure",
event: "pull_request",
path: ".github/workflows/ci.yml@main",
head_sha: sha("a"),
head_branch: "feature",
repository: { full_name: "owner/rea" },
pull_requests: [
{ base: { sha: sha("b"), repo: { full_name: "owner/rea" } } },
],
...overrides,
});
const job = (overrides: Partial<CiJob> = {}) => ({
name: "Static checks",
status: "completed",
conclusion: "failure",
...overrides,
});
const comparison = (): Parameters<typeof classifyFailure>[0] => ({
repository: "owner/rea",
defaultBranch: "main",
run: run(),
job: job(),
diagnostics: [diagnostic],
context: context(sha("a")),
mainRun: run({
id: 20,
event: "push",
head_sha: sha("c"),
head_branch: "main",
pull_requests: [],
}),
mainJob: job(),
mainDiagnostics: [diagnostic],
mainContext: context(sha("c")),
executionSourcesVerified: true,
ancestry: { status: "ahead", mergeBaseSha: sha("c") },
blobs: { "src/example.ts": true },
});
it("attributes only an identical structured lint failure with matching task, context, ancestry, and source blob", () => {
const result = classifyFailure(comparison());
expect(result).toEqual({
classification: "matching-main-diagnostics",
reason:
"supported_diagnostics_match_failed_task_and_source_on_verified_main_ancestor",
});
});
it.each([
["task hash", { mainContext: context(sha("c"), "b".repeat(16)) }],
["dirty source", { context: { ...context(sha("a")), sourceClean: false } }],
[
"toolchain",
{ mainContext: { ...context(sha("c")), turboVersion: "other" } },
],
[
"source sha",
{ context: context(sha("d")), executionSourcesVerified: false },
],
[
"diagnostic",
{ mainDiagnostics: [{ ...diagnostic, message: "different message" }] },
],
["content", { blobs: { "src/example.ts": false } }],
["ancestry", { ancestry: { status: "diverged", mergeBaseSha: sha("e") } }],
["canceled job", { job: job({ conclusion: "cancelled" }) }],
["not a static job", { job: job({ name: "Test shard (1)" }) }],
] as const)("does not attribute on mismatched %s", (_label, overrides) => {
const result = classifyFailure({ ...comparison(), ...overrides });
expect(result.classification).toBe("unknown");
});
it("writes informational JSON and Markdown from the supplied manifest using only read API calls", async () => {
const directory = await createTestTempDirectory("rea-ci-failure-test-");
const calls: string[] = [];
const manifest = {
repository: "owner/rea",
truncation: [],
coverage: { exhaustive: false },
runs: [
{
id: 10,
runAttempt: 1,
workflow: "CI",
event: "pull_request",
conclusion: "failure",
status: "collected",
},
{
id: 11,
runAttempt: 1,
workflow: "CI",
event: "pull_request",
conclusion: "success",
status: "collected",
},
],
};
const failedRun = run();
const api: FailureReportApi = {
async getRun(_repository, id) {
calls.push(`run:${id}`);
return failedRun;
},
async getJobs(_repository, id) {
calls.push(`jobs:${id}`);
return [{ ...job(), run_id: Number(id), run_attempt: 1 }];
},
async getStaticEvidence() {
calls.push("artifact");
return null;
},
async getAncestry() {
throw new Error("must not compare without evidence");
},
async verifyExecutionSource() {
throw new Error("must not verify source without evidence");
},
async getBlobIdentity() {
throw new Error("must not inspect blobs without evidence");
},
};
const report = await reportCiFailures({
repository: "owner/rea",
defaultBranch: "main",
batchManifest: manifest,
api,
outputDirectory: directory,
});
expect(calls).toEqual(["run:10", "jobs:10", "artifact"]);
expect(report).toMatchObject({
informationalOnly: true,
gateEffect: "none; this report never changes CI conclusions",
counts: { unknown: 1 },
});
const json = JSON.parse(
await readFile(join(directory, "report.json"), "utf8"),
);
const markdown = await readFile(join(directory, "report.md"), "utf8");
expect(json.failures[0]).toMatchObject({
classification: "unknown",
reason: "no_matching_verified_main_failure",
});
expect(markdown).toContain("Informational only");
expect(markdown).toContain("Unknown means evidence was incomplete");
});
it.each([
[22, false],
[6, true],
] as const)(
"finds verified main evidence after %s newer PR runs (extra incorrect main hint: %s)",
async (prCount, incorrectMainHints) => {
const directory = await createTestTempDirectory("rea-ci-main-proof-");
const mainSha = sha("c");
const prHeadSha = sha("a");
const mergeSha = sha("d");
const mainRun = run({
id: 20,
event: "push",
head_sha: mainSha,
head_branch: "main",
pull_requests: [],
});
const prRun = run({
head_sha: prHeadSha,
pull_requests: [
{ base: { sha: mainSha, repo: { full_name: "owner/rea" } } },
],
});
const evidence = (sourceSha: string, prHead?: string) => ({
context: context(sourceSha, undefined, prHead),
diagnostics: [diagnostic],
});
const lookups: number[] = [];
const api: FailureReportApi = {
async getRun(_repository, id) {
lookups.push(Number(id));
return Number(id) === 20 ? mainRun : { ...prRun, id };
},
async getJobs(_repository, id) {
return [{ ...job(), run_id: Number(id), run_attempt: 1 }];
},
async getStaticEvidence(_repository, selectedRun) {
return Number(selectedRun.id) === 20
? evidence(mainSha)
: evidence(mergeSha, prHeadSha);
},
async verifyExecutionSource(_repository, selectedRun, sourceContext) {
return selectedRun.event === "pull_request"
? sourceContext.sourceSha === mergeSha &&
sourceContext.prHeadSha === selectedRun.head_sha
: sourceContext.sourceSha === selectedRun.head_sha;
},
async getAncestry() {
return { status: "ahead", mergeBaseSha: mainSha };
},
async getBlobIdentity() {
return { "src/example.ts": true };
},
};
const result = await reportCiFailures({
repository: "owner/rea",
defaultBranch: "main",
batchManifest: {
repository: "owner/rea",
runs: [
...(incorrectMainHints
? [
{
id: 30,
runAttempt: 1,
workflow: "CI",
event: "push",
headBranch: "main",
conclusion: "failure",
status: "collected",
},
]
: []),
...Array.from({ length: prCount }, (_, index) => ({
id: 100 + index,
runAttempt: 1,
workflow: "CI",
event: "pull_request",
headBranch: "feature",
conclusion: "failure",
status: "collected",
})),
{
id: 20,
runAttempt: 1,
workflow: "CI",
event: "push",
headBranch: "main",
conclusion: "failure",
status: "collected",
},
],
},
api,
outputDirectory: directory,
});
expect(result.failures[0]).toMatchObject({
runId: 100,
classification: "matching-main-diagnostics",
reason:
"supported_diagnostics_match_failed_task_and_source_on_verified_main_ancestor",
});
expect(lookups).toHaveLength(
Math.min(prCount, 20) + 1 + Number(incorrectMainHints),
);
expect(lookups[0]).toBe(incorrectMainHints ? 30 : 20);
expect(new Set(lookups).size).toBe(lookups.length);
expect(result.boundedInput.mainCandidateRuns).toBe(1);
expect(result.failures).toHaveLength(Math.min(prCount, 20));
},
);
it("proves checkout merge parents in base-then-PR-head order and rejects false identities", async () => {
const baseSha = sha("b");
const headSha = sha("a");
const mergeSha = sha("c");
const prRun = run({
head_sha: headSha,
pull_requests: [
{ base: { sha: baseSha, repo: { full_name: "owner/rea" } } },
],
});
let parents = [baseSha, headSha];
const calls: string[] = [];
const api = createGitHubApi({
token: "fixture-token",
apiRequest: async (_method, endpoint) => {
calls.push(endpoint);
return {
sha: mergeSha,
parents: parents.map((parentSha) => ({ sha: parentSha })),
};
},
});
const mergeContext = context(mergeSha, undefined, headSha);
expect(
await api.verifyExecutionSource("owner/rea", prRun, mergeContext),
).toBe(true);
expect(calls).toEqual([`repos/owner/rea/commits/${mergeSha}`]);
parents = [headSha, baseSha];
expect(
await api.verifyExecutionSource("owner/rea", prRun, mergeContext),
).toBe(false);
expect(
await api.verifyExecutionSource("owner/rea", prRun, {
...mergeContext,
prHeadSha: sha("e"),
}),
).toBe(false);
});
it("uses static artifacts only when listing is complete, unique, and tied to this run attempt", () => {
const artifact = {
id: 12,
name: "static-check-reports",
expired: false,
workflow_run: { id: 10, head_sha: sha("a") },
};
const runRecord = { id: 10, head_sha: sha("a") };
expect(
selectStaticCheckArtifact(
{ total_count: 1, artifacts: [artifact] },
runRecord,
1,
),
).toBe(artifact);
expect(
selectStaticCheckArtifact(
{ total_count: 2, artifacts: [artifact] },
runRecord,
1,
),
).toBeNull();
expect(
selectStaticCheckArtifact(
{ total_count: 2, artifacts: [artifact, artifact] },
runRecord,
1,
),
).toBeNull();
expect(
selectStaticCheckArtifact(
{
total_count: 1,
artifacts: [
{ ...artifact, workflow_run: { id: 11, head_sha: sha("a") } },
],
},
runRecord,
1,
),
).toBeNull();
expect(
selectStaticCheckArtifact(
{ total_count: 1, artifacts: [artifact] },
runRecord,
2,
),
).toBeNull();
});
it("rejects artifact redirects that could send the bearer token to a non-HTTPS host", async () => {
const calls: string[] = [];
await expect(
readStaticCheckArtifact({
repository: "owner/rea",
artifactId: 12,
token: "fixture-token",
fetchImpl: (async (url: string | URL | Request) => {
calls.push(String(url));
return new Response(null, {
status: 302,
headers: { location: "http://untrusted.example/artifact.zip" },
});
}) as typeof fetch,
}),
).rejects.toThrow("static_artifact_redirect_invalid");
expect(calls).toEqual([
"https://api.github.com/repos/owner/rea/actions/artifacts/12/zip",
]);
});

View File

@ -0,0 +1,290 @@
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
reportCiFailures,
type CiRun,
type FailureReportApi,
type StaticDiagnostic,
type StaticEvidence,
} from "../../../scripts/ci/failure-report.mjs";
const repository = "owner/rea";
const mainSha = "c".repeat(40);
const prSha = "a".repeat(40);
const diagnostic: StaticDiagnostic = {
kind: "lint",
path: "src/example.ts",
code: "no-debugger",
message: "Unexpected debugger statement.",
severity: "error",
};
const evidence = (
run: CiRun,
diagnostics: readonly StaticDiagnostic[],
): StaticEvidence => ({
context: {
version: 1,
sourceSha: run.head_sha!,
sourceClean: true,
nodeVersion: "v24.18.0",
npmVersion: "11.16.0",
turboVersion: "2.11.7",
platform: "linux",
architecture: "x64",
taskHashes: { "lint:built": "a".repeat(16) },
},
diagnostics,
});
const entry = (id: number, event = "pull_request", runAttempt = 1) => ({
id,
runAttempt,
workflow: "CI",
event,
headBranch: event === "pull_request" ? "feature" : "main",
status: "collected",
conclusion: "failure",
});
function fixture(mainIds: readonly number[], prCount: number) {
const jobs: number[] = [];
const artifacts: number[] = [];
const paths: (readonly string[])[] = [];
const runs: Array<
Omit<ReturnType<typeof entry>, "event"> & { event?: string }
> = [
...mainIds.map((id) => entry(id, "schedule")),
...Array.from({ length: prCount }, (_, index) => entry(100 + index)),
];
const api: FailureReportApi = {
async getRun(_repository, id) {
const selected = runs.find((run) => run.id === Number(id))!;
return {
id,
run_attempt: selected.runAttempt,
status: "completed",
conclusion: "failure",
...(selected.event ? { event: selected.event } : {}),
head_branch: selected.headBranch,
path: ".github/workflows/ci.yml@main",
head_sha: mainIds.includes(Number(id)) ? mainSha : prSha,
repository: { full_name: repository },
pull_requests:
selected.event === "pull_request"
? [{ base: { sha: mainSha, repo: { full_name: repository } } }]
: [],
};
},
async getJobs(_repository, id, attempt) {
jobs.push(Number(id));
return [
{
name: "Static checks",
status: "completed",
conclusion: "failure",
run_id: Number(id),
run_attempt: attempt,
},
];
},
async getStaticEvidence(_repository, run) {
artifacts.push(Number(run.id));
return evidence(run, [diagnostic]);
},
async getAncestry() {
return { status: "identical", mergeBaseSha: mainSha };
},
async getBlobIdentity(_repository, _ancestor, _descendant, requested) {
paths.push(requested);
return Object.fromEntries(requested.map((path) => [path, true]));
},
async verifyExecutionSource() {
return true;
},
};
return { api, runs, jobs, artifacts, paths };
}
async function report(input: ReturnType<typeof fixture>) {
return await reportCiFailures({
repository,
defaultBranch: "main",
batchManifest: { repository, runs: input.runs },
api: input.api,
outputDirectory: await createTestTempDirectory("rea-ci-budget-"),
});
}
it("selects PR failures before the cap even after a day of scheduled main failures", async () => {
const input = fixture(
Array.from({ length: 24 }, (_, index) => 20 + index),
1,
);
input.runs.push({
id: 200,
runAttempt: 1,
workflow: "CI",
headBranch: "feature",
status: "collected",
conclusion: "failure",
});
const result = await report(input);
expect(result.failures).toMatchObject([
{ runId: 100, classification: "matching-main-diagnostics" },
]);
expect(result.boundedInput).toMatchObject({
candidateRuns: 1,
mainCandidateRuns: 5,
manifestTruncated: false,
});
expect(input.jobs).toEqual([100, 20]);
});
it("reuses five main job/artifact observations across twenty completed PR comparisons", async () => {
const input = fixture([20, 21, 22, 23, 24], 20);
const load = input.api.getStaticEvidence;
input.api.getStaticEvidence = async (...args) => {
const result = await load(...args);
return Number(args[1].id) < 24
? evidence(args[1], [{ ...diagnostic, message: "different" }])
: result;
};
const result = await report(input);
expect(result.counts).toEqual({
"matching-main-diagnostics": 20,
unknown: 0,
});
expect(input.paths).toHaveLength(100);
expect(input.jobs).toHaveLength(25);
expect(input.artifacts).toHaveLength(25);
for (const id of [20, 21, 22, 23, 24]) {
expect(input.jobs.filter((item) => item === id)).toHaveLength(1);
expect(input.artifacts.filter((item) => item === id)).toHaveLength(1);
}
});
it.each(["missing", "artifact error", "job error", "wrong job attempt"])(
"reuses unavailable main evidence without retries: %s",
async (failure) => {
const input = fixture([20], 3);
const load = input.api.getStaticEvidence;
const jobs = input.api.getJobs;
input.api.getJobs = async (...args) => {
const result = await jobs(...args);
if (Number(args[1]) !== 20) return result;
if (failure === "job error") throw new Error("job_snapshot_unavailable");
return failure === "wrong job attempt"
? result.map((job) => ({ ...job, run_attempt: 2 }))
: result;
};
input.api.getStaticEvidence = async (...args) => {
const result = await load(...args);
if (Number(args[1].id) !== 20) return result;
if (failure === "artifact error") throw new Error("artifact_unavailable");
return null;
};
const result = await report(input);
expect(result.counts.unknown).toBe(3);
expect(input.jobs.filter((id) => id === 20)).toHaveLength(1);
expect(input.artifacts.filter((id) => id === 20)).toHaveLength(
failure === "job error" || failure === "wrong job attempt" ? 0 : 1,
);
},
);
it.each([1, 2])(
"refreshes unavailable main evidence in the next report (attempt %s)",
async (attempt) => {
const input = fixture([20], 1);
expect((await report(input)).counts["matching-main-diagnostics"]).toBe(1);
input.runs[0]!.runAttempt = attempt;
const load = input.api.getStaticEvidence;
input.api.getStaticEvidence = async (...args) => {
const result = await load(...args);
return Number(args[1].id) === 20 ? null : result;
};
expect((await report(input)).counts.unknown).toBe(1);
expect(input.jobs.filter((id) => id === 20)).toHaveLength(2);
expect(input.artifacts.filter((id) => id === 20)).toHaveLength(2);
},
);
it.each([1, 51])(
"fetches only PR paths with %s PR diagnostics and noisy main evidence",
async (count) => {
const input = fixture([20], 1);
const load = input.api.getStaticEvidence;
const diagnostics = Array.from({ length: count }, (_, index) => ({
...diagnostic,
path: `src/pr-${index}.ts`,
}));
input.api.getStaticEvidence = async (...args) => {
await load(...args);
const noise =
Number(args[1].id) === 20
? Array.from({ length: 60 }, (_, index) => ({
...diagnostic,
path: `src/main-${index}.ts`,
}))
: [];
return evidence(args[1], [...noise, ...diagnostics]);
};
const result = await report(input);
expect(input.paths).toEqual([
diagnostics.slice(0, 50).map((item) => item.path),
]);
expect(result.failures[0]!.classification).toBe(
count === 1 ? "matching-main-diagnostics" : "unknown",
);
},
);
it.each(["jobs", "artifact", "comparison"])(
"continues after an isolated main %s failure and preserves its provenance",
async (failure) => {
const input = fixture([20, 21], 3);
const jobs = input.api.getJobs;
const load = input.api.getStaticEvidence;
input.api.getJobs = async (...args) => {
const result = await jobs(...args);
if (Number(args[1]) === 20 && failure === "jobs")
throw new Error("main_jobs_unavailable");
return result;
};
input.api.getStaticEvidence = async (...args) => {
const result = await load(...args);
if (Number(args[1].id) === 20 && failure === "artifact")
throw new Error("main_artifact_unavailable");
return Number(args[1].id) === 20 && failure === "comparison"
? {
...result!,
context: { ...result!.context, sourceSha: "d".repeat(40) },
}
: result;
};
input.api.verifyExecutionSource = async (_repository, run, context) => {
if (Number(run.id) === 20 && context.sourceSha !== run.head_sha)
throw new Error("main_comparison_unavailable");
return true;
};
const result = await report(input);
expect(result.counts).toEqual({
"matching-main-diagnostics": 3,
unknown: 0,
});
for (const item of result.failures) {
expect(item.candidateFailures).toEqual([
{
runId: 20,
runAttempt: 1,
reason: `main_${failure}_unavailable`,
},
]);
}
expect(input.jobs.filter((id) => id === 20)).toHaveLength(1);
expect(input.jobs.filter((id) => id === 21)).toHaveLength(1);
expect(input.artifacts.filter((id) => id === 20)).toHaveLength(
failure === "jobs" ? 0 : 1,
);
expect(input.artifacts.filter((id) => id === 21)).toHaveLength(1);
},
);

View File

@ -0,0 +1,313 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import {
parseStaticTaskContext,
recordStaticTaskContext,
} from "../../../scripts/ci/static-task-context.mjs";
import { createGhApiRequest } from "../../../scripts/ci/github-api.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const scopeFlags = async () =>
z
.object({ CI_FLAGS: z.array(z.string()) })
.parse(
await import(
new URL("../../../scripts/ci/scopes.mjs", import.meta.url).href
),
).CI_FLAGS;
const workflow = async () =>
z
.object({
jobs: z.record(
z.string(),
z
.object({
needs: z.union([z.string(), z.array(z.string())]).optional(),
if: z.string().optional(),
steps: z
.array(
z
.object({
run: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough(),
),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci.yml", import.meta.url),
"utf8",
),
),
).jobs;
it("starts source feedback before build and requires both groups without waiving missing jobs", async () => {
const jobs = await workflow();
expect(
jobs.static?.steps?.find(
(step) => step.run === "npm run ci:static-context",
)?.["continue-on-error"],
).toBe(true);
expect(jobs["test-source"]?.needs).toBe("changes");
expect(jobs["test-shard"]?.needs).toEqual(["changes", "build"]);
expect(
jobs["test-source"]?.steps?.some(
(step) =>
step.run === "npm run test:fast -- --reporter=default --reporter=blob",
),
).toBe(true);
const required = fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
);
const flags = await scopeFlags();
for (const tier of ["fast", "full"]) {
for (const result of [
"success",
"failure",
"cancelled",
"skipped",
"missing",
]) {
const needs = Object.fromEntries(
Object.keys(jobs)
.filter((name) => name !== "required")
.map((name) => [name, { result: "success" }]),
);
const outputs = {
...Object.fromEntries(
flags.map((flag) => [flag, tier === "full" ? "true" : "false"]),
),
tier,
code: "true",
};
const context: Record<
string,
{ result: string; outputs?: Record<string, string> }
> = { ...needs, changes: { result: "success", outputs } };
if (result === "missing") delete context["test-source"];
else context["test-source"] = { result };
const run = execute(process.execPath, [required], {
env: {
...process.env,
CI_JOBS: JSON.stringify(context),
CI_QUALIFICATION_DIRECTORY: "",
},
});
if (
result === "success" ||
(tier === "full" && ["skipped", "missing"].includes(result))
)
await expect(run).resolves.toMatchObject({ stderr: "" });
else
await expect(run).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("test-source"),
});
}
}
});
it("records the actual static task graph with explicit source identity and no ambient environment", async () => {
const directory = await createTestTempDirectory("rea-ci-context-");
const context = await recordStaticTaskContext({ outputDirectory: directory });
const head = await execute("git", ["rev-parse", "HEAD"]);
expect(context.sourceSha).toBe(head.stdout.trim());
expect(context.taskHashes["lint:built"]).toMatch(/^[a-f0-9]{16}$/u);
expect(context.taskHashes.jscpd).toMatch(/^[a-f0-9]{16}$/u);
expect(context).not.toHaveProperty("environmentVariables");
expect(context).not.toHaveProperty("env");
expect(
JSON.parse(await readFile(`${directory}/static-task-context.json`, "utf8")),
).toEqual(context);
for (const invalid of [
undefined,
{},
{ ...context, sourceSha: "bad" },
{ ...context, prHeadSha: "bad" },
{ ...context, taskHashes: { jscpd: "stale" } },
])
expect(() => parseStaticTaskContext(invalid)).toThrow();
});
it("rejects mutation methods at the shared GitHub transport before invoking the CLI", async () => {
const request = createGhApiRequest({ env: { PATH: "" } });
await expect(
request("POST", "repos/owner/repo/actions/runs/1/rerun", {
requests: 0,
startedAt: Date.now(),
limitMs: 1000,
}),
).rejects.toThrow("read-only GET");
});
it("collects basic CI telemetry before optional dependency installation can fail", async () => {
const { jobs } = z
.object({
jobs: z.object({
report: z.object({
steps: z.array(
z.object({
id: z.string().optional(),
run: z.string().optional(),
if: z.string().optional(),
"continue-on-error": z.boolean().optional(),
}),
),
}),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci-timings.yml", import.meta.url),
"utf8",
),
),
);
const steps = jobs.report.steps;
const installation = steps.findIndex((step) => step.id === "failure-deps");
for (const script of ["pr-ci-health.mjs", "job-timings-batch.mjs"]) {
const collection = steps.findIndex(
(step) => step.run === `node scripts/ci/${script}`,
);
expect(collection).toBeGreaterThanOrEqual(0);
expect(installation).toBeGreaterThan(collection);
}
expect(steps[installation]).toMatchObject({
run: "npm ci",
"continue-on-error": true,
});
expect(
steps.find((step) => step.id === "npm-cache")?.["continue-on-error"],
).toBe(true);
expect(
steps.find((step) => step.run === "node scripts/ci/failure-report.mjs")?.if,
).toContain("steps.failure-deps.outcome == 'success'");
});
it("merges source and built reports exactly once using the workflow's shard matrix", async () => {
const jobs = await workflow();
const matrix = z
.object({
matrix: z.object({ shard: z.string().regex(/^\$\{\{.*\}\}$/u) }),
})
.parse(jobs["test-shard"]?.strategy).matrix.shard;
const shards = z
.array(z.string().regex(/^\d+\/\d+$/u))
.min(1)
.parse(
runInNewContext(
matrix.slice(3, -2),
{
fromJSON: JSON.parse,
needs: { changes: { outputs: { tier: "fast" } } },
},
{ timeout: 1_000 },
),
);
const labels = Object.fromEntries(
["source", "built"].map((group) => [
group,
z
.string()
.min(1)
.parse(
jobs[group === "source" ? "test-source" : "test-shard"]?.steps?.find(
(step) => step.env?.VITEST_BLOB_LABEL,
)?.env?.VITEST_BLOB_LABEL,
),
]),
);
const root = await createTestTempDirectory("rea-ci-group-merge-");
const config = join(root, "vitest.config.mjs");
await writeFile(
config,
`export default { test: { maxWorkers: 1, projects: [${["source", "built"].map((name) => `{ test: { name: "${name}", globals: true, include: ["${name}*.test.js"] } }`).join(",")}] } };`,
);
const files = [
"source.test.js",
...Array.from(
{ length: shards.length },
(_, index) => `built-${index}.test.js`,
),
];
for (const file of files)
await writeFile(
join(root, file),
'it("runs its selected group", () => expect(true).toBe(true));',
);
const vitest = fileURLToPath(
new URL("../../../node_modules/vitest/vitest.mjs", import.meta.url),
);
const blobs = new Map<string, Buffer>();
const runs = [
{ group: "source", shard: [] },
...shards.map((shard) => ({ group: "built", shard: [`--shard=${shard}`] })),
];
for (const { group, shard } of runs) {
await execute(
process.execPath,
[
vitest,
"run",
"--config",
config,
"--project",
group,
"--reporter=blob",
...shard,
],
{
cwd: root,
env: { ...process.env, VITEST_BLOB_LABEL: labels[group]! },
timeout: 20_000,
},
);
for (const filename of await readdir(join(root, ".vitest/blob"))) {
expect(blobs.has(filename)).toBe(false);
blobs.set(filename, await readFile(join(root, ".vitest/blob", filename)));
}
// Separate hosted jobs start with independent report directories.
await rm(join(root, ".vitest/blob"), { recursive: true });
}
expect(blobs.size).toBe(runs.length);
await mkdir(join(root, ".vitest/blob"), { recursive: true });
for (const [filename, bytes] of blobs)
await writeFile(join(root, ".vitest/blob", filename), bytes);
await execute(
process.execPath,
[
vitest,
"--config",
config,
"--merge-reports",
"--reporter=json",
"--outputFile.json=merged.json",
],
{ cwd: root, timeout: 20_000 },
);
const merged = z
.object({
success: z.literal(true),
testResults: z.array(z.object({ name: z.string() })),
})
.parse(JSON.parse(await readFile(join(root, "merged.json"), "utf8")));
expect(
merged.testResults.map(({ name }) => name.split("/").at(-1)).sort(),
).toEqual(files.toSorted());
});

View File

@ -18,6 +18,8 @@ it("collects the latest attempt for recent completed CI and Release runs into a
workflow_runs: [
{
id: 42,
event: "push",
head_branch: "main",
run_attempt: 3,
status: "completed",
conclusion: "failure",
@ -43,51 +45,9 @@ it("collects the latest attempt for recent completed CI and Release runs into a
},
],
},
[
{
jobs: [
{
name: "Release lane",
status: "completed",
conclusion: "success",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 50_000).toISOString(),
steps: [],
},
],
},
],
[
{
jobs: [
{
name: "Failed lane",
status: "completed",
conclusion: "failure",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 55_000).toISOString(),
steps: [],
},
],
},
],
[
{
jobs: [
{
name: "Cancelled lane",
status: "completed",
conclusion: "cancelled",
created_at: recent,
started_at: recent,
completed_at: new Date(Date.parse(recent) + 30_000).toISOString(),
steps: [],
},
],
},
],
completedJobs("Release lane", "success", recent, 50),
completedJobs("Failed lane", "failure", recent, 55),
completedJobs("Cancelled lane", "cancelled", recent, 30),
]);
const result = await runBatch(fixture);
const manifest = JSON.parse(
@ -116,8 +76,20 @@ it("collects the latest attempt for recent completed CI and Release runs into a
expect(manifest.errors).toEqual([]);
expect(manifest.runs).toMatchObject([
{ id: 43, runAttempt: 2, conclusion: "success" },
{ id: 42, runAttempt: 3, conclusion: "failure" },
{ id: 41, runAttempt: 1, conclusion: "cancelled" },
{
id: 42,
runAttempt: 3,
conclusion: "failure",
event: "push",
headBranch: "main",
},
{
id: 41,
runAttempt: 1,
conclusion: "cancelled",
event: null,
headBranch: null,
},
]);
expect(manifest.runs).toEqual(
expect.arrayContaining([
@ -234,6 +206,31 @@ it("retains a partial manifest when its collection budget is exhausted", async (
expect(manifest.truncation.join(" ")).toContain("Time budget");
});
function completedJobs(
name: string,
conclusion: string,
startedAt: string,
seconds: number,
) {
return [
{
jobs: [
{
name,
status: "completed",
conclusion,
created_at: startedAt,
started_at: startedAt,
completed_at: new Date(
Date.parse(startedAt) + seconds * 1000,
).toISOString(),
steps: [],
},
],
},
];
}
async function createFixture(responses: unknown[]) {
const directory = await createTestTempDirectory("rea-ci-job-timings-batch-");
const fakeBin = join(directory, "bin");

View File

@ -0,0 +1,617 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
classifyPrCi,
collectPrCiHealth,
matchRunToPullRequest,
writePrCiHealthReport,
} from "../../../scripts/ci/pr-ci-health.mjs";
import { createGhApiRequest } from "../../../scripts/ci/github-api.mjs";
import type {
PrCiPullRequest,
PrCiWorkflowRun,
} from "../../../scripts/ci/pr-ci-health.mjs";
import { executeWorkflowFixture } from "../../support/workflowGit.js";
const reporter = fileURLToPath(
new URL("../../../scripts/ci/pr-ci-health.mjs", import.meta.url),
);
const observedAt = Date.parse("2026-10-14T00:00:00Z");
const headSha = "a".repeat(40);
const mergeSha = "b".repeat(40);
it("classifies workflow states without confusing absent, pending, and completed CI", () => {
const pull = validPull();
const cases = [
[
"startup_failure",
run({ status: "completed", conclusion: "startup_failure" }),
],
[
"queued_pending_executing",
run({ status: "in_progress", conclusion: null }),
],
["approval_required", run({ status: "waiting", conclusion: null })],
["completed_failure", run({ status: "completed", conclusion: "failure" })],
[
"completed_canceled",
run({ status: "completed", conclusion: "cancelled" }),
],
["completed_success", run({ status: "completed", conclusion: "success" })],
["completed_other", run({ status: "completed", conclusion: "neutral" })],
[
"completed_other",
run({ status: "completed", conclusion: "constructor" }),
],
] as const;
for (const [category, evidence] of cases) {
for (const runsComplete of [true, false]) {
const result = classifyPrCi({
pull,
runs: [
{
run: { ...evidence, run_attempt: 1 },
basis: "event_pr_number_and_head_sha",
},
],
runsComplete,
pullRequestInventoryComplete: true,
ambiguousRuns: [],
});
expect(result).toMatchObject({
category: runsComplete ? category : "unknown",
...(runsComplete
? {}
: { reason: "workflow_run_inventory_incomplete" }),
runs: [
{
id: 77,
attempt: 1,
status: evidence.status,
conclusion: evidence.conclusion,
},
],
});
}
}
for (const runsComplete of [true, false]) {
expect(
classifyPrCi({
pull: { ...pull, mergeable: false },
runs: [],
runsComplete,
pullRequestInventoryComplete: true,
ambiguousRuns: [],
}).category,
).toBe("merge_conflict");
}
});
it("requires complete inventories, known mergeability, and immutable head identity to report missing", () => {
const pull = validPull({ mergeable: true });
const classify = (overrides = {}) =>
classifyPrCi({
pull,
runs: [],
runsComplete: true,
pullRequestInventoryComplete: true,
ambiguousRuns: [],
...overrides,
});
expect(classify().category).toBe("missing");
expect(classify({ runsComplete: false })).toMatchObject({
category: "deferred",
reason: "lookup_inventory_incomplete",
});
expect(classify({ pullRequestInventoryComplete: false }).category).toBe(
"deferred",
);
expect(
classify({
ambiguousRuns: [{ run: run(), basis: "ambiguous_head_identity" }],
}).category,
).toBe("unknown");
expect(classify({ pull: { ...pull, mergeable: undefined } }).category).toBe(
"unknown",
);
expect(classify({ pull: { ...pull, identityValid: false } }).category).toBe(
"unknown",
);
});
it("accepts event PR/head evidence for merge-SHA runs and refuses ambiguous branch identities", () => {
const pull = validPull();
const associatedMergeRun = run({
head_sha: mergeSha,
pull_requests: [
{
number: pull.number!,
head: { sha: headSha, repo: { id: pull.head.repositoryId } },
},
],
});
expect(
matchRunToPullRequest({
run: associatedMergeRun,
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}),
).toEqual({ kind: "match", basis: "event_pr_number_and_head_sha" });
expect(
matchRunToPullRequest({
run: run({
pull_requests: [
{ number: pull.number!, head: { sha: "c".repeat(40) } },
],
}),
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}).kind,
).toBe("none");
const branchRun = run({ pull_requests: [] });
expect(
matchRunToPullRequest({
run: branchRun,
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
inventoryComplete: false,
}).kind,
).toBe("ambiguous");
expect(
matchRunToPullRequest({
run: branchRun,
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}),
).toEqual({
kind: "match",
basis: "event_unique_head_ref_and_repository_id",
});
expect(
matchRunToPullRequest({
run: run({ head_sha: mergeSha, pull_requests: [] }),
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}).kind,
).toBe("none");
expect(
matchRunToPullRequest({
run: run({
pull_requests: [{ number: pull.number!, head: { sha: headSha } }],
}),
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}).kind,
).toBe("ambiguous");
expect(
matchRunToPullRequest({
run: branchRun,
pull,
identityCounts: new Map([["73\u0000feature", 2]]),
}).kind,
).toBe("ambiguous");
expect(
matchRunToPullRequest({
run: run({ event: "workflow_dispatch" }),
pull,
identityCounts: new Map([["73\u0000feature", 1]]),
}).kind,
).toBe("none");
});
it("scans target-branch PRs through public gh GET requests and writes JSON and Markdown", async () => {
const fixture = await createFixture();
const calls: string[] = [];
const apiRequest: NonNullable<
Parameters<typeof collectPrCiHealth>[0]["apiRequest"]
> = async (method, endpoint) => {
calls.push(`${method} ${endpoint}`);
if (method !== "GET") throw new Error("fixture rejected a non-GET request");
if (endpoint.includes("/pulls?"))
return [
rawPull(42, { mergeable: true }),
rawPull(43, { mergeable: false }),
rawPull(44, {
base: { ref: "release", repo: { id: 11 } },
mergeable: true,
}),
rawPull(45, { draft: true, mergeable: true }),
];
if (endpoint.includes("/actions/workflows/ci.yml/runs?"))
return {
total_count: 1,
workflow_runs: [
run({
id: 9001,
event: "pull_request",
status: "completed",
conclusion: "failure",
head_sha: mergeSha,
pull_requests: [
{
number: 42,
head: { sha: headSha, repo: { id: 73 } },
},
],
}),
],
};
throw new Error(`unexpected GET endpoint: ${endpoint}`);
};
const report = await collectPrCiHealth({
repository: "owner/rea",
repositoryId: 11,
defaultBranch: "main",
now: observedAt,
apiRequest,
});
expect(calls).toHaveLength(2);
expect(calls.every((call) => call.startsWith("GET "))).toBe(true);
expect(calls[0]).toContain("state=open");
expect(calls[0]).not.toContain("base=");
expect(calls[1]).toContain("event=pull_request");
expect(report.pullRequests).toHaveLength(2);
expect(report.pullRequests.map(({ number }) => number)).toEqual([42, 43]);
expect(report.pullRequests.map(({ ci }) => ci.category)).toEqual([
"completed_failure",
"merge_conflict",
]);
expect(report.scan).toMatchObject({
status: "complete",
pullRequestInventoryComplete: true,
workflowRunInventoryComplete: true,
apiRequests: 2,
});
const output = join(fixture.directory, ".cache", "ci-pr-health");
const paths = await writePrCiHealthReport(report, output);
const written = JSON.parse(await readFile(paths.jsonPath, "utf8"));
const markdown = await readFile(paths.markdownPath, "utf8");
expect(written.counts.completed_failure).toBe(1);
expect(markdown).toContain("# Pull request CI health snapshot");
expect(markdown).toContain("#42");
expect(markdown).toContain("completed_failure");
});
it("counts older open PRs when deciding whether a branch/ref fallback is unique", async () => {
const olderPull = rawPull(63, {
created_at: "2026-09-01T00:00:00Z",
head: { ...rawPull(63).head, ref: "shared-feature" },
});
const currentPull = rawPull(64, {
mergeable: true,
head: { ...olderPull.head },
});
const apiRequest: NonNullable<
Parameters<typeof collectPrCiHealth>[0]["apiRequest"]
> = async (_method, endpoint) => {
if (endpoint.includes("/pulls?")) return [currentPull, olderPull];
if (endpoint.includes("/actions/workflows/ci.yml/runs?"))
return {
workflow_runs: [
run({
head_branch: "shared-feature",
pull_requests: [],
}),
],
};
throw new Error(`unexpected GET endpoint: ${endpoint}`);
};
const report = await collectPrCiHealth({
repository: "owner/rea",
repositoryId: 11,
defaultBranch: "main",
now: observedAt,
apiRequest,
});
expect(report.scan.pullRequestInventoryComplete).toBe(true);
expect(report.pullRequests.map(({ number }) => number)).toEqual([64]);
expect(report.pullRequests[0]?.ci).toMatchObject({
category: "unknown",
reason: "workflow_run_identity_ambiguous",
});
});
it.each([false, true])(
"keeps page-capped scans uncertain (observed success: %s)",
async (observedSuccess) => {
const pull = rawPull(62, { mergeable: true });
const apiRequest: NonNullable<
Parameters<typeof collectPrCiHealth>[0]["apiRequest"]
> = async (_method, endpoint) => {
if (endpoint.includes("/pulls?")) return [pull];
const page = Number(
new URL(`https://fixture.invalid/${endpoint}`).searchParams.get("page"),
);
return {
workflow_runs: Array.from({ length: 100 }, (_item, index) =>
observedSuccess && page === 1 && index === 0
? run({
run_attempt: 1,
pull_requests: [
{ number: 62, head: { sha: headSha, repo: { id: 73 } } },
],
})
: run({
id: page * 100 + index,
head_branch: "different-branch",
head_repository: { id: 999 },
pull_requests: [],
}),
),
};
};
const report = await collectPrCiHealth({
repository: "owner/rea",
repositoryId: 11,
defaultBranch: "main",
now: observedAt,
apiRequest,
});
expect(report.scan.workflowRunPages).toBe(5);
expect(report.scan.workflowRunInventoryComplete).toBe(false);
const firstPull = report.pullRequests[0];
if (firstPull === undefined) throw new Error("fixture PR was not scanned");
expect(firstPull.ci).toMatchObject({
category: observedSuccess ? "unknown" : "deferred",
reason: observedSuccess
? "workflow_run_inventory_incomplete"
: "lookup_inventory_incomplete",
});
expect(firstPull.ci.runs).toHaveLength(observedSuccess ? 1 : 0);
},
);
it("keeps the result unknown when the head changes between API snapshots", async () => {
const apiRequest: NonNullable<
Parameters<typeof collectPrCiHealth>[0]["apiRequest"]
> = async (_method, endpoint) => {
if (endpoint.includes("/pulls?")) return [rawPull(63)];
if (endpoint.includes("/actions/workflows/ci.yml/runs?"))
return { workflow_runs: [] };
return {
...rawPull(63, { mergeable: true }),
head: {
...rawPull(63).head,
sha: mergeSha,
},
};
};
const report = await collectPrCiHealth({
repository: "owner/rea",
repositoryId: 11,
defaultBranch: "main",
now: observedAt,
apiRequest,
});
expect(report.pullRequests[0]).toMatchObject({
identityChangedDuringScan: true,
ci: { category: "unknown", reason: "pull_request_head_identity_invalid" },
});
expect(report.scan).toMatchObject({
status: "partial",
errors: ["pull_request_identity_changed_during_scan"],
});
});
it("public CLI uses only GET and produces a report from fixture GitHub responses", async () => {
const fixture = await createFixture();
const ghCalls = join(fixture.directory, "gh-calls.jsonl");
const ghResponse = join(fixture.directory, "gh-response.json");
const fakeGh = join(fixture.fakeBin, "gh");
await writeFile(
ghResponse,
JSON.stringify([
[rawPull(51, { mergeable: true })],
{ total_count: 0, workflow_runs: [] },
]),
);
await writeFile(
fakeGh,
`#!/usr/bin/env node\nconst fs=require("node:fs");\nconst args=process.argv.slice(2);\nfs.appendFileSync(process.env.CI_GH_CALLS, JSON.stringify(args)+"\\n");\nif(args[0]!=="api"||args[1]!=="--method"||args[2]!=="GET")process.exit(41);\nconst page=Number(new URL("https://fixture.invalid/"+args.at(-1)).searchParams.get("page"));\nconst responses=JSON.parse(fs.readFileSync(process.env.CI_GH_RESPONSE,"utf8"));\nprocess.stdout.write(JSON.stringify(responses[page===1&&args.at(-1).includes("/pulls?")?0:1]));\n`,
);
await chmod(fakeGh, 0o755);
const outputDirectory = join(fixture.directory, ".cache", "ci-pr-health");
await executeWorkflowFixture(process.execPath, [reporter], {
cwd: fixture.directory,
env: {
...process.env,
PATH: `${fixture.fakeBin}${delimiter}${process.env.PATH ?? ""}`,
CI_REPOSITORY: "owner/rea",
CI_REPOSITORY_ID: "11",
CI_DEFAULT_BRANCH: "main",
CI_PR_HEALTH_DIRECTORY: outputDirectory,
CI_GH_CALLS: ghCalls,
CI_GH_RESPONSE: ghResponse,
},
});
const calls = (await readFile(ghCalls, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(calls).toHaveLength(2);
expect(
calls.every(
(args: string[]) => args[1] === "--method" && args[2] === "GET",
),
).toBe(true);
const report = JSON.parse(
await readFile(join(outputDirectory, "report.json"), "utf8"),
);
expect(report.pullRequests[0].ci.category).toBe("missing");
await expect(
readFile(join(outputDirectory, "summary.md"), "utf8"),
).resolves.toContain("missing");
});
it("rejects non-GET methods before launching the GitHub client", async () => {
const fixture = await createFixture();
const request = createGhApiRequest({
env: {
...process.env,
PATH: `${fixture.fakeBin}${delimiter}${process.env.PATH ?? ""}`,
CI_GH_CALLS: join(fixture.directory, "should-not-exist"),
},
});
await expect(
request("POST", "repos/owner/rea/pulls", {
requests: 0,
startedAt: observedAt,
limitMs: 1_000,
}),
).rejects.toThrow("read-only GET");
await expect(
readFile(join(fixture.directory, "should-not-exist")),
).rejects.toMatchObject({ code: "ENOENT" });
});
function validPull(overrides: Partial<PrCiPullRequest> = {}): PrCiPullRequest {
return {
number: 42,
identityValid: true,
mergeable: true,
head: { repositoryId: 73, ref: "feature", sha: headSha },
...overrides,
};
}
it.each([
["newer run", { id: 78, created_at: "2026-10-13T01:00:00Z" }, "unknown"],
[
"older run",
{ id: 78, created_at: "2026-10-12T23:00:00Z" },
"completed_success",
],
["equal timestamps", { id: 78 }, "unknown"],
[
"retry of an older run",
{ id: 78, created_at: "2026-10-12T23:00:00Z", run_attempt: 2 },
"unknown",
],
["unknown timestamp", { id: 78, created_at: "invalid" }, "unknown"],
["newer attempt", { run_attempt: 3 }, "unknown"],
["older attempt", { run_attempt: 1 }, "completed_success"],
] as const)(
"retains ambiguity evidence for a %s without reporting a stale completion",
(_label, overrides, category) => {
const currentAttempt = "id" in overrides ? 1 : 2;
const result = classifyPrCi({
pull: validPull(),
runs: [
{
run: run({ run_attempt: currentAttempt }),
basis: "event_pr_number_and_head_sha",
},
],
ambiguousRuns: [
{
run: run({ run_attempt: currentAttempt, ...overrides }),
basis: "ambiguous_head_identity",
},
],
runsComplete: true,
pullRequestInventoryComplete: true,
});
expect(result.category).toBe(category);
expect(result.runs).toHaveLength(2);
},
);
it("does not let an older proven run hide a newer run with an unverified repository ID", async () => {
const pull = rawPull(42);
const older = run({
id: 70,
created_at: "2026-10-13T00:00:00Z",
pull_requests: [{ number: 42, head: { sha: headSha, repo: { id: 73 } } }],
});
const newer = run({
id: 71,
created_at: "2026-10-13T01:00:00Z",
status: "in_progress",
conclusion: null,
pull_requests: [{ number: 42, head: { sha: headSha } }],
});
const report = await collectPrCiHealth({
repository: "owner/rea",
repositoryId: 11,
defaultBranch: "main",
now: observedAt,
apiRequest: async (_method, endpoint) => {
if (endpoint.includes("/pulls?")) return [pull];
if (endpoint.endsWith("/pulls/42")) return { ...pull, mergeable: true };
return { workflow_runs: [newer, older] };
},
});
expect(report.pullRequests[0]?.ci).toMatchObject({
category: "unknown",
reason: "workflow_run_identity_ambiguous",
});
expect(report.pullRequests[0]?.ci.runs).toHaveLength(2);
});
it("keeps a proven newest run even when two older matched runs have equal timestamps", () => {
const result = classifyPrCi({
pull: validPull(),
runs: [
run({ id: 70, run_attempt: 1 }),
run({ id: 71, run_attempt: 1, conclusion: "failure" }),
run({
id: 72,
run_attempt: 1,
created_at: "2026-10-13T01:00:00Z",
status: "in_progress",
conclusion: null,
}),
].map((run) => ({ run, basis: "event_pr_number_and_head_sha" })),
ambiguousRuns: [],
runsComplete: true,
pullRequestInventoryComplete: true,
});
expect(result.category).toBe("queued_pending_executing");
expect(result.runs).toHaveLength(3);
});
function run(overrides: Partial<PrCiWorkflowRun> = {}): PrCiWorkflowRun {
return {
id: 77,
event: "pull_request",
head_branch: "feature",
head_repository: { id: 73 },
head_sha: headSha,
status: "completed",
conclusion: "success",
created_at: "2026-10-13T00:00:00Z",
pull_requests: [],
...overrides,
};
}
function rawPull(number: number, overrides: Record<string, unknown> = {}) {
return {
number,
draft: false,
created_at: "2026-10-13T00:00:00Z",
html_url: `https://github.com/owner/rea/pull/${String(number)}`,
base: { ref: "main", repo: { id: 11 } },
head: {
ref: `feature-${String(number)}`,
sha: headSha,
repo: { id: 73, full_name: "contributor/rea" },
},
...overrides,
};
}
async function createFixture() {
const directory = await createTestTempDirectory("rea-ci-pr-health-");
const fakeBin = join(directory, "bin");
await mkdir(fakeBin);
return { directory, fakeBin };
}

View File

@ -44,7 +44,7 @@ function run(overrides: Record<string, unknown> = {}) {
id: 7,
status: "completed",
conclusion: "success",
path: ".github/workflows/ci.yml",
path: ".github/workflows/ci.yml@main",
event: "schedule",
created_at: new Date(now - 120_000).toISOString(),
head_sha: validSha,

View File

@ -121,7 +121,11 @@ it("exports every canonical scope with read authority for admission and keeps te
);
expect(timings.on.workflow_run).toBeUndefined();
expect(timings.on.schedule).toHaveLength(1);
expect(timings.permissions).toEqual({ contents: "read", actions: "read" });
expect(timings.permissions).toEqual({
contents: "read",
actions: "read",
"pull-requests": "read",
});
const checkout = timings.jobs.report.steps.find((step) =>
step.uses?.startsWith("actions/checkout@"),
);
@ -154,6 +158,7 @@ it("holds native and provider fan-out behind static validation while portable fe
"static",
"build",
"test-shard",
"test-source",
"test",
"docs",
"required",
@ -369,7 +374,7 @@ it("keeps fast feedback separate from complete qualification and release proof",
// release their concurrency slot without waiting for another runner.
expect(ci.jobs.required?.if).toBe("${{ !cancelled() }}");
const shards = ci.jobs["test-shard"]?.steps ?? [];
const fast = shards.find((step) => step.run?.includes("npm run test:run"));
const fast = shards.find((step) => step.run?.includes("npm run test:built"));
const full = shards.find((step) => step.run?.includes("test:ci:shard:run"));
expect(fast?.if).toBe("needs.changes.outputs.tier == 'fast'");
expect(fast?.run).toContain("--reporter=blob");

View File

@ -0,0 +1,99 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parseStaticArchive } from "../../../scripts/ci/artifacts.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const producer = fileURLToPath(
new URL("../../../scripts/ci/static-check-report.mjs", import.meta.url),
);
it.each([3.5, 100, 0])(
"retains real lint failures when the duplication threshold is %s",
async (threshold) => {
const root = await createTestTempDirectory("rea-ci-static-archive-");
await mkdir(join(root, "src"));
await mkdir(join(root, "tests"));
await writeFile(
join(root, ".oxlintrc.json"),
JSON.stringify({ rules: { "no-debugger": "error" } }),
);
await writeFile(
join(root, ".jscpd.json"),
JSON.stringify({ threshold, minLines: 5, minTokens: 50 }),
);
await writeFile(
join(root, "src/example.ts"),
"debugger;\nexport const example = true;\n",
);
const duplicate =
"export function compute(value: number) {\n" +
Array.from({ length: 20 }, (_, index) => ` value += ${index};\n`).join(
"",
) +
" return value;\n}\n";
for (const name of threshold === 3.5 ? [] : ["first", "second"])
await writeFile(join(root, `src/${name}.ts`), duplicate);
await expect(
execute(process.execPath, [producer, "lint", "src/example.ts"], {
cwd: root,
}),
).rejects.toMatchObject({ code: 1 });
const duplication = execute(process.execPath, [producer, "duplicates"], {
cwd: root,
});
if (threshold === 0)
await expect(duplication).rejects.toMatchObject({ code: 1 });
else await duplication;
const reports = join(root, ".cache/static-checks");
expect(
JSON.parse(
await readFile(join(reports, "jscpd/jscpd-report.json"), "utf8"),
),
).toMatchObject({ reaCheck: { exitCode: threshold === 0 ? 1 : 0 } });
await writeFile(
join(reports, "static-task-context.json"),
JSON.stringify({
version: 1,
sourceSha: "a".repeat(40),
sourceClean: true,
nodeVersion: process.version,
npmVersion: "11.16.0",
turboVersion: "2.11.7",
platform: process.platform,
architecture: process.arch,
taskHashes: { "lint:built": "a".repeat(16), jscpd: "b".repeat(16) },
}),
);
const archive = join(root, "reports.zip");
await execute(
"zip",
[
"-q",
archive,
"oxlint.json",
"jscpd/jscpd-report.json",
"static-task-context.json",
],
{ cwd: reports },
);
const evidence = await parseStaticArchive(archive);
expect(evidence.diagnostics.filter(({ kind }) => kind === "lint")).toEqual([
expect.objectContaining({
kind: "lint",
path: "src/example.ts",
severity: "error",
}),
]);
const duplicates = evidence.diagnostics.filter(
({ kind }) => kind === "jscpd",
);
if (threshold === 0) expect(duplicates.length).toBeGreaterThan(0);
else expect(duplicates).toEqual([]);
},
);

View File

@ -35,7 +35,7 @@ function run(id: number, overrides: Record<string, unknown> = {}) {
id,
status: "completed",
conclusion: "success",
path: ".github/workflows/ci.yml",
path: ".github/workflows/ci.yml@main",
event: "schedule",
created_at: new Date(now - 120_000).toISOString(),
head_branch: "main",

View File

@ -0,0 +1,75 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
BUILT_TEST_PROJECTS,
SOURCE_TEST_PROJECTS,
projectForTest,
} from "../../../scripts/lib/test-projects.mjs";
import {
developmentTestPlan,
parseDevelopmentTestRequest,
} from "../../../scripts/lib/development-tests.mjs";
const repository = process.cwd();
describe("canonical test-project ownership", () => {
it("assigns every tracked test file exactly once to source or built", () => {
const files = execFileSync("git", ["ls-files", "*.test.ts"], {
cwd: repository,
encoding: "utf8",
})
.trim()
.split("\n")
.filter(Boolean);
const grouped = new Map<string, string[]>();
for (const file of files) {
const project = projectForTest(file);
grouped.set(project.group, [...(grouped.get(project.group) ?? []), file]);
}
expect(grouped.get("source")).not.toBeUndefined();
expect(grouped.get("built")).not.toBeUndefined();
expect(
(grouped.get("source")?.length ?? 0) +
(grouped.get("built")?.length ?? 0),
).toBe(files.length);
expect(
SOURCE_TEST_PROJECTS.reduce(
(count, project) => count + project.include.length,
0,
),
).toBeGreaterThan(0);
expect(
BUILT_TEST_PROJECTS.every((project) => project.group === "built"),
).toBe(true);
expect(() => projectForTest("tests/unowned/example.test.ts")).toThrow(
"No test project owns",
);
});
it("owns the Hopper real-provider conformance test as built and requires preparation", () => {
const testPath =
"tests/conformance/providers/hopper/realHopperSearch.test.ts";
expect(projectForTest(testPath)).toMatchObject({
name: "boundary",
group: "built",
});
expect(() => parseDevelopmentTestRequest("local", [testPath])).toThrow(
`Use test:focused for built test projects: ${testPath}`,
);
const request = parseDevelopmentTestRequest("focused", [testPath]);
expect(developmentTestPlan(request)).toMatchObject({ needsBuild: true });
});
it("uses canonical selectors in Vitest while retaining independent runner policy", async () => {
const config = await readFile(join(repository, "vitest.config.ts"), "utf8");
expect(config).toContain(
"[...SOURCE_TEST_PROJECTS, ...BUILT_TEST_PROJECTS]",
);
expect(config).toContain("PROJECT_POLICIES");
expect(config).not.toContain('"src/{contracts,domain}/**/*.test.ts"');
});
});

View File

@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { parse } from "yaml";
@ -48,9 +49,7 @@ const workflow = async () =>
it("merges available failed-shard diagnostics and skips unavailable shard reports", async () => {
const steps = (await workflow()).test!.steps;
const download = steps.find(
(step) =>
step.uses?.includes("actions/download-artifact") &&
step.with?.pattern === "vitest-report-*",
(step) => step.with?.pattern === "vitest-report-*",
);
const merge = steps.findIndex((step) => step.run?.includes("test:ci:merge"));
const upload = steps.findIndex((step) => step.with?.name === "test-results");
@ -59,8 +58,7 @@ it("merges available failed-shard diagnostics and skips unavailable shard report
);
expect(merge).toBeGreaterThan(0);
expect(download?.if).toContain("!cancelled()");
expect(download?.if).toContain("needs.test-shard.result == 'success'");
expect(download?.if).toContain("needs.test-shard.result == 'failure'");
expect(upload).toBeGreaterThan(merge);
expect(gate).toBeGreaterThan(upload);
const merges = steps.filter((step) => step.run?.includes("--reporter=json"));
@ -70,8 +68,6 @@ it("merges available failed-shard diagnostics and skips unavailable shard report
"--reporter=./scripts/ci/test-timing-reporter.mjs",
);
expect(step.if).toContain("!cancelled()");
expect(step.if).toContain("needs.test-shard.result == 'success'");
expect(step.if).toContain("needs.test-shard.result == 'failure'");
}
expect(steps[upload]?.with?.path).toContain("test-timings.json");
expect(steps[upload]?.with).toMatchObject({ "include-hidden-files": true });
@ -86,12 +82,23 @@ it("merges available failed-shard diagnostics and skips unavailable shard report
);
const command = steps[gate]?.run;
if (command === undefined) throw new Error("Missing shard result gate");
for (const result of ["failure", "cancelled", "skipped", ""]) {
await expect(
execute("bash", ["-c", command], {
env: { ...process.env, CODE_REQUIRED: "true", SHARD_RESULT: result },
}),
).rejects.toMatchObject({ code: 1 });
for (const tier of ["fast", "full"]) {
for (const shard of ["success", "failure", "cancelled", "skipped", ""]) {
for (const source of ["success", "failure", "cancelled", "skipped", ""]) {
const check = execute("bash", ["-c", command], {
env: {
...process.env,
CODE_REQUIRED: "true",
SHARD_RESULT: shard,
SOURCE_REQUIRED: String(tier === "fast"),
SOURCE_RESULT: source,
},
});
if (shard === "success" && (tier !== "fast" || source === "success"))
await expect(check).resolves.toMatchObject({ stderr: "" });
else await expect(check).rejects.toMatchObject({ code: 1 });
}
}
}
await expect(
execute("bash", ["-c", command], {
@ -100,6 +107,80 @@ it("merges available failed-shard diagnostics and skips unavailable shard report
).resolves.toMatchObject({ stderr: "" });
});
// These workflow conditions use the shared boolean/string expression subset.
// Evaluate the checked-in expressions so failure-state coverage cannot drift
// into a separate test implementation of the collection policy.
function conditionValue(
condition: string | undefined,
needs: Record<string, unknown>,
canceled: boolean,
) {
if (!condition) throw new Error("Missing report collection condition");
return runInNewContext(
condition
.replaceAll("needs.test-shard", 'needs["test-shard"]')
.replaceAll("needs.test-source", 'needs["test-source"]'),
{ needs, cancelled: () => canceled },
{ timeout: 1_000 },
);
}
it("collects either fast test group while keeping full reports tied to full shards", async () => {
const steps = (await workflow()).test.steps;
const download = steps.find(
(step) => step.with?.pattern === "vitest-report-*",
);
const fast = steps.find(
(step) => step.name === "Merge fast-suite JUnit and timing reports",
);
const full = steps.find(
(step) => step.name === "Merge coverage, JUnit, and timing reports",
);
for (const tier of ["fast", "full"]) {
for (const shard of ["success", "failure", "cancelled", "skipped", ""]) {
for (const source of ["success", "failure", "cancelled", "skipped", ""]) {
for (const [code, canceled] of [
["true", false],
["true", true],
["false", false],
["false", true],
] as const) {
const needs = {
changes: { outputs: { code, tier } },
"test-shard": { result: shard },
"test-source": { result: source },
};
const builtAvailable = ["success", "failure"].includes(shard);
const sourceAvailable = ["success", "failure"].includes(source);
const enabled = code === "true" && !canceled;
const available =
builtAvailable || (tier === "fast" && sourceAvailable);
const expected = enabled
? {
download: available,
fast: tier === "fast" && available,
full: tier === "full" && builtAvailable,
}
: { download: false, fast: false, full: false };
expect(conditionValue(download?.if, needs, canceled)).toBe(
expected.download,
);
expect(conditionValue(fast?.if, needs, canceled)).toBe(expected.fast);
expect(conditionValue(full?.if, needs, canceled)).toBe(expected.full);
}
}
}
}
const summary = steps.find((step) =>
step.run?.includes("scripts/ci/test-report.mjs"),
);
expect(summary?.env?.SHARD_RESULT).toBe("${{ needs.test-shard.result }}");
expect(summary?.env?.SOURCE_RESULT).toBe("${{ needs.test-source.result }}");
expect(summary?.env?.SOURCE_REQUIRED).toBe(
"${{ needs.changes.outputs.tier == 'fast' }}",
);
});
it("keeps diagnosis available after merge failure and publishes only complete successful history", async () => {
const steps = (await workflow()).test!.steps;
const diagnosis = steps.find((step) => step.with?.name === "test-results");
@ -220,7 +301,12 @@ it("exports portable timing history and removes it on a failed, partial, or inva
{ name: join(root, "tests/fast.test.ts"), durationMs: 10 },
],
};
const run = async (value: unknown, shards = "success", merge = "success") => {
const run = async (
value: unknown,
shards = "success",
merge = "success",
source?: string,
) => {
await writeFile(join(root, "test-timings.json"), JSON.stringify(value));
await writeFile(summary, "");
return execute(process.execPath, [fileURLToPath(script)], {
@ -229,6 +315,8 @@ it("exports portable timing history and removes it on a failed, partial, or inva
...process.env,
GITHUB_STEP_SUMMARY: summary,
SHARD_RESULT: shards,
SOURCE_REQUIRED: String(source !== undefined),
SOURCE_RESULT: source ?? "",
MERGE_RESULT: merge,
},
});
@ -256,6 +344,24 @@ it("exports portable timing history and removes it on a failed, partial, or inva
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(summary, "utf8")).toContain("diagnostic only");
}
await run(report, "success", "success", "success");
expect(await readFile(history, "utf8")).toContain('"success":true');
for (const [shards, source] of [
["skipped", "success"],
["success", "skipped"],
["success", "cancelled"],
["success", "failure"],
["success", ""],
]) {
await run(report);
await run(report, shards, "success", source);
await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" });
const text = await readFile(summary, "utf8");
expect(text).toContain(`Source result: ${source}.`);
expect(text).toContain("diagnostic only");
if (shards === "skipped" || source !== "failure")
expect(text).toContain("Partial test diagnostics");
}
await run(report);
await expect(
run({
@ -352,6 +458,21 @@ it("recovers complete module costs from real blob reports without executing test
expect(file.durationMs).toBeCloseTo(
Object.values(file.phases).reduce((sum, value) => sum + value, 0),
);
const partial = await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "skipped",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: "success",
MERGE_RESULT: "success",
},
});
expect(partial.stdout).toContain("Partial test diagnostics");
expect(partial.stdout).toContain("sample.test.js");
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: { ...process.env, SHARD_RESULT: "success", MERGE_RESULT: "success" },
@ -420,13 +541,15 @@ it("records failed omissions from full reports without treating partial or unhan
state: index === 1 ? "failed" : "passed",
})),
};
const run = async (report: unknown) => {
const run = async (report: unknown, source = "failure") => {
await writeFile(join(root, "test-timings.json"), JSON.stringify(report));
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "failure",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: source,
MERGE_RESULT: "failure",
CI_VERIFY_TEST_INVENTORY: "true",
CI_TEST_SELECTION: JSON.stringify(
@ -458,6 +581,8 @@ it("records failed omissions from full reports without treating partial or unhan
{ ...timing, reason: "interrupted" },
])
expect(await run(report)).toMatchObject({ complete: false });
for (const source of ["skipped", "cancelled", ""])
expect(await run(timing, source)).toMatchObject({ complete: false });
await expect(run({ ...timing, files: "invalid" })).rejects.toMatchObject({
code: 1,
});

View File

@ -0,0 +1,54 @@
import { expect, it } from "vitest";
import { normalizeWorkflowRun } from "../../../scripts/ci/workflow-run.mjs";
it.each([
[".github/workflows/ci.yml", ".github/workflows/ci.yml", null],
[".github/workflows/ci.yml@main", ".github/workflows/ci.yml", "main"],
[
".github/workflows/ci.yml@refs/heads/release/v1",
".github/workflows/ci.yml",
"refs/heads/release/v1",
],
[
".github/workflows/ci.yml@feature@v1",
".github/workflows/ci.yml",
"feature@v1",
],
[
".github/workflows/release.yaml@main",
".github/workflows/release.yaml",
"main",
],
])(
"preserves API path %s while separating its file identity and ref",
(path, file, ref) => {
const raw = { id: 10, path, head_sha: "a".repeat(40) };
const parsed = normalizeWorkflowRun(raw);
expect(parsed).toEqual({ ...raw, workflowPath: { file, ref } });
expect(raw).not.toHaveProperty("workflowPath");
},
);
it.each([
undefined,
null,
42,
"",
".github/workflows/ci.yml@",
".github/workflows/ci.yml@main\n",
".github/workflows/ci.yml@main\0",
"../.github/workflows/ci.yml",
".github/workflows/../ci.yml@main",
".github/workflows/ci.yml-extra@main",
"https://example.test/ci.yml@main",
])(
"keeps malformed workflow path %s unknown instead of inventing a file identity",
(path) => {
const parsed = normalizeWorkflowRun({
path,
workflowPath: { file: ".github/workflows/ci.yml", ref: "main" },
});
expect(parsed.path).toBe(path);
expect(parsed.workflowPath).toBeNull();
},
);

View File

@ -63,9 +63,7 @@
"!docs/.vitepress/dist/**",
"!website/_site/**",
"!experiments/analysis-efficiency/results/**",
"vitest.reverse-tools.config.ts",
"vitest.reverse-tools-semantics.config.ts",
"vitest.config.ts"
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"
]
},
"lint:built": {
@ -86,9 +84,7 @@
"!docs/.vitepress/dist/**",
"!website/_site/**",
"!experiments/analysis-efficiency/results/**",
"vitest.reverse-tools.config.ts",
"vitest.reverse-tools-semantics.config.ts",
"vitest.config.ts"
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"
]
},
"format:check": {},
@ -104,8 +100,8 @@
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json",
"vitest.config.ts",
"docs/.vitepress/config.ts"
"docs/.vitepress/config.ts",
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"
]
},
"jscpd": {

View File

@ -3,6 +3,10 @@ import { availableParallelism, tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "vitest/config";
import {
BUILT_TEST_PROJECTS,
SOURCE_TEST_PROJECTS,
} from "./scripts/lib/test-projects.mjs";
import { CiTestSequencer } from "./tests/ciTestSequencer.js";
@ -28,57 +32,32 @@ const COVERAGE_SHARD = process.argv.some((argument) =>
// Keep local and CI concurrency bounded by the runner rather than command locks.
const MAX_TEST_WORKERS = Math.min(2, availableParallelism());
const TEST_PROJECTS = [
{
name: "domain",
include: ["src/{contracts,domain}/**/*.test.ts"],
const TEST_PROJECTS = [...SOURCE_TEST_PROJECTS, ...BUILT_TEST_PROJECTS];
const PROJECT_POLICIES = {
domain: {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Domain and contract modules are pure and own no process-global state, so
// per-file module isolation adds startup cost without protecting a seam.
isolate: false,
},
{
name: "services",
include: ["src/application/**/*.test.ts"],
services: {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Service tests use recording ports and own no process-global state. Share
// their module graph so file startup does not dominate the lane.
isolate: false,
},
{
name: "adapters",
include: [
"src/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
},
{
name: "composition",
include: ["tests/composition/**/*.test.ts"],
adapters: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS },
composition: {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Composition tests use provider-neutral sessions and test-scoped cleanup;
// sharing their immutable module graph avoids per-file startup work.
isolate: false,
},
{
name: "boundary",
include: ["tests/boundary/**/*.test.ts"],
exclude: [
"tests/boundary/cli/**/*.test.ts",
"tests/boundary/mcp/**/*.test.ts",
"tests/boundary/process/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
},
{
name: "cli-boundary",
include: ["tests/boundary/cli/**/*.test.ts"],
boundary: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS },
"cli-boundary": {
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
// CLI tests create fresh command instances or subprocesses and own their
@ -88,54 +67,36 @@ const TEST_PROJECTS = [
// before their two heaps compete with the real CLI child processes.
execArgv: ["--max-old-space-size=384"],
},
{
// Process-tree observations share host sampling resources; keep this small
// lane serial while the independent projects run together.
name: "process-boundary",
include: ["tests/boundary/process/**/*.test.ts"],
"process-boundary": {
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
// Process-tree observations share host sampling resources; keep this small
// lane serial while the independent projects run together.
fileParallelism: false,
sequence: { groupOrder: 1 },
},
{
name: "mcp-boundary",
include: ["tests/boundary/mcp/**/*.test.ts"],
"mcp-boundary": {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Each file creates and closes an explicit in-memory MCP session. Reuse the
// immutable server graph so module startup does not outweigh transport work.
isolate: false,
},
{
name: "acceptance",
include: ["tests/acceptance/**/*.test.ts"],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
},
{
name: "process-global",
include: ["tests/process-global/**/*.test.ts"],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
},
{
name: "conformance",
include: ["tests/conformance/**/*.test.ts"],
acceptance: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS },
"process-global": { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS },
conformance: {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Contract cases use recording providers and own no process-global state.
isolate: false,
},
{
name: "evaluation",
include: ["tests/evaluation/**/*.test.ts"],
evaluation: {
pool: "threads" as const,
maxWorkers: MAX_TEST_WORKERS,
// Evaluator tests inspect deterministic inputs without mutating shared state.
isolate: false,
},
];
};
const ZERO_COVERAGE_THRESHOLDS = {
statements: 0,
@ -156,10 +117,21 @@ const ZERO_COVERAGE_THRESHOLDS = {
},
};
const projects = TEST_PROJECTS.map((project) => ({
extends: true as const,
test: project,
}));
const projects = TEST_PROJECTS.map((project) => {
const policy =
PROJECT_POLICIES[project.name as keyof typeof PROJECT_POLICIES];
if (!policy)
throw new Error(`Missing test runner policy for ${project.name}`);
return {
extends: true as const,
test: {
name: project.name,
include: [...project.include],
...(project.exclude ? { exclude: [...project.exclude] } : {}),
...policy,
},
};
});
export default defineConfig({
test: {