Skip to content

Quality Gates ​

Implements: GATE-01, GATE-02, GATE-03, GATE-04, GATE-05, GATE-06, GATE-07 — see CHARTER.md Part 5 for the requirements and their status.

What runs at commit, push, pull request, nightly and release, what blocks, and what files an issue instead. This guide describes the gates as the workflow files and hooks define them today. Where the charter's matrix and the YAML disagree, the YAML wins and the difference is called out.

How the tests themselves are organised is in TESTING.md. The release flow end to end is in RELEASE.md. Supply-chain controls (SHA pinning, provenance, signing) are in SECURITY.md.


The gate matrix ​

● blocks; ◐ runs but does not block; · does not run.

CheckCommitPushPRNightlyReleaseSDE (12)
lint-staged: ESLint fix + Prettier●····●
commitlint (conventional commits)●····●
ESLint, Prettier check, build, typecheck·●●·● (1)●
ESLint warning ratchet (lint:ratchet)··●··●
Examples type-check (typecheck:examples)·●●◐ files issue·●
Scripts type-check (typecheck:scripts)·●●··●
Unit tests (includes BDD and composition)·●● 18/20/22·●●
Coverage thresholds + PR comment··●··●
BDD suite·● (2)●·●●
EARS spec audit, Rule/schema consistency··●·●●
EARS verdict per Rule (ears.yml)··◐ (9)··◐
Determinism lint (time in src/)··●··●
Layer lint (lint:layers)·●●··●
Import cycles (lint:cycles)··●··●
Test lints: transport seam, suite health·●●··●
Generated types fresh, schema drift··● (3)(7)◐ files issue●·
Generated operations fresh··●·●·
Endpoint definitions (validate:esi)··● (3)(7)·●·
ESI spec lint (validate:spec)··● (7)···
Version check (validate:versions)··●·●●
Auth/scope alignment··●·●·
Export coverage (every export in a test)··●··●
Live contract tests··● (3)◐ weekly (4)● (3)·
Recorded payload replay··●◐ opens a PR●·
Fuzz, integration (mocked), type tests··●·●●
Properties at 10,000 runs···◐ files issue·◐
Fault catalogue (transport faults)··●◐ files issue●·
API surface diff (api-extractor)··●··●
Breaking API change declared (SemVer gate)··●··●
Lockfile consistency··●··●
Registry signatures (audit:signatures)··●···
publint, attw, size budgets (packed)··●··●
Consumer contract (packed tarball)··● 18/20/22/24◐●●
Documentation examples (packed tarball)··●··●
Dependency audit (diff-aware / allowlist)··● new advisories◐ files issue● ≥ high (6)●
knip dead-code··●◐ weekly (4)●●
CodeQL·◐ protected branches (5)◐ (5)◐ weekly·◐
zizmor (workflow security)··●··●
Benchmarks, head against base (8)··●◐ files issue·●
Heap soak, 100 000 requests···◐ files issue·◐
TypeDoc generation··●·●●
Unit + BDD, fails if any test was retried···◐·◐
Interleavings, four calls, seeded···◐··
Examples against live ESI··◐ (10)◐ files issue·◐
Tranquility smoke, one authenticated route···◐ files issue··
Stryker mutation, unit suite··◐ changed files◐ files issue·◐
Stryker mutation, BDD-only···◐ files issue·◐
Type mutation (tsd ratchet)···◐ files issue·◐
Schemathesis API fuzz···◐ files issue··
Payload fuzz, every endpoint (seeded)···◐ files issue··
Missing-endpoint spec drift···◐ files issue··
OpenSSF Scorecard···◐ weekly··
Post-publish canary (registry install)····◐ files issue (11)·
Real SDE export (nightly-sde.yml)···◐ files issue·◐
  1. The release job runs lint, format check and build. It has no separate typecheck step; npm run build runs tsc --emitDeclarationOnly, which type-checks src/.
  2. npm test uses config/jest/unit.config.cjs, whose testMatch includes tests/tdd/**/*.test.ts (composition among them), tests/bdd/step-definitions/**/*.steps.ts and tests/bdd/specs/**/*.spec.ts. Every push therefore runs the BDD scenarios as part of the unit suite; npm run bdd runs the same two BDD globs alone.
  3. Soft-skips with a warning annotation when ESI returns HTTP 503 (Tranquility downtime).
  4. From maintenance.yml, which runs weekly and only uploads artifacts.
  5. Runs and reports a status, but is outside ci-success, so a red result does not stop a merge. See What actually blocks a merge.
  6. Threshold high, after removing advisories accepted in scripts/quality/audit-exceptions.json.
  7. Blocks only when the pull request touches the check's inputs; otherwise a failure is pre-existing drift, reported as a warning and left to the nightly spec-drift issue. See static-analysis.
  8. The measurement runs only when the pull request touches src/core/, src/schemas/, tests/benchmark/, the bench scripts or package-lock.json; otherwise the job reports success with a summary line, so it stays inside ci-success without a job-level if:. A regression can be accepted with a reviewed Performance-Accepted: commit trailer; a comparison that could not be made (no baseline, too few rounds, a dropped task) cannot.
  9. Advisory: ears.yml runs on pull requests that touch src/, tests/bdd/ or the EARS scripts, outside ci-success. bdd-tests and spec-audit gate the same ground.
  10. nightly-examples.yml also runs on pull requests that touch examples/ or its tooling, outside ci-success; the job result is the signal and no issue is opened.
  11. Runs after a GitHub release is published, so it cannot stop that release; a failure opens a release-verification issue.
  12. Whether the check covers src/sde and its tests (Track S): ● it does, ◐ it runs there without blocking (the SDE soak, the SDE examples and the SDE mutation shards run in nightlies), · it has no SDE side (the check is about the ESI spec, endpoints or the live API).

What actually blocks a merge ​

Branch protection on master should require exactly one status check, with "require branch to be up to date" (strict) enabled:

Required checkWorkflowJob
ci-successci.ymlci-success

ci-success fans in every job in ci.yml, so requiring any of those jobs individually adds nothing, and requiring a job that can be skipped or path-filtered is exactly what the gate exists to avoid. Lint, Build & Test from ci-fast.yml repeats lint-and-build and unit-tests for pushes before a pull request exists; it stays useful as early feedback but does not need to be required. The previous required checks were Quality Gate (renamed to ci-success) and Lint, Build & Test; branch protection has to be switched to ci-success when this change merges, or pull requests wait forever for a Quality Gate check that no longer reports.

Everything else on a pull request is visible but advisory: codeql.yml, skill-eval.yml, ears.yml, and nightly-examples.yml on a pull request that touches the examples. Force-pushes and branch deletion are disabled. Administrators are not yet included in enforcement (tracked under SEC-07); the settings that change it, with the API call that verifies them, are in SECURITY.md.

.github/CODEOWNERS names @lgriffin as owner of everything, with explicit entries for the files automation rewrites (package.json, package-lock.json, .github/, .zizmor.yml, the release-please config and manifest). It only blocks a merge once "Require review from Code Owners" is enabled on master. With a single owner, that setting also means the owner cannot satisfy it on their own pull requests (GitHub does not let an author approve their own PR), so those need an admin override; on Dependabot and release-please PRs it requires the owner's approval.


The requirements in practice ​

GATE-01 · ci-success fans in every job, and a skip is a failure ​

ci.yml ends with a ci-success job that needs every other job in the workflow and runs with if: always(), so it reports even when an upstream job fails, is skipped or is cancelled. Without if: always() a failure upstream would skip the gate itself, and GitHub treats a skipped required check as passing.

The gate's rules (R10):

  • Every needed job must finish with result success. skipped, cancelled and failure all fail the gate. There is no allow-list.
  • No job in ci.yml has a job-level if:. A job that sometimes has nothing to do runs anyway and makes that decision in a step, with a notice or summary line, so it still reports success and the decision is visible: lockfile exits early for Dependabot, dependency-audit skips its steps when neither manifest changed. A job-level if: added later fails the gate whenever it evaluates false.
  • Its first step reads the job ids from ci.yml (with yq) and compares them with the needs context. A job added to the workflow but not to needs fails the gate with its name, so nothing can run outside it.
  • The results are read from toJSON(needs), so adding a job means adding it to needs once. The step summary lists every job with its result, and the failure message names each job that was not green.

To add a blocking job: add the job, add its id to ci-success.needs. To add an advisory job, put it in a different workflow.

GATE-02 · Every push gets fast feedback ​

ci-fast.yml runs on a push to any branch ('**') on Node 22: ESLint, lint:layers, lint:bdd-seam, lint:suite-health, Prettier check, build, typecheck, typecheck:examples, typecheck:scripts, typecheck:isolated, npm test. It has one unconditional job, so it has no gate of its own; everything it checks is also inside ci-success.

GATE-03 · The public API surface is diffed ​

The api-surface job in ci.yml builds, runs npm run api-report (api-extractor in local mode, which rewrites etc/esi.ts.api.md), and compares the result with the committed file. Both sides are CRLF-normalised and sorted so that Windows line endings and enum ordering do not cause false failures. A difference fails the job with "API surface report is out of date". Fix it by running npm run api-report locally and committing the report alongside the change.

The api-semver job then checks that the change is declared correctly for release-please. It compares the committed report between the base tip (HEAD^1 of GitHub's merge commit) and the merge result (HEAD), line by line as multisets, ignoring blanks, // comments and warnings, import lines and bracket-only lines:

Report changePasses when
NoneAlways
Lines only addedAlways. A new export, member or overload ships as a minor or a patch
A line lost (removal or signature)A commit in HEAD^1..HEAD^2 is type!: or has a BREAKING CHANGE: footer, so release-please cuts a major
A line lost, and no consumer breaksA commit carries an API-Compatible: <why> trailer; the reason is echoed in the log and step summary

The rule is deliberately strict. A changed line is often a widening (an optional parameter, a union member, an implements clause), and the gate cannot tell those from a narrowing, so the author states which it is. Replayed one commit at a time over the 34 non-merge commits in history that touched the report, 21 lost at least one line and none carried a marker: renames and removals such as generatedSchemas, the ILogger interface gaining required methods, and union widenings. What additions miss is a new required member on an interface consumers implement (ILogger, ICircuitBreaker, IRetryStrategy); that stays a reviewer call. The gate holds under both merge buttons. A merge commit keeps every commit, so a marker on any of them reaches release-please. A squash merge of several commits takes the pull request title as its commit title, so when the break is declared in a commit the title must be type!: too (verdict breaking-title-undeclared otherwise); a single-commit squash keeps that commit's message. The job reads the current title through the API, so after fixing it, re-running the failed jobs is enough. A squash message edited by hand in the merge dialog is outside what the gate can see. Only the root entry point is in the report, so the sub-path exports (./schemas, ./errors, ./testing, ./sde) are not covered; the consumer contract test at least proves they still resolve.

Locally, on a feature branch: npm run api-report:semver -- --base $(git merge-base origin/master HEAD) --pr-head HEAD.

GATE-04 · knip blocks the release ​

release.yml (validate-release) runs npx knip, so an unused file, export or dependency under src/ stops the publish. npm run validate and npm run check:all block on it too. On pull requests ci.yml (static-analysis) blocks on it too (npm run knip, since 2026-10-08), and maintenance.yml runs it weekly with || true for the report. Configuration is knip.jsonc: tests, scripts and examples are entry points, project is src/, and each exception carries its reason as a comment. Run npm run knip before tagging.

GATE-05 · Nightlies file issues ​

NightlyFinds a problem →
nightly-audit.ymlCreates or comments on an issue labelled security-audit; auto-closes it when clean
nightly-spec-drift.ymlCreates or comments on an issue labelled spec-drift; auto-closes it when clean. If the check itself fails, the same for spec-drift-check-failed
nightly-sde.ymlCreates or comments on the issue titled "sde: nightly real-data run failed or the export drifted" when the real-data suite, the drift check or an SDE example fails, or the export drifted; closes it after the next green night
nightly-mutation.ymlFails if a directory is below its mutation floor; creates or comments on the issue titled "mutation: nightly run failed" (label mutation), listing the jobs that did not succeed; closes it when every job succeeds
nightly-benchmarks.ymlCreates or comments on an issue labelled performance-nightly; auto-closes it when the benchmarks and the soak both pass
nightly-schemathesis.ymlCreates or comments on the issue titled "api-fuzz: nightly Schemathesis run failed" (label api-fuzz); closes it after the next green night
nightly-no-retry.ymlUploads reports/no-retry/; creates or comments on the issue titled "no-retry: nightly unit and BDD run without retries failed" (label no-retry); closes it after the next green night
nightly-interleave.ymlThe log names the broken invariant and the replay command; creates or comments on the issue titled "interleave: nightly composition schedules failed" (label interleave); closes it after the next green night
nightly-properties.ymlCreates or comments on the issue titled "Nightly property run failed"; closes it after the next green night
nightly-faults.ymlCreates or comments on the issue titled "Nightly fault tier failing"; closes it after the next green run
nightly-recorded-payloads.ymlOpens or updates a pull request with the shape diff; a failed run keeps a recorded-payloads-check-failed issue open
nightly-examples.ymlOne issue per failing example, titled Nightly example failing: examples/<file>, and one for a type-check failure; a passing run closes each. A failure Tranquility being unavailable explains only warns
nightly-live-health.ymlCreates or comments on the issue titled "Live health: the Tranquility smoke failed" when SSO rejects the token, ESI rejects the request or a response fails validation; closes it after the next healthy run. An outage (5xx or no answer) is a warning, not an issue
nightly-mutation-retry.ymlRe-runs the failed jobs of a nightly mutation run once, after its first failed attempt; files nothing itself, and the mutation issue is filed from the retried attempt
consumer-matrix-nightly.ymlThe step summary names the failing cell; creates or comments on the issue titled "consumer-matrix: nightly consumer contract failed" (label consumer-matrix); closes it after the next green night
post-publish-canary.ymlAfter a release is published: creates or comments on a release-verification issue when the published package fails to install, verify or run

The issue-filing workflows keep at most one open issue per label or title: if one is open they comment on it, otherwise they create one. Mutation and Schemathesis gained theirs in #277; the no-retry run, the interleaving run and the consumer matrix followed on 2026-09-28. Their report jobs act only on runs of master that were not cancelled, so a manual dispatch from a branch neither opens nor closes an issue; the last three also skip manual runs on master, because a seed replay is a diagnosis rather than the next night. When a job stops before its tests (install, packing), the issue says so instead of blaming a test.

GATE-06 · Scripts resolve to files ​

Checked by tests/tdd/scripts/package-scripts.test.ts, so it runs in npm test: every path a script names under scripts/, examples/ or tests/, and every runner config it is pointed at, must be a file that exists. Adding a script whose target is not there fails the suite and names the command that would break. The same test reads the other direction: every npm run <name> that README.md, CLAUDE.md, AGENTS.md, the guides (ROADMAP.md aside, since it names scripts later plan items add) and the BDD README and GUIDE tell the reader to type must be a script package.json defines; a placeholder such as npm run bdd:<domain> is skipped.

Two scripts used to point at files that were never committed — sde:seed at scripts/seed-sde-test-db.ts and example:sde-cross-ref at examples/sde-cross-reference.ts. Both are gone; the three SDE examples that do exist (sde-fitting, sde-industry, sde-market-tree) have scripts now.

GATE-07 · The real SDE export is loaded every night ​

nightly-sde.yml (05:15 UTC, Track S Run 9) resolves CCP's current build number from the build feed, restores the export ZIP from actions/cache keyed on that build (downloading only on a miss), extracts it with npm run sde:ingest -- --from-zip, and then runs three things against it: tests/integration/sde with SDE_REQUIRE_DATA=1, so a missing export fails instead of skipping (the suite also queries one table per entity family of the SDE reference's API section); npm run sde:drift, which compares the export's file list with SDE_FILE_REGISTRY and every registered file's record keys, after the providers' transform, with the table's Zod schema, writes reports/sde-drift.json and the step summary, and exits 1 on drift; and npm run examples:nightly -- --tier sde, the four SDE examples. A scheduled run that fails, or finds drift, opens or comments on one issue titled "sde: nightly real-data run failed or the export drifted"; the next green night closes it. tests/tdd/scripts/sde-drift.test.ts drives the check over a fixture export with one unknown file and one record carrying a key the schema does not declare, checks that every registered file maps to a schema, and fails if the workflow stops downloading from the URLs src/sde/ingestion/constants.ts names.

Counts in the documentation (DOC-04) ​

The counts the documentation quotes, such as clients, routes, operations, requirements, scenarios, examples and test files, sit between metric: markers and come from etc/doc-metrics.json. When a change adds or removes one of those things, run npm run docs:metrics and commit what it rewrites. npm run validate:versions, in static-analysis, fails the pull request otherwise and names each stale file and metric; npm run docs:metrics -- --check runs the same check without writing.


Local hooks ​

Installed by husky through the prepare script (which also runs a build after npm install).

HookRunsEffect
pre-commitnpx lint-stagedStaged src/**/*.ts and tests/**/*.ts: eslint --fix then prettier --write. Staged *.{json,md,yml,yaml}: prettier --write
commit-msgnpx --no -- commitlint --edit "$1"Rejects messages that do not follow @commitlint/config-conventional

Test files and repository scripts are linted at commit and in CI with the src/ rule set (npm run lint is eslint src tests scripts), under the relaxations the tests/** block of eslint.config.mjs declares, each with its reason; no-floating-promises, no-misused-promises and await-thenable stay errors (TEST-09). scripts/ is parsed against tsconfig.scripts.json under the relaxations its own block declares (console output, repository file paths, commands from PATH), and npm run typecheck:scripts compiles it the way ts-node runs it, with exactOptionalPropertyTypes off; before 2026-10-08 neither checked it. Two narrower configs, lint:bdd-seam and lint:suite-health (Suite-health lint), add test-only rules. Commit types map to changelog sections through release-please-config.json; see RELEASE.md.


Workflows ​

All 29 workflows live in .github/workflows/ (ls .github/workflows/*.yml; the directory's README.md points here). Every action is pinned to a full commit SHA and every workflow declares read-only top-level permissions with per-job escalation (SEC-03, see SECURITY.md).

WorkflowTriggerBlocksOutput
ci-fast.ymlPush, any branchNo (covered by ci-success)Status
ci.ymlPull request to master, main, developRequired check (ci-success)Status, coverage comment, artifacts
codeql.ymlPush and PR to master/main/develop; Mondays 06:00 UTCNoCode scanning alerts
skill-eval.ymlPR touching .claude/skills/** or the skill eval runner; manualNoStatus, artifacts
ears.ymlPR touching src/, tests/bdd/ or the EARS scripts; manualNoStatus, step summary, ears-report artifact
mutation-pr.ymlEvery pull request to masterNoStatus and the mutation-pr-report artifact; advisory until the release gate
nightly-schemathesis.ymlDaily 01:00 UTC; manualNoArtifact; issue "api-fuzz: nightly Schemathesis run failed"
nightly-mutation.ymlDaily 02:00 UTC; manualNoArtifacts; issue "mutation: nightly run failed"
nightly-mutation-retry.ymlworkflow_run: nightly-mutation.yml failed on its first attemptNoRe-runs the failed jobs once
nightly-no-retry.ymlDaily 03:00 UTC; manualNoArtifact; issue "no-retry: nightly unit and BDD run without retries failed"
nightly-interleave.ymlDaily 03:30 UTC; manualNoStep summary; issue "interleave: nightly composition schedules failed"
nightly-properties.ymlDaily 03:30 UTC; manualNoIssue "Nightly property run failed"
nightly-faults.ymlDaily 03:30 UTC; manualNoIssue "Nightly fault tier failing"
nightly-examples.ymlDaily 04:15 UTC; manual; PR touching examples/ or its toolingNoOne issue per failing example
nightly-audit.ymlDaily 05:00 UTC; manualNosecurity-audit issue
nightly-live-health.ymlDaily 05:45 UTC; manualNoStep summary; issue "Live health: the Tranquility smoke failed"
nightly-sde.ymlDaily 05:15 UTC; manualNoIssue "sde: nightly real-data run failed or the export drifted"; sde-drift-report and sde-performance artifacts
nightly-spec-drift.ymlDaily 06:00 UTC; manualNospec-drift / spec-drift-check-failed issue
nightly-recorded-payloads.ymlDaily 06:30 UTC; manualNoPull request with the shape diff; recorded-payloads-check-failed issue
scorecard.ymlMondays 04:00 UTC; manual; branch protection rule changeNoSARIF to code scanning, public score
maintenance.ymlMondays 09:00 UTC; manualNoArtifacts
quality-trend.ymlMondays 07:00 UTC; manualNoStep summary and artifact (quality-trend): weekly complexity, any, hotspots
release-please.ymlPush to master—Release PR, tag, GitHub release
release.ymlTag v*.*.* pushed; GitHub release publishedPublishingnpm, GitHub Packages, gh-pages, signed assets
docs-site.ymlCalled by release.yml deploy-docs; manual on master or a tagPublishing the siteThe documentation site on the gh-pages branch
post-publish-canary.ymlGitHub release published; manual with a versionNorelease-verification issue
spec-refresh.ymlPush to spec-refresh/**; manualNoCommits the re-vendored spec and regenerated files
nightly-benchmarks.ymlDaily 04:30 UTC; manualNoperformance-nightly issue, bench-data branch, artifacts
consumer-matrix-nightly.ymlDaily 04:45 UTC; manualNoStep summary; issue "consumer-matrix: nightly consumer contract failed"

ci-fast.yml — CI Fast ​

One job, Lint, Build & Test, on Node 22: npm ci, lint, lint:layers, lint:bdd-seam, lint:suite-health, format:check, build, typecheck, typecheck:examples, typecheck:scripts, typecheck:isolated, test. It runs on every push to every branch, before a pull request exists. Everything it runs is repeated inside ci-success, so it is early feedback rather than a required check.

ci.yml — CI/CD Pipeline ​

Runs on pull requests only. lint-and-build runs first; most test jobs need it. pr-info, static-analysis, lockfile, dependency-audit and zizmor run in parallel with it. No job has a job-level if:, and every job is in the gate (GATE-01). The pull-request mutation job moved to mutation-pr.yml on 2026-09-27 and is advisory until the release gate (below).

Job (display name)What it doesIn gate
pr-info (PR Information)Writes title, author, branches and change size to the step summary. Runs on drafts tooyes
lint-and-build (Lint & Build)audit:signatures (registry signatures and provenance of every installed package), lint:determinism (fetches master for its baseline), ESLint through lint:ratchet (errors fail; warnings per file and rule held to config/eslint/warning-baseline.json, shrink-only against the target branch), lint:layers, lint:cycles, Prettier check, build, typecheck, typecheck:examples, typecheck:scripts, typecheck:isolated, spec:generate:check, spec:coverage, spec:response-schemas; uploads dist/yes
static-analysis (Static Analysis)Regenerates types and diffs src/types/generated/ and every src/core/endpoints/esi-*.generated.ts (TTLs, rate-limit groups, scopes); knip (blocking since 2026-10-08); schema:drift:ci; validate:auth-scopes; validate:esi; validate:spec. The four live-spec checks block only when the pull request touches their inputs (below)yes
unit-tests (Unit Tests)npm test (unit, composition and BDD) on Node 22 and 24yes
coverage (Test Coverage)npm run coverage with the thresholds in config/jest/unit.config.cjs; posts or updates a PR comment; uploads coverage/yes
bdd-tests (BDD Scenarios)npm run bdd -- --json, then npm run bdd:report, which fails when a scenario did not execute; uploads the bdd-junit artifact (reports/bdd/: the JUnit report and scenarios.json, one record per scenario with its feature, Rule, charter tags, status and test file)yes
spec-audit (EARS Spec Audit)npm run spec:audit (also: every feature carries a charter tag naming a CHARTER.md requirement, and a feature a "Verified by" line cites carries that id); emits inline GitHub annotations when GITHUB_ACTIONS is set. Then npm run charter:audit (every #### block of CHARTER.md is well-formed EARS, every Enforced row names a script, job or file that exists, every "Verified by" clause is labelled Test:, Gate:, Review: or To add:, and every Jest test file a row cites names the row's ID in a test title, PROC-06), npm run spec:coverage:sde:ci (every IStaticDataProvider method is named by a Rule or reached by a bound step, or listed in the shrink-only scripts/sde/sde-spec-coverage-baseline.json, TEST-10), npm run spec:coverage:clients:ci (every public domain-client method is named by a Rule or called by a bound step, or listed in the shrink-only scripts/spec/client-spec-coverage-baseline.json, TEST-10), npm run validate:spec-consistency (Rule titles against response schemas), npm run lint:bdd-seam, which fails on any scenario that spies on or reassigns an ESI client method, and npm run lint:suite-health (Suite-health lint)yes
security-model (Security Model Audit)Runs the BDD scenarios with --json, then npm run security:model -- --results=… (scripts/quality/security-model-audit.ts): the properties, disclaimed properties, non-findings and dispositions of guides/SECURITY-MODEL.md match guides/security-model.yaml with the same severities (SEC-09); every claimed property names test files that exist and a Rule tagged @sec-<id> that ran and passed, or a rule: none reason, and a gap has an issue and an unexpired date with no more gaps than master (SEC-10); every network call and response-header read in src/ is in the index (SEC-11, also tests/tdd/core/untrusted-input-sites.test.ts); every citation and triage-log disposition is in the closed set (SEC-12)yes
contract-tests (Contract Tests)npm run contract:live with ESI_LIVE_TESTS=true; fails if the variable is missing rather than skipping; soft-skips on 503; uploads test-results-contract-liveyes
contract-replay (Contract Replay)npm run contract:replay: recorded ESI payloads through the pipeline, no network; fetches master for the shrink-only lists (see TESTING.md); uploads test-results-contract-replayyes
fuzz-tests (Fuzz Tests)npm run fuzz (fast-check); uploads test-results-fuzzyes
fault-catalogue (Fault Catalogue)npm run faults -- --ci: the transport fault catalogue and its self-test; fetches master for known-gaps.json (see TESTING.md); uploads test-results-faultsyes
full-test-suite (Complete Test Suite)the tiers of npm run test:all (unit, BDD, mocked integration, fuzz, type tests) as one step each, every step running even after a failure; uploads test-results-full-suiteyes
consumer-contract (Consumer Contract)npm run test:consumer on four rows (Node 22 with the oldest supported TypeScript and with latest, 24 with the repository's and with latest): installs the npm pack tarball into a clean consumer, type-checks and runs it under CommonJS, ES module and bundler resolution, and checks every exports sub-path (see TESTING.md)yes
doc-examples (Documentation Examples)npm run test:docs-examples on Node 22: type-checks every ts block in the README, guides and SDE docs against the packed tarball, runs the runnable ones and rejects the negative fixtures (see DOCUMENTATION.md)yes
api-surface (API Surface Check)Rebuilds etc/esi.ts.api.md and fails on a difference (GATE-03)yes
api-semver (API SemVer Gate)npm run api-report:semver: fails when the report lost or changed a line and no commit in the pull request is type!:, has a BREAKING CHANGE: footer or an API-Compatible: trailer, or when a declared break spans several commits and the pull request title is not type!: (GATE-03)yes
lockfile (Lockfile Consistency)npm install --package-lock-only --ignore-scripts then git diff --exit-code package-lock.json. For dependabot[bot] the step exits early with a notice; the job still reports successyes
dependency-audit (Dependency Audit)Audits base and head, fails only on advisories the PR introduces. Its steps skip when package.json and package-lock.json are unchanged; the job still reports successyes
documentation (Generate Documentation)npm run docs:site (TypeDoc, the guide and example pages generated from guides/ and examples/, VitePress with its dead-link check) and validate:versions -- --site; uploads the built site. A guide, example or TSDoc change that breaks the site fails here (see DOCUMENTATION.md)yes
zizmor (Workflow Security (zizmor))uvx zizmor@<pinned> over .github/ with .zizmor.yml, on every pull requestyes
ci-success (ci-success)Fails unless every other job succeeded, and fails if a job is missing from its needs (GATE-01)—
benchmarks (Benchmarks (base vs head))Builds the base tip and the head on one runner and runs them in 10 alternating processes each (npm run bench:ab), then decides statistically (npm run bench:compare). Its steps skip when no hot path changed; the job still reports success. See TESTING.mdyes
consumer-tarball (Consumer Tarball)Packs the dist/ uploaded by lint-and-build once, so every consumer-contract row installs the same bytesyes
package-lint (Package Lint)npm run lint:package -- --skip-build then npm run size (the job also repeats the test:docs-examples step of doc-examples), on the dist/ uploaded by lint-and-build: publint and attw on the npm pack tarball, and a size ceiling per exports sub-path (see Package lint and size budgets)yes

The generated-types, schema-drift, validate:esi, validate:spec and contract steps all call the live ESI spec. Each except validate:spec captures its log and, if the failure contains HTTP 503, downgrades it to a ::warning:: and passes (TEST-08).

Like npm audit, the generated-types, schema-drift, endpoint and spec-lint checks report the state of the world: CCP changing ESI turns them red on every open pull request. static-analysis therefore first compares the merge commit with its base tip (HEAD^1) and blocks on each check only when the pull request touches that check's inputs:

CheckInputs
Generated types freshnessscripts/spec/generate-esi-types.ts, src/types/generated/, src/core/endpoints/esi-*.generated.ts
Schema driftscripts/spec/generate-schema-drift-report.ts, scripts/spec/schema-drift-core.ts, scripts/spec/schema-drift-baseline.json, scripts/spec/schema-drift-exceptions.json, src/schemas/, src/core/endpoints/, package-lock.json
Endpoint definitions (validate:esi)scripts/spec/validate-esi-endpoints.ts, src/core/endpoints/, src/core/constants.ts (the compatibility date), package.json
Spec lint (validate:spec)redocly.yaml, package.json, package-lock.json (the Redocly version)

Otherwise the result would be the same on the base branch, so a failure is reported as a ::warning:: and a step-summary line, and the job passes; nightly-spec-drift.yml files that drift as an issue. Both steps now run with pipefail, so a generator or drift script that fails outright is reported rather than masked by tee. The contract tests are not diff-aware yet. release.yml blocks on generated types, schema drift and validate:esi unconditionally. Schema drift that is already tracked turns neither red: it is listed in a ratcheted baseline, described under Schema drift.

The lockfile check skips Dependabot because Dependabot's npm version produces byte-level lockfile differences. When regenerating a lockfile locally, use the npm major that CI uses so the file round-trips.

codeql.yml — CodeQL ​

GitHub CodeQL analysis for javascript-typescript with the security-extended query suite (the default suite plus lower-precision security queries; security-and-quality was passed over because its maintainability queries repeat sonarjs) on pushes and pull requests to master, main and develop, and weekly on Mondays at 06:00 UTC. Findings go to the repository's code scanning alerts. Not a required check.

zizmor — Workflow Security ​

The zizmor job in ci.yml runs zizmor (pinned version, via uvx) over .github/ with .zizmor.yml on every pull request, inside ci-success. It used to live in a separate zizmor.yml that only ran when workflow files changed; a path-filtered workflow cannot be a required check, so it never blocked a merge.

.zizmor.yml requires a justification comment on every ignore and currently has none. The three it used to carry were resolved: cache-poisoning on release.yml by setting package-manager-cache: false on its setup-node steps, dependabot-cooldown by configuring a Dependabot cooldown, and use-trusted-publishing was producing no finding.

skill-eval.yml — Skill Eval ​

Gates changes to agent skills (R14). deterministic unit-tests the judges, fails if a skill's SKILL.md changed without a skill.version bump in its eval/eval.yaml, and runs scripts/quality/skill-eval.ts offline against each case's recorded outputs: one shall per Rule, scenarios under Rules, no spyOn(client…), transport-seam mocking, step bindings, and the spec audit. There is no live, LLM-judged tier in CI (removed 2026-10-08): nothing calls a model and no API key is needed. The native claude plugin eval run with the manifest's score and cost thresholds is still available locally as skill-eval.ts --live. Not a required check yet.

ears.yml — EARS Requirements ​

Runs npm run ears on its own: the spec audit, then every BDD scenario, rolled up to one verdict per Rule: (PASS, FAIL or NOT RUN). The job summary lists the requirements not verified and the advisory feedback on the specification; the ears-report artifact holds the full per-requirement report and the browsable ears-dashboard.html (npm run ears:dashboard). It repeats what bdd and spec-audit in ci.yml already gate, so it is not part of ci-success; its purpose is a per-requirement answer that can be run and read independently. See TESTING.md.

nightly-schemathesis.yml — Nightly Schemathesis API Fuzz ​

Daily at 01:00 UTC on Node 22 with a 40-minute timeout. Pulls a digest-pinned Schemathesis image and runs scripts/spec/run-schemathesis.sh, which downloads the ESI spec, strips security requirements, serves it with a Prism mock on port 4010, and fuzzes it. Schemathesis exits non-zero for both schema violations and network errors, so the script parses the JUnit report and fails only when it records real failures. The report is uploaded as schemathesis-report. A report job then keeps one open issue titled "api-fuzz: nightly Schemathesis run failed" and labelled api-fuzz while the fuzz fails: it creates it, or comments on it with the run link, and closes it on the next green night. It creates the label if it is missing.

nightly-no-retry.yml — Nightly No-Retry Test Run ​

Daily at 03:00 UTC on Node 22 with a 60-minute timeout. Runs the npm test suite (config/jest/unit.config.cjs: tests/tdd, the BDD step files and the BDD spec entries) and writes Jest's JSON report. No Jest retries are configured anywhere, so there is nothing to switch off; instead the job reads the report and fails, naming each test, if any test was invoked more than once. A future jest.retryTimes that hides a flaky test therefore turns this run red even while pull request runs stay green. A missing or unparsable report also fails it. The report is uploaded as no-retry-report, and the step summary lists pass and fail counts and the failed tests. A report job then keeps one open issue titled "no-retry: nightly unit and BDD run without retries failed" and labelled no-retry while the run fails: it creates it, or comments on it with the run link, and closes it on the next green night. It creates the label if it is missing.

Test order within each file is randomised (jest --randomize), so a test that passes only because of what ran before it in the same file fails here rather than hiding behind declaration order. Each run picks a new seed, or uses the seed input of a manual dispatch. The seed appears as a notice annotation and in the job log, the step summary, and seed.txt in the uploaded report; npx jest --config config/jest/unit.config.cjs --randomize --seed=<seed> replays the same order locally.

nightly-interleave.yml — Nightly Interleaving Run ​

Daily at 03:30 UTC on Node 22 with a 60-minute timeout. Runs the composition tier (tests/tdd/composition) with ESI_INTERLEAVE_MODE=random: each scenario starts four overlapping calls and runs ESI_INTERLEAVE_RUNS schedules (5000 by default) chosen by a PRNG seeded with ESI_INTERLEAVE_SEED. Pull requests already explore every schedule of two and three calls inside npm test. Each run picks a new seed, or uses the seed input of a manual dispatch; the seed is a notice annotation and in the step summary with the command that replays the run, and a failing schedule prints its own ESI_INTERLEAVE_REPLAY command. A report job then keeps one open issue titled "interleave: nightly composition schedules failed" and labelled interleave while the run fails: it creates it, or comments on it with the run link, and closes it on the next green night. It creates the label if it is missing. See TESTING.md.

consumer-matrix-nightly.yml — Nightly Consumer Matrix ​

Daily at 04:45 UTC. Builds, packs, and runs npm run test:consumer -- --tarball on the rows pull requests skip: TypeScript next and latest on Node lts/* and current, and the oldest supported TypeScript on current. A failure is a toolchain release breaking a consumer, not a pull request; the job title and step summary name the Node and TypeScript versions and the failing cell. On TypeScript next only, a cell whose errors all lie inside a runtime dependency's own declarations is an upstream break when a control consumer that imports just those dependencies (zod, pino), in the same module format and resolution as the cell, reports every one of the cell's diagnostics (file, position and error code): it is shown as upstream break with a warning and does not fail the run, because nothing in this package can fix it (zod 4.6.5 on TypeScript 7.1 nightly, issue #564). A report job then keeps one open issue titled "consumer-matrix: nightly consumer contract failed" and labelled consumer-matrix while the run fails: it creates it, or comments on it with the run link, and closes it on the next green night. It creates the label if it is missing.

nightly-mutation.yml — Nightly Mutation Testing ​

Daily at 02:00 UTC on Node 22 with a 240-minute timeout per job. Runs npm run mutation -- --incremental --force (Stryker, config/mutation/stryker.config.mjs) over src/core/** and src/sde/** excluding endpoint definitions, pure interface files and the SDE test-data factory, as one job per shard in config/mutation/unit-shards.json — a single job stopped finishing inside the timeout as the suite grew. Each shard saves its own incremental file under stryker-unit-shard-<name>-<sha>-<run id>; mutation-pr in mutation-pr.yml restores the one that covers every file a pull request run mutates, and runs cold when a change spans shards (esi-23g.55). A mutation-ratchet job merges the shard reports before scoring. There is no global break threshold: npm run mutation:ratchet scores every directory against config/mutation/unit-thresholds.json and fails if one is below its floor or has none. The HTML, JSON and incremental reports are uploaded as mutation-report whether or not it passed. Details in TESTING.md.

A matrix of jobs, bdd-mutation-testing (300-minute timeout each), runs npm run mutation:bdd (config/mutation/stryker.bdd.config.mjs) once per shard in config/mutation/bdd-shards.json: all of src/ except generated files, types, interfaces and test helpers, with only the BDD step definitions as the test suite, so a surviving mutant is a behaviour no Rule protects. It is sharded because the single job it replaces never finished. fail-fast is off, so one slow shard does not hide the others' scores. There is no global break threshold.

bdd-mutation-ratchet then downloads every shard's report, rebuilds one report with npm run mutation:bdd:merge and scores it with npm run mutation:bdd:ratchet per directory (src/<area>, and src/core/<sub> inside core), writing the table to the step summary. The merge fails on a shard that is missing, mutated nothing, or overlaps another, because each of those would score a directory on a partial run. Every scored directory must have an entry in config/mutation/bdd-thresholds.json, so a new directory fails the ratchet until it has a floor — a ratchet with nothing in it gates nothing. The floors were first seeded on 19 September 2026. Dispatching the workflow with seed_bdd_thresholds prints and uploads the file raised to that run's scores; -- --update never lowers a floor and nothing is committed automatically. The merged report is uploaded as bdd-mutation-report.

A third job, type-mutation-testing (45-minute timeout), is the type-level equivalent. After npm run build it runs npm run test:type-mutation -- --ratchet (scripts/mutation/type-mutation.ts): each mutant is one edit to a copy of dist/**/*.d.ts (return type to unknown, readonly dropped, optional to required and back, a union member dropped, a literal widened, an overload removed, a generic constraint to unknown), found with the TypeScript compiler API in the declarations the exports entries reach, and the tsd suite runs against it with its src imports pointed at the copy. A mutant is killed when a type test fails, invalid (excluded from the score, like Stryker's compile errors) when the mutated declarations no longer compile, and survives when tsd passes. At most 500 mutants run, shared between entry points and ranked by a seeded hash of each mutant's id (seed 1, printed), so the sample is reproducible and each added mutant displaces at most one sampled mutant rather than reshuffling it. The run fails if an entry point scores below its floor in config/mutation/type-thresholds.json, if a floor names an entry point that scored nothing, or if the file lowers or drops a floor relative to origin/master (fetched explicitly; no base ref fails closed). The score table and every surviving mutant (file, symbol, operator, before and after) go to the step summary and to the type-mutation-report artifact.

A last report job needs all seven jobs and keeps one open issue titled "mutation: nightly run failed" and labelled mutation (created if missing): when any job did not succeed it creates the issue, or comments on the open one, with a table of those jobs and their results and the run link; when every job succeeded it closes the issue. A first attempt with a failed job files nothing, because nightly-mutation-retry.yml re-runs the failed jobs once and this job with them, and the issue reflects that retried attempt; a first attempt whose only non-successes are cancelled or skipped jobs is filed at once, since the retry does not pick it up.

nightly-benchmarks.yml — Nightly Benchmarks and Heap Soak ​

Daily at 04:30 UTC on Node 22. Three parts:

  • benchmarks builds the commit named in reference.json on the bench-data branch alongside master and runs both in 15 alternating processes (45-minute timeout). Pull requests only compare against their own base, so a series of slowdowns each below the threshold would pass one at a time; the pinned reference catches the sum. With no bench-data branch the run bootstraps: it measures and records the reference. A branch that exists but holds no usable reference.json, or a git ls-remote that fails for any other reason, fails the job.
  • soak first runs npm run soak -- --inject-leak --expect-fail, which passes only if the analysis flags a client that retains every response, then the real 100 000-request soak.
  • publish appends the night's medians to history.jsonl on bench-data and moves reference.json forward only on a bootstrap, on an improvement with no regression, or when a maintainer dispatched the workflow with accept_reference after reviewing an accepted slowdown. That is the ratchet: the reference only moves towards faster code unless someone says otherwise. It pushes with the job's contents: write token supplied through GIT_CONFIG_* environment variables, so no credential is written to disk.
  • report keeps one open issue labelled performance-nightly while either job fails, and closes it when both pass.

nightly-audit.yml — Nightly Security Audit ​

Daily at 05:00 UTC. Runs npm audit --json, filters out accepted advisories with scripts/quality/audit-check.ts --filter (which also hard-fails on an expired acceptance), and counts what remains by severity. If anything remains it ensures the security-audit label exists, then creates an issue with a severity table, a per-package table and resolution steps, or comments the same tables on the open one. If nothing remains it closes any open security-audit issue with a comment. See Dependency audit.

nightly-sde.yml — Nightly SDE Real Data ​

Daily at 05:15 UTC. Loads CCP's current Static Data Export, cached per build, and runs the real-data integration suite, the registry and schema drift check and the SDE examples against it (job real-data), then the sde/ benchmark tasks and the SDE heap soak against the same archive (job performance, Track S Run 10: load time, lookups, cold and warm foreign-key lists, name searches, two rounds; then load, 100 000 lookups and close three times with the heap required back at its baseline), publishing both tables to the step summary and the sde-performance artifact. A scheduled failure of either job, or drift, keeps one fixed-title issue open. Described under GATE-07.

nightly-spec-drift.yml — Nightly ESI Spec Drift ​

Daily at 06:00 UTC. Detects three kinds of drift between the repository and the live ESI spec and reports them in one issue. Described in full under Nightly spec drift.

nightly-live-health.yml — Nightly Live Health ​

Daily at 05:45 UTC and on manual dispatch. npm run health:live (scripts/quality/live-health.ts) refreshes a maintained EVE SSO token with EveSsoClient, then calls /status and /characters/{id}/online through the real EsiClient, so the pipeline, the bearer header and the Zod validation all run against Tranquility. Three repository secrets configure it: ESI_HEALTH_CLIENT_ID, ESI_HEALTH_REFRESH_TOKEN and, for a confidential SSO application, ESI_HEALTH_CLIENT_SECRET; the token must carry esi-location.read_online.v1. Until the secrets exist the run reports "not configured" as a warning and files nothing. A failure the client or the token owns (exit 1) fails the run and keeps one issue open, "Live health: the Tranquility smoke failed", closed on the next healthy run; Tranquility or SSO being unavailable (exit 2) is a warning only. This is the AUDIT.md "Live health" gate.

scorecard.yml — OSSF Scorecard ​

Weekly on Mondays at 04:00 UTC, on manual dispatch, and whenever a branch protection rule changes. Runs ossf/scorecard-action, publishes the results to the public Scorecard API, and uploads the SARIF to code scanning. Scorecard scores the controls described in SECURITY.md.

Two of its checks are held in the repository on every npm test: tests/tdd/workflows/workflow-permissions.test.ts fails when a workflow's top-level permissions are missing or writable, when a job gains a write scope not in its list (Token-Permissions), or when release.yml stops signing, attesting or uploading the release assets (Signed-Releases). Branch-Protection, Code-Review and CII-Best-Practices are repository and account settings; what the maintainer changes for each is in SECURITY.md.

quality-trend.yml — Quality trend ​

Weekly on Mondays at 07:00 UTC, and manually (weeks input, default 12). A report, never a gate, and it needs no write scope. npm run quality:trend (scripts/quality/quality-trend.ts) checks out each of the last N weekly commits of master into a temporary worktree and measures hand-written src/ (generated files left out):

  • the lines and the total cognitive complexity of every function (sonarjs), with the complexity per 1,000 lines and the functions over 20;
  • the identifiers in value positions that the checker types as any;
  • the eslint-disable comments, and the lint warning total where config/eslint/warning-baseline.json exists.

For the newest commit it also ranks hotspots, which are files by commits in the last 90 days × complexity, and lists every function over 20 and every any site. Everything is recomputed from git, so the trend needs no stored state and reproduces locally. The step summary carries the table and mermaid charts, and the quality-trend artifact carries trend.md, trend.csv and trend.json.

maintenance.yml — Maintenance & Security ​

Weekly on Mondays at 09:00 UTC, and manually. Every job but Branch Protection produces a report artifact and none of those fail on findings:

JobReport
Dependency Updatesnpm outdated → dependency-report.md, outdated.json
Security Auditnpm audit, then --audit-level=high → security-report.md and JSON (does not apply the allowlist)
Code Qualitylint, format check and knip output captured to files; npm run coverage (this step can fail the job)
Health CheckBuild, npm run health-check against live ESI, dist size and versions → health-report.md
ESI Spec Driftcontract:snapshot, live contract tests, contract:diff (oasdiff breaking changes) → spec-drift-summary.md
Branch Protectionscripts/quality/branch-protection.ts reads master's protection through the API and fails the job when master is unprotected or ci-success is no longer required (CHARTER PROC-03); what its token cannot read, and the administrator exemption, are warnings
Maintenance SummaryResult of each job → maintenance-summary.md

Much of this overlaps the nightlies, which file issues rather than artifacts. It is the one place the oasdiff breaking-change report runs in CI.

release-please.yml and release.yml ​

release-please.yml runs on every push to master, maintains the release pull request from conventional commits, and creates the tag and GitHub release when that pull request merges.

release.yml runs on the v*.*.* tag push and again when the GitHub release is published:

JobRuns onWhat it does
validate-releaseboth triggersTag and version checks, lint, format, knip, audit:check, changelog entry, build, schema drift, generated freshness, the spec validators, spec:audit, the test tiers, docs; listed below
publish-npmrelease publishednpm publish --provenance to npmjs.org
publish-githubrelease publishednpm publish --provenance to GitHub Packages
deploy-docsboth triggersCalls docs-site.yml: builds the documentation site with TypeDoc under /api/ and the EARS dashboard under /requirements/ and publishes docs-site/.vitepress/dist to gh-pages
create-assetsboth triggersnpm pack, CycloneDX SBOM, docs archive, checksums.txt; no OIDC permission
consumer-contractboth triggersnpm run test:consumer -- --tarball on the tarball create-assets packed, same four rows as ci.yml; publish-npm, publish-github and sign-and-publish-assets need it
sign-and-publish-assetsrelease publishedKeyless cosign signatures and SLSA build provenance (.intoto.jsonl), uploads assets to the release; the only job that can mint an OIDC token for signing
notify-successafter publishLog line when the npm publish succeeded

Every publishing job needs validate-release (REL-02). Its steps: the tag equals the package.json version and validate:versions passes; lint, format check, knip, audit:check, CHANGELOG.md has ## [<version>]; build; schema:drift:ci; generated-types freshness over every esi-*.generated.ts; spec:generate:check, validate:auth-scopes, validate:esi, spec:audit, validate:spec-consistency; test:all, contract:replay, faults -- --ci and contract:live (503 soft-skips); docs. The release gate runs every validator the charter lists for it; the ratchets in spec:audit, contract:replay and the fault catalogue compare with the previous vX.Y.Z tag (git describe --tags --abbrev=0 HEAD^ on a full-history checkout), and the job fails if none resolves; schema drift still takes the tag itself as its base. It does not run the API surface check or the SemVer gate, which need a base branch. The procedure, versioning and support window are in RELEASE.md.

Secrets: NPM_TOKEN for npmjs.org; GITHUB_TOKEN (automatic) for GitHub Packages, gh-pages, release uploads and issue filing.

Dependabot ​

Not a workflow, but it drives a lot of CI traffic. .github/dependabot.yml opens npm updates weekly on Monday (prefix chore(deps):, grouped into minor-and-patch, eslint majors and jest majors, at most five open) and GitHub Actions updates weekly on Wednesday (prefix chore(ci):, CodeQL actions grouped). This is what keeps the pinned action SHAs current.

Both ecosystems have a cooldown (R13): a version has to have been public for a number of days before Dependabot proposes it, so a compromised or broken release has time to be caught upstream first. npm uses 7 days by default, 14 for majors, 7 for minors and 3 for patches. GitHub Actions uses 7 days for every update, including digest bumps, because that ecosystem has no semver-specific keys. Cooldown does not delay Dependabot security updates.

There is no npm-side equivalent in .npmrc. npm's min-release-age needs npm 11.10 or later; the npm 10 bundled with Node 22 in CI accepts the key without a warning and ignores it (checked locally: npm 10.9.9 installed a day-old release that npm 11 refused). The reason is recorded in dependabot.yml; add the setting once CI runs npm 11.10 or later.

There is no auto-merge workflow for Dependabot pull requests; each one is merged by hand after ci-success passes.


Nightly examples ​

nightly-examples.yml checks that the examples still do what they say against live ESI. It runs at 04:15 UTC, on demand, and on any pull request that touches examples/, scripts/docs/examples-* or tsconfig.examples.json.

Each example declares a tier in its header comment with @nightly <tier>:

TierMeaningNightly
publicNeeds no tokenRun
mixedPublic calls, plus authenticated ones it skips without a tokenRun
authNeeds an access tokenType-checked only
sdeReads the Static Data ExportType-checked here; run in nightly-sde.yml

The job type-checks every example (npm run typecheck:examples, also a step in ci.yml and ci-fast.yml), then npm run examples:nightly runs each public and mixed example from source with no token. The sde examples join the run only where an export is on disk (SDE_DATA_PATH, else sde-data/), which is nightly-sde.yml with --tier sde; without one, --tier sde fails rather than reporting an empty run as green. An example fails when it exits non-zero, times out after 120 seconds, or exits 0 after writing to console.error (the scripts/docs/examples-strict.cjs preload turns that into exit 86). A failure is retried once. The run step stops at 90 minutes; the results file is rewritten after every example, so the issue steps still act on the examples that finished.

Scheduled and manual runs share one concurrency group and queue, so their issue updates land in order. On those runs, each failing example gets an issue titled Nightly example failing: examples/<file>, or a comment on the open one; a later passing run closes it. A type-check failure keeps one issue, Nightly examples do not type-check. A pull request run opens no issues; the job result is the signal.

tests/tdd/scripts/examples-nightly.test.ts keeps the run complete: every example needs a tier and an npm script, and every public endpoint must be called by a public or mixed example. A new public endpoint therefore fails npm test until an example calls it, or it is listed with a reason in scripts/docs/examples-coverage-exceptions.json.


Nightly spec drift ​

nightly-spec-drift.yml is how the project learns that CCP changed ESI without anyone noticing. It runs three checks against the live spec, combines the results, and keeps one labelled issue current.

What it checks ​

StepCommandDrift when
Missing endpointsnpx ts-node scripts/spec/check-spec-drift.ts --latestExit code 1: the spec has an endpoint no *Endpoints.ts file declares. Exit 2 or unparsable JSON fails the run
Generated types freshnessnpm run generate:types, then git diff on src/types/generated/ and src/core/endpoints/esi-*.generated.tsThe regenerated files differ from master, or generation failed for a reason other than 503
Schema driftnpm run schema:drift:ciDrift not in scripts/spec/schema-drift-baseline.json, a baseline entry that no longer occurs, or a broken check

A 503 from ESI on the second or third step is logged as a warning and treated as no drift. The schema step runs with pipefail; before it did, it read the exit status of tee and never registered drift.

The missing-endpoint check works in five steps:

  1. Fetch the newest compatibility date from https://esi.evetech.net/meta/compatibility-dates.
  2. Download the OpenAPI spec for that date from https://esi.evetech.net/meta/openapi.json?compatibility_date=<date>.
  3. Parse the endpoint definitions in src/core/endpoints/*Endpoints.ts.
  4. Compare: endpoints in the spec but not the codebase are missing; the reverse are extra.
  5. Hand the JSON report to the workflow, which builds the issue.

The issue ​

If any of the three checks found drift, the workflow ensures the spec-drift label exists and builds one body with a section per finding: a method/path table of missing endpoints with the compatibility date, the git diff --stat of stale generated files, and the last 50 lines of the schema drift report. The title names what was found, for example spec-drift: 3 missing endpoints, schema drift.

  • No open spec-drift issue: a new one is created.
  • An open one exists: the new body is added as a comment.
  • No drift at all: any open spec-drift issue is closed automatically with a dated comment.

If check-spec-drift itself fails (the endpoint script exits 2, the report is unreadable, npm ci or a gh call fails), the report-check-failure job opens an issue titled spec-drift: nightly check failed with the label spec-drift-check-failed and a link to the run, or comments on the open one. The next run that completes closes it. A cancelled run does neither.

The run's step summary also carries the compatibility date, the three flags and the raw drift report.

Running it locally ​

bash
# Against the newest compatibility date
npx ts-node scripts/spec/check-spec-drift.ts --latest

# Against a specific date
npx ts-node scripts/spec/check-spec-drift.ts --compatibility-date=2026-08-04

# No flag: the script's own baseline date, 2025-12-16
npx ts-node scripts/spec/check-spec-drift.ts

The report goes to stdout:

json
{
  "compatibilityDate": "2026-08-04",
  "specEndpointCount": 225,
  "codebaseEndpointCount": 208,
  "matchedCount": 198,
  "missing": [
    {
      "tag": "Military Campaigns",
      "method": "GET",
      "path": "/military-campaigns"
    }
  ],
  "extra": [
    {
      "method": "GET",
      "path": "mercenary/dens",
      "name": "getMercenaryDens",
      "file": "mercenaryEndpoints.ts"
    }
  ]
}

Exit codes: 0 no missing endpoints, 1 missing endpoints found, 2 the check itself failed.

The other two checks run locally as npm run generate:types followed by git diff, and npm run schema:drift (report only) or npm run schema:drift:ci (non-zero exit on drift outside the baseline).

Compatibility dates ​

CCP versions breaking changes to ESI with compatibility dates. A new endpoint only appears in a spec requested with a date at or after its introduction, so the nightly uses --latest. The available dates are listed at https://esi.evetech.net/meta/compatibility-dates.

COMPATIBILITY_DATE in src/core/constants.ts is the one the client sends on every request, and generate-esi-types.ts now defaults to it rather than keeping its own copy. Each file it writes records the date in its header, and tests/tdd/scripts/generated-spec-date.test.ts fails when a header stops matching the constant. That pairing is what was missing: the two dates had drifted to 2026-05-19 and 2025-12-16, so twelve routes ESI added in between had no cache TTL and were revalidated on every call, and no committed file said which spec the artefacts came from (esi-23g.31).

The other spec-reading tools still pin 2025-12-16 independently — generate-okf.ts, generate-endpoint-scaffold.ts, create-token.ts, run-schemathesis.sh and tests/contract/helpers.ts. validate-esi-endpoints.ts, snapshot-openapi.ts, check-spec-drift.ts and generate-schema-drift-report.ts import the constant, and redocly.yaml names the same date, which generated-spec-date.test.ts checks. Moving the others can move their baselines too, so each is its own change. The drift report moved in #638: at the newer date five list endpoints turned out to be wrapped in an object and several schemas had never matched ESI, so the schemas were fixed in the same change.

Responding to drift ​

  1. Read the issue and decide which findings are real gaps and which are intentional.
  2. Check the ESI changelog and the developer blog for context.
  3. For stale generated types, run npm run generate:types and commit the result on its own branch.
  4. For missing endpoints, file a bead per coherent group and follow the "add an endpoint" walkthrough in DESIGN-RULES.md.
  5. For schema drift, fix the schema and remove its baseline entries in the same change, or remove the entries whose drift CCP fixed. See Schema drift.
  6. The issue closes itself on the first clean nightly after the fixes merge. Close it by hand only if a gap is being skipped on purpose.

Related scripts:

ScriptPurpose
scripts/spec/check-spec-drift.tsJSON missing/extra endpoint report (nightly)
scripts/spec/validate-esi-endpoints.tsHuman-readable endpoint validation (npm run validate:esi)
scripts/spec/generate-esi-types.tsRegenerate types, TTLs, rate limits and scopes
scripts/spec/generate-schema-drift-report.tsZod schema versus spec field comparison
scripts/spec/snapshot-openapi.tsSnapshot the spec for contract tests

Schema drift ​

npm run schema:drift compares the Zod responseSchema of every endpoint definition with the response body the ESI spec defines for that operation, at the client's COMPATIBILITY_DATE (src/core/constants.ts). The comparison lives in scripts/spec/schema-drift-core.ts, with unit tests in tests/tdd/schema-drift/; the CLI is scripts/spec/generate-schema-drift-report.ts.

How an endpoint finds its spec operation ​

The script loads src/core/endpoints/*Endpoints.ts as modules, so each definition's path pairs with its own responseSchema, inline schemas included. Definition paths and spec paths are spelled differently (corporations/{corporationId}/projects/ against /corporations/{corporation_id}/projects), so both reduce to a shape before matching:

  • exactly one leading slash and no trailing slash;
  • every {parameter} becomes {}, so parameters match by position and never by name;
  • literal segments keep their case.

The method must match too. A mapping that finds no operation is listed as path_not_in_spec, method_not_in_spec, or ambiguous_in_spec when its shape matches two spec paths. One that matches an operation with no JSON 2xx body is listed as not compared. Nothing is skipped silently.

What counts as drift ​

The schema and the first 2xx JSON body are walked together through objects and arrays, following $ref and allOf. Each finding has a field path such as [].bidder_id or details.expires ((response) for the body itself) and one of these kinds:

KindMeaning
required_not_in_specThe schema requires a field the spec does not define: every body fails
required_but_optional_in_specThe schema requires a field ESI may omit: a body without it fails
type_mismatchObject, array, string, number or boolean disagree
optional_but_required_in_specThe schema is looser than the spec
missing_from_schemaThe spec defines a field the schema does not declare (looseObject keeps it)
optional_not_in_specThe schema declares an optional field the spec does not define

.nullable() counts as present and .default() as optional. Unions of one scalar kind (esiEnum), records, free-form objects and oneOf/anyOf are not compared further.

A check that compares nothing fails ​

The script exits 2, in report mode as well as under --ci, when no schema was compared or when more than 25% of mappings resolve to no spec operation. Before this guard the report said "154 schemas checked, 0 drift" while matching no path at all (esi-v2s.22).

The known-drift baseline ​

scripts/spec/schema-drift-baseline.json lists drift that is tracked but not yet fixed. Keys are <endpoint> <field> <kind> under findings and <endpoint> under unmatched; each value is the bead that tracks the fix, and an entry without a bead id is rejected. The seed from master held 203 findings on 52 endpoints (esi-v2s.10, .12, .14 and .21) and 28 endpoints newer than the then-pinned 2025-12-16 (esi-v2s.25). Since the move to the client's date (#638) it holds 12 optional fields the spec does not define (esi-v2s.28) and getOpenApiJson, whose route serves the spec itself.

--ci exits 1 when:

  1. a finding or unmatched endpoint is not in the baseline: fix the schema;
  2. a baseline entry no longer occurs, because the schema or ESI was fixed: remove the entry;
  3. the baseline has an entry the base ref's copy lacks: the baseline only shrinks.

The base ref is SCHEMA_DRIFT_BASE_REF, else origin/master, else master. When none resolves, every entry reads as an addition and the check fails closed. When the ref resolves but has no baseline file, the change is the one introducing it and additions are allowed.

WhereBase refEffect
ci.yml static-analysisHEAD^1The pull request's base tip, fetched by fetch-depth: 2; blocks only when inputs change
release.yml validate-releaseHEADNew or stale drift blocks the release; shrink-only is enforced on pull requests
nightly-spec-drift.ymlHEADNew drift and stale entries go into the spec-drift issue
Localorigin/masterFetch first, or set SCHEMA_DRIFT_BASE_REF

A pull request that fixes drift therefore removes its entries in the same change, and one that changes a schema so a finding changes kind fixes it rather than re-baselining it. npm run schema:drift -- --update-baseline rewrites the file from the current findings and keeps existing bead ids; new entries need --bead=esi-<id> and still fail the ratchet on a pull request, so baselining drift that CCP introduced needs a maintainer's decision, as with any ratchet exception.

scripts/spec/schema-drift-exceptions.json is separate and not ratcheted: schema name to field paths, relative to that schema, accepted as permanent deviations. An entry that suppresses nothing prints a warning.


Export coverage ​

Line and branch coverage only see code that something runs, so an exported helper that no test calls, or an exported type no test names, never shows up in them. npm run test:export-coverage asks the question of the public surface instead: for each package.json exports entry, which exported names does no test reference? The analysis is in scripts/package/export-coverage-core.ts, the CLI in scripts/package/export-coverage.ts, and its self-tests and fixture project in tests/tdd/export-coverage/. ci.yml runs it with --ci in static-analysis.

  • The surface is what the TypeScript checker reports as the exports of each entry's source (./dist/<name>.d.ts, and the import condition's ./dist/<name>.d.mts, map to src/<name>.ts), following named, type-only, export * and export * as re-exports. The entry list must match tsup.config.ts, or the check exits 2.
  • A reference is an identifier in a .ts, .mts or .cts file under tests/ that the checker resolves to the same declaration, whether imported from the entry, from the source module directly, or by package name (the consumer contract tests). Files under fixtures, step-fixtures, snapshots and __snapshots__ directories do not count. Neither does a name in a comment or string, or an import that is never used. Type tests in tests/typetests count.
  • Classes count as referenced when the class is; members are not checked individually.

Report mode prints the unreferenced names by entry point and exits 0. --ci exits 1 when an unreferenced export is not in scripts/package/export-coverage-baseline.json, when a baseline entry is now referenced or no longer exported, or when the baseline has an entry the base ref's copy lacks. The base ref is EXPORT_COVERAGE_BASE_REF (HEAD^1 in static-analysis), else origin/master, else master; when none resolves the check fails closed, and when the ref has no baseline file yet additions are allowed, as for schema drift. --write-baseline rewrites the file from today's result, and the ratchet still rejects anything it adds.

The seed baseline has 424 entries: . 197 of 366 exports, ./schemas 46 of 201, ./errors 1 of 24, ./testing 0 of 1, ./sde 90 of 123 and ./sde/memory 90 of 122. Most are response and SDE row types; the runtime functions among them are tracked in esi-23g.19.

Suite-health lint ​

npm run lint:suite-health lints every .ts, .mts and .cts file under tests/ except the fixtures/ and step-fixtures/ trees, which break rules on purpose. It is Testing Runway tier N: it catches the decay that turns a green suite into a decorative one. It is not the src/ rule set; the config and rules are in config/eslint/suite-health.rules.cjs, loaded by config/eslint/suite-health.config.mjs.

RuleRejects
jest/no-focused-tests.only, fit, fdescribe
suite-health/no-focused-scenariotest.only on the scenario parameter of a jest-cucumber defineFeature callback, which eslint-plugin-jest does not see as Jest's test
jest/no-disabled-tests.skip, xit, xtest, xdescribe, pending(), a test without a callback
suite-health/no-disabled-scenariotest.skip and test.todo on a defineFeature scenario, and Jest's it.todo / test.todo
jest/expect-expectA test with no assertion. expect, any expect* helper (expectEsiError, tsd's expectType) and fc.assert count. Under tests/bdd a Then step file registration and a legacy then step are checked the same way; spec files register no callbacks, so a scenario is covered through its Then steps
suite-health/no-swallowed-assertionA try block containing an assertion whose catch neither rethrows nor asserts
suite-health/no-unrestored-console-mockjest.spyOn(console, m).mockImplementation(...) or .mockReturnValue(...) not restored in the file (no mockRestore() on the spy, no jest.restoreAllMocks()), and console.m = jest.fn() never reassigned

Every rule is an error and there is no baseline: the initial run found no focused, skipped, swallowed or silenced tests, and the 18 tests without an assertion were fixed. A conditional skip (LIVE ? describe : describe.skip) is not flagged; that is how the live tiers opt out. describeClientErrors needs no configuration: its it blocks are linted where the helper defines them. tests/bdd/support/binder.ts is exempt from jest/expect-expect, because the test it registers per scenario asserts through the Then steps.

tests/tdd/suite-health/suite-health-lint.test.ts loads the same config and lints one fixture per rule, as if it lived at a real test path, and requires exactly the expected findings, plus a compliant fixture that must produce none. It runs in npm test, so a rule that stops firing fails the unit suite. The script runs in ci-fast.yml on every push and in the spec-audit job inside ci-success.

Package lint and size budgets ​

The package-lint job in ci.yml checks what npm publish would ship, statically, before the consumer contract installs and runs it. It downloads the dist/ that lint-and-build uploaded rather than building again, packs it once, and runs two commands.

npm run lint:package: publint and Are The Types Wrong ​

scripts/package/package-lint.ts builds (unless --skip-build), runs npm pack, and hands the tarball to both tools; --tarball <path> lints one that is already packed. The logic lives in scripts/package/package-lint-core.ts.

ToolBlocks onDoes not block
publint (API, on the tarball)Errors and warningsSuggestions, which are printed
attw (--format json, tarball)Any problem under node16-cjs, node16-esm, bundlernode10 (below)

node10 is out of the profile on purpose. It ignores exports, so no sub-path other than . resolves under it; engines.node is >=22, where every runtime reads exports; and TypeScript deprecated moduleResolution: node10, while this repository builds with TypeScript 6. The root entry still resolves under node10 through main and types, but the check makes no promise about it. The advisory package-checks.yml this job replaces ignored no-resolution for that reason and false-cjs across the board; the first is now out of profile and the second is fixed (esi-23g.29, below).

Known findings are in scripts/package/package-lint-baseline.json, keyed <tool>:<code> <where> (for example attw:FalseCJS ./errors node16-esm) with the tracking bead as the value. It is a ratchet like the others: a blocking finding outside the baseline fails, an entry that no longer occurs fails, and an entry the base ref's copy lacks fails. The base ref is PACKAGE_LINT_BASE_REF, else origin/master, else master; the job fetches master first, and with no ref the check fails closed. The baseline is empty. Its last seven entries were one defect, esi-23g.29: every import condition resolved to a .d.ts in a type: commonjs package, so TypeScript read the ES module entry points as CommonJS ("Masquerading as CJS"). The build now writes .d.mts declarations for the import condition (scripts/package/esm-declarations.cjs).

npm run size: a budget per sub-path ​

.size-limit.cjs holds a budget for the ESM build and one for the CJS build of each exports sub-path (., ./schemas, ./errors, ./testing, ./sde, ./sde/memory). scripts/package/size-limit-checks.cjs derives the checks from the exports map, so a new sub-path without a budget, or a budget for a removed one, fails before anything is measured. Each check bundles the entry with esbuild the way a consumer loading that sub-path would: the entry plus every shared chunk-* file it imports (tsup builds with splitting: true), minified and uncompressed, for platform: node. dependencies and peerDependencies (pino, zod, js-yaml, adm-zip) and Node built-ins stay external, so the number is this package's own code, and a helper that starts inlining a dependency shows up. Budgets are the size measured at 9.9.0 plus 5%; each line's comment records the measurement. To raise a budget, run npm run build && npm run size, set the new measurement plus 5%, update the comment, and justify the growth in the pull request body. .size-limit.cjs and the package-lint baseline have explicit CODEOWNERS entries.

Negative fixtures ​

tests/tdd/package-lint/package-lint.test.ts, part of npm test, runs the real tools against packages it writes to a temporary directory. A package whose ./sub entry names a missing types file must produce publint:FILE_DOES_NOT_EXIST and an attw problem under all three resolutions, while a clean control produces none. A size-limit fixture whose CJS budget is below its entry plus chunk must exit 1, while its ESM check passes. The ratchet rules and the budget-to-exports matching have unit tests in the same file. size-limit 13 needs Node 22.18, so the size-limit suite skips on an older Node 22 and runs in the Node 22 and 24 unit-tests jobs.


Dependency audit ​

npm audit reports the state of the world, not the state of a diff. An unchanged commit passes before an advisory is published and fails after it. Used as a plain merge gate, it turns every open pull request red for something its author did not do. The audit is therefore split three ways, all driven by scripts/quality/audit-check.ts:

WhereQuestionModeBlocking
ci.yml → dependency-auditDoes this PR introduce an advisory the base lacks?--diffYes
nightly-audit.ymlDoes master have any unaccepted advisory today?--filterFiles an issue
release.yml → validate-releaseIs the tree clean at or above high before publishing?--checkYes

The pull request job audits base and head in one run, each from its package.json and package-lock.json alone in a bare directory, so both hit the same advisory snapshot and neither depends on how node_modules was installed. Any difference is a real difference between the trees. The job skips entirely when neither file changed.

Advisories are keyed by GHSA id (taken from the advisory URL), falling back to npm-<source> for older advisories without one. One advisory reached through several dependency paths counts once. The nightly --filter also drops a package npm lists only because a dependency carries an accepted advisory (its via names that dependency), so accepting braces hides micromatch, fast-glob, globby and tsd too; a package that also reaches an unaccepted advisory stays.

Accepting a known risk ​

When an advisory has no acceptable fix, record it in scripts/quality/audit-exceptions.json:

json
{
  "exceptions": [
    {
      "ghsa": "GHSA-xxxx-xxxx-xxxx",
      "package": "some-package",
      "severity": "moderate",
      "reason": "Why there is no acceptable fix, and why the exposure is limited.",
      "expires": "2026-12-09"
    }
  ]
}
FieldRule
ghsaThe match key. Must equal the id the audit reports
packageThe vulnerable package, for readers
severityAs reported, for readers
reasonWhy it cannot be fixed now and why the risk is acceptable: runtime or dev dependency, reachable code, input trust
expiresYYYY-MM-DD. Keep it short enough that the decision is genuinely revisited

All three audit paths honour the file. An accepted advisory stops blocking pull requests and releases and stops appearing in the nightly issue.

An entry whose expires is before today is a hard failure in every mode, before any advisory is examined. The run prints the expired ids and the file to edit. That is deliberate (SEC-05): an acceptance that silently outlives its review is indistinguishable from an advisory nobody looked at.

To renew, re-check whether a fix now exists. If it does, upgrade or add an overrides entry and delete the exception. If not, update reason with what changed and set a new expires. Either way it goes through a pull request so the decision is reviewed.

In --filter mode a package entry is dropped only if every advisory on it is accepted, so accepting one advisory cannot hide a second one arriving through the same package.

bash
npm run audit:check                        # unaccepted advisories at or above high
npm run audit:check -- --level=moderate    # lower the threshold
npm run audit:diff -- --base base.json --head head.json
npx ts-node scripts/quality/audit-check.ts --filter --in audit-raw.json --out audit-report.json

Other exception files ​

The same "explicit, reasoned exception" pattern appears in ten more places:

FileConsumed byRule
config/mutation/bdd-thresholds.jsonnpm run mutation:bdd:ratchetPer-directory BDD mutation floors; every scored directory needs one; --update only raises them
config/mutation/unit-thresholds.jsonnpm run mutation:prPer-directory unit mutation floors; every mutated directory needs one; they may only rise
config/mutation/type-thresholds.jsonnpm run test:type-mutationIn scripts/. Per-entry-point type mutation floors; only rise against origin/master
scripts/spec/spec-audit-exceptions.jsonnpm run spec:auditThree ratchets: unconverted (empty; the audit fails if a listed file passes), legacyStepFiles (38; gains no entry) and unchartedFeatures (empty; features no charter requirement covers, gains no entry)
scripts/spec/schema-drift-exceptions.jsonnpm run schema:driftSchema name → accepted permanent deviations (field paths); an unused entry warns
scripts/spec/schema-drift-baseline.jsonnpm run schema:drift:ciKnown drift → bead id; shrink-only, stale entries fail. See Schema drift
scripts/quality/determinism-baseline.jsonnpm run lint:determinismClock, timer and Math.random() sites per file and construct; shrink-only, stale counts fail
scripts/spec/auth-scope-exceptions.jsonnpm run validate:auth-scopesMETHOD:path key with a reason, for endpoints whose scope mapping lags the generated map; empty; stale entries fail
scripts/spec/esi-endpoint-exceptions.jsonnpm run validate:esiMETHOD path key with a reason, for definitions the spec does not list (today the two /meta/openapi.* documents); any other definition absent from the spec fails
scripts/package/package-lint-baseline.jsonnpm run lint:packageKnown publint/attw finding → bead id; shrink-only, stale entries fail
scripts/sde/sde-spec-coverage-baseline.jsonnpm run spec:coverage:sde:ciIStaticDataProvider methods no Rule names and no bound step reaches, by entity family; shrink-only against origin/master; a stale or misfiled entry fails
scripts/spec/client-spec-coverage-baseline.jsonnpm run spec:coverage:clients:ciPublic domain-client methods no Rule names and no bound step calls, by client; shrink-only against origin/master; a stale or misfiled entry fails

npm scripts ​

npm run help prints the scripts grouped by intent and accepts a keyword (npm run help -- wallet). The tables below list every script in package.json except the two large families, which are summarised.

Build and static checks ​

ScriptRuns
buildtsup then tsc --emitDeclarationOnly
typechecktsc --noEmit
typecheck:scriptstsc -p tsconfig.scripts.json: scripts/ the way ts-node runs them
lint / lint:fixESLint over src, tests and scripts (relaxations declared in eslint.config.mjs)
lint:ratchetlint that also fails when warnings per file and rule exceed config/eslint/warning-baseline.json; -- --update lowers it
lint:bdd-seamESLint over tests/bdd with only the transport-seam rule (config/eslint/bdd-seam.rules.cjs)
lint:determinismTime and randomness in src only via the clock module; shrink-only baseline
lint:cyclesNo runtime import cycle among src/ files; import type edges do not count (scripts/quality/import-cycles.ts)
lint:layersImports in src point inward (config/eslint/layers.rules.cjs); shrink-only baseline
lint:suite-healthESLint over tests/ with only the suite-health rules (config/eslint/suite-health.rules.cjs)
lint:packageBuild, npm pack, then publint and attw on the tarball (-- --skip-build)
sizesize-limit budget per exports sub-path, ESM and CJS (.size-limit.cjs)
format / format:checkPrettier over src/**/*.ts and tests/**/*.ts
knipDead code and unused exports (knip.jsonc)
api-reportapi-extractor, local mode: rewrites etc/esi.ts.api.md
api-report:checkapi-extractor, check mode
api-report:semverFails if the report lost a line without a breaking-change commit (GATE-03)
clean / clean:docsRemove dist, coverage, and the generated docs (public/api, pages, sidebar, build)
prepareInstall husky hooks, then build

Tests ​

ScriptRuns
test / test:watchJest unit config: tests/tdd (composition included), BDD step files and spec entries
coverageThe same, with coverage thresholds
bddThe BDD suites only: legacy step files and spec entries
bdd:<suite>One BDD suite, for example bdd:market, bdd:resilience, bdd:sde. See npm run help -- bdd
bdd:stepsBDD dry run: every step matches one definition, and every definition is used
bdd:reportJoins a BDD run's Jest JSON to the feature files; fails on a scenario that did not execute; writes reports/bdd/junit.xml and reports/bdd/scenarios.json
spec:audit / spec:audit:verboseEARS and Gherkin structure audit over the feature files, charter tags included
earsStandalone EARS check: spec:audit, then the BDD scenarios, one PASS/FAIL/NOT RUN verdict per Rule:; writes reports/ears/ (TESTING.md)
ears:dashboardOne HTML page of every requirement: pattern, verdict, what runs it, a guide to writing one; reads the ears report; writes reports/ears/ears-dashboard.html (--since=<ref> marks changes)
test:integrationIntegration tests, mocked
test:integration:livetests/integration/live-esi.test.ts against live ESI (config/jest/integration.live.config.cjs). Fails in global setup unless ESI_LIVE_TESTS=true
test:integration:gatedAuthenticated integration tests (ESI_GATED_TESTS, reads .env)
test:alltest, bdd, test:integration, fuzz, test:types
contractContract tests; the live-spec suites skip unless ESI_LIVE_TESTS=true
contract:liveContract tests against the live spec and the committed snapshot (config/jest/contract.live.config.cjs). Fails in global setup unless ESI_LIVE_TESTS=true; what CI runs
contract:replayRecorded ESI payloads through the pipeline, no network (config/jest/contract.replay.config.cjs); see TESTING.md
contract:recordRe-records the public payload fixtures; refuses to run unless ESI_LIVE_TESTS=true; -- --only=<endpoint key>
contract:shape-diffCompares recorded fixtures by shape, not value; -- --revert-unchanged restores those whose shape did not change
contract:snapshotRefresh the committed spec snapshot
contract:diffoasdiff breaking changes, snapshot versus live spec (Docker)
fuzzProperty-based fuzz tests (fast-check)
fuzz:propertiesThe model-based *.property.test.ts and sde-schema-fuzz.test.ts; FC_NUM_RUNS, FC_SEED and FC_PATH replay a run
faultsFault catalogue and its self-test (config/jest/faults.config.cjs); see TESTING.md
faults:nightlySeeded payload fuzz over every endpoint definition (config/jest/faults.nightly.config.cjs); FAULTS_SEED replays, FAULTS_RUNS sets cases per endpoint
fuzz:apiSchemathesis against a Prism mock (Docker)
mock:esiPrism mock of ESI on port 4010
test:consumerConsumer contract: pack, install into a clean consumer, type-check and run it (not part of npm test; see TESTING.md)
test:export-coveragePublic exports no test references, by entry point; --ci gates against scripts/package/export-coverage-baseline.json
spec:coverage:sdeIStaticDataProvider methods no Rule names and no bound step reaches, by entity family, checked against scripts/sde/sde-spec-coverage-baseline.json family by family; spec:coverage:sde:ci also fails an entry origin/master lacks
spec:coverage:clientsPublic domain-client methods no Rule names and no bound step calls, by client, checked against scripts/spec/client-spec-coverage-baseline.json client by client; spec:coverage:clients:ci also fails an entry origin/master lacks
test:docs-examplesDocumentation examples checked against the packed tarball (not part of npm test; see DOCUMENTATION.md)
test:typestsd type tests
test:type-mutationMutates the built dist/**/*.d.ts and runs tsd against each mutant; -- --ratchet gates per-entry-point scores (config/mutation/type-thresholds.json)
benchmarkBenchmark suite
bench:abBundles the harness for two trees and runs them in alternating processes
bench:compareThe statistical verdict: exit 3 on a regression, 1 when the comparison cannot be made
bench:trendOne JSON record per nightly run for the bench-data history
soakHeap soak under --expose-gc; -- --inject-leak --expect-fail checks the detector; -- --sde [--dir <export>] runs the SDE load, lookup and close cycles
mutation / mutation:reportStryker
mutation:bdd / mutation:bdd:merge / mutation:bdd:ratchetStryker with only the BDD step definitions as tests, one shard at a time (BDD_MUTATION_SHARD); :merge rebuilds the whole report and refuses an incomplete run; per-directory score ratchet against config/mutation/bdd-thresholds.json
mutation:ratchet / mutation:prUnit-suite per-directory ratchet against config/mutation/unit-thresholds.json; :pr mutates only the changed src/ files (TESTING.md, Mutation testing)
mutation:fixtureStryker over the known-weak fixture in tests/mutation-fixture/; fails unless mutants both die and survive

Spec alignment and generation ​

ScriptRuns
generate:typesTypes, cache TTLs, rate-limit groups and scopes from the live spec
generate:okfOKF knowledge bundle in okf/ (see OKF.md)
generate:endpointsEndpoint definition scaffold (see DESIGN-RULES.md)
generate:allgenerate:types, generate:okf, contract:snapshot, schema:drift, validate:esi
schema:driftZod schema versus spec report; exits 2 only when the check compared nothing
schema:drift:ciThe same, also exiting 1 on drift outside the baseline or a stale or grown baseline
validate:esiEndpoint definitions versus the spec at COMPATIBILITY_DATE; fails on a method mismatch or a definition the spec lacks (scripts/spec/esi-endpoint-exceptions.json lists the reasoned ones)
validate:esi:vendoredThe same against tests/contract/snapshots/esi-openapi.snapshot.json, offline; in check:local and after spec-refresh.yml regenerates
validate:auth-scopesrequiresAuth versus the generated scope map (DES-04); fails in both directions
validate:specRedocly lint of the ESI spec (redocly.yaml)
validate:versionspackage.json version equals PACKAGE_VERSION in src/core/constants.ts, and the docs-site selector reads package.json; -- --site also checks the built site

Security ​

ScriptRuns
audit:checkaudit-check.ts --check (default high)
audit:diffaudit-check.ts --diff
audit:signaturesnpm audit signatures: registry signatures and provenance of every installed package

Aggregates ​

ScriptRuns
validatelint, format:check, build, coverage, knip
check:allvalidate's steps, then validate:esi, validate:spec, validate:versions, spec:audit, validate:spec-consistency
check:local / check:local:fast / check:local:allEvery offline CI tier; see Running the gates locally

Documentation, tokens, SDE and examples ​

ScriptRuns
docs / docs:watchTypeDoc into docs-site/public/api
docs:serveServe the TypeDoc output on port 8080
docs:syncGenerate the site's guide and example pages from guides/, examples/
docs:earsThe EARS dashboard into docs-site/public/requirements/
docs:sitedocs, docs:sync, docs:ears, then the VitePress build (the published site)
docs:site:dev / :previewVitePress dev server after docs:sync; serve the built site
token:create / token:refreshPKCE token into .env, and refresh it (see SECURITY.md)
sde:ingestDownload and extract CCP's archive; --from-zip extracts one on disk
sde:driftAn extracted export against SDE_FILE_REGISTRY and the Zod schemas
health-checkstatus.getStatus() against live ESI
start / exampleexamples/character-profile.ts
example:<name>One runnable example from examples/; see npm run help -- example
helpGrouped script listing

Running the gates locally ​

npm run check:local runs every tier ci.yml gates on that works offline, in one command, and keeps going after a failure so the answer is the whole list rather than the first thing to break:

bash
npm run check:local        # the quick tiers, then build and the tiers that need dist/
npm run check:local:fast   # quick only: no build
npm run check:local:all    # adds the slow tiers (type mutation)

What it runs, and what it deliberately does not, is scripts/quality/verify-local-core.ts. The list is held to CI by tests/tdd/verify-local/verify-local.test.ts, which reads ci.yml two ways and fails unless each thing it finds is in the tier list, reachable from a composite, or excluded with a written reason — so a tier added to CI cannot silently go unrun locally. It reads every npm run, and every tool a step invokes directly through npx, uvx, pipx or bunx. The second was added after zizmor spent a day as a CI-only gate precisely because nothing looked for it: it is not an npm script, so the first pass never saw it. format:check is one of the excluded ones: on a Windows checkout CRLF endings fail it across the whole repository, so run it on a specific path instead.

Two older aggregate scripts cover the static side:

bash
npm run validate     # lint, format:check, build, coverage, knip
npm run check:all    # validate + validate:esi + validate:spec + validate:versions + spec:audit + validate:spec-consistency

Neither reproduces ci-success completely, and neither includes the tiers added around the unit suite. check:all needs network access for the spec checks.

check:local covers typecheck, the examples type-check, generated-operation freshness and coverage, every lint (lint, lint:suite-health, lint:determinism, lint:layers, lint:bdd-seam, lint:workflows), spec:audit, charter:audit, security:model (without --results, so it does not check that the tagged Rules passed), spec:coverage:sde and spec:coverage:clients (each the committed baseline against the working tree; :ci adds the shrink check against origin/master), validate:spec-consistency, validate:auth-scopes, validate:esi:vendored (the endpoint check against the vendored snapshot), validate:versions, test, test:integration, faults, contract:replay, fuzz and test:export-coverage; then, after a build, test:types, test:docs-examples, lint:package, size and test:consumer; --all adds test:type-mutation. What it leaves out, each with the reason NOT_RUN_LOCALLY records in scripts/quality/verify-local-core.ts:

bash
npm run format:check                              # CRLF on a Windows checkout fails ~994 files; run it on a path
ESI_LIVE_TESTS=true npm run contract:live        # calls the live ESI API
npm run generate:types && git diff --exit-code src/types/generated/ 'src/core/endpoints/esi-*.generated.ts'
npm run schema:drift:ci                           # downloads the live ESI OpenAPI document
npm run validate:esi                              # downloads the live ESI OpenAPI document
npm run validate:spec                             # Redocly downloads and lints the live ESI OpenAPI document
npm run api-report && git diff etc/esi.ts.api.md  # rewrites a committed file; commit any change
npm run api-report:semver                         # needs the base branch
npm run audit:diff                                # queries the advisory database
npm run audit:signatures                          # fetches the registry's signing keys
npm run mutation:pr                               # needs a base ref; the nightly owns the full run
npm run bench:ab                                  # needs a second tree and a quiet machine

bdd and coverage are left out because test already runs the same suites and docs because it asserts nothing.

validate:versions runs in static-analysis and in release.yml: release-please keeps src/core/constants.ts in step with package.json through its extra-files setting, and the check is what catches a hand edit of either. The docs-site version selector is read from package.json when the site builds; the check fails if docs-site/.vitepress/config.ts stops doing that, and validate:versions -- --site fails on a built site showing another version (the documentation job and docs-site.yml run it).

Released under the GPL-3.0-or-later license.