diff --git a/.claude/context/admin.md b/.claude/context/admin.md index 87887ce0ac..f1ac12ec01 100644 --- a/.claude/context/admin.md +++ b/.claude/context/admin.md @@ -11,7 +11,7 @@ Loaded when working in `packages/admin/`. Extends CLAUDE.md. | Command | Purpose | |---------|---------| | `bun run test` | Run tests (vitest) | -| `bun build` | Build (includes TypeScript check) | +| `bun run build` | Build (includes TypeScript check) | | `bun lint` | Lint with oxlint | | `bun dev` | Start dev server (via PM2 from root) | diff --git a/.claude/context/client.md b/.claude/context/client.md index a357ad6f71..2bdd27e394 100644 --- a/.claude/context/client.md +++ b/.claude/context/client.md @@ -9,7 +9,7 @@ Loaded when working in `packages/client/`. Extends CLAUDE.md. | Command | Purpose | |---------|---------| | `bun run test` | Run tests (vitest) | -| `bun build` | Build (includes TypeScript check) | +| `bun run build` | Build (includes TypeScript check) | | `bun lint` | Lint with oxlint | | `bun dev` | Start dev server (via PM2 from root) | diff --git a/.claude/context/contracts.md b/.claude/context/contracts.md index 4bb03e3d71..7dae277e1c 100644 --- a/.claude/context/contracts.md +++ b/.claude/context/contracts.md @@ -8,7 +8,7 @@ Loaded when working in `packages/contracts/`. Extends CLAUDE.md. |---------|---------| | `bun run test` | Run unit tests (skips E2E) | | `bun run test:gas` | Tests with gas report | -| `bun build` | Adaptive build (changed Solidity targets with shared-file fallback to `src`) | +| `bun run build` | Adaptive build (changed Solidity targets with shared-file fallback to `src`) | | `bun build:changed` | Build changed Solidity under `src/test/script` only | | `bun build:target -- ` | Build explicit Solidity target(s) only | | `bun build:fast` | Explicit fast mode (`src` only, skips Foundry test/script) | diff --git a/.claude/context/validation-pipeline.md b/.claude/context/validation-pipeline.md index 233c49cd00..7aa69edff3 100644 --- a/.claude/context/validation-pipeline.md +++ b/.claude/context/validation-pipeline.md @@ -5,17 +5,61 @@ instead of restating the pipeline, so a change to the gate (adding a step, renaming a script) happens in exactly one place. The intent ladder that decides *which* rung to run lives in `CLAUDE.md § Validation Intent Ladder`. -## Review Readiness Gate (non-mutating) +## Select before executing -The strict evidence gate for plain `/review`. It proves bounded production readiness without -editing tracked files: +Render the repository-owned plan first: + +```bash +bun run validation:plan -- --intent +``` + +The selector combines intent, changed paths, dependency impact, and criticality. Agents execute the +returned plan instead of inventing a broader command set. If the selector command is unavailable or +fails, fall back to the intent ladder and commands below and report the selector problem. A missing +or failed selector never authorizes omitting a required check or critical override. + +Every selected check states: + +- **Risk** — the concrete regression, invariant, or acceptance criterion it covers. +- **Expected signal** — the observable pass/fail evidence the command provides. +- **Freshness** — the source inputs, validated paths, validation entrypoint, policy, toolchain, and + environment profile that must remain identical before a passing receipt can be reused. +- **Stop** — which dependent checks stop after a deterministic failure and which explicitly + independent diagnostics may continue. + +Receipt reuse is opt-in and off by default. Pass `--reuse-passing-receipts` to +`node scripts/dev/ci-local.js` to skip checks whose exact fingerprint already passed. The store +lives in `.cache/validation`, holds passes only, and any change to the command, policy, toolchain, +validated paths, or environment profile invalidates the fingerprint. A tampered store is rejected +rather than trusted. + +Never reuse failures. User cancellation is terminal: stop active validation, schedule nothing else, +and report only evidence already collected. An unavailable browser, RPC, secret, service, or other +capability produces `BLOCKED`, not passing; do not retry the identical check until that capability +changes. Budgets warn and profile but never skip contract, deployment/release, authentication, +JobQueue, Work-provider, mutation-hook, security, ontology, supply-chain, or release gates. Contracts use Bun +wrappers only, never raw Forge. + +## Diagnosis and evidence review (non-mutating) + +Inspect existing evidence first, then run only the check needed to prove or disprove each material +finding. Keep commands non-mutating and stop dependent work on the first deterministic failure. This +rung supports diagnosis, audit, and ordinary evidence review; it does not certify production +readiness. When the user explicitly asks for production quality, approval, PR/merge readiness, or a +readiness verdict, use the full Production Review Readiness Gate below. + +## Production Review Readiness Gate (non-mutating) + +The strict evidence gate for an explicit production-readiness review. It proves bounded production +readiness without editing tracked files: ```bash bun format:check && bun lint && bun run test && VITE_CHAIN_ID=11155111 bun run build ``` -Run every stage fresh in the current review. A required failure means `REQUEST_CHANGES`. A required -check that cannot run means `COMMENT_ONLY`; do not downgrade or replace the proof silently. +Run every selected stage fresh unless an exact matching receipt satisfies the freshness contract +above. A required failure means `REQUEST_CHANGES`. A required check that cannot run means +`COMMENT_ONLY`; do not downgrade or replace the proof silently. Conditional additions when the change touches the relevant surface: @@ -35,6 +79,11 @@ Conditional additions when the change touches the relevant surface: matching Playwright CI project — client: `PLAYWRIGHT_APP=client APP_ENV=test bunx playwright test --project=client-ci`; admin: `PLAYWRIGHT_APP=admin APP_ENV=test bunx playwright test --project=admin-ci` +- Agent runtime changes: `bun run build:agent` +- Docs runtime, navigation, or build configuration changes: `bun run build:docs` + +The root `bun run build` covers Contracts, Shared, Indexer, Client, and Admin. It does not build +Agent or Docs; the conditional commands above close those scopes. Visible UI additionally requires rendered proof through the authenticated Brave QA profile. If that path is unavailable, record browser proof as `BLOCKED` and return `COMMENT_ONLY` unless a @@ -46,10 +95,10 @@ clean-room browser-proof commands cannot substitute for authenticated local QA. The pre-merge/pre-push gate — required before claiming a branch is ready: ```bash -bun format && bun lint && bun run test && bun build +bun format && bun lint && bun run test && bun run build ``` -Ship uses the same conditional additions listed in the Review Readiness Gate. Unlike review, ship +Ship uses the same conditional additions listed in the Production Review Readiness Gate. Unlike review, ship may run mutating format and branch/commit safety steps because the user explicitly requested ship, PR, commit, merge, or release readiness. @@ -67,9 +116,13 @@ node scripts/dev/ci-local.js --quick Targeted proof for an isolated fix — the package-local test file or command that proves the touched behavior (see the intent ladder). Common shapes: -- Style only: `bun format && bun lint` +- Style only: `bunx @biomejs/biome format --no-errors-on-unmatched ` and, for changed JavaScript or + TypeScript, `bunx oxlint --deny-warnings`. Both commands are + path-scoped and non-mutating. Do not use workspace-mutating `bun format` or broad `bun lint` for + isolated style-only QA. - One behavior: `bun run --filter test ` -- Baseline capture before a sweep: `bun format && bun lint && bun run test` +- Baseline capture before a sweep: non-mutating `bun run format:check && bun lint`, then the + selector-chosen tests. Use the mutating `bun format` only in explicit fix/Ship intent. (build intentionally omitted until the sweep lands) Failing tests are never cached and never skipped around — fix the test, not diff --git a/.claude/skills/clean/SKILL.md b/.claude/skills/clean/SKILL.md index 983adc0ddc..ca002327a1 100644 --- a/.claude/skills/clean/SKILL.md +++ b/.claude/skills/clean/SKILL.md @@ -79,7 +79,7 @@ Remove unused files/exports/types/deps found by `bunx knip --reporter compact`. ### Agent 4: Circular Dependency Resolution (madge) -Zero out cycles from `npx madge --circular --extensions ts,tsx packages/`. Resolution preference: `import type` → extract shared interface → dependency inversion → merge modules. Rules: respect build order `contracts -> shared -> indexer -> client/admin/agent`; never create upward dependencies; hooks stay in shared; `bun build` must pass after. +Zero out cycles from `npx madge --circular --extensions ts,tsx packages/`. Resolution preference: `import type` → extract shared interface → dependency inversion → merge modules. Rules: respect build order `contracts -> shared -> indexer -> client/admin/agent`; never create upward dependencies; hooks stay in shared; `bun run build` must pass after. ### Agent 5: Type Strengthening @@ -211,7 +211,7 @@ Use `--no-codex` when: git diff --check # Whitespace / conflict marker sanity bun format:check && bun lint # Non-mutating style gate bun run test # Correctness -bun build # Build integrity +bun run build # Build integrity madge --circular --extensions ts,tsx packages/ # Only when locally installed or explicitly approved bunx knip --reporter compact # Checked-in dependency; reduced dead code ``` diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 7b5404bd65..242dbe4b5c 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -176,7 +176,7 @@ Simple fixes (<10 lines, single file, root cause proven) apply directly. Complex ## Part 3: Verification Before Completion -CLAUDE.md § Verify Before Claiming Success is the contract: evidence in the same turn, no "should work / probably / seems to". Standard proofs: `bun run test` (never `bun test`), `bun build`, `bun lint`, `npx tsc --noEmit` in the touched package. +CLAUDE.md § Verify Before Claiming Success is the contract: evidence in the same turn, no "should work / probably / seems to". Standard proofs: `bun run test` (never `bun test`), `bun run build`, `bun lint`, `npx tsc --noEmit` in the touched package. --- diff --git a/.claude/skills/debug/health-diagnostics.md b/.claude/skills/debug/health-diagnostics.md index 6cdf675c0d..1218f49c88 100644 --- a/.claude/skills/debug/health-diagnostics.md +++ b/.claude/skills/debug/health-diagnostics.md @@ -20,7 +20,7 @@ Moved from the debug SKILL.md body — load on demand, not on every activation. ```bash # Compile and check artifacts -cd packages/contracts && bun build +cd packages/contracts && bun run build # Inspect deployment addresses cat deployments/11155111-latest.json | jq '.gardenToken' @@ -76,7 +76,7 @@ cd packages/shared && npx tsc --noEmit cd packages/client && npx tsc --noEmit # Vite build with verbose output -cd packages/client && DEBUG=vite:* bun build +cd packages/client && DEBUG=vite:* bun run build # Check bundle analysis cd packages/client && npx vite-bundle-visualizer diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 899185fc25..ea88a76cff 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -7,9 +7,15 @@ user-invocable: true # Review -One command for the standing request: **"review this — ensure no regressions, no remaining gaps, and production quality."** Three passes over one resolved scope, then a verdict. Read-only unless `--fix` is explicitly requested. +One command for change review. Three passes over one resolved scope, then a verdict. Read-only unless +`--fix` is explicitly requested. Evidence/diagnosis review is targeted by default; full production +readiness is a separate, explicit intent. -It answers three questions with fresh evidence: **regression safety** (Pass 1), **requirement closure** (Pass 2), **production readiness** (Pass 3). `APPROVE` is a bounded, evidence-backed readiness verdict for the reviewed scope — not a claim that unrelated repository or production failures are impossible. +It answers three questions with fresh evidence: **regression safety** (Pass 1), **requirement closure** +(Pass 2), and the user's requested **evidence or readiness level** (Pass 3). `APPROVE` is reserved for +an explicit production-quality, approval, PR/merge-readiness, or equivalent request whose full +non-mutating readiness gate passed. It is a bounded verdict for the reviewed scope, not a claim that +unrelated repository or production failures are impossible. ## Scoping @@ -24,6 +30,22 @@ Valid package scopes map to `packages//**` (contracts, indexer, shared, cl - `--scope cross-package` — verify blast radius in dependency order (contracts → shared → indexer → apps → agent); only cross-boundary findings. - `--scope design-system` — delegate to [`design/system-alignment-review.md`](../design/system-alignment-review.md), read-only; return its sections directly, don't mix into diff findings. Fires only on explicit invocation, on DESIGN.md-dialect + theme/tokens co-changes, or when a change touches ≥2 visual surfaces at once. +### Review intent + +Resolve intent separately from code scope: + +- **Evidence review / diagnosis** — ordinary "review this", regression investigation, gap analysis, + audit evidence, or a specific question. Inspect first and run only the non-mutating checks needed + to prove findings. A clean targeted review returns `COMMENT_ONLY`, not a readiness approval. +- **Production readiness** — explicit requests for production quality, approval, PR/merge readiness, + or whether the branch is ready. Run the full Production Review Readiness Gate. + +Render the planned checks with `bun run validation:plan -- --intent review`. For explicit production +readiness, use `--intent readiness` so the plan remains non-mutating while criticality can only add +checks. Execute the returned plan. If the selector is unavailable or fails, follow CLAUDE.md's +intent ladder and the shared validation +pipeline directly, report the selector problem, and preserve every hard gate. + ### Authoritative requirements After resolving the code scope, establish the requirement baseline in this order: (1) the user's current request and explicit acceptance criteria; (2) the PR description and any Linear issue linked there with `Fixes`, `Refs`, or `Relates to`, plus the legacy `Closes` and `Linear:` forms while existing PRs transition; (3) any `.plans/` lane referenced by the request, PR, or issue (`brief.md`, `spec.md`, `plan.todo.md`, `status.json`); (4) directly applicable package documentation those sources reference. Never infer issue identity from the branch name. Record which sources were available. If no authoritative requirements can be established, continue with useful findings but set the final verdict to `COMMENT_ONLY` — do not claim that no gaps remain. @@ -83,9 +105,18 @@ Then sweep for the repo's recurring gap shapes: Report gaps in their own section — a gap is not a defect; it's unfinished intent. -## Pass 3 — Production Quality +## Pass 3 — Evidence or Production Quality + +Evidence review runs only the selector-chosen, non-mutating checks needed to prove or disprove its +findings. Do not add full tests or builds merely to make the command count look comprehensive. A +clean evidence review is not production certification and returns `COMMENT_ONLY`. -Plain review runs the non-mutating **Review Readiness Gate** defined in [`.claude/context/validation-pipeline.md`](../../context/validation-pipeline.md) (`format:check`, lint, tests, pinned `VITE_CHAIN_ID=11155111` build, plus its scope-conditional additions). Run every required stage fresh in this invocation — never reuse stale evidence. A required stage that fails → `REQUEST_CHANGES`; a required stage that cannot run → `COMMENT_ONLY`, never silently downgraded or substituted. +Explicit production-readiness review runs the non-mutating **Production Review Readiness Gate** +defined in [`.claude/context/validation-pipeline.md`](../../context/validation-pipeline.md) +(`format:check`, lint, tests, pinned `VITE_CHAIN_ID=11155111` root build, plus scope-conditional +additions including Agent or Docs builds). Run every selected stage fresh unless an exact matching +receipt satisfies the shared freshness contract. A required stage that fails → `REQUEST_CHANGES`; a +required stage that cannot run → `COMMENT_ONLY`, never silently downgraded or substituted. For narrower explicit intents, pick the lightest honest rung per CLAUDE.md § Validation Intent Ladder: @@ -93,6 +124,7 @@ For narrower explicit intents, pick the lightest honest rung per CLAUDE.md § Va - cross-package or shared-surface impact → Repo Quick Gate - explicit ship/merge readiness → full Ship Gate + conditional design/vocab/story gates when those surfaces moved +For every selected check, name its risk, expected signal, freshness rule, and stopping condition. State what ran with real output. Record the tested commit SHA, UTC timestamp, exact command, and summarized result. Write green, passed, or merge-ready claims only after those commands finish in the current review and an empty @@ -102,8 +134,10 @@ path-scoped `git diff --exit-code ..HEAD -- ` proving a implementation, dependency, configuration, and validation-entrypoint surfaces are unchanged, plus an empty `git status --porcelain=v1 --untracked-files=all -- ` proving no staged, unstaged, or untracked path changes exist. If a -rung can't run here (env-gated, needs authenticated browser), say -"unverified: X" instead of hedging. Visible-UI claims need rendered proof via the authenticated Brave +rung can't run here (env-gated, or it requires an authenticated browser), mark it `BLOCKED`, name the unavailable +capability, and do not retry until that capability changes. User cancellation is terminal: stop +active validation, schedule no further checks, and report evidence already collected. Visible-UI +claims need rendered proof via the authenticated Brave QA path or are reported as blocked (CLAUDE.md § Agentic Modern Web Standard). Dated reports under `.plans/**/reports/` are immutable audit inputs; put corrections or closure evidence in a new report. @@ -144,7 +178,7 @@ Lead with findings, keep the list actionable: 3. **Remaining Gaps** — unfinished intent, each with the smallest completing step 4. **Human Call-Outs** — dependencies, auth/permissions, migrations, contract deploys, trust-boundary changes (never auto-fix these) 5. **Verification** — what ran, real results, what remains unverified -6. **Verdict** — `APPROVE` | `REQUEST_CHANGES` | `COMMENT_ONLY`. Rules: any `MISSING` requirement or failed required check → `REQUEST_CHANGES`; any `BLOCKED` requirement/check, or no authoritative requirements available → `COMMENT_ONLY`; `APPROVE` only when every requirement is `SATISFIED`/`OUT_OF_SCOPE` and the readiness gate passed fresh. +6. **Verdict** — `APPROVE` | `REQUEST_CHANGES` | `COMMENT_ONLY`. Rules: any `MISSING` requirement or failed required check → `REQUEST_CHANGES`; any `BLOCKED` requirement/check, no authoritative requirements, or evidence-review-only intent → `COMMENT_ONLY`; `APPROVE` only for explicit production-readiness intent when every requirement is `SATISFIED`/`OUT_OF_SCOPE` and the full readiness gate passed under the freshness contract. Finding format: `[Title] — severity · type · file:line · why it matters · next step`. diff --git a/.codex/config.toml b/.codex/config.toml index 528a2faf0b..01009a6d56 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,6 +1,8 @@ project_doc_fallback_filenames = ["CLAUDE.md"] project_doc_max_bytes = 40960 +model_verbosity = "low" +model_reasoning_summary = "concise" [features] hooks = true diff --git a/.github/actions/setup-js/action.yml b/.github/actions/setup-js/action.yml new file mode 100644 index 0000000000..62f008444a --- /dev/null +++ b/.github/actions/setup-js/action.yml @@ -0,0 +1,26 @@ +name: Setup Green Goods JavaScript +description: Pin Node and Bun, then install the frozen lockfile + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.22.1" + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: "1.3.14" + + # Deliberately uncached. Measured on this repository (PR #719, run + # 31963610888): restoring the ~878 MB Bun download store cost 20.7s + # (4s transfer, 16.6s extraction) to make `bun install` 21.8s -> 6.9s, + # a net loss of ~6s per job. The store also consumed 1.02 GB of an + # already-over-quota 10 GB repository cache, evicting the per-commit + # Foundry build caches that do pay for themselves. Reintroduce only with + # fresh before/after evidence that restore cost is below install savings. + - name: Install dependencies from frozen lockfile + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml index 28011a9d57..f96829f19d 100644 --- a/.github/workflows/admin.yml +++ b/.github/workflows/admin.yml @@ -12,7 +12,6 @@ on: - "tests/**" - "packages/admin/**" - "packages/shared/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/ops/upload-sourcemaps.js" @@ -22,6 +21,7 @@ on: - "scripts/lib/env-parity.d.mts" - "scripts/dev/env-check.js" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/admin.yml" pull_request: paths: @@ -33,7 +33,6 @@ on: - "tests/**" - "packages/admin/**" - "packages/shared/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/ops/upload-sourcemaps.js" @@ -43,6 +42,7 @@ on: - "scripts/lib/env-parity.d.mts" - "scripts/dev/env-check.js" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/admin.yml" workflow_dispatch: inputs: @@ -72,18 +72,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run admin tests with coverage working-directory: packages/admin @@ -101,18 +91,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run env schema parser tests run: bun run test:env-schema @@ -129,9 +109,6 @@ jobs: working-directory: packages/admin run: bun run lint - - name: Check formatting - run: bun run format:check - - name: Build admin working-directory: packages/admin run: bun run build @@ -152,18 +129,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run env schema parser tests run: bun run test:env-schema @@ -203,18 +170,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Generate indexer types working-directory: packages/indexer @@ -266,18 +223,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run Lighthouse advisory run: bun run lighthouse:admin diff --git a/.github/workflows/agent.yml b/.github/workflows/agent.yml index 159ce4028e..16ceb13cd2 100644 --- a/.github/workflows/agent.yml +++ b/.github/workflows/agent.yml @@ -11,6 +11,7 @@ on: - "packages/agent/**" - "packages/shared/**" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/agent.yml" pull_request: paths: @@ -21,6 +22,7 @@ on: - "packages/agent/**" - "packages/shared/**" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/agent.yml" permissions: @@ -38,18 +40,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run agent tests with coverage working-directory: packages/agent @@ -69,18 +61,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check source structure env: @@ -91,10 +73,6 @@ jobs: working-directory: packages/agent run: bun run lint - - name: Check agent formatting - working-directory: packages/agent - run: bun run format:check - - name: Typecheck agent working-directory: packages/agent run: bun run typecheck diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index d9589553af..4cd230d4a5 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -9,12 +9,14 @@ name: CI Gate # forever on a check that never reports. # # This gate derives the workflows expected from the PR's changed files, then -# waits for every expected workflow run to register and report success. A -# missing workflow is a failure, so trigger drift and workflow-name typos cannot -# silently weaken branch protection. +# fails as soon as a registered expected workflow completes without success. It +# otherwise waits for every expected workflow run to register and report +# success. A missing workflow is a failure, so trigger drift and workflow-name +# typos cannot silently weaken branch protection. # -# Maintenance: keep scripts/quality/ci-gate.mjs in sync with workflow path -# filters. Its fixture tests run before the live gate. +# Maintenance: workflow selection lives in scripts/data/validation-policy.json +# and is rendered by scripts/quality/select-validation.mjs. Standard-library +# selector, local-runner, and polling fixtures run before the live gate. # # Base branches are deliberately unfiltered: stacked pull requests target # another feature branch as their base, and a base filter here (and in the @@ -43,8 +45,13 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Test CI Gate path mappings - run: node --test scripts/quality/ci-gate.test.mjs + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.22.1" + + - name: Test validation selection and polling + run: node --test scripts/quality/select-validation.test.mjs scripts/dev/ci-local.test.mjs scripts/quality/ci-gate.test.mjs - name: Verify all expected CI workflows pass env: diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index a3f88ce726..de5fe97a77 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -12,7 +12,6 @@ on: - "tests/**" - "packages/client/**" - "packages/shared/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/ops/upload-sourcemaps.js" @@ -22,6 +21,7 @@ on: - "scripts/lib/env-parity.d.mts" - "scripts/dev/env-check.js" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/client.yml" pull_request: paths: @@ -33,7 +33,6 @@ on: - "tests/**" - "packages/client/**" - "packages/shared/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/ops/upload-sourcemaps.js" @@ -43,6 +42,7 @@ on: - "scripts/lib/env-parity.d.mts" - "scripts/dev/env-check.js" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/client.yml" workflow_dispatch: inputs: @@ -72,18 +72,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run client tests with coverage working-directory: packages/client @@ -101,18 +91,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run env schema parser tests run: bun run test:env-schema @@ -129,9 +109,6 @@ jobs: working-directory: packages/client run: bun run lint - - name: Check formatting - run: bun run format:check - - name: Build client working-directory: packages/client run: bun run build @@ -153,18 +130,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run env schema parser tests run: bun run test:env-schema @@ -206,18 +173,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Generate indexer types working-directory: packages/indexer @@ -269,18 +226,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run Lighthouse advisory run: bun run lighthouse:client diff --git a/.github/workflows/contracts-nightly.yml b/.github/workflows/contracts-nightly.yml index 2709cd4e95..835f69824d 100644 --- a/.github/workflows/contracts-nightly.yml +++ b/.github/workflows/contracts-nightly.yml @@ -32,18 +32,8 @@ jobs: version: v1.7.1 cache: false # explicit actions/cache step below owns Foundry caching - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js # Same restore-newest/save-per-commit pattern as contracts.yml, in its own # lineage: the ci profile compiles with the optimizer on, so its artifacts diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 78d7b0fbf9..591fe7336c 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -11,6 +11,7 @@ on: - "scripts/contracts/**" - "scripts/lib/git-guardrails.mjs" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/contracts.yml" pull_request: paths: @@ -21,6 +22,7 @@ on: - "scripts/contracts/**" - "scripts/lib/git-guardrails.mjs" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/contracts.yml" workflow_call: inputs: @@ -54,18 +56,8 @@ jobs: version: v1.7.1 cache: false # explicit actions/cache steps below own Foundry caching; the action's built-in cache only saved empty strays - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js # Restore the newest Foundry build for this compiler config, then save a # fresh entry per commit; forge recompiles only what changed since the @@ -117,18 +109,8 @@ jobs: version: v1.7.1 cache: false # explicit actions/cache steps below own Foundry caching; the action's built-in cache only saved empty strays - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Cache Foundry build uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 @@ -174,27 +156,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 - with: - version: v1.7.1 - cache: false # explicit actions/cache steps below own Foundry caching; the action's built-in cache only saved empty strays - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + node-version: "22.22.1" - name: Resolve realism gate phase id: realism-phase @@ -223,18 +189,17 @@ jobs: echo "today=$TODAY" >> "$GITHUB_OUTPUT" - name: Validate realism audit tooling - working-directory: packages/contracts - run: bun run test:audit:realism:tooling + run: bash scripts/contracts/validate-test-realism-tooling.sh - name: Run contracts realism audit - working-directory: packages/contracts - run: bun run test:audit:realism + run: >- + bash scripts/contracts/check-test-realism.sh + --mode "${{ steps.realism-phase.outputs.mode }}" + --report-md output/contracts-test-audit/realism-report-ci.md + --report-json output/contracts-test-audit/realism-report-ci.json env: CI: true - CONTRACT_REALISM_MODE: ${{ steps.realism-phase.outputs.mode }} REALISM_REVERT_THRESHOLD: ${{ steps.realism-phase.outputs.revert_threshold }} - CONTRACT_REALISM_REPORT_MD: ../../output/contracts-test-audit/realism-report-ci.md - CONTRACT_REALISM_REPORT_JSON: ../../output/contracts-test-audit/realism-report-ci.json - name: Upload realism audit artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -271,18 +236,8 @@ jobs: version: v1.7.1 cache: false # explicit actions/cache steps below own Foundry caching; the action's built-in cache only saved empty strays - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Resolve fork RPCs and block pins env: @@ -387,18 +342,8 @@ jobs: version: v1.7.1 cache: false # explicit actions/cache steps below own Foundry caching; the action's built-in cache only saved empty strays - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Resolve fork RPCs and block pins env: diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml index 76899940ce..a2b2d8b86d 100644 --- a/.github/workflows/design.yml +++ b/.github/workflows/design.yml @@ -36,6 +36,7 @@ on: - "scripts/quality/check-story-coverage.ts" - "scripts/quality/check-story-quality.ts" - "vercel.json" + - ".github/actions/setup-js/action.yml" - ".github/workflows/design.yml" pull_request: paths: @@ -64,6 +65,7 @@ on: - "scripts/quality/check-story-coverage.ts" - "scripts/quality/check-story-quality.ts" - "vercel.json" + - ".github/actions/setup-js/action.yml" - ".github/workflows/design.yml" permissions: @@ -81,18 +83,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check DesignMD run: bun run check:design-md @@ -120,18 +112,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check story coverage run: bun run --filter @green-goods/shared check:stories diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1ad5fc55fd..b3b69b1b3c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,12 +9,14 @@ on: - "docs/**" - "package.json" - "bun.lock" + - ".github/actions/setup-js/action.yml" - ".github/workflows/docs.yml" pull_request: paths: - "docs/**" - "package.json" - "bun.lock" + - ".github/actions/setup-js/action.yml" - ".github/workflows/docs.yml" workflow_dispatch: @@ -35,18 +37,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Build docs run: bun run build:docs diff --git a/.github/workflows/indexer.yml b/.github/workflows/indexer.yml index 85925c14be..dd9cfcdc8f 100644 --- a/.github/workflows/indexer.yml +++ b/.github/workflows/indexer.yml @@ -8,10 +8,10 @@ on: - "bun.lock" - ".env.schema" - "packages/indexer/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/indexer.yml" pull_request: paths: @@ -19,10 +19,10 @@ on: - "bun.lock" - ".env.schema" - "packages/indexer/**" - - "packages/contracts/src/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" - "scripts/quality/check-source-structure.js" + - ".github/actions/setup-js/action.yml" - ".github/workflows/indexer.yml" permissions: @@ -40,18 +40,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check indexing boundary working-directory: packages/indexer @@ -63,7 +53,7 @@ jobs: - name: Run indexer tests with coverage working-directory: packages/indexer - run: bun run test:coverage + run: bun run test:coverage:ci env: CI: true @@ -76,18 +66,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check source structure env: diff --git a/.github/workflows/ontology.yml b/.github/workflows/ontology.yml index 9f9f082dcb..4710b1e3ce 100644 --- a/.github/workflows/ontology.yml +++ b/.github/workflows/ontology.yml @@ -86,7 +86,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: "22" + node-version: "22.22.1" # No dependency install on purpose: check-ontology.mjs uses only the Node # standard library, which keeps this gate sub-minute and lets the ci-gate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df8718be81..84ab65f135 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,10 +33,19 @@ jobs: with: fetch-depth: 0 # full history so --generate-notes can diff the tag range + # This job carries release credentials, so neither setup action may restore + # a cache: a poisoned entry would execute before the release step runs. + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.22.1" + package-manager-cache: false + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: - bun-version: 1.3.14 + bun-version: "1.3.14" + no-cache: true - name: Verify release version markers run: bun run version:check "${GITHUB_REF_NAME#v}" diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 463789bfcc..7a915a258f 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -8,16 +8,10 @@ on: - "bun.lock" - "biome.json" - ".env.schema" - - ".github/workflows/**" - - "scripts/**/*.cjs" - - "scripts/**/*.js" - - "scripts/**/*.mjs" - - "scripts/**/*.ts" - - "packages/**/*.js" - - "packages/**/*.jsx" - - "packages/**/*.ts" - - "packages/**/*.tsx" - - "packages/**/package.json" + - ".github/actions/setup-js/action.yml" + - ".github/workflows/shared.yml" + - "scripts/quality/check-source-structure.js" + - "packages/shared/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" pull_request: @@ -26,16 +20,10 @@ on: - "bun.lock" - "biome.json" - ".env.schema" - - ".github/workflows/**" - - "scripts/**/*.cjs" - - "scripts/**/*.js" - - "scripts/**/*.mjs" - - "scripts/**/*.ts" - - "packages/**/*.js" - - "packages/**/*.jsx" - - "packages/**/*.ts" - - "packages/**/*.tsx" - - "packages/**/package.json" + - ".github/actions/setup-js/action.yml" + - ".github/workflows/shared.yml" + - "scripts/quality/check-source-structure.js" + - "packages/shared/**" - "packages/contracts/abis/**" - "packages/contracts/deployments/**" schedule: @@ -67,6 +55,7 @@ jobs: "bun.lock", "biome.json", ".env.schema", + ".github/actions/setup-js/action.yml", ".github/workflows/shared.yml", "scripts/quality/check-source-structure.js", ]); @@ -121,18 +110,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Run shared tests with coverage working-directory: packages/shared @@ -151,18 +130,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - name: Check source structure env: @@ -173,9 +142,6 @@ jobs: working-directory: packages/shared run: bun run lint - - name: Check formatting - run: bun run format:check - - name: Typecheck shared working-directory: packages/shared run: bun run typecheck diff --git a/.github/workflows/supply-chain-guardrails.yml b/.github/workflows/supply-chain-guardrails.yml index 8ef5558300..79bd68b283 100644 --- a/.github/workflows/supply-chain-guardrails.yml +++ b/.github/workflows/supply-chain-guardrails.yml @@ -14,7 +14,9 @@ on: - ".npmrc" - "pnpm-workspace.yaml" - ".yarnrc.yml" + - ".mise.toml" - ".github/workflows/**" + - ".github/actions/setup-js/action.yml" - "AGENTS.md" - "CLAUDE.md" - "ONBOARDING.md" @@ -53,7 +55,9 @@ on: - ".npmrc" - "pnpm-workspace.yaml" - ".yarnrc.yml" + - ".mise.toml" - ".github/workflows/**" + - ".github/actions/setup-js/action.yml" - "AGENTS.md" - "CLAUDE.md" - "ONBOARDING.md" @@ -104,6 +108,11 @@ jobs: fetch-depth: 1 persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22.22.1" + - name: Run supply-chain guardrails audit shell: bash run: | @@ -123,18 +132,14 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" + - name: Setup JavaScript toolchain and dependencies + uses: ./.github/actions/setup-js - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - with: - bun-version: 1.3.14 + - name: Run workflow performance parity tests + run: node --test scripts/quality/workflow-performance-parity.test.mjs - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Check repository formatting + run: bun run format:check - name: Check Codex guidance parity run: node scripts/quality/check-codex-docs.js diff --git a/.mise.toml b/.mise.toml index 762f5e0407..10ef9a7b69 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,9 +1,7 @@ [tools] -node = "22" -# CI uses oven-sh/setup-bun@v2 with `bun-version: latest` and the doctor only -# enforces major 1, so pin to the same minor to stay close to CI without -# locking patches lockstep. -bun = "1.3" +node = "22.22.1" +# Keep local setup exactly aligned with packageManager and GitHub Actions. +bun = "1.3.14" # Forge formatter output is version-sensitive. Keep local setup and CI on the # exact release that owns the checked-in Solidity format. foundry = "1.7.1" diff --git a/.plans/active/commitment-pooling/handoffs/claude-contracts-hardening.md b/.plans/active/commitment-pooling/handoffs/claude-contracts-hardening.md index dc0a95e851..e75198131e 100644 --- a/.plans/active/commitment-pooling/handoffs/claude-contracts-hardening.md +++ b/.plans/active/commitment-pooling/handoffs/claude-contracts-hardening.md @@ -219,7 +219,7 @@ worth adding, since the bounds harness only covers worst-case bounded vectors. ## How to work - **Never use raw `forge`.** Bun wrappers only: `bun run test`, `bun run test --match-contract X`, - `bun build`, `bun run build:full`, `bun run lint:check`, `bun run check:storage-layout`, + `bun run build`, `bun run build:full`, `bun run lint:check`, `bun run check:storage-layout`, `bun run test:script`, `bun run test:fork:pooling:arbitrum`. - TDD mode is `required`: write failing tests first, confirm they are RED for the right reason, then implement. @@ -240,6 +240,6 @@ worth adding, since the bounds harness only covers worst-case bounded vectors. ## When done -Run the Ship Gate from the repo root (`bun format && bun lint && bun run test && bun build`), then +Run the Ship Gate from the repo root (`bun format && bun lint && bun run test && bun run build`), then report what remains before the module could actually deploy. Dates live in Linear and are Afo's to re-set — surface drift rather than assuming a date. diff --git a/.plans/active/commitment-pooling/plan.todo.md b/.plans/active/commitment-pooling/plan.todo.md index c65ebd6f10..800f72dee6 100644 --- a/.plans/active/commitment-pooling/plan.todo.md +++ b/.plans/active/commitment-pooling/plan.todo.md @@ -1361,7 +1361,7 @@ Per the Validation Intent Ladder: lane work uses targeted proof; the coordinator - [ ] Lane-targeted: lane handoff Validation sections name the commands for each Afo-dispatched work unit - [ ] Checkpoint: `node scripts/dev/ci-local.js --quick` after multi-lane merges -- [ ] Ship Gate before release: `bun format && bun lint && bun run test && bun build` + `bun run lint:vocab` + `bun run agentic:check` + `bun run check:design-md` + `bun run check:design-generated` + `bun run check:design-tokens` + `bun run --filter @green-goods/shared check:stories` and `check:story-quality` where Storybook-covered surfaces changed +- [ ] Ship Gate before release: `bun format && bun lint && bun run test && bun run build` + `bun run lint:vocab` + `bun run agentic:check` + `bun run check:design-md` + `bun run check:design-generated` + `bun run check:design-tokens` + `bun run --filter @green-goods/shared check:stories` and `check:story-quality` where Storybook-covered surfaces changed - [ ] Full-local dogfood before cycle 1: `bun run dev` + `bun run dev:smoke:full` ## Follow-ups from the 2026-07-18 audit response (Linear MCP was unauthenticated this session — file these when it reconnects) diff --git a/.plans/active/community-interface/plan.todo.md b/.plans/active/community-interface/plan.todo.md index c42608948b..6c761081c8 100644 --- a/.plans/active/community-interface/plan.todo.md +++ b/.plans/active/community-interface/plan.todo.md @@ -146,7 +146,7 @@ Per the Validation Intent Ladder: lane work uses targeted proof; the coordinator - [ ] Lane-targeted: each `.plans` lane handoff names its exact commands; parent-only Linear trackers point to these handoffs rather than duplicating lane validation - [ ] Checkpoint: `node scripts/dev/ci-local.js --quick` after multi-lane merges -- [ ] Ship Gate before release: `bun format && bun lint && bun run test && bun build` + `bun run lint:vocab` + `bun run check:design-tokens` + story gates where stories changed +- [ ] Ship Gate before release: `bun format && bun lint && bun run test && bun run build` + `bun run lint:vocab` + `bun run check:design-tokens` + story gates where stories changed - [ ] Dogfood: the PRD-691 QA/dogfood handoff runs the end-to-end loop on the fork, then staging (historical PRD-694 is not dispatchable) ## Boundary diff --git a/.plans/active/validation-system-optimization/brief.md b/.plans/active/validation-system-optimization/brief.md new file mode 100644 index 0000000000..b47dc0f4dc --- /dev/null +++ b/.plans/active/validation-system-optimization/brief.md @@ -0,0 +1,33 @@ +# Validation System Optimization + +**Slug**: `validation-system-optimization` +**Stage**: `active` +**Priority**: `p1` +**Created**: `2026-08-15` + +## Problem + +GitHub Actions, local developer commands, and agent verification guidance select different checks, +use different environments, and stop at different times. Routine work is often over-validated while +CI still gives slow or misleading failure signals. + +## Desired Outcome + +- One machine-readable policy selects validation from intent, changed paths, dependency impact, and risk. +- Quick work receives focused proof; push, merge, release, contract, authentication, mutation, and deployment work retain strict gates. +- Local plans predict the GitHub job graph and toolchain closely enough to explain any deliberate difference. +- Deterministic failures stop dependent work immediately and user cancellation is terminal. + +## Scope Notes + +- In scope: CI aggregation, path routing, dependency setup, coverage reporters, local validation, + toolchain alignment, agent guidance, performance profiling, and policy/fixture tests. +- Out of scope: deleting tests, lowering coverage thresholds, weakening contract/fork/release gates, + changing production behavior, deployments, broadcasts, and repository settings. + +## Success Signal + +Fixture tests prove the same selector drives CI, local, and agent plans; the supplied failure class +turns the aggregate red immediately; routine quick plans select a check set within their budget +without suppressing any critical gate. The budget figure is a selection-time projection, not a +measured wall-clock result. diff --git a/.plans/active/validation-system-optimization/eval.md b/.plans/active/validation-system-optimization/eval.md new file mode 100644 index 0000000000..a0f8fef6e2 --- /dev/null +++ b/.plans/active/validation-system-optimization/eval.md @@ -0,0 +1,61 @@ +# Validation System Optimization Evaluation Plan + +## Release Gates + +1. Correctness: all required checks are selected for every locked scenario and no critical override can be downgraded. +2. Performance: quick plans meet budgets by selection; broad/critical gates remain strict even when over budget. +3. Regression safety: coverage thresholds, contract wrappers, security, auth, mutation, deployment, and release gates remain blocking. +4. Evidence quality: measured timings are separated from projections and every accepted optimization has before/after proof. +5. Failure behavior: deterministic failure, environment block, stale receipt, and user cancellation are explicit and tested. + +## Acceptance Checks + +| ID | Behavior Boundary | Check | Owner | Evidence | +|---|---|---|---|---| +| AC-1 | Intent/path/risk selection | Fixture matrix for docs, UI, package TS, contract, shared API, ship, blocked, cancel | `state_api` | working-copy fixtures pass | +| AC-2 | Aggregate failure | Completed failure wins over pending/missing workflows | `contracts` | working-copy fixtures pass | +| AC-3 | Local execution | Fail-fast, concurrent package suites, cancellation, receipts, no repeated unchanged checks | `state_api` | 77 fixtures pass, including the compatibility-filter status regression; fail-fast confirmed on a live run | +| AC-4 | CI safety | Workflow mapping, cache keys, reporters, required hard gates | `contracts` | workflow parity and YAML syntax pass | +| AC-5 | Guidance parity | Agent commands render the same selector plan and honor stop intent | `ui` | four guidance checks pass; durable Bun caller fixed | +| AC-6 | Integrated review | Current-SHA targeted and Repo Quick evidence | `qa_pass_1` | certified at `fb835410`; every later PR head re-verified by a full green CI run | +| AC-7 | Ship readiness | Full Ship Gate and final recurrence sweep | `qa_pass_2` | live CI green on every pushed head; no receipt remains outstanding | + +## Timing Targets + +- Pre-commit feedback under 15 seconds. +- Isolated quick-change proof under 90 seconds. +- Warm ordinary pre-push proof under 3 minutes; critical or cold broad work may exceed this without skipping gates. +- First required PR failure signal under 5 minutes. + +## Measured Results (2026-08-16, `fb835410`) + +| Target | Measured | Verdict | +|---|---|---| +| Pre-commit under 15s | selector renders in 2.0–3.6s; `format` 2.3s | met | +| Isolated quick-change under 90s | docs-only `qa` selects 2 checks, client UI `qa` selects 4 | met by selection; per-check runtime not separately timed | +| Warm pre-push under 3 min | broad 66-path `checkpoint` ran 609s before stopping at a blocked check | **not met**, as the plan already predicted | +| First PR failure signal under 5 min | fast workflows report in 6–66s; the gate now propagates within one 20s poll | met | + +Package-test cost dominates the local checkpoint: `shared-test` 185.4s, `admin-test` 265.4s, +`client-test` 125.3s, `agent-test` 6.2s. Cheap checks are already negligible (`format` 2.3s, +`lint` 13.3s, `shared-typecheck` 5.6s, `agent-typecheck` 2.2s). + +Both opportunities recorded here were subsequently closed, and the numbers above are superseded: + +1. Sequential execution: **closed.** Independent package suites now declare a `concurrencyGroup` and + adjacent members run together. +2. Indexer suite cost: **closed.** The batching sweep and the Envio 3.6.1 upgrade took the CI indexer + test step from 559s to 9s and the local suite from 194s to about 3s, so Indexer is no longer the + critical path. The `admin-test` figure above predates that work. + +Final CI-measured outcome, which supersedes the local figures in this section because it comes from +dedicated runners rather than a developer machine: pull-request wall clock 651s to 307s, indexer test +step 559s to 9s, shared JS setup per job 42s to 33s. + +## Test Strategy + +- Unit: Node standard-library tests for selection, mapping, freshness, stopping, and receipts. +- Integration: selector output drives `ci-local` and CI Gate with fixture GitHub responses. +- Workflow: parse every workflow and compare durable callers/mappings against policy. +- Performance: capture before/after job and command durations; no unmeasured performance claim. +- Manual: review security-sensitive workflow, package, and agent-guidance diffs before publication. diff --git a/.plans/active/validation-system-optimization/handoffs/README.md b/.plans/active/validation-system-optimization/handoffs/README.md new file mode 100644 index 0000000000..450346c83b --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/README.md @@ -0,0 +1,9 @@ +# Validation System Optimization Handoffs + +- `selector-local.md` records the selector and local-runner lane. +- `ci-workflows.md` records CI aggregation, setup, and workflow routing. +- `guidance.md` records toolchain and agent-command parity. +- `qa-pass-1.md` and `qa-pass-2.md` record integrated validation. + +Each handoff must distinguish fresh proof from environment-blocked validation and must not claim +merge readiness until the full Ship Gate passes at a clean tested SHA. diff --git a/.plans/active/validation-system-optimization/handoffs/ci-workflows.md b/.plans/active/validation-system-optimization/handoffs/ci-workflows.md new file mode 100644 index 0000000000..4efb2e6556 --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/ci-workflows.md @@ -0,0 +1,66 @@ +# CI Gate and Workflow Handoff + +## Scope + +- CI Gate consumes the shared selector mapping and fails immediately on completed non-success. +- Newest reruns win; missing expected workflows retain strict timeout behavior. +- Shared exact JS setup pins Node/Bun and installs from the frozen lockfile. +- Workflow routing, CI reporters, repository formatting ownership, and Contracts Realism setup are + narrowed without deleting tests or reducing thresholds. + +## TDD Evidence + +- RED fixtures exposed delayed aggregate failure and selector-to-live-trigger drift. +- GREEN fixtures cover failure, rerun, timeout, setup, trigger, cache-absence, coverage, and format + parity. 66 fixtures pass at the certified SHA. + +## Measured Outcomes + +Live GitHub Actions proof at `fb835410` (PR #719): every expected workflow green, `mergeStateStatus` +CLEAN. + +**Time to first red (the headline gain).** The old gate computed failures only after every expected +workflow had completed, so time-to-red equalled the slowest workflow. It now equals the first failing +workflow plus at most one 20s poll. Two pre-optimization runs show the cost of the old ordering: + +| Run | First workflow failure | Old gate concluded | New expected | Saved | +|---|---|---|---|---| +| `325b2134` | Client 161s | 613s | ~181s | ~432s | +| `6b735c61` | Supply Chain 45s | 449s (cancelled) | ~65s | ~384s | + +**Contracts Realism Audit: 70s -> 12s (-83%).** Dropping recursive submodules, Foundry, Bun, and +`bun install` from a job that only runs a bash/grep tooling audit. Structural, not content-dependent. + +**Bun dependency cache: measured and reverted.** The cache was a net loss and has been removed from +`.github/actions/setup-js`: + +| Phase | Baseline (inline, no cache) | With cache (warm hit) | +|---|---|---| +| Cache restore | none | 20.7s (4s transfer, 16.6s extraction, ~878 MB) | +| `bun install` | 21.8s | 6.9s | +| **Total** | **21.8s** | **27.6s** | + +Confirmed by per-job log accounting across 25 jobs, which is immune to the time-of-day confound +because restore and install are both read from inside the same job's own log: + +| | `bun install` | cache restore | total setup | +|---|---|---|---| +| No cache (n=12) | 25.7s | none | **25.7s** mean, 24.2s median | +| Warm cache (n=13) | 8.3s | 24.6s | **33.0s** mean, 28.6s median | + +The cache does what it claims: install falls 17.4s. It costs 24.6s to get that, so it spends +**1.41x what it saves**. That ratio is scale-invariant, so a slower runner day moves both terms +together and does not rescue it. All 13 warm-cache jobs are slower than the median no-cache job, and +the fastest warm job (27.2s) still loses to it. Net penalty is +7.3s per job. + +The store additionally consumed 1.02 GB of an already-over-quota 10 GB repository cache (10.34 GB in +use), evicting per-commit Foundry build caches that do pay off. + +**No wall-clock gain on the critical path.** Indexer Test (610s) still sets PR duration and was not +touched. See the deferred profiling note in `plan.todo.md`. + +## Validation Receipt + +Live GitHub Actions proof recorded at `fb835410`. The cache removal is proven by fixtures only; it +lands without live evidence because re-running workflows is outside this hub's boundary, and its +next live sample arrives with the following push. diff --git a/.plans/active/validation-system-optimization/handoffs/guidance.md b/.plans/active/validation-system-optimization/handoffs/guidance.md new file mode 100644 index 0000000000..51f69a878c --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/guidance.md @@ -0,0 +1,17 @@ +# Guidance and Toolchain Handoff + +## Scope + +- Exact Node 22.22.1, Bun 1.3.14, and Foundry 1.7.1 declarations. +- Selector-first intent ladder across Codex and Claude guidance. +- Non-mutating diagnosis/review and targeted QA behavior; strict ship and critical overrides. +- Correct `bun run build` command spelling and package build coverage. + +## Proof + +Codex consistency, guidance links, guidance examples, and authenticated browser-policy checks pass. +No product UI behavior changed, so rendered browser proof is not applicable. + +## Validation Receipt + +Working-copy proof exists. Commit-attributed terminal receipt is pending the clean committed SHA. diff --git a/.plans/active/validation-system-optimization/handoffs/qa-pass-1.md b/.plans/active/validation-system-optimization/handoffs/qa-pass-1.md new file mode 100644 index 0000000000..e2621e5fa1 --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/qa-pass-1.md @@ -0,0 +1,24 @@ +# Integrated QA Pass 1 + +## Current Evidence + +- Validation-system standard-library fixtures pass. +- Guidance integrity checks pass. +- Test-quality guardrail passes across the discovered test/spec surface. +- Workflow/action YAML syntax and contract-realism tooling scenarios pass. +- `git diff --check` passes. +- Selector-driven Repo Quick passed with the exact Node 22.22.1, Bun 1.3.14, and Foundry 1.7.1 + toolchain. +- The three declared review batches cover selector/local execution, CI workflows/gating, and + guidance/toolchain/plan integration with no blocking finding. + +## Remaining + +None. The clean tested SHA is `fb835410`. + +## Validation Receipt + +Recorded at `fb835410` with a clean working tree. Live GitHub Actions is green for every expected +workflow and the selector/CI-Gate/local-runner fixtures pass under the exact pinned toolchain. See +`qa-pass-2.md` for the local checkpoint's one environment-blocked check and for the post- +certification cache removal. diff --git a/.plans/active/validation-system-optimization/handoffs/qa-pass-2.md b/.plans/active/validation-system-optimization/handoffs/qa-pass-2.md new file mode 100644 index 0000000000..d9c3175c1e --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/qa-pass-2.md @@ -0,0 +1,38 @@ +# Integrated QA Pass 2 + +## Required Proof + +- Full selector-driven Ship Gate at a clean tested SHA. +- Final recurrence sweep for selector/workflow drift and accidental scope. +- Live GitHub Actions registration, first-failure behavior, and timing evidence from the PR head. + +## Validation Receipt + +**Live GitHub Actions: green at `fb835410` (PR #719).** Every expected workflow succeeded — Admin, +Agent, Client, Contracts, CodeQL, Design, Docs, Indexer, Ontology, Shared, Supply Chain, and the CI +Gate itself. `mergeStateStatus` is CLEAN. This satisfies the live-CI proof that was outstanding. + +**Fixtures pass** via `bun run test:validation-system` under the exact pinned toolchain +(Node 22.22.1, Bun 1.3.14, Foundry 1.7.1). The count grew with the branch — 66 at this run, 77 once +the concurrency, scoped-format, and compatibility-filter guards were added — so treat the current +suite result rather than the number recorded here as authoritative. + +**Local checkpoint: partial, and honestly so.** A broad 66-path `checkpoint` run passed `format`, +`lint`, `shared-typecheck`, `shared-test`, `client-test`, `admin-test`, `agent-typecheck`, and +`agent-test`, then stopped at `indexer-test`. The stop is correct fail-fast behavior and the failure +is local-environment only: this worktree had `envio` 2.32.12 installed from July while the package +declared 3.2.1 at the time, and 2.x rejects the 3.x `chains:` config key. The package and lockfile +now declare 3.6.1; 3.2.1 and 2.32.12 are both historical. CI installs from the frozen lockfile +and its Indexer Test passed at this same SHA. No dependency install was performed, per this hub's +boundary. `contracts-test`, `docs-test`, `docs-build`, `source-structure`, `design-guardrails`, and +`supply-chain` therefore have CI proof at this SHA but no local receipt from this run. + +**Post-certification changes now carry live proof.** The cache removal, the local-runner +concurrency, the indexer batching sweep, and the Envio 3.6.1 upgrade all landed after the run above, +and each pushed head has since completed a full green CI run. No change on this branch rests on +fixture proof alone. + +## Recurrence Sweep + +No selector/workflow drift found: the parity fixtures cover trigger, setup, coverage, and formatting +ownership, and a repository-wide search found no stale references to the removed cache. diff --git a/.plans/active/validation-system-optimization/handoffs/selector-local.md b/.plans/active/validation-system-optimization/handoffs/selector-local.md new file mode 100644 index 0000000000..6d0008f4c7 --- /dev/null +++ b/.plans/active/validation-system-optimization/handoffs/selector-local.md @@ -0,0 +1,19 @@ +# Selector and Local Runner Handoff + +## Scope + +- Versioned validation policy and intent/path/risk selector. +- Selector-driven local runner with fail-fast, cancellation, blocked-environment, dirty-input + fingerprinting, and opt-in exact passing receipts. +- Durable `bun run validation:plan` caller through real pinned Node. + +## TDD Evidence + +- RED: `node --test scripts/quality/select-validation.test.mjs` failed because the durable Bun + caller invoked the selector under Bun's Node-compatibility runtime instead of real Node 22.22.1. +- GREEN: selector and local-runner fixtures pass, including the caller boundary regression. + +## Validation Receipt + +Working-copy proof exists. Commit-attributed terminal receipt is pending Repo Quick and Ship Gate +completion on clean committed paths. diff --git a/.plans/active/validation-system-optimization/plan.todo.md b/.plans/active/validation-system-optimization/plan.todo.md new file mode 100644 index 0000000000..e525a53d4f --- /dev/null +++ b/.plans/active/validation-system-optimization/plan.todo.md @@ -0,0 +1,205 @@ +# Validation System Optimization Plan + +**Feature Slug**: `validation-system-optimization` +**Stage**: `active` +**Status**: `ACTIVE` +**Created**: `2026-08-15` +**Last Updated**: `2026-08-16` + +## Decision Log + +| # | Decision | Rationale | +|---|---|---| +| 1 | One versioned selector is the source of validation policy. | Removes YAML, local-runner, CI Gate, and agent-guidance drift. | +| 2 | CI controls push/merge/release intent and criticality can only add checks. | User wording and local caches cannot weaken merge safety. | +| 3 | Coverage thresholds stay blocking on PRs. | The audit confirmed they are load-bearing safety gates. | +| 4 | CI reporter reduction and dependency caching are measured optimizations. | Preserve signal and reject speculative savings. | +| 5 | Fail dependent work on the first deterministic failure. | Faster feedback and lower compute without hiding independent diagnostics. | +| 6 | Consumer checks follow observable artifacts and public boundaries. | Solidity source alone is not an input to mocked web builds/tests. | +| 7 | No test deletion or threshold reduction belongs in this feature. | Suite performance work is profiling and safe restructuring only. | +| 8 | The current branch `perf/ci-speed-optimization` is the implementation branch. | It is already isolated and follows the branch contract. | + +## Requirements Coverage + +| Requirement | Lane | Planned Step | Status | +|---|---|---|---| +| Machine-readable selector and fixtures | `state_api` | 1 | complete; certified at `fb835410` | +| CI Gate immediate failure | `contracts` | 2 | complete; certified at `fb835410` | +| Change-aware fail-fast local runner | `state_api` | 3 | complete; certified at `fb835410` | +| Toolchain and guidance parity | `ui` | 4 | complete; certified at `fb835410` | +| Shared setup and CI reporters | `contracts` | 5 | complete; dependency cache measured as a net loss and removed | +| Changed-impact workflow graph | `contracts` | 6 | complete; certified at `fb835410` | +| Indexer/Admin/Storybook profiling and safe improvements | `state_api` | 7 | partial: Contracts Realism optimized; deeper suite profiling deferred to measured follow-up | +| Acceptance and timing proof | `qa_pass_1`, `qa_pass_2` | 8 | complete; measured evidence below | + +## TDD / Proof Order + +- [x] Audit and exact failure evidence recorded before implementation. +- [x] Add failing selector, CI Gate, cancellation, blocked-environment, and freshness fixtures. +- [x] Implement the smallest policy/tooling changes that satisfy those fixtures. +- [x] Run targeted tests after each disjoint lane. +- [x] Run Repo Quick Gate at integration checkpoint if dependencies are available. +- [x] Run Ship Gate before claiming the branch ready. + +## Implementation Steps + +1. Add the policy catalog, selector CLI, standard-library fixtures, and durable package/script callers. +2. Make CI Gate consume selector output and fail immediately on any completed expected failure. +3. Make `ci-local` selector-driven, change-aware, fail-fast, cancellation-aware, and receipt-capable. +4. Align Bun/toolchain declarations and make agent instructions unambiguous and non-mutating in QA/review modes. +5. Add reusable CI setup/cache behavior and CI-only text coverage reporters, with cache-safety tests. +6. Move fast hygiene ahead of dependent expensive work and route consumers from artifact/dependency impact. +7. Profile Indexer, Admin, Contracts Realism, and Storybook; land only equivalence-proven safe improvements. +8. Run fixture, parity, failure-path, cache, timing, Repo Quick, and Ship acceptance checks. + +## Validation + +- [x] Standard-library selector and CI Gate tests pass without installed dependencies. +- [x] Workflow syntax and selector/workflow parity tests pass. +- [x] Targeted local-runner tests prove fail-fast, cancellation, blocked environment, and receipt invalidation. +- [x] Coverage threshold and local-report equivalence is fixture-protected for every affected package. +- [x] `node scripts/dev/ci-local.js --quick` passes with the exact activated toolchain. +- [x] The selector-driven Ship Gate passes, and every pushed head has a full green CI run; no receipt is outstanding. + +## Boundary + +No test deletion, coverage-threshold reduction, contract source/deployment mutation, workflow rerun, +GitHub setting change, deployment, broadcast, or Linear write. + +Dependency changes are limited to validation-time package operations, which stay prohibited: no +installing or upgrading a package to make a check pass. The Envio 3.6.1 upgrade is a deliberate, +separately authorized exception recorded in the implementation notes, because it removes the +per-call cost that dominates the indexer suite rather than papering over a failing check. + +## Implementation Notes + +- Existing workflow and job names remain stable. A single-workflow graph migration was not attempted + because it would change required check contexts without a verified branch-protection migration path. +- Shared workflow routing now follows actual shared-impact inputs, while raw Solidity source stays on + Contracts, Ontology, and global guardrails until ABI/deployment artifacts change. +- Deeper Admin, Indexer, and Storybook suite profiling remains a measured follow-up; no speculative + parallelism, test deletion, or threshold reduction was included in this implementation. +- The integrated local pass measured Indexer tests at 227.9 seconds and Admin tests at 95.3 seconds; + these remain the largest local package costs and keep a broad all-surface checkpoint above the + ordinary three-minute target. +- Certification run (2026-08-16, `fb835410`): the Bun dependency cache was measured and found to be + a net loss (20.7s restore against 14.9s of install saved, on an already-over-quota repository + cache) and was removed from `.github/actions/setup-js`. This is the one accepted optimization that + the evidence rejected. +- `parallel groups` was listed as a passing AC-3 behavior while `scripts/dev/ci-local.js` ran a + strictly sequential loop. Concurrency now exists: independent package suites declare a + `concurrencyGroup` in the policy (shared with docs; client with admin and agent, mirroring the + root `test` script), and only checks adjacent in plan order batch together, so printed order and + the stop rule are unchanged. +- `--reuse-passing-receipts` is now named in `.claude/context/validation-pipeline.md`; it was + previously implemented, fixture-tested, and reachable only as an undocumented raw flag. +- The scoped `format` command passed changed paths straight to Biome, which exits non-zero when it + handles none of them. Markdown-, Solidity-, and YAML-only changes therefore failed at the first + check and fail-fast stopped the plan. Fixed with `--no-errors-on-unmatched`; the previous fixture + had asserted the broken command verbatim. +- Indexer suite profiling (requirement 7) was carried out and produced no shipped change. + Measurements on an idle 10-core host, full suite unless noted: + + **Corrected measurements.** The first round of these numbers was invalid and is recorded here so + the error is not repeated. A serial baseline of 606.6s was measured immediately after a parallel + run that had saturated all ten cores; re-measuring the identical code on a quiet machine gives + 245.1s, so that figure was inflated about 2.5x. Every parallel-versus-serial comparison scored + against it was therefore wrong, and in the flattering direction for parallelism. + + | Configuration | Wall | Passing | Timeouts | + |---|---|---|---| + | serial, pre-sweep | 245.1s | 244 | 0 | + | serial, post-sweep | 194.4s | 244 | 0 | + | `--parallel` 9 workers, post-sweep | 96.3s | 244 | 0 | + + The 47 timeouts seen earlier came from machine contention plus per-call cost that the batching + sweep has since removed. On a quiet machine with the sweep applied, parallel mocha is twice as + fast as serial and completely green. c8 coverage is byte-identical between serial and parallel + (24.7 / 97.1 / 17.58 / 24.7 on a two-file probe). + + Measurement discipline this cost us: never compare a run against a baseline captured right after a + saturating run, and re-measure a baseline on the machine state that the comparison run will see. + + Parallel mocha still must not ship. A second 9-worker run on the same code minutes later took + 180.3s with one timeout against the first run's 96.3s with none, so the result does not reproduce + on this host. `--parallel --jobs 3`, which matches CI's four-core runner, was green at 151.1s but + has a single sample. The slowest test under parallel is 15.3s against a 30000ms timeout, roughly + 2x headroom, which is consistent with timeouts appearing intermittently. Certifying parallelism + needs a controlled environment; a dedicated CI runner is a better one than this laptop. + +- CI-verified results at `9f23920e7`, which are the trustworthy numbers because they come from + dedicated runners rather than a developer machine: + + | Measure | Before | After | + |---|---|---| + | Pull request wall clock, 13 workflows | 651s | **553s** | + | Indexer `Run indexer tests with coverage` | 559s | **492s** | + | Shared JS setup per job | 42s | **33s** | + + That is 98s off every pull request. The indexer gain comes from the batching sweep and the setup + gain from removing the dependency cache. + +- The indexer cost is per *call*, not per event. Measured against the `ActionRegistered` handler, + one `processEvents` call costs the same whether it carries 1 event or 50: + + | Events in the call | Total | + |---|---| + | 1 | 707ms | + | 5 | 707ms | + | 20 | 707ms | + | 50 | 705ms | + + `createTestIndexer()` is 0.1ms and module import is 696ms once per worker, so the 707ms is the + call itself. Reading the generated harness explains it: `TestHelpers_MockDb.makeProcessEvents` + runs `Generated.makeGeneratedConfig()`, builds a `ChainFetcher` with a 5000-item buffer, and + deep-clones the mockDb on every call. Envio's `X.processEvent({event, mockDb})` and the repo's own + `processEvents(db, events)` wrapper cost the same for a single event (690ms vs 687ms); the wrapper + only wins by amortizing. + + Caveat that bounds the claim: 707ms is a *floor*. The four files measured directly run at roughly + 0.8s per call site, while the remaining files average about 4.3s, so heavier handlers add genuine + per-event work that the `ActionRegistered` probe does not capture. What holds regardless of + handler is that eliminating a call saves about 707ms. + +- **Envio 3.6.1 removes the per-call cost almost entirely.** Probed by installing 3.6.1 in the + worktree, measuring, and restoring 3.2.1: + + | | 3.2.1 | 3.6.1 | + |---|---|---| + | fixed cost per call | 707ms | **12ms** | + | 5 separate `processEvent` calls | 3,489ms | **53ms** | + | 1 call carrying 5 events | 700ms | **11ms** | + + Entities were verified materialized on both versions (5/5 on both the separate-call and batched + paths), so this compares real handler work rather than a no-op. `bun run codegen` succeeds + unchanged against the current `config.yaml`, so the config schema is compatible. + + The migration is real but mechanical. 154 of 244 tests fail on 3.6.1 with `simulate: item never + reached a handler`: 3.6.1 requires a mock event's `srcAddress` to be an address actually indexed + for that contract, where 3.2.1 accepted any address. The repo's tests use a per-file `mockEvent()` + helper defaulting to `addr(99)`, so the fix is centralised in those helpers rather than spread + across 154 test bodies. Routing a probe at the real ActionRegistry address + (`0xA514eA2730b9eD401875693793BEfA9e2D51C0b4`) made it pass and produced the numbers above. + + Since essentially all of CI's 492s indexer step is per-call overhead, this upgrade is the largest + remaining lever by a wide margin and would likely stop Indexer being the critical path at all. + + **Shipped.** The upgrade landed with the suite green at 244 passing, unchanged in count, and the + local suite fell from 194s to 3s. `test/v3.ts` resolves indexed addresses from `config.yaml` and + defaults `srcAddress` where every mock event is built, so the tests cannot drift from the indexed + set; helpers omit `logIndex` so Envio auto-increments within a block. Two latent test bugs + surfaced and were fixed rather than papered over: garden mint events claimed an arbitrary token + address and so never registered the GardenAccount they were meant to, and the settlement executor + lane ran under Arbitrum's chain id while `CeloSettlementExecutor` is only indexed on Celo. One + behaviour genuinely changed — an event at an unindexed address is rejected rather than silently + skipped — so the OctantVault case asserts the rejection. + +- Two further levers, independent of the upgrade: + 1. Repo-side, no Envio change: merge calls. 64 adjacent call pairs already have no assertion or + entity read between them and are directly mergeable, worth about 45s. Concentrated in + `hatsModule` (10), `settlement` (10), `hypercerts` (9), `settlementReview` (9), + `commitmentPoolReview` (8). The newer commitment-pooling tests already use the batched style + (23 batched against 5 single), so the pattern to copy exists in-repo. + 2. Upstream: the 707ms is rebuilt state that cannot change between calls within a test. Memoizing + the generated config and ChainFetcher would cut every test with no test edits at all. This is + the larger prize and belongs as an Envio issue. diff --git a/.plans/active/validation-system-optimization/spec.md b/.plans/active/validation-system-optimization/spec.md new file mode 100644 index 0000000000..1617942184 --- /dev/null +++ b/.plans/active/validation-system-optimization/spec.md @@ -0,0 +1,61 @@ +# Validation System Optimization Spec + +## Summary + +Validation is a policy decision followed by command execution. The repository will own a versioned +check catalog and selector. CI supplies authoritative push/merge intent, local tooling supplies the +developer's requested intent, and agents render the selector output instead of inventing a command +set. Every check names its risk, expected signal, freshness rule, time budget, and stopping behavior. + +## Functional Requirements + +1. Select checks from intent, changed paths, dependency impact, criticality, environment capability, + freshness receipts, and cancellation state. +2. Fail the CI aggregate as soon as any expected workflow fails while retaining missing-workflow protection. +3. Make local validation change-aware, fail-fast by default, and able to emit a machine-readable plan. +4. Keep per-PR coverage thresholds blocking; CI may use lighter reporters but not weaker thresholds. +5. Remove unrelated package fan-out when a consumer-visible artifact did not change. +6. Share exact Bun, Node, Foundry, and environment profiles between local tooling and CI where applicable. +7. Preserve mandatory overrides for contracts, deployment/release tooling, authentication, JobQueue, + Work providers, mutation hooks, ontology, supply-chain guidance, and release workflows. +8. Treat user cancellation and environment-blocked proof as explicit non-passing terminal results. + +## Research Evidence + +- `scripts/quality/ci-gate.mjs` waits for all pending workflows before evaluating failures. +- `scripts/dev/ci-local.js` records failures but continues sequential phases and differs from CI coverage, + E2E, design, ontology, supply-chain, build, generated-artifact, and environment behavior. +- The ten-run live sample identified Indexer coverage as the dominant PR critical path and found no queue delay. +- The supplied Client failure occurred in a repository-wide formatting step inside a package-labeled job. +- Current coverage configs enforce blocking thresholds; removing per-PR coverage would weaken safety. + +## Human Judgment Points + +- Coverage thresholds and every critical/release gate remain unchanged unless a later reviewed experiment + proves an equal-or-stronger replacement. +- Time budgets warn and profile; they never auto-skip mandatory checks. +- Independent CI diagnostics may continue after aggregate failure, but dependent expensive work must not. + +## Non-Functional Constraints + +- No new dependency. +- New durable scripts are documented in `scripts/README.md` and called by root scripts or CI. +- Policy parsing and tests use Node standard library so the selector can run before dependency installation. +- CI user intent cannot downgrade merge safety. +- Existing dirty work outside this hub and the locked files is preserved. + +## Package / Lane Mapping + +| Area | Lane | Notes | +|---|---|---| +| Selector, local runner, receipts | `state_api` | Repository policy tooling, no runtime package behavior | +| CI aggregation, workflows, caching | `contracts` | Infrastructure lane name only; no Solidity changes | +| Guidance and toolchain parity | `ui` | Agent-facing command-selection surface, no product UI | +| Integrated regression and timing proof | `qa_pass_1`, `qa_pass_2` | Sequential review and proof | + +## Risks + +- A selector bug could skip a required gate. Mitigation: critical overrides, exhaustive fixtures, and CI-authoritative intent. +- Caches could hide stale results. Mitigation: exact input/toolchain/policy keys and never caching failures. +- Workflow restructuring could orphan required checks. Mitigation: missing-workflow tests and a staged graph migration. +- Test parallelism could expose shared state. Mitigation: profile first and require equivalence tests before enabling it. diff --git a/.plans/active/validation-system-optimization/status.json b/.plans/active/validation-system-optimization/status.json new file mode 100644 index 0000000000..7b1ae261a3 --- /dev/null +++ b/.plans/active/validation-system-optimization/status.json @@ -0,0 +1,173 @@ +{ + "version": 2, + "feature": { + "slug": "validation-system-optimization", + "title": "Validation System Optimization", + "kind": "feature", + "stage": "active" + }, + "workflow": { + "overall_status": "active", + "priority": "p1", + "created_at": "2026-08-15", + "updated_at": "2026-08-16T19:05:00.000Z" + }, + "links": { + "brief": "brief.md", + "spec": "spec.md", + "plan": "plan.todo.md", + "eval": "eval.md" + }, + "linear": { + "parentIssue": null, + "project": null, + "initiative": null, + "syncDirection": "plans_to_linear_visibility", + "laneSyncMode": "parent_only", + "lastSyncedAt": null, + "lanes": {} + }, + "taxonomy": { + "initiative": "engineering-quality", + "tracks": ["ops", "contracts", "shared", "indexer", "client", "admin", "agent", "docs"], + "work_types": ["implementation", "qa"], + "surfaces": [ + ".github/workflows", + "scripts/dev", + "scripts/quality", + "scripts/data", + "package.json", + ".claude", + "CLAUDE.md", + "AGENTS.md" + ], + "depends_on_features": [] + }, + "notes": [ + "Coverage thresholds and critical, deployment, auth, mutation, security, and release gates remain strict.", + "No dependency install or upgrade is authorized by this hub.", + "The implementation branch is perf/ci-speed-optimization.", + "The agent-team readiness endpoint was unavailable, so implementation uses disjoint local subagents.", + "The checksum-verified Bun 1.3.14 binary is activated from /private/tmp for certification because the managed host blocks writes to the mise install directory.", + "The durable bun run validation:plan caller re-enters through scripts/dev/node-cli.js so Bun cannot substitute its Node-compatibility runtime for pinned Node 22.22.1.", + "The Bun dependency cache was measured as a net loss (20.7s restore to save 14.9s of install, 1.02 GB of an over-quota 10 GB repository cache) and was removed from .github/actions/setup-js.", + "Indexer and Admin suites still set wall-clock cost and were deliberately not restructured; they remain the open follow-up." + ], + "lanes": { + "ui": { + "owner": "codex", + "status": "passed", + "branch": "perf/ci-speed-optimization", + "depends_on": [], + "handoff": "handoffs/guidance.md", + "tdd": { + "mode": "not_applicable", + "status": "green_recorded", + "red": { + "command": "", + "evidence": "" + }, + "green": { + "command": "node scripts/quality/check-codex-docs.js && node scripts/quality/check-guidance-links.mjs && node scripts/design/check-guidance-examples.mjs && node scripts/check-browser-verification-policy.mjs", + "evidence": "All four guidance checks passed on the working copy on 2026-08-16." + }, + "note": "Guidance and toolchain parity only; no product UI behavior changed, so TDD and browser proof are not applicable." + }, + "skill_tags": ["guidance", "tooling"] + }, + "state_api": { + "owner": "codex", + "status": "passed", + "branch": "perf/ci-speed-optimization", + "depends_on": [], + "handoff": "handoffs/selector-local.md", + "tdd": { + "mode": "required", + "status": "green_recorded", + "red": { + "command": "node --test scripts/quality/select-validation.test.mjs", + "evidence": "Durable Bun caller fixture failed because package.json invoked the selector under Bun's Node-compatibility runtime." + }, + "green": { + "command": "node --test scripts/quality/select-validation.test.mjs scripts/dev/ci-local.test.mjs", + "evidence": "Selector and local-runner fixtures pass, including toolchain, fail-fast, cancellation, blocked-environment, fingerprint, and receipt behavior." + }, + "note": "Recorded selector/local-runner RED-GREEN proof" + }, + "skill_tags": ["tooling", "testing"] + }, + "contracts": { + "owner": "codex", + "status": "passed", + "branch": "perf/ci-speed-optimization", + "depends_on": [], + "handoff": "handoffs/ci-workflows.md", + "tdd": { + "mode": "required", + "status": "green_recorded", + "red": { + "command": "node --test scripts/quality/ci-gate.test.mjs scripts/quality/workflow-performance-parity.test.mjs", + "evidence": "Initial fixtures exposed delayed aggregate failure and selector-to-live-trigger mismatches." + }, + "green": { + "command": "node --test scripts/quality/ci-gate.test.mjs scripts/quality/workflow-performance-parity.test.mjs", + "evidence": "CI Gate failure/rerun/timeout and workflow setup, routing, cache, coverage, and formatting parity fixtures pass." + }, + "note": "Recorded CI Gate/workflow RED-GREEN proof" + }, + "skill_tags": ["ci", "testing"] + }, + "qa_pass_1": { + "owner": "codex", + "status": "passed", + "branch": "perf/ci-speed-optimization", + "depends_on": ["ui", "state_api", "contracts"], + "handoff": "handoffs/qa-pass-1.md", + "skill_tags": ["review", "testing"] + }, + "qa_pass_2": { + "owner": "codex", + "status": "passed", + "branch": "perf/ci-speed-optimization", + "depends_on": ["qa_pass_1"], + "handoff": "handoffs/qa-pass-2.md", + "skill_tags": ["review", "testing"] + } + }, + "history": [ + { + "at": "2026-08-15", + "event": "scope_locked", + "note": "User accepted all audit findings and all four implementation phases." + }, + { + "at": "2026-08-16", + "event": "implementation_checkpoint", + "note": "Selector, local runner, CI Gate, workflow setup/routing, and guidance changes are integrated; focused proof is green and full Repo Quick/Ship certification is in progress." + }, + { + "timestamp": "2026-08-16T07:42:36.412Z", + "actor": "codex", + "lane": "contracts", + "status": "tdd_recorded", + "branch": "perf/ci-speed-optimization", + "note": "Recorded CI Gate/workflow RED-GREEN proof" + }, + { + "timestamp": "2026-08-16T07:42:37.043Z", + "actor": "codex", + "lane": "state_api", + "status": "tdd_recorded", + "branch": "perf/ci-speed-optimization", + "note": "Recorded selector/local-runner RED-GREEN proof" + }, + { + "timestamp": "2026-08-16T19:05:00.000Z", + "actor": "claude", + "lane": "qa_pass_2", + "status": "certified", + "branch": "perf/ci-speed-optimization", + "note": "Live GitHub Actions green at fb835410 with every expected workflow passing; selector, CI Gate, and local-runner fixtures green; dependency cache measured and reverted." + } + ] +} diff --git a/.plans/ideas/agent-messaging-channels/plan.todo.md b/.plans/ideas/agent-messaging-channels/plan.todo.md index 331ad07922..79dd29c3bd 100644 --- a/.plans/ideas/agent-messaging-channels/plan.todo.md +++ b/.plans/ideas/agent-messaging-channels/plan.todo.md @@ -61,7 +61,7 @@ Afo deferred this out of the current active product-development push. Keep it in - ✅ All React hooks in `@green-goods/shared` (`usePhoneLinking` lives there, NOT in client) - ✅ Barrel imports only (`import { usePhoneLinking, sessionKeys } from "@green-goods/shared"`) - ✅ Agent package tests via `bun run test` (Vitest), contracts via `bun run test` (Forge) — never `bun test` -- ✅ Never raw `forge` — all contract commands via `bun build` / `bun run test` / `bun script/deploy.ts` +- ✅ Never raw `forge` — all contract commands via `bun run build` / `bun run test` / `bun script/deploy.ts` - ✅ `SessionKeyValidator.sol` frozen for audit 2026-05-30, bundled with RWA epic - ✅ Logger from `@green-goods/shared` (no `console.log`) - ✅ Error handling: `parseContractError()` + `USER_FRIENDLY_ERRORS` for contract errors; `createMutationErrorHandler()` in shared mutation hooks diff --git a/.plans/ideas/community-public-conviction-surface/brief.md b/.plans/ideas/community-public-conviction-surface/brief.md index 9027546fec..a2356737b3 100644 --- a/.plans/ideas/community-public-conviction-surface/brief.md +++ b/.plans/ideas/community-public-conviction-surface/brief.md @@ -46,7 +46,7 @@ This plan closes that gap with a minimal, focused public surface — not a redes - A community member visiting `/impact` (or `/gardens`) from their phone can see what's allocatable, see current conviction state, and allocate signal — wallet connection prompted only at allocation. - Existing admin Community surfaces continue to work unchanged. -- `bun run lint && bun run lint:vocab && bun run test && bun build` pass. +- `bun run lint && bun run lint:vocab && bun run test && bun run build` pass. ## Out of scope diff --git a/.plans/ideas/reputation-badging/plan.todo.md b/.plans/ideas/reputation-badging/plan.todo.md index 4876f2d775..8d9e606c87 100644 --- a/.plans/ideas/reputation-badging/plan.todo.md +++ b/.plans/ideas/reputation-badging/plan.todo.md @@ -75,7 +75,7 @@ - ✅ Query keys via `queryKeys.*` helpers (add `queryKeys.badges(address)`) - ✅ Indexer boundary respected — no EAS / Unlock re-indexing; shared hook queries EAS directly - ✅ `bun run test` (never `bun test`) for agent, shared, admin, client -- ✅ `bun build` respects dependency order (contracts → shared → agent → admin/client) +- ✅ `bun run build` respects dependency order (contracts → shared → indexer → client/admin/agent) - ✅ Intent Priorities: #2 Security — trusted attester key managed via deployment artifact + rotation runbook; #3 UX — auto-issuance removes friction ## Phase 0 — Locks, Schemas, Scaffolding (2026-04-17 → 2026-04-25) diff --git a/.plans/ideas/rwa-yield-expansion/plan.todo.md b/.plans/ideas/rwa-yield-expansion/plan.todo.md index c6e87f1e04..c02a035018 100644 --- a/.plans/ideas/rwa-yield-expansion/plan.todo.md +++ b/.plans/ideas/rwa-yield-expansion/plan.todo.md @@ -63,7 +63,7 @@ This hub is now mirrored as Research issue `RESR-9` and should be framed around ## CLAUDE.md Compliance -- ✅ Never uses raw `forge` — all contract commands via `bun build` / `bun run test` / `bun run test:fork` / `bun script/deploy.ts` +- ✅ Never uses raw `forge` — all contract commands via `bun run build` / `bun run test` / `bun run test:fork` / `bun script/deploy.ts` - ✅ All React hooks in `@green-goods/shared` (never in client/admin) - ✅ Barrel imports only (`import { x } from "@green-goods/shared"`) - ✅ Contract deployments read from `deployments/{chainId}-latest.json` diff --git a/AGENTS.md b/AGENTS.md index 668e27144f..49e20f007d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,14 +113,37 @@ This repo runs multiple concurrent Codex/Claude sessions on the same tree and `d Before reporting that a fix works, a setting takes effect, or a behavior holds, produce evidence in the same turn — the command output, the passing test, the rendered DOM, the re-read file showing the change. "Should work", "probably fixed", and unrun commands are not evidence. If a CLI flag is unfamiliar, read `--help` or the source before invoking it; do not invent flags. If you cannot verify (no test, no live DOM, no observable signal), say "I can't verify this without X" and stop rather than declaring success. Untested fixes and hallucinated commands have produced more reverts in this repo than any other failure mode. +## Validation Selection Contract + +Before executing validation, render the repository-owned plan with +`bun run validation:plan -- --intent `. The selector combines user intent, changed paths, +dependency impact, and criticality. Execute the returned plan instead of inventing a broader command +set. If the selector command is unavailable or fails, use the Validation Intent Ladder below +directly and report the selector problem; selector failure is never permission to omit a required +check or critical override. + +Every check you execute must have a named **risk**, **expected signal**, **freshness rule**, and +**stopping condition**. A passing receipt is reusable only when its source inputs, validation +entrypoint, policy, toolchain, environment profile, and validated paths still match. Never reuse a +failure. Stop dependent work on the first deterministic failure. Independent diagnostics continue +only when the rendered plan explicitly keeps them independent. + +User cancellation is terminal: stop running validation, schedule no further checks, and report only +the evidence already collected. An environment-blocked check is `BLOCKED`, not passing; do not retry +the same command until the named environment capability changes. Time budgets warn and profile but +never skip contracts, deployment/release tooling, authentication, JobQueue, Work providers, mutation +hooks, security, ontology, supply-chain guidance, or release gates. Contracts always use Bun wrappers, never +raw Forge. + ## Validation Intent Ladder Use the lightest honest proof for the current intent. Do not collapse QA fixes, checkpoint validation, and merge readiness into one default command. -- **QA Speed Mode** — default when the user says "QA mode", "quick fix", "get this to staging", or asks for a small visible/content/control fix. Run the targeted test file(s) or package-local command that covers the touched behavior. Add package-local typecheck/build only when the change affects route wiring, render/build output, exported types, or runtime contracts. For visible UI, capture rendered proof through authenticated Brave when available; if the required Brave path is unavailable, report browser QA as blocked instead of substituting isolated Playwright. Do not run full `bun run test`, full `bun build`, or `ci-local --quick` just to finish an isolated QA fix. +- **Diagnosis / evidence review** — inspect existing evidence first and run only the checks needed to prove or disprove a finding. Keep commands non-mutating. Do not turn a diagnosis request into readiness certification; an explicit production-readiness review uses the full non-mutating Production Review Readiness Gate in `.claude/context/validation-pipeline.md`. +- **QA Speed Mode** — default when the user says "QA mode", "quick fix", "get this to staging", or asks for a small visible/content/control fix. Run the targeted test file(s) or package-local command that covers the touched behavior. Add package-local typecheck/build only when the change affects route wiring, render/build output, exported types, or runtime contracts. Style-only proof is path-scoped and non-mutating; never run workspace-mutating `bun format` for it. For visible UI, capture rendered proof through authenticated Brave when available; if the required Brave path is unavailable, report browser QA as blocked instead of substituting isolated Playwright. Do not run full `bun run test`, full `bun run build`, or `ci-local --quick` just to finish an isolated QA fix. - **Repo Quick Gate** — use `node scripts/dev/ci-local.js --quick` for cross-package/shared-impact changes, checkpoint validation after several QA fixes, or when touched shared exports, hook signatures, provider contracts, data shapes, or mutation flows can affect multiple apps. This is broader than QA Speed Mode and is not the default for every small fix. -- **Ship Gate** — use the full ship pipeline (`bun format && bun lint && bun run test && bun build`, plus conditional design/vocab/contract checks) only for explicit ship/PR/commit/merge/release readiness, critical surfaces, or when the user asks to prove the branch is ready. Keep this gate strict; do not use QA Speed Mode to claim merge or release readiness. +- **Ship Gate** — use the full ship pipeline (`bun format && bun lint && bun run test && bun run build`) plus every conditional check that the touched surface requires. `.claude/context/validation-pipeline.md` is the canonical list of those conditional gates; consult it rather than relying on this summary, which is deliberately not exhaustive. Use it only for explicit ship/PR/commit/merge/release readiness, critical surfaces, or when the user asks to prove the branch is ready. Keep this gate strict; do not use QA Speed Mode to claim merge or release readiness. - **Multiple agents in QA mode** — each agent runs targeted proof for its own lane and reports blockers. A coordinator or final checkpoint runs Repo Quick Gate or Ship Gate before merge/release instead of every agent duplicating broad validation. ## User-Observed UI Regression Debugging @@ -203,7 +226,7 @@ When you see a layout bug that "looks like" a missing class, first check: was th - Lint check: `bun run format:check && bun lint` - Lint fix: `bun format && bun lint` - Full tests: `bun run test` -- Full build: `VITE_CHAIN_ID=11155111 bun run build` _(Sepolia is the deterministic validation chain — overrides local environment files so the build is reproducible across machines without requiring Arbitrum-specific deployment artifacts)_ +- Root application build: `VITE_CHAIN_ID=11155111 bun run build` _(contracts, shared, indexer, client, and admin; Agent and Docs use `bun run build:agent` / `bun run build:docs`. Sepolia is the deterministic validation chain — overrides local environment files so the build is reproducible across machines without requiring Arbitrum-specific deployment artifacts)_ ## Test Suite Speed Follow-Up diff --git a/CLAUDE.md b/CLAUDE.md index 8168ace6d4..b7812bd896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,33 +15,49 @@ bun run dev:prod # Start local browser surfaces against production A bun run dev:prod:health # Check prerequisites for hosted production-backed local mode bun run dev:prod:smoke # Read-only prod smoke: local surfaces, RPC, bytecode, agent, indexer lag bun run dev:stop # Stop repo-owned Green Goods dev services -bun format && bun lint # Format and lint workspace +bun format && bun lint # Mutating workspace format + lint (ship/fix intent only) bun run check:source-structure # Check changed non-test package source structure bun run test # Run all tests (CRITICAL: not `bun test`) bun run test:fast # Same scope, cache-aware via Turborepo (skips packages with unchanged inputs) bun run test:fast:force # Same as test:fast but bypasses cache (use when debugging a stale cache hit) bun run eval:skills # On-demand skill description-routing eval (run after trigger edits) -bun build # Build everything (respects dependency order) +bun run build # Build contracts, shared, indexer, client, and admin in dependency order +bun run build:agent # Build the Agent package when its surface changed +bun run build:docs # Build Docs when its surface changed ``` > **`bun test` vs `bun run test`**: `bun test` uses bun's built-in runner (ignores vitest config). `bun run test` runs the package.json script (vitest with proper environment). Always use `bun run test`. -> **`test` vs `test:fast`**: `bun run test` always runs every package via `bun --filter`. `bun run test:fast` runs the same scope through Turborepo, which caches passing test runs by input hash. Cache invalidates automatically when a package's source, its workspace dependencies' source (shared/contracts), `.env`, `biome.json`, or root tsconfigs change. **Failing tests are never cached** — fix the test, not the cache. To force a fresh run, use `bun run test:fast:force` or `rm -rf .turbo`. +> **`test` vs `test:fast`**: `bun run test` follows the root dependency-ordered script: Contracts; Shared + Docs in parallel; Indexer; then Client + Admin + Agent in parallel. `bun run test:fast` covers the same package test scripts through Turborepo, which caches passing test runs by input hash. Cache invalidates automatically when a package's source, its workspace dependencies' source (shared/contracts), `.env`, `biome.json`, or root tsconfigs change. **Failing tests are never cached** — fix the test, not the cache. To force a fresh run, use `bun run test:fast:force` or `rm -rf .turbo`. -Per-package: `bun run test`, `bun build`, `bun lint` (check each package.json for available scripts). Secondary dev commands (`dev:web`, `dev:full`, `dev:prod:mirror`, PM2 fallbacks) are catalogued in [`scripts/README.md`](scripts/README.md). +Per-package: `bun run test`, `bun run build`, `bun lint` (check each package.json for available scripts). Secondary dev commands (`dev:web`, `dev:full`, `dev:prod:mirror`, PM2 fallbacks) are catalogued in [`scripts/README.md`](scripts/README.md). -**Contracts** (never use raw `forge` commands): `bun build` (adaptive changed-target compile), `bun build:changed` (changed Solidity only), `bun build:target -- src/...` (single-target compile), `bun build:full` (CI/deploy only), `bun run test:fork` (needs RPC URLs), `bun run check:sizes` (EIP-170 deployed-bytecode gate — Foundry tests don't enforce the 24,576-byte limit; CI runs this, and pooling behavior must land in `src/lib/CommitmentPooling/` per `.plans/active/commitment-pooling/contract-spec.md` §6.1). Use `script/deploy.ts` for initial deployments and `script/upgrade.ts` for named UUPS upgrades; do not use `deploy.ts --force` as an upgrade or rollback path. For Arbitrum deploy/upgrade operations, use the named root `contracts:*` scripts; they set `FOUNDRY_KEYSTORE_ACCOUNT=green-goods-deployer`, clear unrelated Pinata upload secret resolution, and encode the current proxy-owner sender where required. +**Contracts** (never use raw `forge` commands): `bun run build` (adaptive changed-target compile), `bun run build:changed` (changed Solidity only), `bun run build:target -- src/...` (single-target compile), `bun run build:full` (CI/deploy only), `bun run test:fork` (needs RPC URLs), `bun run check:sizes` (EIP-170 deployed-bytecode gate — Foundry tests don't enforce the 24,576-byte limit; CI runs this, and pooling behavior must land in `src/lib/CommitmentPooling/` per `.plans/active/commitment-pooling/contract-spec.md` §6.1). Use `script/deploy.ts` for initial deployments and `script/upgrade.ts` for named UUPS upgrades; do not use `deploy.ts --force` as an upgrade or rollback path. For Arbitrum deploy/upgrade operations, use the named root `contracts:*` scripts; they set `FOUNDRY_KEYSTORE_ACCOUNT=green-goods-deployer`, clear unrelated Pinata upload secret resolution, and encode the current proxy-owner sender where required. ## Validation Intent Ladder Use the lightest honest proof for the user's intent. QA fixes, checkpoint validation, and merge readiness are different modes. -- **QA Speed Mode**: default for "QA mode", "quick fix", "get this to staging", and small visible/content/control fixes. Run targeted test file(s) or the package-local command that proves the touched behavior. Add package-local typecheck/build only when route wiring, render/build output, exported types, or runtime contracts move. For visible UI, use authenticated Brave rendered proof when available; if that path is unavailable, report browser QA as blocked rather than replacing it with isolated Playwright. Do not run full `bun run test`, full `bun build`, or `ci-local --quick` just to close an isolated QA fix. +First render the repository-owned plan with `bun run validation:plan -- --intent ` and execute +that plan. If the selector is unavailable or fails, apply this ladder directly and report the +selector problem; never weaken a gate because the selector could not run. Every selected check names +the risk it covers, expected signal, freshness rule, and stopping condition. Passing evidence is +reusable only while source inputs, validated paths, policy, command, toolchain, and environment still +match. A deterministic failure stops dependent checks. + +- **Diagnosis / evidence review**: inspect evidence first and run only the non-mutating checks needed to prove or disprove a finding. This is not production-readiness certification. An explicit production-quality, approval, or merge-readiness review runs the full non-mutating Production Review Readiness Gate. +- **QA Speed Mode**: default for "QA mode", "quick fix", "get this to staging", and small visible/content/control fixes. Run targeted test file(s) or the package-local command that proves the touched behavior. Add package-local typecheck/build only when route wiring, render/build output, exported types, or runtime contracts move. Style-only proof is path-scoped and non-mutating; do not run workspace-mutating `bun format`. For visible UI, use authenticated Brave rendered proof when available; if that path is unavailable, report browser QA as blocked rather than replacing it with isolated Playwright. Do not run full `bun run test`, full `bun run build`, or `ci-local --quick` just to close an isolated QA fix. - **Repo Quick Gate**: run `node scripts/dev/ci-local.js --quick` for cross-package/shared-impact changes, after several QA fixes as a coordinator checkpoint, or when shared exports, hook signatures, provider contracts, data shapes, or mutation flows move. -- **Ship Gate**: run `bun format && bun lint && bun run test && bun build` plus conditional design/vocab/contract checks only for explicit ship/PR/commit/merge/release readiness, critical surfaces, or when asked to prove the branch is ready. +- **Ship Gate**: run `bun format && bun lint && bun run test && bun run build` plus conditional design/vocab/contract checks only for explicit ship/PR/commit/merge/release readiness, critical surfaces, or when asked to prove the branch is ready. - **Multiple agents in QA mode**: each agent runs targeted proof for its own lane; one coordinator runs Repo Quick Gate or Ship Gate at checkpoints before merge/release. +User cancellation is terminal: stop active validation, start nothing else, and report the evidence +already collected. An environment-blocked check is `BLOCKED`, not passing; retry only after the +named capability changes. Time budgets never suppress contract, deployment/release, authentication, +JobQueue, Work-provider, mutation-hook, security, ontology, supply-chain, or release gates. Contracts use Bun +wrappers only, never raw Forge. + Command definitions for every rung: [`.claude/context/validation-pipeline.md`](.claude/context/validation-pipeline.md). ## Architecture @@ -261,7 +277,7 @@ This repo runs multiple concurrent Claude/Codex sessions on the same tree and `d - Types: feat, fix, refactor, chore, docs, test, perf, ci - Scopes: contracts, indexer, shared, client, admin, agent, claude -**Validation before committing**: `bun format && bun lint && bun run test && bun build`. This is the Ship Gate, not the default loop for every QA-speed fix. +**Validation before committing**: `bun format && bun lint && bun run test && bun run build`. This is the Ship Gate, not the default loop for every QA-speed fix. ## Codex Dispatch diff --git a/bun.lock b/bun.lock index 1884acf0dc..f67511272b 100644 --- a/bun.lock +++ b/bun.lock @@ -81,7 +81,7 @@ }, "packages/admin": { "name": "@green-goods/admin", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@green-goods/shared": "workspace:*", "@hookform/resolvers": "5.4.0", @@ -126,13 +126,13 @@ }, "packages/agent": { "name": "@green-goods/agent", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@green-goods/shared": "workspace:*", "@huggingface/transformers": "4.2.0", "@sentry/bun": "10.65.0", "@sentry/cli": "3.6.0", - "hono": "4.12.30", + "hono": "4.12.34", "pino": "10.3.1", "pino-pretty": "^13.1.3", "posthog-node": "5.41.0", @@ -150,7 +150,7 @@ }, "packages/client": { "name": "@green-goods/client", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@ethereum-attestation-service/eas-sdk": "2.9.0", "@green-goods/shared": "workspace:*", @@ -208,7 +208,7 @@ }, "packages/contracts": { "name": "@green-goods/contracts", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@chainlink/contracts-ccip": "^1.6.4", "@ethereum-attestation-service/eas-contracts": "1.9.0", @@ -224,9 +224,9 @@ }, "packages/indexer": { "name": "@green-goods/indexer", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { - "envio": "3.2.1", + "envio": "3.6.1", "viem": "2.55.0", }, "devDependencies": { @@ -239,7 +239,7 @@ }, "packages/shared": { "name": "@green-goods/shared", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@green-goods/contracts": "workspace:*", "@hypercerts-org/contracts": "^2.0.0-alpha.0", @@ -932,20 +932,6 @@ "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], - "@envio-dev/hyperfuel-client": ["@envio-dev/hyperfuel-client@1.2.2", "", { "optionalDependencies": { "@envio-dev/hyperfuel-client-darwin-arm64": "1.2.2", "@envio-dev/hyperfuel-client-darwin-x64": "1.2.2", "@envio-dev/hyperfuel-client-linux-arm64-gnu": "1.2.2", "@envio-dev/hyperfuel-client-linux-x64-gnu": "1.2.2", "@envio-dev/hyperfuel-client-linux-x64-musl": "1.2.2", "@envio-dev/hyperfuel-client-win32-x64-msvc": "1.2.2" } }, "sha512-raKA6DshYSle0sAOHBV1OkSRFMN+Mkz8sFiMmS3k+m5nP6pP56E17CRRePBL5qmR6ZgSEvGOz/44QUiKNkK9Pg=="], - - "@envio-dev/hyperfuel-client-darwin-arm64": ["@envio-dev/hyperfuel-client-darwin-arm64@1.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eQyd9kJCIz/4WCTjkjpQg80DA3pdneHP7qhJIVQ2ZG+Jew9o5XDG+uI0Y16AgGzZ6KGmJSJF6wyUaaAjJfbO1Q=="], - - "@envio-dev/hyperfuel-client-darwin-x64": ["@envio-dev/hyperfuel-client-darwin-x64@1.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-l7lRMSoyIiIvKZgQPfgqg7H1xnrQ37A8yUp4S2ys47R8f/wSCSrmMaY1u7n6CxVYCpR9fajwy0/356UgwwhVKw=="], - - "@envio-dev/hyperfuel-client-linux-arm64-gnu": ["@envio-dev/hyperfuel-client-linux-arm64-gnu@1.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-kNiC/1fKuXnoSxp8yEsloDw4Ot/mIcNoYYGLl2CipSIpBtSuiBH5nb6eBcxnRZdKOwf5dKZtZ7MVPL9qJocNJw=="], - - "@envio-dev/hyperfuel-client-linux-x64-gnu": ["@envio-dev/hyperfuel-client-linux-x64-gnu@1.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XDkvkBG/frS+xiZkJdY4KqOaoAwyxPdi2MysDQgF8NmZdssi32SWch0r4LTqKWLLlCBg9/R55POeXL5UAjg2wQ=="], - - "@envio-dev/hyperfuel-client-linux-x64-musl": ["@envio-dev/hyperfuel-client-linux-x64-musl@1.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-DKnKJJSwsYtA7YT0EFGhFB5Eqoo42X0l0vZBv4lDuxngEXiiNjeLemXoKQVDzhcbILD7eyXNa5jWUc+2hpmkEg=="], - - "@envio-dev/hyperfuel-client-win32-x64-msvc": ["@envio-dev/hyperfuel-client-win32-x64-msvc@1.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-SwIgTAVM9QhCFPyHwL+e1yQ6o3paV6q25klESkXw+r/KW9QPhOOyA6Yr8nfnur3uqMTLJHAKHTLUnkyi/Nh7Aw=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -2876,8 +2862,6 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="], - "blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="], "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], @@ -3444,17 +3428,17 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "envio": ["envio@3.2.1", "", { "dependencies": { "@clickhouse/client": "1.17.0", "@elastic/ecs-pino-format": "1.4.0", "@envio-dev/hyperfuel-client": "1.2.2", "@fuel-ts/crypto": "0.96.1", "@fuel-ts/errors": "0.96.1", "@fuel-ts/hasher": "0.96.1", "@fuel-ts/math": "0.96.1", "@fuel-ts/utils": "0.96.1", "@rescript/react": "0.14.1", "@rescript/runtime": "12.2.0", "bignumber.js": "9.3.1", "date-fns": "3.3.1", "dotenv": "16.4.5", "eventsource": "4.1.0", "express": "4.19.2", "ink": "6.8.0", "ink-big-text": "2.0.0", "ink-spinner": "5.0.0", "js-sdsl": "4.4.2", "pino": "10.3.1", "pino-pretty": "13.1.3", "postgres": "3.4.8", "prom-client": "15.1.3", "react": "19.2.5", "rescript-schema": "9.5.1", "tsx": "4.21.0", "viem": "2.46.2", "yargs": "17.7.2" }, "optionalDependencies": { "envio-darwin-arm64": "3.2.1", "envio-darwin-x64": "3.2.1", "envio-linux-arm64": "3.2.1", "envio-linux-x64": "3.2.1", "envio-linux-x64-musl": "3.2.1" }, "bin": { "envio": "bin.mjs" } }, "sha512-mPvVeomNzi3pz7KqBEpfaiVM8JKZzNphAipT47KiTB+HUrvuMBGmqODgLYUJqQ+iJxiRuCqOVNDy8oCMsZjfwg=="], + "envio": ["envio@3.6.1", "", { "dependencies": { "@clickhouse/client": "1.17.0", "@elastic/ecs-pino-format": "1.4.0", "@fuel-ts/crypto": "0.96.1", "@fuel-ts/errors": "0.96.1", "@fuel-ts/hasher": "0.96.1", "@fuel-ts/math": "0.96.1", "@fuel-ts/utils": "0.96.1", "@rescript/react": "0.14.1", "@rescript/runtime": "12.2.0", "bignumber.js": "9.3.1", "date-fns": "3.3.1", "dotenv": "16.4.5", "eventsource": "4.1.0", "express": "4.19.2", "ink": "6.8.0", "ink-big-text": "2.0.0", "ink-spinner": "5.0.0", "js-sdsl": "4.4.2", "pino": "10.3.1", "pino-pretty": "13.1.3", "postgres": "3.4.8", "react": "19.2.5", "rescript-schema": "9.5.1", "tsx": "4.21.0", "viem": "2.54.0", "yargs": "17.7.2" }, "optionalDependencies": { "envio-darwin-arm64": "3.6.1", "envio-darwin-x64": "3.6.1", "envio-linux-arm64": "3.6.1", "envio-linux-x64": "3.6.1", "envio-linux-x64-musl": "3.6.1" }, "bin": { "envio": "bin.mjs" } }, "sha512-n8N3ih7rEL6saDTXPOI1sxwruaD4HfJBtNnrlrmDDzv9Ccj8IFElBQJc+JvSIfT8TwzVZynPiHiL6e/gvtc7fg=="], - "envio-darwin-arm64": ["envio-darwin-arm64@3.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PohM1rGjlNxV0W/BOQOitsVPua944B2t2M0p81x9VpsZOwNWc9SgPsEjIsV4ZJOIceJaHrmWZnfwaWubgDGKMg=="], + "envio-darwin-arm64": ["envio-darwin-arm64@3.6.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ecGuGkptoCbfH5ULq4yAhRRB8pAK8pzQp6JahmBsH7dYSOmgM8xQRqgS5QwVgjrfQWl5GH3S4dyZpnSsCNcy+w=="], - "envio-darwin-x64": ["envio-darwin-x64@3.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-70zjTm4tNFzhigja0mHqmG03JQu56e9JCTQR7zoEhVvwj47cSgAPmQIihpaPuDCcMIYibzrlW7pFKdnXAZp3fQ=="], + "envio-darwin-x64": ["envio-darwin-x64@3.6.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zADvYOhv8Yjsp4a5AooJg4kRPKcidyjljru8XpUIIvLq5VdHQcs/5r1EnhDwPgA2Y9gBqTNCWZJ80pQs6Vg/0g=="], - "envio-linux-arm64": ["envio-linux-arm64@3.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OOVnX+aM8xJ5zYBmpqzSVCX5KFU+wi1gSfbv9X+nXk1rLjicHGm5U847oDmYBF5iyxkJDQBMsW9sr+7X4g8Yhg=="], + "envio-linux-arm64": ["envio-linux-arm64@3.6.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-dTbMq6BtAn0tEduGHdunyomIVNVTx32pr7tzRDdJ/rlPQxdNDkKboPIFeuxMoTiLKtUIyrkDRb/KZUNna72suQ=="], - "envio-linux-x64": ["envio-linux-x64@3.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YACCs+YdemYj4KBYOw/o6fi3hofAWrJ8VOeCwaOWEAzlqQxqkZ6h3O7puZWLnmjgdJvBAReUikFxI+E9MiyHOQ=="], + "envio-linux-x64": ["envio-linux-x64@3.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eynhAQXaEimq/QT0REEXPqjAs3J7rURymL7PRSXBvrfQVUUKjiO4l5S9jL5jlSpWpZR4o2xcW6xZAuk7O1B9sw=="], - "envio-linux-x64-musl": ["envio-linux-x64-musl@3.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/0lWrS/l2VYwdx7t0QvBE8b1R2vsnWpgi2q2xWqSmyOI9SOPU1q9YUElkhaDQFHEUXvqzyDDZPqxQacFtJggXw=="], + "envio-linux-x64-musl": ["envio-linux-x64-musl@3.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-APwQYvKhzMEsrWsKAhjx2+p8kqJi00Oz46YyW7Ln005pt96Am6ACqC7pWoo5J5Zuui8Y2lR4wJmEWKZeH9pkRQ=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -4962,8 +4946,6 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], - "promptly": ["promptly@2.2.0", "", { "dependencies": { "read": "^1.0.4" } }, "sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -5510,8 +5492,6 @@ "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], - "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], - "telegraf": ["telegraf@4.16.3", "", { "dependencies": { "@telegraf/types": "^7.1.0", "abort-controller": "^3.0.0", "debug": "^4.3.4", "mri": "^1.2.0", "node-fetch": "^2.7.0", "p-timeout": "^4.1.0", "safe-compare": "^1.1.4", "sandwich-stream": "^2.0.2" }, "bin": { "telegraf": "lib/cli.mjs" } }, "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w=="], "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="], @@ -7160,7 +7140,7 @@ "envio/tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "envio/viem": ["viem@2.46.2", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.4", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-w8Qv5Vyo7TfXcH3vgmxRa1NRvzJCDy2aSGSRsJn3503nC/qVbgEQ+n3aj/CkqWXbloudZh97h5o5aQrQSVGy0w=="], + "envio/viem": ["viem@2.54.0", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.29", "ws": "8.20.1" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-DW6KW3m89+3MLozFNPuI9Nhz2hJw375hUo1bpbo3qXipBvjdjF26B/d3U5adVyLGKOfqK4XeA1wPDWQTFQ/ltA=="], "envio/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -7582,8 +7562,6 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "prom-client/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "protobufjs/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], @@ -9316,9 +9294,9 @@ "envio/viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "envio/viem/ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="], + "envio/viem/ox": ["ox@0.14.29", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg=="], - "envio/viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "envio/viem/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "envio/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], diff --git a/docs/docs/builders/deployments/client-deploy.mdx b/docs/docs/builders/deployments/client-deploy.mdx index 2034ca7245..e800138291 100644 --- a/docs/docs/builders/deployments/client-deploy.mdx +++ b/docs/docs/builders/deployments/client-deploy.mdx @@ -65,7 +65,7 @@ The client depends on packages built earlier in the dependency chain: 1. `packages/contracts` -- ABI JSON files and deployment artifacts 2. `packages/shared` -- React hooks, modules, types, and components -Both must be built before the client. The monorepo's `bun build` command handles this order automatically. +Both must be built before the client. The monorepo's `bun run build` command handles this order automatically. ### Environment Variables diff --git a/docs/docs/builders/packages/contracts.mdx b/docs/docs/builders/packages/contracts.mdx index 795b7c9aaa..13faa3e196 100644 --- a/docs/docs/builders/packages/contracts.mdx +++ b/docs/docs/builders/packages/contracts.mdx @@ -100,7 +100,7 @@ packages/contracts/ - **CREATE2 deterministic deployment** -- contracts deploy to the same address across chains. Existing addresses are skipped via `_isDeployed()` check. - **Hub-and-spoke modules** -- GardenToken uses typed module slots. Optional modules degrade gracefully with `if (address != 0)` + `try/catch` guards. - **Selective `via_ir`** -- local builds stay mostly non-IR, while `foundry.toml` enables `via_ir` selectively for stack-heavy contracts and uses `via_ir=true` in the `test`, `ci`, `fork`, and `production` profiles. -- **Adaptive build system** -- `bun build` defaults to fast mode (~2s cached), detecting what changed. Only `bun build:full` triggers the slow full build (~180-400s). +- **Adaptive build system** -- `bun run build` defaults to fast mode (~2s cached), detecting what changed. Only `bun run build:full` triggers the slow full build (~180-400s). - **Two-layer deploy** -- TypeScript CLI (`deploy/core.ts`) orchestrates environment and parameters, then shells out to Solidity (`Deploy.s.sol`) for on-chain execution. ### Deployment Artifacts @@ -129,7 +129,7 @@ Each resolver implements `onAttest()` to validate attestation data. Resolvers ch ```bash # Build -bun build # Adaptive fast build (~2s cached) +bun run build # Adaptive fast build (~2s cached) bun build:full # CI/deploy only (skip test/script, ~180-400s) bun build:target -- src/... # Single-target compile diff --git a/docs/docs/builders/testing/forge.mdx b/docs/docs/builders/testing/forge.mdx index 52177bc5e3..740f560f42 100644 --- a/docs/docs/builders/testing/forge.mdx +++ b/docs/docs/builders/testing/forge.mdx @@ -128,10 +128,10 @@ cd packages/contracts && bun run test:e2e:celo ### Build System -The adaptive build system (`bun build`) defaults to fast mode (~2s cached) and only triggers a full build when necessary. For CI and deployments, use `bun build:full` which compiles with `--skip test --skip script`. +The adaptive build system (`bun run build`) defaults to fast mode (~2s cached) and only triggers a full build when necessary. For CI and deployments, use `bun run build:full` which compiles with `--skip test --skip script`. ```bash -bun build # Adaptive (fast by default) +bun run build # Adaptive (fast by default) bun build:fast # Force fast mode bun build:full # Full build (CI/deploy) bun build:target -- src/registries/Action.sol # Single target diff --git a/docs/routines/pr-review.md b/docs/routines/pr-review.md index 09678a00bb..e6be81e396 100644 --- a/docs/routines/pr-review.md +++ b/docs/routines/pr-review.md @@ -62,7 +62,7 @@ Ethereum addresses must use the `Address` type from `@green-goods/shared`, not ` ### 4. No raw forge commands -Contracts workflows must use `bun build`, `bun build:changed`, `bun build:target`, or `bun build:full`. Flag any raw `forge build`, `forge test`, or `forge script` in scripts or docs. +Contracts workflows must use `bun run build`, `bun run build:changed`, `bun run build:target`, or `bun run build:full`. Flag any raw `forge build`, `forge test`, or `forge script` in scripts or docs. ### 5. Deployment artifacts diff --git a/package.json b/package.json index 8410f9451f..ce11032def 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "setup:doctor": "node scripts/dev/doctor.js", "dev:doctor": "node scripts/dev/doctor.js", "ci:local": "node scripts/dev/ci-local.js", + "validation:plan": "node scripts/dev/node-cli.js scripts/quality/select-validation.mjs", + "test:validation-system": "node scripts/dev/node-cli.js node --test scripts/quality/select-validation.test.mjs scripts/dev/ci-local.test.mjs scripts/quality/ci-gate.test.mjs scripts/quality/workflow-performance-parity.test.mjs", "env:template:init": "node scripts/dev/env-template-init.js", "env:sync": "node scripts/dev/env-sync.js", "env:bootstrap": "node scripts/dev/env-bootstrap.js", diff --git a/packages/admin/src/__tests__/components/AdminDialogStandard.guard.test.ts b/packages/admin/src/__tests__/components/AdminDialogStandard.guard.test.ts index 7979fccb28..a3a7b007f3 100644 --- a/packages/admin/src/__tests__/components/AdminDialogStandard.guard.test.ts +++ b/packages/admin/src/__tests__/components/AdminDialogStandard.guard.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest"; * The size scale collapsed to three tiers (sm | md | lg); `xl`/`2xl` no longer * exist and an out-of-scale size fails silently at runtime (sizeClasses[size] * is undefined → the dialog loses its width constraint). The admin package has - * no whole-package tsc gate wired into `bun build` (its tsconfig.json is + * no whole-package tsc gate wired into `bun run build` (its tsconfig.json is * solution-style with `files: []`), so this guard is the durable check that: * * 1. every `= 80% pass (testnet), 100% (mainnet) -bun build # Clean compilation, no errors +bun run build # Clean compilation, no errors bun script/deploy.ts core --network sepolia # Dry run (omit --broadcast) ``` diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 4dd2880f03..0379045b95 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -172,7 +172,7 @@ bun run test:e2e:celo # Fork and test Celo mainnet bun run test:e2e:arbitrum # Fork and test Arbitrum mainnet # 🔧 DEVELOPMENT -bun build # Adaptive compile (changed Solidity targets) +bun run build # Adaptive compile (changed Solidity targets) bun build:changed # Compile changed Solidity in src/test/script bun build:target -- src/registries/ENS.sol # Compile a specific Solidity target bun build:fast # Compile src only (skip Foundry test/script) @@ -545,7 +545,7 @@ See the [Contracts Handbook](https://docs.greengoods.app/builders/deployments/co bun install # Build contracts with IR optimization -bun build +bun run build # Run comprehensive test suite bun run test diff --git a/packages/indexer/package.json b/packages/indexer/package.json index 9c5fecff86..53ce62ce1a 100644 --- a/packages/indexer/package.json +++ b/packages/indexer/package.json @@ -22,6 +22,7 @@ "db:down": "docker ps -q --filter label=dev.envio.config-hash | xargs -r docker stop", "test": "bun run codegen && bun run mocha", "test:coverage": "c8 bun run mocha", + "test:coverage:ci": "c8 --reporter text --reporter json bun run mocha", "test:full": "bun run test", "lint": "bun --bun run oxlint src test --deny-warnings", "reset": "node ../../scripts/dev/node-cli.js envio stop" @@ -34,7 +35,7 @@ "typescript": "~7.0.2" }, "dependencies": { - "envio": "3.2.1", + "envio": "3.6.1", "viem": "2.55.0" }, "engines": { diff --git a/packages/indexer/test/actionRegistry.test.ts b/packages/indexer/test/actionRegistry.test.ts index 442e452e38..64d6ff1ef3 100644 --- a/packages/indexer/test/actionRegistry.test.ts +++ b/packages/indexer/test/actionRegistry.test.ts @@ -19,9 +19,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } diff --git a/packages/indexer/test/commitmentPool.test.ts b/packages/indexer/test/commitmentPool.test.ts index 605ac3a539..c746849fa0 100644 --- a/packages/indexer/test/commitmentPool.test.ts +++ b/packages/indexer/test/commitmentPool.test.ts @@ -23,7 +23,7 @@ function eventData(blockNumber: number, logIndex: number, transactionIndex = log return { chainId: CHAIN_ID, block: { timestamp: blockNumber, number: blockNumber }, - srcAddress: address(90), + srcAddress: undefined, transaction: { hash: hash(transactionIndex) }, logIndex, }; @@ -432,7 +432,7 @@ describe("Commitment Pooling read model", () => { unitLabel: "hours", units: 2n, totalCommitted: 2n, - mockEventData: eventData(START_BLOCK + 1, 0, 31), + mockEventData: eventData(START_BLOCK + 1, 1, 31), }); const distinctLabel = CommitmentRegistry.UnitsCommitted.createMockEvent({ classId: 12n, @@ -444,7 +444,18 @@ describe("Commitment Pooling read model", () => { totalCommitted: 3n, mockEventData: eventData(START_BLOCK + 3, 0, 32), }); - db = await processEvents(db, [released, released, committed, committed, distinctLabel]); + // The duplicates carry a distinct logIndex: Envio rejects two items sharing a + // (block, logIndex) in one batch, and it will not accept an older block in a + // later batch either, so the redelivery has to sit alongside the original. + const releasedReplay = { ...released, logIndex: 1 }; + const committedReplay = { ...committed, logIndex: 2 }; + db = await processEvents(db, [ + released, + releasedReplay, + committed, + committedReplay, + distinctLabel, + ]); const summaries = await db.CommitmentUnitSummary.getAll(); const poolHours = summaries.find((row) => row.scope === "POOL" && row.unitLabel === "hours"); diff --git a/packages/indexer/test/commitmentPoolReview.test.ts b/packages/indexer/test/commitmentPoolReview.test.ts index 1dbbf92829..ea145f131a 100644 --- a/packages/indexer/test/commitmentPoolReview.test.ts +++ b/packages/indexer/test/commitmentPoolReview.test.ts @@ -39,7 +39,7 @@ function eventData(blockNumber: number, logIndex = 0) { return { chainId: CHAIN_ID, block: { timestamp: blockNumber, number: blockNumber }, - srcAddress: address(90), + srcAddress: undefined, transaction: { hash: hash(blockNumber * 10 + logIndex) }, logIndex, }; diff --git a/packages/indexer/test/garden.test.ts b/packages/indexer/test/garden.test.ts index 5969e65481..55a2c0e233 100644 --- a/packages/indexer/test/garden.test.ts +++ b/packages/indexer/test/garden.test.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import { Addresses, createTestIndexer, GardenAccount, GardenToken } from "./v3"; +import { Addresses, createTestIndexer, GardenAccount, GardenToken, indexedAddress } from "./v3"; const CHAIN_ID = 42161; @@ -19,9 +19,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -56,7 +56,7 @@ describe("GardenToken.GardenMinted", () => { it("creates a new garden entity with all fields", async () => { const mockDb = createTestIndexer(); const gardenAddress = addr(10); - const tokenContract = addr(11); + const tokenContract = indexedAddress("GardenToken", CHAIN_ID); const event = GardenToken.GardenMinted.createMockEvent({ tokenId: 42n, @@ -66,7 +66,7 @@ describe("GardenToken.GardenMinted", () => { location: "Berlin", bannerImage: "ipfs://bafk-banner", openJoining: true, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: tokenContract }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); const result = await GardenToken.GardenMinted.processEvent({ event, mockDb }); @@ -133,7 +133,7 @@ describe("GardenAccount.NameUpdated", () => { location: "", bannerImage: "", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -153,7 +153,7 @@ describe("GardenAccount.NameUpdated", () => { it("creates a default garden if not existing", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const event = GardenAccount.NameUpdated.createMockEvent({ updater: addr(1), @@ -184,7 +184,7 @@ describe("GardenAccount.DescriptionUpdated", () => { location: "", bannerImage: "", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -206,7 +206,7 @@ describe("GardenAccount.DescriptionUpdated", () => { it("creates default garden when missing", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const event = GardenAccount.DescriptionUpdated.createMockEvent({ updater: addr(1), @@ -236,7 +236,7 @@ describe("GardenAccount.LocationUpdated", () => { location: "Old Location", bannerImage: "", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -258,7 +258,7 @@ describe("GardenAccount.LocationUpdated", () => { it("creates default garden when missing", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const event = GardenAccount.LocationUpdated.createMockEvent({ updater: addr(1), @@ -288,7 +288,7 @@ describe("GardenAccount.BannerImageUpdated", () => { location: "", bannerImage: "ipfs://old", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -310,7 +310,7 @@ describe("GardenAccount.BannerImageUpdated", () => { it("creates default garden when missing", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const event = GardenAccount.BannerImageUpdated.createMockEvent({ updater: addr(1), @@ -340,7 +340,7 @@ describe("GardenAccount.GAPProjectCreated", () => { location: "", bannerImage: "", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -395,7 +395,7 @@ describe("GardenAccount.OpenJoiningUpdated", () => { location: "", bannerImage: "", openJoining: false, - mockEventData: mockEvent(CHAIN_ID, 1000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 1000), }); mockDb = await GardenToken.GardenMinted.processEvent({ event: mintEvent, mockDb }); @@ -417,7 +417,7 @@ describe("GardenAccount.OpenJoiningUpdated", () => { it("does nothing when garden not found", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const updateEvent = GardenAccount.OpenJoiningUpdated.createMockEvent({ updater: addr(1), diff --git a/packages/indexer/test/gardenIdentityCompatibility.test.ts b/packages/indexer/test/gardenIdentityCompatibility.test.ts index 16e8376523..b51c2b1f70 100644 --- a/packages/indexer/test/gardenIdentityCompatibility.test.ts +++ b/packages/indexer/test/gardenIdentityCompatibility.test.ts @@ -16,7 +16,6 @@ describe("Commitment Pooling Garden identity compatibility", () => { mockEventData: { chainId: CHAIN_ID, block: { timestamp: 1, number: 433_713_812 }, - srcAddress: Addresses.mockAddresses[9], transaction: { hash: `0x${"1".padStart(64, "0")}` }, logIndex: 0, }, diff --git a/packages/indexer/test/greenWill.test.ts b/packages/indexer/test/greenWill.test.ts index 8cc94c40b3..4f165313fc 100644 --- a/packages/indexer/test/greenWill.test.ts +++ b/packages/indexer/test/greenWill.test.ts @@ -23,9 +23,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } diff --git a/packages/indexer/test/handlers.test.ts b/packages/indexer/test/handlers.test.ts index bcc1533f20..95d20cf87b 100644 --- a/packages/indexer/test/handlers.test.ts +++ b/packages/indexer/test/handlers.test.ts @@ -8,6 +8,7 @@ import { GardenAccount, HypercertMinter, serveJson, + indexedAddress, YieldSplitter, } from "./v3"; @@ -105,9 +106,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp), input: opts.txInput }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -158,7 +159,7 @@ function createCookieJarInput(metadata: string): string { describe("retained garden + role handlers", () => { it("creates a default garden on GardenAccount.NameUpdated", async () => { const mockDb = createTestIndexer(); - const gardenAddress = addr(10); + const gardenAddress = indexedAddress("GardenAccount", CHAIN_ID); const event = GardenAccount.NameUpdated.createMockEvent({ updater: addr(1), @@ -303,7 +304,6 @@ describe("campaign cookie jar factory handlers", () => { jarAddress, creator, mockEventData: mockEvent(CHAIN_ID, 40_000, { - srcAddress: addr(52), txHash: txHash(400), txInput: createCookieJarInput(metadata), }), @@ -334,7 +334,6 @@ describe("campaign cookie jar factory handlers", () => { jarAddress, creator, mockEventData: mockEvent(CHAIN_ID, 40_000, { - srcAddress: addr(52), txHash: txHash(400), }), }); @@ -371,7 +370,6 @@ describe("campaign cookie jar factory handlers", () => { jarAddress, metadata, mockEventData: mockEvent(CHAIN_ID, 41_000, { - srcAddress: addr(52), txHash: txHash(410), }), }); @@ -401,7 +399,6 @@ describe("campaign cookie jar factory handlers", () => { jarAddress, metadata: JSON.stringify({ kind: "other", version: 1, slug: "test", title: "Test" }), mockEventData: mockEvent(CHAIN_ID, 42_000, { - srcAddress: addr(52), txHash: txHash(420), }), }); diff --git a/packages/indexer/test/hatsModule.test.ts b/packages/indexer/test/hatsModule.test.ts index bd8780ac65..f48b373ae0 100644 --- a/packages/indexer/test/hatsModule.test.ts +++ b/packages/indexer/test/hatsModule.test.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import { Addresses, createTestIndexer, GardenToken, HatsModule } from "./v3"; +import { Addresses, createTestIndexer, HatsModule, processEvents } from "./v3"; const CHAIN_ID = 42161; @@ -19,9 +19,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -174,8 +174,6 @@ describe("HatsModule.RoleGranted", () => { mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event1, mockDb }); - // Grant same role again const event2 = HatsModule.RoleGranted.createMockEvent({ garden: addr(10), @@ -184,7 +182,7 @@ describe("HatsModule.RoleGranted", () => { mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event2, mockDb }); + mockDb = await processEvents(mockDb, [event1, event2]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -277,8 +275,6 @@ describe("HatsModule.RoleGranted — Gardener entity", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event1, mockDb }); - // Grant gardener role for second garden const event2 = HatsModule.RoleGranted.createMockEvent({ garden: addr(11), @@ -286,7 +282,7 @@ describe("HatsModule.RoleGranted — Gardener entity", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event2, mockDb }); + mockDb = await processEvents(mockDb, [event1, event2]); const gardener = await mockDb.Gardener.get(gardenerId); assert.ok(gardener); @@ -307,8 +303,6 @@ describe("HatsModule.RoleGranted — Gardener entity", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event1, mockDb }); - // Grant again to same garden const event2 = HatsModule.RoleGranted.createMockEvent({ garden: addr(10), @@ -316,7 +310,7 @@ describe("HatsModule.RoleGranted — Gardener entity", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: event2, mockDb }); + mockDb = await processEvents(mockDb, [event1, event2]); const gardener = await mockDb.Gardener.get(gardenerId); assert.ok(gardener); @@ -357,8 +351,6 @@ describe("HatsModule.RoleRevoked", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grantEvent, mockDb }); - // Revoke const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), @@ -366,7 +358,7 @@ describe("HatsModule.RoleRevoked", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grantEvent, revokeEvent]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -383,15 +375,13 @@ describe("HatsModule.RoleRevoked", () => { role: 1n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grantEvent, mockDb }); - const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), account, role: 1n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grantEvent, revokeEvent]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -408,15 +398,13 @@ describe("HatsModule.RoleRevoked", () => { role: 3n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grantEvent, mockDb }); - const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), account, role: 3n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grantEvent, revokeEvent]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -433,15 +421,13 @@ describe("HatsModule.RoleRevoked", () => { role: 4n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grantEvent, mockDb }); - const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), account, role: 4n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grantEvent, revokeEvent]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -458,15 +444,13 @@ describe("HatsModule.RoleRevoked", () => { role: 5n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grantEvent, mockDb }); - const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), account, role: 5n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grantEvent, revokeEvent]); const garden = await mockDb.Garden.get(addr(10)); assert.ok(garden); @@ -502,16 +486,12 @@ describe("HatsModule.RoleRevoked", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 2000), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grant1, mockDb }); - const grant2 = HatsModule.RoleGranted.createMockEvent({ garden: addr(11), account, role: 0n, mockEventData: mockEvent(CHAIN_ID, 2500), }); - mockDb = await HatsModule.RoleGranted.processEvent({ event: grant2, mockDb }); - // Revoke from first garden const revokeEvent = HatsModule.RoleRevoked.createMockEvent({ garden: addr(10), @@ -519,7 +499,7 @@ describe("HatsModule.RoleRevoked", () => { role: 0n, mockEventData: mockEvent(CHAIN_ID, 3000), }); - mockDb = await HatsModule.RoleRevoked.processEvent({ event: revokeEvent, mockDb }); + mockDb = await processEvents(mockDb, [grant1, grant2, revokeEvent]); const gardener = await mockDb.Gardener.get(gardenerId); assert.ok(gardener); diff --git a/packages/indexer/test/hypercertAllocationReview.test.ts b/packages/indexer/test/hypercertAllocationReview.test.ts index 24bfe0938e..1b73b5e96d 100644 --- a/packages/indexer/test/hypercertAllocationReview.test.ts +++ b/packages/indexer/test/hypercertAllocationReview.test.ts @@ -79,7 +79,7 @@ describe("commitment hypercert allocation reconciliation", () => { mockEventData: { chainId: CHAIN_ID, block: { timestamp: 3, number: 3 }, - srcAddress: address(90), + srcAddress: undefined, transaction: { hash: `0x${"3".padStart(64, "0")}` }, logIndex: 0, }, diff --git a/packages/indexer/test/hypercerts.test.ts b/packages/indexer/test/hypercerts.test.ts index 2013ea55ac..8f27f06014 100644 --- a/packages/indexer/test/hypercerts.test.ts +++ b/packages/indexer/test/hypercerts.test.ts @@ -27,9 +27,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -98,8 +98,6 @@ describe("HypercertMinter.TransferSingle — mints", () => { totalUnits: 1000n, mockEventData: mockEvent(CHAIN_ID, 4000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.ClaimStored.processEvent({ event: claimStored, mockDb }); - // TransferSingle mint const transferEvent = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), @@ -109,7 +107,7 @@ describe("HypercertMinter.TransferSingle — mints", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: transferEvent, mockDb }); + mockDb = await processEvents(mockDb, [claimStored, transferEvent]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); @@ -165,8 +163,6 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: mint, mockDb }); - // Subsequent mint (claim) const claim = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), @@ -176,7 +172,7 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 300n, mockEventData: mockEvent(CHAIN_ID, 6000, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: claim, mockDb }); + mockDb = await processEvents(mockDb, [mint, claim]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); @@ -203,8 +199,6 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: mint, mockDb }); - // Claim all 1000 units const claim = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), @@ -214,7 +208,7 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 6000, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: claim, mockDb }); + mockDb = await processEvents(mockDb, [mint, claim]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); @@ -235,8 +229,6 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: mint, mockDb }); - const claim = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), from: ZERO_ADDRESS, @@ -245,7 +237,7 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 500n, mockEventData: mockEvent(CHAIN_ID, 6000, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: claim, mockDb }); + mockDb = await processEvents(mockDb, [mint, claim]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); @@ -266,8 +258,6 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: mint, mockDb }); - // First claim const claim1 = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), @@ -277,8 +267,6 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 300n, mockEventData: mockEvent(CHAIN_ID, 6000, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: claim1, mockDb }); - // Same claim again (same claimant) const claim2 = HypercertMinter.TransferSingle.createMockEvent({ operator: addr(1), @@ -288,7 +276,7 @@ describe("HypercertMinter.TransferSingle — claims", () => { value: 300n, mockEventData: mockEvent(CHAIN_ID, 7000, { txHash: txHash(300), logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: claim2, mockDb }); + mockDb = await processEvents(mockDb, [mint, claim1, claim2]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); @@ -520,8 +508,6 @@ describe("HypercertMinter.ClaimStored", () => { value: 1000n, mockEventData: mockEvent(CHAIN_ID, 5000, { txHash: tx1, logIndex: 1 }), }); - mockDb = await HypercertMinter.TransferSingle.processEvent({ event: mint, mockDb }); - // ClaimStored second const claimStored = HypercertMinter.ClaimStored.createMockEvent({ claimID: 42n, @@ -529,7 +515,7 @@ describe("HypercertMinter.ClaimStored", () => { totalUnits: 1000n, mockEventData: mockEvent(CHAIN_ID, 5001, { txHash: tx2, logIndex: 1 }), }); - mockDb = await HypercertMinter.ClaimStored.processEvent({ event: claimStored, mockDb }); + mockDb = await processEvents(mockDb, [mint, claimStored]); const hc = await mockDb.Hypercert.get(`${CHAIN_ID}-42`); assert.ok(hc); diff --git a/packages/indexer/test/octantVault.test.ts b/packages/indexer/test/octantVault.test.ts index bab21b3dd3..2253acf01f 100644 --- a/packages/indexer/test/octantVault.test.ts +++ b/packages/indexer/test/octantVault.test.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import { Addresses, createTestIndexer, OctantModule, OctantVault } from "./v3"; +import { Addresses, createTestIndexer, OctantModule, OctantVault, processEvents } from "./v3"; const CHAIN_ID = 42161; @@ -19,9 +19,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -174,8 +174,6 @@ describe("OctantVault.Deposit", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: event1, mockDb }); - const event2 = OctantVault.Deposit.createMockEvent({ sender: addr(30), owner: addr(31), @@ -187,7 +185,7 @@ describe("OctantVault.Deposit", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: event2, mockDb }); + mockDb = await processEvents(mockDb, [event1, event2]); const depositId = `${CHAIN_ID}-${VAULT.toLowerCase()}-${addr(31).toLowerCase()}`; const deposit = await mockDb.VaultDeposit.get(depositId); @@ -216,8 +214,6 @@ describe("OctantVault.Deposit", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: deposit1, mockDb }); - const deposit2 = OctantVault.Deposit.createMockEvent({ sender: addr(32), owner: addr(33), @@ -229,7 +225,7 @@ describe("OctantVault.Deposit", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: deposit2, mockDb }); + mockDb = await processEvents(mockDb, [deposit1, deposit2]); const vault = await mockDb.GardenVault.get(vaultId()); assert.ok(vault); @@ -298,9 +294,13 @@ describe("OctantVault.Deposit", () => { }), }); - const result = await OctantVault.Deposit.processEvent({ event, mockDb }); - // Should not throw, just skip - assert.equal(await result.GardenVault.get(vaultId()), undefined); + // Envio rejects an event whose address is not indexed rather than silently + // skipping it, so the guarantee is now enforced before the handler runs. + await assert.rejects( + () => OctantVault.Deposit.processEvent({ event, mockDb }), + /never reached a handler/ + ); + assert.equal(await mockDb.GardenVault.get(vaultId()), undefined); }); }); @@ -324,8 +324,6 @@ describe("OctantVault.Withdraw", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: deposit, mockDb }); - // Withdraw const withdraw = OctantVault.Withdraw.createMockEvent({ sender: addr(30), @@ -339,7 +337,7 @@ describe("OctantVault.Withdraw", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Withdraw.processEvent({ event: withdraw, mockDb }); + mockDb = await processEvents(mockDb, [deposit, withdraw]); const vault = await mockDb.GardenVault.get(vaultId()); assert.ok(vault); @@ -367,8 +365,6 @@ describe("OctantVault.Withdraw", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: deposit, mockDb }); - // Withdraw 100 (more than deposited) const withdraw = OctantVault.Withdraw.createMockEvent({ sender: addr(30), @@ -382,7 +378,7 @@ describe("OctantVault.Withdraw", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Withdraw.processEvent({ event: withdraw, mockDb }); + mockDb = await processEvents(mockDb, [deposit, withdraw]); const depositId = `${CHAIN_ID}-${VAULT.toLowerCase()}-${addr(31).toLowerCase()}`; const depositRecord = await mockDb.VaultDeposit.get(depositId); @@ -406,8 +402,6 @@ describe("OctantVault.Withdraw", () => { logIndex: 1, }), }); - mockDb = await OctantVault.Deposit.processEvent({ event: deposit, mockDb }); - const withdraw = OctantVault.Withdraw.createMockEvent({ sender: addr(30), receiver: addr(31), @@ -420,7 +414,7 @@ describe("OctantVault.Withdraw", () => { logIndex: 3, }), }); - mockDb = await OctantVault.Withdraw.processEvent({ event: withdraw, mockDb }); + mockDb = await processEvents(mockDb, [deposit, withdraw]); const vaultEvent = await mockDb.VaultEvent.get(`${CHAIN_ID}-${tx}-3`); assert.ok(vaultEvent); @@ -588,8 +582,6 @@ describe("OctantModule.DonationAddressUpdated", () => { asset: secondAsset, mockEventData: mockEvent(CHAIN_ID, 1500, { txHash: txHash(150), logIndex: 1 }), }); - mockDb = await OctantModule.VaultCreated.processEvent({ event: createEvent, mockDb }); - // Update donation address const updateEvent = OctantModule.DonationAddressUpdated.createMockEvent({ garden: GARDEN, @@ -597,7 +589,7 @@ describe("OctantModule.DonationAddressUpdated", () => { newAddress: addr(27), mockEventData: mockEvent(CHAIN_ID, 4000, { txHash: txHash(400), logIndex: 1 }), }); - mockDb = await OctantModule.DonationAddressUpdated.processEvent({ event: updateEvent, mockDb }); + mockDb = await processEvents(mockDb, [createEvent, updateEvent]); const vault1 = await mockDb.GardenVault.get(vaultId()); const vault2 = await mockDb.GardenVault.get( diff --git a/packages/indexer/test/settlement-lifecycle.test.ts b/packages/indexer/test/settlement-lifecycle.test.ts index 239790c4d8..5cd301650e 100644 --- a/packages/indexer/test/settlement-lifecycle.test.ts +++ b/packages/indexer/test/settlement-lifecycle.test.ts @@ -11,6 +11,8 @@ import { import { executorConfiguration, sourceConfiguration } from "../src/handlers/settlement-projections"; const CHAIN_ID = 42161; +// The executor contract is indexed on Celo; Arbitrum is its remote lane. +const EXECUTOR_CHAIN_ID = 42220; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_BYTES32 = `0x${"0".repeat(64)}`; const SNAPSHOT_PARAMETERS = parseAbiParameters( @@ -25,11 +27,15 @@ function bytes32(index: number): string { return `0x${index.toString(16).padStart(64, "0")}`; } +function celoEvent(timestamp: number, logIndex = 0) { + return { ...mockEvent(timestamp, logIndex), chainId: EXECUTOR_CHAIN_ID }; +} + function mockEvent(timestamp: number, logIndex = 0) { return { chainId: CHAIN_ID, block: { timestamp, number: 0 }, - srcAddress: addr(90), + srcAddress: undefined, transaction: { hash: bytes32(timestamp) }, logIndex, }; @@ -47,7 +53,7 @@ function seedSourceLane(mockDb: ReturnType): void { function seedExecutorLane(mockDb: ReturnType): void { mockDb.SettlementConfiguration.set({ - ...executorConfiguration(CHAIN_ID, addr(90), 0), + ...executorConfiguration(EXECUTOR_CHAIN_ID, addr(90), 0), gDollarToken: addr(91), localRouter: addr(92), localChainSelector: 16_688_752_181_858_512n, @@ -677,33 +683,33 @@ describe("settlement lifecycle projections", () => { previousSourceSettlementModule: addr(69), previousPeerExpiresAt: 100n, protocolVersion: 1n, - mockEventData: mockEvent(1), + mockEventData: celoEvent(1), }), CeloSettlementExecutor.CapsUpdated.createMockEvent({ maxBatchSize: 12n, maxTransferAmount: 1_000n, maxBatchAmount: 5_000n, - mockEventData: mockEvent(2), + mockEventData: celoEvent(2), }), CeloSettlementExecutor.FeePolicyUpdated.createMockEvent({ maxFeeBps: 100n, maxFeeAmount: 25n, - mockEventData: mockEvent(3), + mockEventData: celoEvent(3), }), CeloSettlementExecutor.PeriodicCapUpdated.createMockEvent({ periodDuration: 86_400n, maxPeriodAmount: 10_000n, - mockEventData: mockEvent(4), + mockEventData: celoEvent(4), }), CeloSettlementExecutor.AcknowledgmentFeeReserveMinimumUpdated.createMockEvent({ previousMinimum: 0n, minimum: 10n, - mockEventData: mockEvent(5), + mockEventData: celoEvent(5), }), CeloSettlementExecutor.AcknowledgmentFeeReserveFunded.createMockEvent({ funder: addr(71), amount: 100n, - mockEventData: mockEvent(6), + mockEventData: celoEvent(6), }), CeloSettlementExecutor.GardenRouteConfigured.createMockEvent({ garden: addr(1), @@ -712,12 +718,12 @@ describe("settlement lifecycle projections", () => { roleKey: bytes32(310), allowanceKey: bytes32(311), permissionsConfigHash: bytes32(312), - mockEventData: mockEvent(7), + mockEventData: celoEvent(7), }), CeloSettlementExecutor.GardenRouteStatusChanged.createMockEvent({ garden: addr(1), active: false, - mockEventData: mockEvent(8), + mockEventData: celoEvent(8), }), CeloSettlementExecutor.SettlementExecutionStored.createMockEvent({ executionKey, @@ -730,13 +736,13 @@ describe("settlement lifecycle projections", () => { attempt: 0n, status: 1n, failureCode: 0n, - mockEventData: mockEvent(9), + mockEventData: celoEvent(9), }), CeloSettlementExecutor.AcknowledgmentDeferred.createMockEvent({ executionKey, commandMessageId, reasonCode: 2n, - mockEventData: mockEvent(10), + mockEventData: celoEvent(10), }), CeloSettlementExecutor.AcknowledgmentSent.createMockEvent({ executionKey, @@ -744,27 +750,31 @@ describe("settlement lifecycle projections", () => { acknowledgmentMessageId, fee: 5n, reserveFunded: true, - mockEventData: mockEvent(11), + mockEventData: celoEvent(11), }), CeloSettlementExecutor.DuplicateSettlementMessage.createMockEvent({ executionKey, commandMessageId: bytes32(303), - mockEventData: mockEvent(12), + mockEventData: celoEvent(12), }), CeloSettlementExecutor.ExcessAcknowledgmentFeesWithdrawn.createMockEvent({ recipient: addr(74), amount: 15n, - mockEventData: mockEvent(13), + mockEventData: celoEvent(13), }), CeloSettlementExecutor.PausedSet.createMockEvent({ paused: false, - mockEventData: mockEvent(14), + mockEventData: celoEvent(14), }), ]); - const config = await mockDb.SettlementConfiguration.get(`${CHAIN_ID}-settlement-config`); - const route = await mockDb.SettlementGardenRoute.get(`${CHAIN_ID}-${addr(1).toLowerCase()}`); - const execution = await mockDb.SettlementExecution.get(`${CHAIN_ID}-${executionKey}`); + const config = await mockDb.SettlementConfiguration.get( + `${EXECUTOR_CHAIN_ID}-settlement-config` + ); + const route = await mockDb.SettlementGardenRoute.get( + `${EXECUTOR_CHAIN_ID}-${addr(1).toLowerCase()}` + ); + const execution = await mockDb.SettlementExecution.get(`${EXECUTOR_CHAIN_ID}-${executionKey}`); assert.equal(config?.role, "EXECUTOR"); assert.equal(config?.batchSizeLimit, 12); assert.equal(config?.nativeFeeBalance, 80n); @@ -775,7 +785,8 @@ describe("settlement lifecycle projections", () => { assert.equal(execution?.acknowledgmentDeferralCode, "NONE"); assert.deepEqual(execution?.duplicateMessageIds, [bytes32(303)]); assert.equal( - (await mockDb.SettlementMessage.get(`${CHAIN_ID}-${acknowledgmentMessageId}`))?.reserveFunded, + (await mockDb.SettlementMessage.get(`${EXECUTOR_CHAIN_ID}-${acknowledgmentMessageId}`)) + ?.reserveFunded, true ); }); @@ -813,11 +824,13 @@ describe("settlement lifecycle projections", () => { remoteChainSelector: 4_949_039_107_694_359_620n, localChainSelector: 1_346_049_177_634_351_622n, sourceEvmChainId: 42_161n, - mockEventData: mockEvent(1_000), + mockEventData: celoEvent(1_000), }); const after = await processEvents(mockDb, [pinned]); - const config = await after.SettlementConfiguration.get(`${CHAIN_ID}-settlement-config`); + const config = await after.SettlementConfiguration.get( + `${EXECUTOR_CHAIN_ID}-settlement-config` + ); assert.equal(config?.role, "EXECUTOR"); assert.equal(config?.localChainSelector, 1_346_049_177_634_351_622n); assert.equal(config?.remoteEvmChainId, 42_161); diff --git a/packages/indexer/test/settlement.test.ts b/packages/indexer/test/settlement.test.ts index ad9ab430d8..0c4eac2928 100644 --- a/packages/indexer/test/settlement.test.ts +++ b/packages/indexer/test/settlement.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; -import { Addresses, createTestIndexer, SettlementModule } from "./v3"; +import { Addresses, createTestIndexer, SettlementModule, processEvents } from "./v3"; const CHAIN_ID = 42161; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -18,7 +18,7 @@ function mockEvent(timestamp: number, logIndex = 0) { return { chainId: CHAIN_ID, block: { timestamp, number: 0 }, - srcAddress: addr(90), + srcAddress: undefined, transaction: { hash: txHash(timestamp) }, logIndex, }; @@ -73,11 +73,7 @@ describe("SettlementModule read model", () => { recordedBy: addr(7), mockEventData: mockEvent(1), }); - mockDb = await SettlementModule.DisbursementQueued.processEvent({ event: refund, mockDb }); - mockDb = await SettlementModule.DisbursementQueued.processEvent({ event: refund, mockDb }); - mockDb = await SettlementModule.FundingDepositRecorded.processEvent({ event: deposit, mockDb }); - mockDb = await SettlementModule.FundingPledged.processEvent({ event: pledge, mockDb }); - mockDb = await SettlementModule.FundingPledged.processEvent({ event: pledge, mockDb }); + mockDb = await processEvents(mockDb, [refund, refund, deposit, pledge, pledge]); let funding = await mockDb.CommitmentFunding.get(`${CHAIN_ID}-${fundingId}`); const fundingIndex = await mockDb.CommitmentFundingIndex.get( diff --git a/packages/indexer/test/settlementReview.test.ts b/packages/indexer/test/settlementReview.test.ts index 2dac238369..fc49b7568f 100644 --- a/packages/indexer/test/settlementReview.test.ts +++ b/packages/indexer/test/settlementReview.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { Addresses, createTestIndexer, SettlementModule } from "./v3"; +import { Addresses, createTestIndexer, processEvents, SettlementModule } from "./v3"; const CHAIN_ID = 42161; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -18,7 +18,7 @@ function eventData(timestamp: number) { return { chainId: CHAIN_ID, block: { timestamp, number: timestamp }, - srcAddress: address(90), + srcAddress: undefined, transaction: { hash: hash(timestamp) }, logIndex: 0, }; @@ -140,9 +140,7 @@ describe("Settlement review regressions", () => { const withdrawn = fundingWithdrawn(60n, 50n, 2); let db = createTestIndexer(); - db = await SettlementModule.FundingPledged.processEvent({ event: pledge, mockDb: db }); - db = await SettlementModule.FundingWithdrawn.processEvent({ event: withdrawn, mockDb: db }); - db = await SettlementModule.FundingWithdrawn.processEvent({ event: withdrawn, mockDb: db }); + db = await processEvents(db, [pledge, withdrawn, withdrawn]); let funding = await db.CommitmentFunding.get(`${CHAIN_ID}-60`); const pledgeAudit = (await db.CommitmentEvent.getAll()).find( (row) => row.eventType === "FUNDING_PLEDGED" @@ -153,8 +151,7 @@ describe("Settlement review regressions", () => { assert.equal(pledgeAudit?.actor, address(7).toLowerCase()); db = createTestIndexer(); - db = await SettlementModule.FundingWithdrawn.processEvent({ event: withdrawn, mockDb: db }); - db = await SettlementModule.FundingPledged.processEvent({ event: pledge, mockDb: db }); + db = await processEvents(db, [withdrawn, pledge]); funding = await db.CommitmentFunding.get(`${CHAIN_ID}-60`); assert.equal(funding?.state, "WITHDRAWN"); assert.equal(funding?.garden, address(2).toLowerCase()); diff --git a/packages/indexer/test/test.ts b/packages/indexer/test/test.ts index b3dd6516ea..d1ba12781b 100644 --- a/packages/indexer/test/test.ts +++ b/packages/indexer/test/test.ts @@ -27,9 +27,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } @@ -98,7 +98,7 @@ describe("GardenToken retained surface", () => { location: "Earth", bannerImage: "ipfs://bafk-banner", openJoining: true, - mockEventData: mockEvent(CHAIN_ID, 3_000, { srcAddress: addr(11) }), + mockEventData: mockEvent(CHAIN_ID, 3_000), }); const result = await GardenToken.GardenMinted.processEvent({ event, mockDb }); diff --git a/packages/indexer/test/v3.ts b/packages/indexer/test/v3.ts index 1dd8c4e08d..0b2e9b49fb 100644 --- a/packages/indexer/test/v3.ts +++ b/packages/indexer/test/v3.ts @@ -4,16 +4,20 @@ import { type Address, type TestIndexer, } from "envio"; +import { readFileSync } from "node:fs"; import { createServer } from "node:http"; +import { resolve as resolvePath } from "node:path"; import "../src/EventHandlers"; type MockEventData = { chainId: number; block: { timestamp: number; number: number }; - srcAddress: string; + /** Omit to route the event at the address the contract is indexed at. */ + srcAddress?: string; transaction: { hash: string; input?: string }; - logIndex: number; + /** Omit so Envio auto-increments within a block. */ + logIndex?: number; }; type MockEvent = MockEventData & { @@ -31,9 +35,58 @@ type EventTestApi = { processEvent: (args: { event: MockEvent; mockDb: TestIndexer }) => Promise; }; +// Envio routes a simulated event to a handler only when its srcAddress is one +// the contract is actually indexed at. Rather than repeat those addresses in +// every test, resolve them from config.yaml so they cannot drift from the +// indexed set. Tests that need a specific address (a dynamically registered +// garden, say) still pass srcAddress explicitly and that always wins. +const CONFIGURED_ADDRESSES: ReadonlyMap = (() => { + const source = readFileSync(resolvePath(import.meta.dirname, "../config.yaml"), "utf8"); + const byChainAndContract = new Map(); + let chainId: string | null = null; + let contract: string | null = null; + + for (const line of source.split("\n")) { + const chain = line.match(/^\s{2}- id:\s*(\d+)\s*$/)?.[1]; + if (chain) { + chainId = chain; + contract = null; + continue; + } + const named = line.match(/^\s+- name:\s*(\w+)\s*$/)?.[1]; + if (named) { + contract = named; + continue; + } + const address = line.match(/^\s+address:\s*"(0x[0-9a-fA-F]{40})"\s*$/)?.[1]; + if (address && chainId && contract) { + const key = `${chainId}:${contract}`; + // The regex above proves the 0x-prefixed 40-hex shape, so this is a + // validated narrowing rather than a blind assertion. + if (!byChainAndContract.has(key)) byChainAndContract.set(key, address as Address); + contract = null; + } + } + return byChainAndContract; +})(); + +function configuredAddress(contract: string, chainId: number): Address | undefined { + return CONFIGURED_ADDRESSES.get(`${chainId}:${contract}`); +} + +/** The address a contract is indexed at, for tests that assert on it. */ +export function indexedAddress(contract: string, chainId: number): Address { + const address = configuredAddress(contract, chainId); + if (!address) { + throw new Error(`No indexed address configured for ${contract} on chain ${chainId}`); + } + return address; +} + const CHAIN_START_BLOCK = { 42161: 433_713_812, 11155111: 10_243_363, + 42220: 74_691_430, } as const; type SupportedChainId = keyof typeof CHAIN_START_BLOCK; type ProcessConfig = Parameters[0]; @@ -49,7 +102,7 @@ type MutableChainConfig = { const nextBlockByIndexer = new WeakMap>(); function normalizeChainId(chainId: number): SupportedChainId { - if (chainId !== 42161 && chainId !== 11155111) { + if (chainId !== 42161 && chainId !== 11155111 && chainId !== 42220) { throw new Error(`Unsupported Green Goods test chain: ${chainId}`); } return chainId; @@ -114,6 +167,7 @@ export async function processEvents( chains: { ...(chainConfigs[42161] ? { 42161: chainConfigs[42161] } : {}), ...(chainConfigs[11155111] ? { 11155111: chainConfigs[11155111] } : {}), + ...(chainConfigs[42220] ? { 42220: chainConfigs[42220] } : {}), }, }; @@ -137,8 +191,10 @@ function createContract( return { createMockEvent(args: EventArguments): MockEvent { const { mockEventData, ...params } = args; + const indexed = configuredAddress(contract, mockEventData.chainId); return { ...mockEventData, + srcAddress: mockEventData.srcAddress ?? indexed ?? mockEventData.srcAddress, contract, event, params, diff --git a/packages/indexer/test/yieldSplitter.test.ts b/packages/indexer/test/yieldSplitter.test.ts index 452f0f2dfc..a006b6cd9f 100644 --- a/packages/indexer/test/yieldSplitter.test.ts +++ b/packages/indexer/test/yieldSplitter.test.ts @@ -19,9 +19,9 @@ function mockEvent( return { chainId, block: { timestamp, number: opts.blockNumber ?? 0 }, - srcAddress: opts.srcAddress ?? addr(99), + srcAddress: opts.srcAddress, transaction: { hash: opts.txHash ?? txHash(timestamp) }, - logIndex: opts.logIndex ?? 0, + logIndex: opts.logIndex, }; } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 878d1546d6..4a767415c7 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -62,7 +62,7 @@ export default defineConfig({ }, coverage: { provider: "v8", - reporter: ["text", "html", "json"], + reporter: process.env.CI ? ["text", "json"] : ["text", "json", "html"], include: ["src/**/*.{ts,tsx}"], exclude: [ "node_modules/", diff --git a/scripts/README.md b/scripts/README.md index 001bf4bbd4..7472918fe6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,7 +41,8 @@ scripts/ | `open-urls.sh` | `ecosystem.config.cjs` (PM2 app) | Wait on dev ports, open Brave to localhost URLs | | `test-e2e.js` | `bun run test:e2e[:smoke]` | Boot the web stack (client + admin + docs + storybook) via `bun run dev:web`, wait on health, run Playwright, stop the PM2 stack via `bun run dev:stack:stop` | | `seed-test-data.ts` | `bun run seed:test` / `seed:anvil` | Seed local/anvil chain with test fixtures | -| `ci-local.js` | `bun run ci:local` | Local mirror of the CI gates | +| `ci-local.js` | `bun run ci:local` | Selector-driven local executor with change-aware plans, fail-fast stopping, explicit blocked/cancelled results, and opt-in exact passing receipts | +| `ci-local.test.mjs` | `bun run test:validation-system`, CI Gate | Fixture coverage for local fail-fast, cancellation, blocking, and exact passing-receipt behavior | ### `mcp/` — project-scoped MCP server launchers | Script | Caller | Purpose | @@ -68,8 +69,11 @@ scripts/ | `check-react-patterns.js` | `bun run lint:rules`, root `bun lint` | Blocks high-confidence state/import violations; `--report` exposes noisier cleanup heuristics without flooding normal lint | | `check-browser-verification-policy.mjs` | `bun run check:browser-verification-policy`, `bun run agentic:check` | Verify authenticated Brave QA guidance across canonical agent docs, reject stale local isolated-browser guidance, and enforce browser-proof guard wiring | | `require-authenticated-browser-qa.mjs` | `bun run browser-proof:routes` via `agentic:browser-proof` | Block local isolated browser-proof runs unless `CI=true`, so clean-room proof cannot be reported as authenticated local QA | -| `ci-gate.mjs` | `.github/workflows/ci-gate.yml` | Fail-closed PR gate that derives expected path-filtered workflows from changed files and waits for every expected workflow run to register and succeed | -| `ci-gate.test.mjs` | `.github/workflows/ci-gate.yml` | Fixture coverage for CI Gate path-to-workflow expectations | +| `select-validation.mjs` | `bun run validation:plan`, `bun run ci:local`, CI Gate | Shared intent/path/dependency/risk selector for agent plans, local execution, and expected PR workflows | +| `select-validation.test.mjs` | `bun run test:validation-system`, CI Gate | Fixture matrix for validation intent, risk overrides, dirty-tree freshness, toolchain blocking, budgets, and workflow routing | +| `ci-gate.mjs` | `.github/workflows/ci-gate.yml` | Fail-closed PR aggregate that consumes the shared selector, fails immediately on terminal non-success, and keeps strict missing-workflow protection | +| `ci-gate.test.mjs` | `bun run test:validation-system`, `.github/workflows/ci-gate.yml` | Fixture coverage for selector parity, immediate failure, missing registration, terminal conclusions, and stale reruns | +| `workflow-performance-parity.test.mjs` | `bun run test:validation-system`, Supply Chain Guardrails | Static guard for exact JS pins, cache scope, workflow routing, CI-only coverage reporters, and Contracts Realism setup equivalence | | `check-ontology.mjs` | `bun run check:ontology` / `ontology:generate`, `ontology.yml`, `drift-check.mjs` (ontology scope), `agentic:check` | Ontology drift gate: cross-checks the sidecar (`packages/shared/src/ontology/`) against Solidity enums, indexer GraphQL, shared TS vocabularies, EAS schema config, and glossary tables, with a burn-down baseline; `--generate` renders the two docs artifacts | | `ontology-render.mjs` | `check-ontology.mjs` | Pure MDX renderers for the generated ontology reference page and entity matrix | | `check-ontology.test.mjs` | `node --test scripts/quality/check-ontology.test.mjs`, `ontology.yml` | Fixture tests for ontology extractors, baseline reconciliation, and renderers | @@ -139,6 +143,7 @@ scripts/ - `git-guardrails.mjs` — shared Git/base-ref resolution for diff-aware quality and contracts checks, including invalid CI base fallback. ### `data/` +- `validation-policy.json` — versioned check catalog, hard overrides, timing budgets, surface impact, and workflow routing consumed by the shared validation selector. - `design-token-usage-baseline.tsv` — audited baseline of legacy token references; consumed by `design/check-tokens.sh`. - `css-custom-property-baseline.tsv` — audited baseline of unresolved legacy CSS custom properties; consumed by `design/check-css-custom-properties.mjs`. - `ontology-drift-baseline.json` — audited burn-down baseline of known ontology drift (owner/expires/note per entry); consumed by `quality/check-ontology.mjs`. diff --git a/scripts/data/validation-policy.json b/scripts/data/validation-policy.json new file mode 100644 index 0000000000..426476759a --- /dev/null +++ b/scripts/data/validation-policy.json @@ -0,0 +1,711 @@ +{ + "version": 1, + "toolchain": { + "bun": "1.3.14", + "node": "22.22.1", + "foundry": "1.7.1" + }, + "environmentProfiles": { + "validation": { "VITE_CHAIN_ID": "11155111" } + }, + "intentOrder": [ + "diagnose", + "qa", + "review", + "checkpoint", + "readiness", + "push", + "ship", + "merge", + "release" + ], + "riskOrder": ["routine", "sensitive", "critical"], + "riskRules": [ + { + "risk": "sensitive", + "prefixes": [ + ".github/", + ".claude/", + ".codex/", + ".plans/", + "scripts/dev/", + "scripts/harness/", + "scripts/quality/", + "packages/agent/src/", + "packages/indexer/src/" + ] + } + ], + "surfaceRules": [ + { "surface": "docs", "prefixes": ["docs/"] }, + { "surface": "client", "prefixes": ["packages/client/"] }, + { "surface": "admin", "prefixes": ["packages/admin/"] }, + { "surface": "agent", "prefixes": ["packages/agent/"] }, + { "surface": "indexer", "prefixes": ["packages/indexer/"] }, + { "surface": "contracts", "prefixes": ["packages/contracts/", "scripts/contracts/"] }, + { + "surface": "shared", + "prefixes": [ + "packages/shared/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + { + "surface": "client", + "prefixes": [ + "packages/shared/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + { + "surface": "admin", + "prefixes": [ + "packages/shared/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + { "surface": "agent", "prefixes": ["packages/shared/"] }, + { + "surface": "indexer", + "prefixes": ["packages/contracts/abis/", "packages/contracts/deployments/"] + }, + { + "surface": "all", + "exact": [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + "tsconfig.json", + "tsconfig.base.json" + ] + } + ], + "conditionalRules": [ + { + "check": "source-structure", + "match": "all", + "prefixes": ["packages/"], + "contains": ["/src/"] + }, + { + "check": "design-guardrails", + "exact": ["DESIGN.md", "docs/DESIGN.md"], + "prefixes": [ + "packages/client/src/", + "packages/admin/src/", + "packages/shared/src/", + ".claude/skills/design/", + "scripts/design/" + ] + }, + { + "check": "ontology", + "prefixes": [ + "packages/contracts/src/", + "packages/contracts/deployments/", + "packages/shared/src/ontology/", + "packages/client/src/components/Public/", + "packages/client/src/views/Public/" + ] + }, + { + "check": "supply-chain", + "exact": [ + "package.json", + "bun.lock", + "bun.lockb", + ".npmrc", + "bunfig.toml", + "AGENTS.md", + "CLAUDE.md" + ], + "prefixes": [ + ".github/workflows/", + ".codex/", + ".claude/", + ".plans/", + "scripts/quality/", + "scripts/harness/" + ] + }, + { + "check": "browser-proof", + "match": "all", + "prefixes": ["packages/client/src/", "packages/admin/src/"], + "extensions": [".jsx", ".tsx", ".css", ".scss"] + } + ], + "checks": [ + { + "id": "format", + "command": "bun run format:check", + "risk": "unformatted tracked files produce misleading package failures", + "expectedSignal": "repository formatting is unchanged", + "freshness": "exact-inputs", + "budgetSeconds": 15, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "lint", + "command": "bun run lint", + "risk": "static defects cross package boundaries", + "expectedSignal": "repository lint rules pass", + "freshness": "exact-inputs", + "budgetSeconds": 45, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "abi-artifacts", + "command": null, + "builtin": "abiArtifacts", + "risk": "committed ABI exports are missing or stale", + "expectedSignal": "shared contract ABI imports resolve to current committed artifacts", + "freshness": "exact-inputs-and-generated-artifacts", + "budgetSeconds": 10, + "stopRule": "stop-dependent-checks", + "capabilities": [] + }, + { + "id": "shared-typecheck", + "command": "node ../../scripts/dev/node-cli.js tsc --noEmit", + "cwd": "packages/shared", + "risk": "shared type contracts no longer compile", + "expectedSignal": "shared TypeScript graph has no errors", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 45, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "shared-test", + "command": "bun run test", + "cwd": "packages/shared", + "risk": "shared behavior regresses direct consumers", + "expectedSignal": "shared unit and integration tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-core", + "capabilities": ["dependencies"] + }, + { + "id": "shared-build", + "command": "bun run build", + "cwd": "packages/shared", + "risk": "shared exports or emitted declarations are invalid", + "expectedSignal": "shared package builds successfully", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 60, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "client-test", + "command": "bun run test", + "cwd": "packages/client", + "risk": "client behavior regresses", + "expectedSignal": "client tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-surface", + "capabilities": ["dependencies"] + }, + { + "id": "client-build", + "command": "bun run build", + "cwd": "packages/client", + "risk": "client routing, exports, or runtime wiring do not bundle", + "expectedSignal": "client production bundle succeeds", + "freshness": "exact-inputs-toolchain-and-environment", + "budgetSeconds": 75, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "admin-test", + "command": "bun run test", + "cwd": "packages/admin", + "risk": "admin behavior regresses", + "expectedSignal": "admin tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 180, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-surface", + "capabilities": ["dependencies"] + }, + { + "id": "admin-hub-test", + "command": "bun run test:hub", + "cwd": "packages/admin", + "risk": "admin Hub checkpoint behavior regresses", + "expectedSignal": "focused admin Hub tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-surface", + "capabilities": ["dependencies"] + }, + { + "id": "admin-build", + "command": "bun run build", + "cwd": "packages/admin", + "risk": "admin routing, exports, or runtime wiring do not bundle", + "expectedSignal": "admin production bundle succeeds", + "freshness": "exact-inputs-toolchain-and-environment", + "budgetSeconds": 75, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "agent-typecheck", + "command": "bun run typecheck", + "cwd": "packages/agent", + "risk": "agent runtime types no longer compile", + "expectedSignal": "agent TypeScript graph has no errors", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 45, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "agent-test", + "command": "bun run test", + "cwd": "packages/agent", + "risk": "agent or SQLite behavior regresses", + "expectedSignal": "agent test lanes pass", + "freshness": "exact-inputs-toolchain-and-environment", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-surface", + "capabilities": ["dependencies"] + }, + { + "id": "agent-build", + "command": "bun run build", + "cwd": "packages/agent", + "risk": "agent runtime does not build", + "expectedSignal": "agent production build succeeds", + "freshness": "exact-inputs-toolchain-and-environment", + "budgetSeconds": 60, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "indexer-test", + "command": "bun run test", + "cwd": "packages/indexer", + "risk": "event projection or generated handlers regress", + "expectedSignal": "indexer codegen and tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 600, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies", "indexerCodegen"] + }, + { + "id": "indexer-build", + "command": "bun run build", + "cwd": "packages/indexer", + "risk": "indexer code generation or build fails", + "expectedSignal": "indexer builds from current schema and ABIs", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies", "indexerCodegen"] + }, + { + "id": "contracts-build", + "command": "bun run build", + "cwd": "packages/contracts", + "risk": "contract sources or ABI artifacts do not compile", + "expectedSignal": "contract Bun build wrapper succeeds", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 90, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies", "foundry"] + }, + { + "id": "contracts-test", + "command": "bun run test", + "cwd": "packages/contracts", + "risk": "protocol invariants or access control regress", + "expectedSignal": "contract unit tests pass through the Bun wrapper", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 180, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies", "foundry"] + }, + { + "id": "contracts-verify-fast", + "command": "bun run verify:contracts:fast", + "risk": "contract size, storage, artifact, or deployment path safety regresses", + "expectedSignal": "fast contract verification suite passes", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 180, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies", "foundry"] + }, + { + "id": "docs-test", + "command": "bun run test", + "cwd": "docs", + "risk": "documentation behavior or generated references regress", + "expectedSignal": "docs tests pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 45, + "stopRule": "stop-dependent-checks", + "concurrencyGroup": "package-tests-core", + "capabilities": ["dependencies"] + }, + { + "id": "docs-build", + "command": "bun run build", + "cwd": "docs", + "risk": "MDX, links, or documentation navigation do not build", + "expectedSignal": "documentation site builds successfully", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 75, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "source-structure", + "command": "bun run check:source-structure", + "risk": "changed package source violates repository structure policy", + "expectedSignal": "changed source structure is accepted", + "freshness": "exact-inputs", + "budgetSeconds": 20, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "design-guardrails", + "command": "bun run check:design-md && bun run check:design-generated && bun run check:design-tokens && bun run lint:vocab", + "risk": "frontend design tokens, generated artifacts, or vocabulary drift", + "expectedSignal": "design and vocabulary guardrails pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 60, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "ontology", + "command": "bun run check:ontology", + "risk": "domain ontology and generated references diverge", + "expectedSignal": "ontology source and projections agree", + "freshness": "exact-inputs", + "budgetSeconds": 20, + "stopRule": "stop-dependent-checks", + "capabilities": [] + }, + { + "id": "supply-chain", + "command": "bun run check:codex-guidance && bun run check:guidance-links && bun run check:immutable-plan-reports && bun run check:test-quality && node scripts/harness/plan-hub.mjs validate && node --test scripts/harness/plan-hub.test.mjs", + "risk": "dependency or repository guidance integrity regresses", + "expectedSignal": "supply-chain and guidance guardrails pass", + "freshness": "exact-inputs-and-toolchain", + "budgetSeconds": 60, + "stopRule": "stop-dependent-checks", + "capabilities": ["dependencies"] + }, + { + "id": "browser-proof", + "command": null, + "risk": "visible UI behavior differs from unit and build evidence", + "expectedSignal": "authenticated Brave rendered proof is recorded", + "freshness": "exact-inputs-toolchain-and-authenticated-session", + "budgetSeconds": 90, + "stopRule": "block-readiness", + "capabilities": ["authenticatedBrave"], + "manual": true + }, + { + "id": "lighthouse-client", + "command": "bunx lhci autorun", + "cwd": "packages/client", + "risk": "client performance budgets regress", + "expectedSignal": "client Lighthouse assertions pass", + "freshness": "exact-build-and-toolchain", + "budgetSeconds": 180, + "stopRule": "report-advisory-failure", + "capabilities": ["dependencies", "browser"] + }, + { + "id": "lighthouse-admin", + "command": "bunx lhci autorun", + "cwd": "packages/admin", + "risk": "admin performance budgets regress", + "expectedSignal": "admin Lighthouse assertions pass", + "freshness": "exact-build-and-toolchain", + "budgetSeconds": 180, + "stopRule": "report-advisory-failure", + "capabilities": ["dependencies", "browser"] + } + ], + "criticalOverrides": [ + { + "prefixes": ["packages/contracts/src/", "packages/contracts/script/", "scripts/contracts/"], + "risk": "critical", + "checks": ["abi-artifacts", "contracts-build", "contracts-test", "contracts-verify-fast"] + }, + { + "prefixes": [ + "packages/shared/src/providers/Auth.tsx", + "packages/shared/src/providers/JobQueue.tsx", + "packages/shared/src/providers/Work.tsx", + "packages/shared/src/modules/auth/", + "packages/shared/src/modules/job-queue/", + "packages/shared/src/modules/work/", + "packages/shared/src/workflows/", + "packages/shared/src/hooks/assessment/", + "packages/shared/src/hooks/auth/", + "packages/shared/src/hooks/conviction/", + "packages/shared/src/hooks/ens/", + "packages/shared/src/hooks/garden/", + "packages/shared/src/hooks/greenwill/", + "packages/shared/src/hooks/work/", + "packages/shared/src/hooks/vault/", + "packages/shared/src/hooks/blockchain/" + ], + "risk": "critical", + "checks": ["shared-typecheck", "shared-test", "client-test", "admin-test", "agent-test"] + }, + { + "contains": ["deploy", "upgrade", "migration", "release", "storage-layout"], + "match": "all", + "prefixes": ["packages/contracts/", "scripts/contracts/"], + "risk": "critical", + "checks": ["abi-artifacts", "contracts-build", "contracts-test", "contracts-verify-fast"] + } + ], + "workflowRules": { + "Admin": { + "exact": [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + "playwright.config.ts", + "scripts/ops/upload-sourcemaps.js", + "scripts/lib/env-schema.mjs", + "scripts/lib/env-schema.test.mjs", + "scripts/lib/env-parity.mjs", + "scripts/lib/env-parity.d.mts", + "scripts/dev/env-check.js", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/admin.yml" + ], + "prefixes": [ + "packages/admin/", + "packages/shared/", + "tests/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + "Agent": { + "exact": [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/agent.yml" + ], + "prefixes": ["packages/agent/", "packages/shared/"] + }, + "Client": { + "exact": [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + "playwright.config.ts", + "scripts/ops/upload-sourcemaps.js", + "scripts/lib/env-schema.mjs", + "scripts/lib/env-schema.test.mjs", + "scripts/lib/env-parity.mjs", + "scripts/lib/env-parity.d.mts", + "scripts/dev/env-check.js", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/client.yml" + ], + "prefixes": [ + "packages/client/", + "packages/shared/", + "tests/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + "Contracts": { + "exact": [ + "package.json", + "bun.lock", + ".env.schema", + "scripts/lib/git-guardrails.mjs", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/contracts.yml" + ], + "prefixes": ["packages/contracts/", "scripts/contracts/"] + }, + "Design": { + "exact": [ + "AGENTS.md", + "CLAUDE.md", + "DESIGN.md", + "package.json", + "bun.lock", + "docs/DESIGN.md", + "docs/docs/builders/packages/admin.mdx", + "docs/docs/builders/testing/storybook.mdx", + "docs/docs/reference/banned-vocabulary.json", + "packages/admin/AGENTS.md", + "packages/admin/DESIGN.md", + "packages/shared/AGENTS.md", + "packages/shared/package.json", + "packages/shared/vitest.storybook.config.ts", + "scripts/data/design-token-usage-baseline.tsv", + "scripts/quality/check-story-coverage.ts", + "scripts/quality/check-story-quality.ts", + "vercel.json", + ".github/actions/setup-js/action.yml", + ".github/workflows/design.yml" + ], + "prefixes": [ + ".claude/skills/design/", + "packages/admin/src/", + "packages/client/DESIGN", + "packages/client/src/", + "packages/shared/.storybook/", + "packages/shared/src/", + "scripts/design/" + ] + }, + "Docs": { + "exact": [ + "package.json", + "bun.lock", + ".github/actions/setup-js/action.yml", + ".github/workflows/docs.yml" + ], + "prefixes": ["docs/"] + }, + "Indexer": { + "exact": [ + "package.json", + "bun.lock", + ".env.schema", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/indexer.yml" + ], + "prefixes": [ + "packages/indexer/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + "Ontology": { + "exact": [ + "packages/contracts/config/schemas.json", + "packages/client/src/views/Home/Garden/Assessment.tsx", + "packages/client/src/views/Home/Garden/Work.tsx", + "packages/indexer/schema.graphql", + "docs/docs/reference/glossary-community.md", + "docs/docs/reference/ontology.generated.mdx", + "docs/docs/reference/ontology-human.generated.mdx", + "docs/docs/community/green-goods-claims.generated.mdx", + "docs/docs/builders/integrations/entity-matrix.mdx", + "scripts/quality/check-ontology.mjs", + "scripts/quality/ontology-render.mjs", + "scripts/quality/check-ontology.test.mjs", + "scripts/data/ontology-drift-baseline.json", + ".github/workflows/ontology.yml", + ".plans/active/commitment-pooling/contract-spec.md", + ".plans/active/commitment-pooling/status.json", + ".plans/active/commitment-pooling/standing-commitments-spec.md", + ".plans/active/community-interface/spec.md", + ".plans/active/community-interface/status.json", + ".plans/active/commitment-pooling/settlement-spec.md", + ".plans/active/commitment-credit-follow-on/spec.md", + "docs/docs/builders/architecture/erd.mdx", + "docs/docs/builders/specs/v1-0.mdx", + "packages/contracts/script/DeployBadgeSchema.s.sol" + ], + "prefixes": [ + "packages/shared/src/", + "packages/contracts/src/", + "packages/contracts/deployments/", + "packages/client/src/components/Public/", + "packages/client/src/views/Public/" + ] + }, + "Shared": { + "exact": [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + "scripts/quality/check-source-structure.js", + ".github/actions/setup-js/action.yml", + ".github/workflows/shared.yml" + ], + "prefixes": [ + "packages/shared/", + "packages/contracts/abis/", + "packages/contracts/deployments/" + ] + }, + "Supply Chain Guardrails": { + "exact": [ + "package.json", + "bun.lock", + "bun.lockb", + "bunfig.toml", + ".npmrc", + ".mise.toml", + "AGENTS.md", + "CLAUDE.md", + ".github/actions/setup-js/action.yml", + ".github/workflows/supply-chain-guardrails.yml" + ], + "prefixes": [ + ".github/workflows/", + ".codex/", + ".claude/", + ".plans/", + "docs/routines/", + "scripts/quality/", + "scripts/harness/" + ], + "extensions": [ + ".css", + ".scss", + ".js", + ".json", + ".jsx", + ".mjs", + ".cjs", + ".md", + ".mdx", + ".sh", + ".sol", + ".ts", + ".tsx", + ".yaml", + ".yml" + ] + } + } +} diff --git a/scripts/dev/ci-local.js b/scripts/dev/ci-local.js index fc59003815..71b3818c27 100755 --- a/scripts/dev/ci-local.js +++ b/scripts/dev/ci-local.js @@ -1,30 +1,20 @@ #!/usr/bin/env node -/** - * scripts/ci-local.js - Run all CI checks locally - * - * Usage: node scripts/ci-local.js [options] - * --skip-contracts Skip contracts tests (requires Foundry) - * --skip-indexer Skip indexer tests (requires Envio v3 codegen) - * --skip-build Skip build step - * --skip-docs Skip docs build (catches broken links) - * --skip-lighthouse Skip Lighthouse performance tests - * --only-lint Only run lint and format checks - * --quick Skip contracts, indexer, build, docs, and lighthouse (fast feedback) - * --lighthouse Run Lighthouse tests (included by default, use --skip-lighthouse to skip) - * --generate-indexer Run indexer codegen if generated types are missing - * - * This script mimics what GitHub Actions CI runs. - */ import { spawn } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; -// Get project root (one level up from scripts/) -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const projectRoot = resolve(__dirname, "../.."); +import { + buildReceiptInputs, + fingerprintReceiptInputs, + resolveGitInputs, + selectValidation, +} from "../quality/select-validation.mjs"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(scriptDirectory, "../.."); +const defaultReceiptPath = resolve(projectRoot, ".cache/validation/passing-receipts.json"); const ABI_EXPORT_SOURCES = { "ActionRegistry.json": "Action.sol/ActionRegistry.json", @@ -35,7 +25,6 @@ const ABI_EXPORT_SOURCES = { "MockEAS.json": "EAS.sol/MockEAS.json", }; -// ANSI color codes const colors = { reset: "\x1b[0m", red: "\x1b[0;31m", @@ -44,560 +33,679 @@ const colors = { blue: "\x1b[0;34m", }; -// Environment variables matching GitHub Actions CI const ciEnv = { - // Common CI: "true", - // Agent tests ENCRYPTION_SECRET: "test-secret-for-ci-encryption-32chars", TELEGRAM_BOT_TOKEN: "test-bot-token", VITE_RPC_URL_11155111: "http://localhost:3009", - // Client/Admin builds VITE_USE_HASH_ROUTER: "false", VITE_CHAIN_ID: "11155111", VITE_WALLETCONNECT_PROJECT_ID: "test", VITE_PIMLICO_API_KEY: "test", - VITE_ENVIO_INDEXER_URL: "http://localhost:3006", -}; - -// Configuration -const config = { - skipContracts: false, - skipIndexer: false, - skipBuild: false, - skipDocs: false, - skipLighthouse: false, - onlyLint: false, - generateIndexer: false, - quick: false, + VITE_ENVIO_INDEXER_URL: "http://localhost:3006/v1/graphql", }; -// Track failures -const failures = []; - -// ============================================================================ -// Helpers -// ============================================================================ +export function parseArguments(argv) { + const options = { + intent: "ship", + changedPaths: [], + testPaths: {}, + checkIds: [], + capabilities: {}, + skipContracts: false, + skipIndexer: false, + skipBuild: false, + skipDocs: false, + skipLighthouse: false, + onlyLint: false, + generateIndexer: false, + lighthouse: false, + failFast: true, + planJson: false, + cancelled: false, + reusePassingReceipts: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = () => { + const value = argv[++index]; + if (!value) throw new Error(`${arg} requires a value`); + return value; + }; + + switch (arg) { + case "--skip-contracts": + options.skipContracts = true; + break; + case "--skip-indexer": + options.skipIndexer = true; + break; + case "--skip-build": + options.skipBuild = true; + break; + case "--skip-docs": + options.skipDocs = true; + break; + case "--skip-lighthouse": + options.skipLighthouse = true; + break; + case "--only-lint": + options.onlyLint = true; + options.intent = "diagnose"; + options.checkIds.push("format", "lint"); + break; + case "--quick": + options.intent = "checkpoint"; + break; + case "--generate-indexer": + options.generateIndexer = true; + break; + case "--lighthouse": + options.lighthouse = true; + options.checkIds.push("lighthouse-client", "lighthouse-admin"); + break; + case "--no-fail-fast": + options.failFast = false; + break; + case "--plan-json": + options.planJson = true; + break; + case "--cancelled": + options.cancelled = true; + break; + case "--reuse-passing-receipts": + options.reusePassingReceipts = true; + break; + case "--intent": + options.intent = next(); + break; + case "--base": + options.base = next(); + break; + case "--head": + options.head = next(); + break; + case "--changed": + options.changedPaths.push(...next().split(",").filter(Boolean)); + break; + case "--risk": + options.risk = next(); + break; + case "--test-path": { + const value = next(); + const separator = value.indexOf(":"); + if (separator < 1) throw new Error("--test-path must use surface:path"); + const surface = value.slice(0, separator); + (options.testPaths[surface] ??= []).push(value.slice(separator + 1)); + break; + } + case "--check": + options.checkIds.push(next()); + break; + case "--capability": { + const [name, value] = next().split("=", 2); + if (!name || !["true", "false"].includes(value)) { + throw new Error("--capability must use name=true or name=false"); + } + options.capabilities[name] = value === "true"; + break; + } + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} -function printHeader(message) { - console.log(""); - console.log(`${colors.blue}========================================${colors.reset}`); - console.log(`${colors.blue} ${message}${colors.reset}`); - console.log(`${colors.blue}========================================${colors.reset}`); - console.log(""); +function showHelp() { + console.log(`Usage: node scripts/dev/ci-local.js [options] + +Selector options: + --intent diagnose|qa|review|checkpoint|readiness|push|ship|merge|release + --base Base revision (default: origin/develop) + --head Head revision (default: HEAD) + --changed Comma-separated changed paths; repeatable + --risk routine|sensitive|critical + --test-path Focus a package test command; repeatable + --check Add an explicit acceptance check; repeatable + --capability k=true Declare an environment capability; repeatable + --plan-json Print the exact plan as JSON without running it + --cancelled Emit a terminal cancelled plan + --reuse-passing-receipts Reuse exact-fingerprint passes from .cache/validation + +Execution options: + --quick Change-aware cross-package checkpoint + --only-lint Run only explicitly requested format and lint evidence + --no-fail-fast Continue independent checks after a failure + --lighthouse Add advisory Lighthouse checks + +Compatibility filters (mandatory critical checks ignore these flags): + --skip-contracts Skip non-mandatory contract checks + --skip-indexer Skip non-mandatory indexer checks + --skip-build Skip non-mandatory build checks + --skip-docs Skip non-mandatory docs checks + --skip-lighthouse Skip Lighthouse checks + --generate-indexer Retained compatibility flag; package commands own codegen + --help, -h Show this help`); } -function printSection(message) { - console.log(""); - console.log(`${colors.yellow}=== ${message} ===${colors.reset}`); +async function commandExists(command) { + return new Promise((resolvePromise) => { + const check = process.platform === "win32" ? `where ${command}` : `command -v ${command}`; + const child = spawn(check, { shell: true, stdio: "ignore" }); + child.once("close", (code) => resolvePromise(code === 0)); + child.once("error", () => resolvePromise(false)); + }); } -function printSuccess(message) { - console.log(`${colors.green}✓ ${message}${colors.reset}`); +async function commandOutput(command) { + return new Promise((resolvePromise) => { + const child = spawn(`${command} --version`, { shell: true, stdio: ["ignore", "pipe", "ignore"] }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.once("close", (code) => resolvePromise(code === 0 ? output.trim() : null)); + child.once("error", () => resolvePromise(null)); + }); } -function printWarning(message) { - console.log(`${colors.yellow}⚠ ${message}${colors.reset}`); +async function detectEnvironment(options) { + const dependencies = [ + "node_modules/.bun", + "node_modules/@biomejs/biome/package.json", + "node_modules/typescript/package.json", + "node_modules/vitest/package.json", + ].every((path) => existsSync(resolve(projectRoot, path))); + const bunVersion = await commandOutput("bun"); + const foundryOutput = await commandOutput("forge"); + const foundryVersion = foundryOutput?.match(/\d+\.\d+\.\d+/)?.[0] ?? null; + return { + profile: "local-ci", + toolchain: { + node: process.version.replace(/^v/, ""), + ...(bunVersion ? { bun: bunVersion } : {}), + ...(foundryVersion ? { foundry: foundryVersion } : {}), + }, + capabilities: { + dependencies, + foundry: await commandExists("forge"), + indexerCodegen: dependencies, + authenticatedBrave: false, + browser: false, + ...options.capabilities, + }, + }; } -function printError(message) { - console.log(`${colors.red}✗ ${message}${colors.reset}`); +export function applyCompatibilityFilters(plan, options) { + const skipped = []; + const keep = (check) => { + let requestedSkip = false; + if (options.onlyLint && !["format", "lint"].includes(check.id)) requestedSkip = true; + if ( + options.skipContracts && + (check.id.startsWith("contracts-") || check.id === "abi-artifacts") + ) { + requestedSkip = true; + } + if (options.skipIndexer && check.id.startsWith("indexer-")) requestedSkip = true; + if ( + options.skipBuild && + (check.id.endsWith("-build") || check.id.startsWith("lighthouse-")) + ) { + requestedSkip = true; + } + if (options.skipDocs && check.id.startsWith("docs-")) requestedSkip = true; + if (options.skipLighthouse && check.id.startsWith("lighthouse-")) requestedSkip = true; + if (!requestedSkip || check.mandatory) return true; + skipped.push({ id: check.id, reason: "compatibility-filter" }); + return false; + }; + const checks = plan.checks.filter(keep); + // Recompute rather than inheriting plan.status: when the only blocked checks + // are the ones a compatibility filter just dropped, the remaining plan is + // runnable and must not keep reporting blocked. + const stillBlocked = + checks.some((check) => check.state === "blocked") || plan.environmentBlockers?.length > 0; + const status = stillBlocked ? "blocked" : plan.status === "blocked" ? "ready" : plan.status; + const automatedSeconds = checks + .filter((check) => !check.manual) + .reduce((total, check) => total + check.budgetSeconds, 0); + const manualSeconds = checks + .filter((check) => check.manual) + .reduce((total, check) => total + check.budgetSeconds, 0); + const budget = { + ...plan.budget, + automatedSeconds, + manualSeconds, + withinTarget: + plan.budget.targetSeconds === null + ? null + : automatedSeconds <= plan.budget.targetSeconds, + mandatoryChecksMayExceedTarget: + checks.some((check) => check.mandatory) && + plan.budget.targetSeconds !== null && + automatedSeconds > plan.budget.targetSeconds, + }; + return { ...plan, checks, status, budget, skipped }; } -/** - * Calculate elapsed time in human-readable format - */ -function getElapsedTime(startTime) { - return ((Date.now() - startTime) / 1000).toFixed(2); +function envForCheck(check) { + const common = { CI: ciEnv.CI }; + if (check.id.startsWith("agent-")) { + return { + ...common, + ENCRYPTION_SECRET: ciEnv.ENCRYPTION_SECRET, + TELEGRAM_BOT_TOKEN: ciEnv.TELEGRAM_BOT_TOKEN, + VITE_RPC_URL_11155111: ciEnv.VITE_RPC_URL_11155111, + }; + } + if (["client-build", "admin-build"].includes(check.id)) return { ...common, ...ciEnv }; + return common; } -/** - * Run a command and track its result with real-time output - * @param {string} name - Display name for the step - * @param {string} command - Command to run - * @param {string} cwd - Working directory (defaults to projectRoot) - * @param {Object} env - Additional environment variables - */ -async function runStep(name, command, cwd = projectRoot, env = {}) { - return new Promise((resolve) => { - const startTime = Date.now(); - console.log(`${colors.blue}Running: ${command}${colors.reset}`); - - const child = spawn(command, { - cwd, - shell: true, - stdio: 'inherit', // Stream output directly to parent process - env: { ...process.env, ...env } - }); +function elapsedSeconds(start) { + return Number(((Date.now() - start) / 1000).toFixed(3)); +} - child.on('close', (code) => { - const duration = getElapsedTime(startTime); - if (code === 0) { - printSuccess(`${name} passed (${duration}s)`); - resolve(true); - } else { - printError(`${name} failed (exit code: ${code}, ${duration}s)`); - failures.push(name); - resolve(false); +async function runAbiArtifactCheck() { + const contractsTs = resolve(projectRoot, "packages/shared/src/utils/blockchain/contracts.ts"); + if (!existsSync(contractsTs)) return { ok: true, exitCode: 0, details: [] }; + + const content = readFileSync(contractsTs, "utf8"); + const importPattern = /from\s+["']@green-goods\/contracts\/abis\/(.+?\.json)["']/g; + const problems = []; + let match; + while ((match = importPattern.exec(content)) !== null) { + const abiFileName = match[1]; + const artifactPath = resolve(projectRoot, `packages/contracts/abis/${abiFileName}`); + if (!existsSync(artifactPath)) { + problems.push(`${abiFileName}: committed ABI is missing`); + continue; + } + const source = ABI_EXPORT_SOURCES[abiFileName]; + if (!source) continue; + const compiledPath = resolve( + projectRoot, + `packages/contracts/.generated/foundry/out/default/${source}`, + ); + if (!existsSync(compiledPath)) continue; + try { + const compiled = JSON.parse(readFileSync(compiledPath, "utf8")); + const expected = `${JSON.stringify(compiled.abi ?? [], null, 2)}\n`; + if (readFileSync(artifactPath, "utf8") !== expected) { + problems.push(`${abiFileName}: committed ABI is stale`); } - }); - - child.on('error', (error) => { - const duration = getElapsedTime(startTime); - printError(`${name} failed: ${error.message} (${duration}s)`); - failures.push(name); - resolve(false); - }); - }); + } catch (error) { + problems.push(`${abiFileName}: ${error.message}`); + } + } + return { ok: problems.length === 0, exitCode: problems.length === 0 ? 0 : 1, details: problems }; } -/** - * Check if a command exists - */ -async function commandExists(cmd) { - return new Promise((resolve) => { - const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`; - const child = spawn(checkCmd, { +export async function runCommandCheck(check, { signal, captureOutput = false } = {}) { + const start = Date.now(); + if (check.builtin === "abiArtifacts") { + const result = await runAbiArtifactCheck(); + return { ...result, durationSeconds: elapsedSeconds(start) }; + } + if (!check.command) { + return { + ok: false, + blocked: true, + exitCode: 2, + durationSeconds: elapsedSeconds(start), + details: ["manual proof required"], + }; + } + + return new Promise((resolvePromise) => { + if (signal?.aborted) { + resolvePromise({ ok: false, cancelled: true, exitCode: 130, durationSeconds: 0 }); + return; + } + const child = spawn(check.command, { + cwd: resolve(projectRoot, check.cwd ?? "."), shell: true, - stdio: 'ignore' + // A check running on its own streams live. Checks running concurrently + // capture instead, so their logs replay in plan order rather than + // interleaving into noise. + stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit", + env: { ...process.env, ...envForCheck(check) }, + detached: process.platform !== "win32", }); - - child.on('close', (code) => { - resolve(code === 0); + let output = ""; + if (captureOutput) { + const collect = (chunk) => { + output += chunk.toString(); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + } + let cancelled = false; + const abort = () => { + cancelled = true; + if (process.platform === "win32") child.kill("SIGTERM"); + else if (child.pid) process.kill(-child.pid, "SIGTERM"); + }; + signal?.addEventListener("abort", abort, { once: true }); + child.once("close", (code) => { + signal?.removeEventListener("abort", abort); + resolvePromise({ + ok: !cancelled && code === 0, + cancelled, + exitCode: cancelled ? 130 : (code ?? 1), + durationSeconds: elapsedSeconds(start), + ...(captureOutput ? { output } : {}), + }); }); - - child.on('error', () => { - resolve(false); + child.once("error", (error) => { + signal?.removeEventListener("abort", abort); + resolvePromise({ + ok: false, + cancelled, + exitCode: cancelled ? 130 : 1, + durationSeconds: elapsedSeconds(start), + details: [error.message], + }); }); }); } -/** - * Show help message - */ -function showHelp() { - console.log("Usage: node scripts/ci-local.js [options]"); - console.log(""); - console.log("Options:"); - console.log(" --skip-contracts Skip contracts tests (requires Foundry)"); - console.log(" --skip-indexer Skip indexer tests (requires codegen setup)"); - console.log(" --skip-build Skip build step"); - console.log(" --skip-docs Skip docs build (catches broken links)"); - console.log(" --skip-lighthouse Skip Lighthouse performance tests"); - console.log(" --only-lint Only run lint and format checks"); - console.log(" --quick Skip contracts, indexer, build, docs, and lighthouse (fast feedback)"); - console.log(" --generate-indexer Run indexer codegen if generated files missing"); - console.log(" --help, -h Show this help message"); - console.log(""); - console.log("This script runs the same checks as GitHub Actions CI:"); - console.log(" 0. Contract ABI artifact tracking (pre-flight)"); - console.log(" 1. Format check (biome)"); - console.log(" 2. Lint (oxlint + solhint)"); - console.log(" 3. Type checking (TypeScript)"); - console.log(" 4. Unit tests (all packages)"); - console.log(" 5. Build (all packages)"); - console.log(" 6. Docs build (catches broken links)"); - console.log(" 7. Lighthouse performance tests (client + admin)"); - process.exit(0); -} - -// ============================================================================ -// Parse Arguments -// ============================================================================ - -const args = process.argv.slice(2); -for (const arg of args) { - switch (arg) { - case "--skip-contracts": - config.skipContracts = true; - break; - case "--skip-indexer": - config.skipIndexer = true; - break; - case "--skip-build": - config.skipBuild = true; - break; - case "--skip-docs": - config.skipDocs = true; - break; - case "--skip-lighthouse": - config.skipLighthouse = true; - break; - case "--only-lint": - config.onlyLint = true; - break; - case "--quick": - config.quick = true; - config.skipContracts = true; - config.skipIndexer = true; - config.skipBuild = true; - config.skipDocs = true; - config.skipLighthouse = true; - break; - case "--generate-indexer": - config.generateIndexer = true; - break; - case "--help": - case "-h": - showHelp(); - break; +export async function executePlan(plan, options = {}) { + const failFast = options.failFast !== false; + const runCheck = options.runCheck ?? runCommandCheck; + const signal = options.signal; + const results = []; + const blocked = []; + const receiptStore = options.receiptStore ?? new Map(); + const reusePassingReceipts = options.reusePassingReceipts === true; + const concurrency = options.concurrency !== false; + + if (plan.status === "cancelled" || signal?.aborted) { + return { status: "cancelled", exitCode: 130, results, blocked }; } -} -// ============================================================================ -// Main -// ============================================================================ + const recordPass = (receiptInputs) => { + if (!reusePassingReceipts) return; + receiptStore.set(receiptInputs.fingerprint, { + status: "passed", + passedAt: new Date().toISOString(), + receiptInputs, + }); + }; + const reusableReceipt = (check) => { + const receiptInputs = buildReceiptInputs(plan, check); + const cached = reusePassingReceipts ? receiptStore.get(receiptInputs.fingerprint) : null; + const reusable = + cached?.status === "passed" && + cached.receiptInputs?.fingerprint === receiptInputs.fingerprint; + return { receiptInputs, reusable }; + }; + const runnableNow = (check) => + check.state !== "blocked" && !(check.manual && !check.command) && !reusableReceipt(check).reusable; + + let index = 0; + while (index < plan.checks.length) { + if (signal?.aborted) return { status: "cancelled", exitCode: 130, results, blocked }; + const check = plan.checks[index]; + + if (check.state === "blocked") { + blocked.push({ id: check.id, blockedBy: [...check.blockedBy] }); + index += 1; + continue; + } + if (check.manual && !check.command) { + blocked.push({ id: check.id, blockedBy: ["manual-proof-required"] }); + index += 1; + continue; + } -async function main() { - printHeader("Green Goods CI Local Validation"); - - // Show configuration - console.log(`${colors.yellow}Configuration:${colors.reset}`); - console.log(` Skip Contracts: ${config.skipContracts ? 'Yes' : 'No'}`); - console.log(` Skip Indexer: ${config.skipIndexer ? 'Yes' : 'No'}`); - console.log(` Skip Build: ${config.skipBuild ? 'Yes' : 'No'}`); - console.log(` Skip Docs: ${config.skipDocs ? 'Yes' : 'No'}`); - console.log(` Skip Lighthouse: ${config.skipLighthouse ? 'Yes' : 'No'}`); - console.log(` Only Lint: ${config.onlyLint ? 'Yes' : 'No'}`); - console.log(` Generate Indexer: ${config.generateIndexer ? 'Yes' : 'No'}`); - console.log(""); - - // Pre-flight checks - if (!config.skipContracts) { - const hasForge = await commandExists("forge"); - if (!hasForge) { - printWarning( - "Foundry not found. Use --skip-contracts or install with: curl -L https://foundry.paradigm.xyz | bash" - ); - config.skipContracts = true; + const { receiptInputs, reusable } = reusableReceipt(check); + if (reusable) { + const evidence = { + id: check.id, + ok: true, + reused: true, + exitCode: 0, + durationSeconds: 0, + receiptInputs, + }; + results.push(evidence); + options.onCheckReuse?.(check, evidence); + index += 1; + continue; } - } - if (!config.skipIndexer) { - const indexerGeneratedPath = resolve(projectRoot, "packages/indexer/.envio/types.d.ts"); - if (!existsSync(indexerGeneratedPath)) { - if (config.generateIndexer) { - printSection("Indexer Code Generation"); - await runStep("Indexer codegen", "bun run codegen", resolve(projectRoot, "packages/indexer")); - } else { - printWarning( - "Indexer v3 types not found. Use --skip-indexer, --generate-indexer, or run: bun run --cwd packages/indexer codegen" - ); - config.skipIndexer = true; + // Independent package suites declare a concurrency group in the policy and + // run together, mirroring the grouping the root `test` script already uses. + // Only checks adjacent in plan order join a batch, so execution order and + // the stop rule stay exactly as the plan printed them. + const batch = [check]; + if (concurrency && check.concurrencyGroup) { + for (let look = index + 1; look < plan.checks.length; look += 1) { + const next = plan.checks[look]; + if (next.concurrencyGroup !== check.concurrencyGroup) break; + if (!runnableNow(next)) break; + batch.push(next); } } - } - // ============================================================================ - // Pre-flight: Contract ABI Artifact Tracking - // ============================================================================ - // Shared imports ABIs from packages/contracts/abis/. CI doesn't build contracts - // first — it relies on these files being committed. If a new ABI import is added - // but the ABI export step is skipped, downstream builds fail. - printSection("Contract ABI Artifact Tracking"); - { - const contractsTs = resolve(projectRoot, "packages/shared/src/utils/blockchain/contracts.ts"); - if (existsSync(contractsTs)) { - const content = readFileSync(contractsTs, "utf8"); - const importPattern = /from\s+["']@green-goods\/contracts\/abis\/(.+?\.json)["']/g; - let match; - const missingArtifacts = []; - const staleArtifacts = []; - - while ((match = importPattern.exec(content)) !== null) { - const abiFileName = match[1]; - const artifactRelPath = `packages/contracts/abis/${abiFileName}`; - const artifactAbsPath = resolve(projectRoot, artifactRelPath); - - if (!existsSync(artifactAbsPath)) { - missingArtifacts.push({ - path: artifactRelPath, - reason: "file does not exist — run `cd packages/contracts && bun run build:abis`", - }); - continue; - } - - // Check if git is tracking the file (not ignored by .gitignore) - try { - const child = spawn("git", ["check-ignore", "-q", artifactRelPath], { - cwd: projectRoot, - stdio: "pipe", - }); - const exitCode = await new Promise((res) => child.on("close", res)); - if (exitCode === 0) { - // exit 0 means git IGNORES the file - missingArtifacts.push({ path: artifactRelPath, reason: "tracked by .gitignore — update .gitignore to un-ignore it" }); - } - } catch { - // git check-ignore not available, skip - } + if (batch.length === 1) { + options.onCheckStart?.(check); + const result = await runCheck(check, { signal }); + const evidence = { id: check.id, ...result, receiptInputs }; + results.push(evidence); + options.onCheckComplete?.(check, evidence); - const sourceArtifactRelPath = ABI_EXPORT_SOURCES[abiFileName]; - if (!sourceArtifactRelPath) continue; - - const compiledArtifactAbsPath = resolve( - projectRoot, - `packages/contracts/.generated/foundry/out/default/${sourceArtifactRelPath}`, - ); - if (!existsSync(compiledArtifactAbsPath)) continue; - - try { - const compiledArtifact = JSON.parse(readFileSync(compiledArtifactAbsPath, "utf8")); - const committedArtifact = readFileSync(artifactAbsPath, "utf8"); - const expectedArtifact = `${JSON.stringify(compiledArtifact.abi ?? [], null, 2)}\n`; - if (committedArtifact !== expectedArtifact) { - staleArtifacts.push({ - path: artifactRelPath, - reason: "stale versus current contracts build output — run `cd packages/contracts && bun run build:abis`", - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - staleArtifacts.push({ - path: artifactRelPath, - reason: `unable to validate against build artifact: ${message}`, - }); - } + if (result.cancelled || signal?.aborted) { + return { status: "cancelled", exitCode: 130, results, blocked }; } - - if (missingArtifacts.length > 0 || staleArtifacts.length > 0) { - printError("Contract ABI artifacts imported by shared are missing or stale:"); - for (const { path, reason } of missingArtifacts) { - console.log(` ${colors.red}✗ ${path}${colors.reset}`); - console.log(` ${colors.yellow}→ ${reason}${colors.reset}`); - } - for (const { path, reason } of staleArtifacts) { - console.log(` ${colors.red}✗ ${path}${colors.reset}`); - console.log(` ${colors.yellow}→ ${reason}${colors.reset}`); - } - console.log(""); - console.log( - `${colors.yellow}Fix: Regenerate and commit packages/contracts/abis/*.json via \`cd packages/contracts && bun run build:abis\`.${colors.reset}`, - ); - failures.push("ABI artifact tracking"); - } else { - printSuccess("All contract ABI imports are tracked in git"); + if (!result.ok && failFast) { + return { status: "failed", exitCode: result.exitCode || 1, results, blocked }; } + if (result.ok) recordPass(receiptInputs); + index += 1; + continue; } - } - - // ============================================================================ - // Phase 1: Format & Lint (matches all workflow lint jobs) - // ============================================================================ - printSection("Format Check"); - await runStep("Format check", "bun run format:check"); - printSection("Lint"); - await runStep("Lint (all packages)", "bun run lint"); + options.onBatchStart?.(batch); + const settled = await Promise.all( + batch.map((member) => runCheck(member, { signal, captureOutput: true })), + ); + for (const [position, member] of batch.entries()) { + const evidence = { + id: member.id, + ...settled[position], + receiptInputs: buildReceiptInputs(plan, member), + }; + results.push(evidence); + options.onCheckComplete?.(member, evidence); + } - // Early exit for lint-only mode - if (config.onlyLint) { - printHeader("Lint-only checks completed!"); - process.exit(failures.length > 0 ? 1 : 0); + // The whole batch is already in flight, so let every member report before + // stopping. Fail-fast still prevents anything after the batch from starting. + if (settled.some((result) => result.cancelled) || signal?.aborted) { + return { status: "cancelled", exitCode: 130, results, blocked }; + } + const failure = settled.find((result) => !result.ok); + if (failure && failFast) { + return { status: "failed", exitCode: failure.exitCode || 1, results, blocked }; + } + for (const [position, member] of batch.entries()) { + if (settled[position].ok) recordPass(buildReceiptInputs(plan, member)); + } + index += batch.length; } - // ============================================================================ - // Phase 2: Type Checking (matches GH Actions type check steps) - // ============================================================================ - printSection("Type Checking"); - - // Shared package type check (matches shared.yml) - await runStep( - "Shared typecheck", - "node ../../scripts/dev/node-cli.js tsc --noEmit", - resolve(projectRoot, "packages/shared") - ); - - // Agent package type check (matches agent.yml) - await runStep("Agent typecheck", "bun run typecheck", resolve(projectRoot, "packages/agent")); - - // ============================================================================ - // Phase 3: Unit Tests (matches all workflow test jobs) - // ============================================================================ - printSection("Shared Package Tests"); - await runStep("Shared tests", "bun run test", resolve(projectRoot, "packages/shared"), { CI: "true" }); - - printSection("Client Tests"); - await runStep("Client tests", "bun run test", resolve(projectRoot, "packages/client"), { CI: "true" }); - - printSection("Admin Tests"); - const adminTestCommand = config.quick ? "bun run test:hub" : "bun run test"; - const adminTestName = config.quick ? "Admin hub tests" : "Admin tests"; - await runStep(adminTestName, adminTestCommand, resolve(projectRoot, "packages/admin"), { CI: "true" }); - - // Indexer tests (matches indexer.yml) - if (!config.skipIndexer) { - printSection("Indexer Tests"); - await runStep("Indexer tests", "bun run test", resolve(projectRoot, "packages/indexer"), { CI: "true" }); - } else { - printSection("Indexer Tests (SKIPPED)"); + if (results.some((result) => !result.ok)) return { status: "failed", exitCode: 1, results, blocked }; + if (blocked.length > 0 || plan.status === "blocked") { + return { status: "blocked", exitCode: 2, results, blocked }; } + return { status: "passed", exitCode: 0, results, blocked }; +} - // Contracts tests (matches contracts.yml - builds first, then tests) - if (!config.skipContracts) { - printSection("Contracts Build & Tests"); - await runStep("Contracts build", "bun run build", resolve(projectRoot, "packages/contracts")); - await runStep("Contracts tests", "bun run test", resolve(projectRoot, "packages/contracts"), { CI: "true" }); - } else { - printSection("Contracts Tests (SKIPPED)"); +export function loadPassingReceiptStore(path = defaultReceiptPath) { + if (!existsSync(path)) return new Map(); + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (parsed.version !== 1 || !parsed.receipts || typeof parsed.receipts !== "object") { + throw new Error(`Invalid passing receipt store: ${path}`); } - - // Agent tests (matches agent.yml with full env vars) - printSection("Agent Tests"); - await runStep( - "Agent tests", - "bun run test", - resolve(projectRoot, "packages/agent"), - { - CI: "true", - ENCRYPTION_SECRET: ciEnv.ENCRYPTION_SECRET, - TELEGRAM_BOT_TOKEN: ciEnv.TELEGRAM_BOT_TOKEN, - VITE_RPC_URL_11155111: ciEnv.VITE_RPC_URL_11155111, + const store = new Map(); + for (const [fingerprint, record] of Object.entries(parsed.receipts)) { + if ( + record?.status === "passed" && + record.receiptInputs?.fingerprint === fingerprint && + fingerprintReceiptInputs(record.receiptInputs) === fingerprint && + record.receiptInputs?.cacheReuse?.failuresCacheable === false + ) { + store.set(fingerprint, record); } - ); - - // ============================================================================ - // Phase 4: Build (matches all workflow build jobs with env vars) - // ============================================================================ - if (!config.skipBuild) { - printSection("Build All Packages"); - - // Build contracts first (dependency for other packages) - if (!config.skipContracts) { - // Already built above during tests - printSuccess("Contracts already built"); - } - - // Build shared (dependency for client/admin) - await runStep("Shared build", "bun run build", resolve(projectRoot, "packages/shared")); + } + return store; +} - // Build indexer - if (!config.skipIndexer) { - await runStep("Indexer build", "bun run build", resolve(projectRoot, "packages/indexer")); - } +export function savePassingReceiptStore(store, path = defaultReceiptPath) { + const directory = dirname(path); + mkdirSync(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.tmp`; + const receipts = Object.fromEntries([...store.entries()].sort(([left], [right]) => left.localeCompare(right))); + writeFileSync(temporaryPath, `${JSON.stringify({ version: 1, receipts }, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(temporaryPath, path); +} - // Build client (matches client.yml lint-and-build job) - await runStep( - "Client build", - "bun run build", - resolve(projectRoot, "packages/client"), - { - VITE_USE_HASH_ROUTER: ciEnv.VITE_USE_HASH_ROUTER, - VITE_CHAIN_ID: ciEnv.VITE_CHAIN_ID, - VITE_WALLETCONNECT_PROJECT_ID: ciEnv.VITE_WALLETCONNECT_PROJECT_ID, - VITE_PIMLICO_API_KEY: ciEnv.VITE_PIMLICO_API_KEY, - VITE_ENVIO_INDEXER_URL: ciEnv.VITE_ENVIO_INDEXER_URL, - } - ); +function printPlan(plan) { + console.log( + `${colors.blue}Validation plan${colors.reset}: ${plan.effectiveIntent} · ${plan.risk} · ${plan.changedPaths.length} changed path(s)`, + ); + console.log( + `${colors.blue}Budget${colors.reset}: ${plan.budget.automatedSeconds}s automated` + + (plan.budget.targetSeconds === null ? "" : ` / ${plan.budget.targetSeconds}s target`), + ); + for (const check of plan.checks) { + const flags = [ + check.mandatory ? "mandatory" : null, + check.state === "blocked" ? `blocked:${check.blockedBy.join(",")}` : null, + ] + .filter(Boolean) + .join(", "); + console.log(` - ${check.id}${flags ? ` (${flags})` : ""}`); + } + for (const skipped of plan.skipped ?? []) { + console.log(` - ${skipped.id} (skipped by compatibility filter)`); + } +} - // Build admin (matches admin.yml lint-and-build job) - await runStep( - "Admin build", - "bun run build", - resolve(projectRoot, "packages/admin"), - { - VITE_CHAIN_ID: ciEnv.VITE_CHAIN_ID, - VITE_WALLETCONNECT_PROJECT_ID: ciEnv.VITE_WALLETCONNECT_PROJECT_ID, - VITE_PIMLICO_API_KEY: ciEnv.VITE_PIMLICO_API_KEY, - VITE_ENVIO_INDEXER_URL: ciEnv.VITE_ENVIO_INDEXER_URL, - } - ); - } else { - printSection("Build (SKIPPED)"); +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + showHelp(); + return; } - // ============================================================================ - // Phase 5: Docs Build (catches broken links before deployment) - // ============================================================================ - if (!config.skipDocs) { - const docsPath = resolve(projectRoot, "docs"); - if (!existsSync(docsPath)) { - printSection("Docs Build (SKIPPED - directory not found)"); - printWarning("Docs directory not found at: " + docsPath); - } else { - // Check if docs has a build script before attempting to run it - const docsPackageJsonPath = resolve(docsPath, "package.json"); - let hasBuildScript = false; - - if (existsSync(docsPackageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(docsPackageJsonPath, "utf8")); - hasBuildScript = packageJson.scripts && packageJson.scripts.build; - } catch (error) { - printWarning(`Failed to read/parse ${docsPackageJsonPath}: ${error.message}`); - // Assume no build script and continue - } - } else { - printSection("Docs Build (SKIPPED - no package.json)"); - printWarning("No package.json found at: " + docsPackageJsonPath); + const gitInputs = options.cancelled + ? { + base: options.base ?? null, + head: options.head ?? null, + changedPaths: options.changedPaths, + workingCopyFingerprint: null, } + : resolveGitInputs(options); + const environment = options.cancelled + ? { profile: "cancelled", toolchain: {}, capabilities: {} } + : await detectEnvironment(options); + let plan = selectValidation({ + intent: options.intent, + base: gitInputs.base, + head: gitInputs.head, + workingCopyFingerprint: gitInputs.workingCopyFingerprint, + changedPaths: gitInputs.changedPaths, + risk: options.risk, + cancelled: options.cancelled, + testPaths: options.testPaths, + checkIds: options.checkIds, + environment, + }); + plan = applyCompatibilityFilters(plan, options); - if (!hasBuildScript && existsSync(docsPackageJsonPath)) { - printSection("Docs Build (SKIPPED - no build script)"); - printWarning("No build script found in " + docsPackageJsonPath); - } else if (hasBuildScript) { - printSection("Docs Build"); - await runStep( - "Docs build", - "bun run build", - docsPath - ); - } - } - } else { - printSection("Docs Build (SKIPPED)"); + if (options.planJson) { + console.log(JSON.stringify(plan, null, 2)); + return; } - // ============================================================================ - // Phase 6: Lighthouse Performance Tests (matches client.yml/admin.yml advisory jobs) - // ============================================================================ - if (!config.skipLighthouse && !config.skipBuild) { - // @lhci/cli is a root devDep; run it via bunx so we use the workspace - // version instead of polluting global node_modules with `npm install -g`. - printSection("Lighthouse CI - Client"); - await runStep( - "Lighthouse client", - "bunx lhci autorun", - resolve(projectRoot, "packages/client"), - { CI: "true" } - ); - - printSection("Lighthouse CI - Admin"); - await runStep( - "Lighthouse admin", - "bunx lhci autorun", - resolve(projectRoot, "packages/admin"), - { CI: "true" } + printPlan(plan); + if (options.generateIndexer) { + console.log( + `${colors.yellow}Note:${colors.reset} --generate-indexer is retained for compatibility; selected Indexer package commands own code generation.`, ); - } else if (config.skipLighthouse) { - printSection("Lighthouse (SKIPPED)"); - } else if (config.skipBuild) { - printSection("Lighthouse (SKIPPED - requires build)"); } - // ============================================================================ - // Summary - // ============================================================================ - printHeader("CI Validation Summary"); + const abortController = new AbortController(); + const cancel = () => abortController.abort("user-cancelled"); + process.once("SIGINT", cancel); + const receiptStore = options.reusePassingReceipts ? loadPassingReceiptStore() : new Map(); + const execution = await executePlan(plan, { + failFast: options.failFast, + signal: abortController.signal, + reusePassingReceipts: options.reusePassingReceipts, + receiptStore, + onCheckStart(check) { + console.log(`\n${colors.blue}Running ${check.id}:${colors.reset} ${check.command ?? check.builtin}`); + }, + onBatchStart(batch) { + console.log( + `\n${colors.blue}Running ${batch.length} checks concurrently:${colors.reset} ${batch + .map((check) => check.id) + .join(", ")}`, + ); + for (const check of batch) console.log(` ${check.id}: ${check.command ?? check.builtin}`); + }, + onCheckComplete(check, result) { + const color = result.ok ? colors.green : colors.red; + if (result.output) { + console.log(`\n${colors.blue}── ${check.id} output ──${colors.reset}`); + process.stdout.write(result.output.endsWith("\n") ? result.output : `${result.output}\n`); + } + console.log( + `${color}${result.ok ? "✓" : "✗"} ${check.id} (${result.durationSeconds ?? 0}s)${colors.reset}`, + ); + for (const detail of result.details ?? []) console.log(` ${detail}`); + }, + onCheckReuse(check) { + console.log(`\n${colors.green}↻ ${check.id} reused exact passing receipt${colors.reset}`); + }, + }); + process.removeListener("SIGINT", cancel); + if (options.reusePassingReceipts) savePassingReceiptStore(receiptStore); - if (failures.length > 0) { - console.log(`${colors.red}Some checks failed:${colors.reset}`); - for (const failure of failures) { - console.log(` - ${failure}`); + if (execution.status === "blocked") { + console.log(`\n${colors.yellow}Validation blocked:${colors.reset}`); + for (const entry of execution.blocked) { + console.log(` - ${entry.id}: ${entry.blockedBy.join(", ")}`); } - console.log(""); - console.log("Fix the issues above and run again."); - process.exit(1); + } else if (execution.status === "cancelled") { + console.log(`\n${colors.yellow}Validation cancelled; no additional checks will run.${colors.reset}`); + } else if (execution.status === "passed") { + console.log(`\n${colors.green}Selected validation plan passed.${colors.reset}`); } else { - console.log(`${colors.green}All CI checks passed! ✓${colors.reset}`); - console.log(""); - console.log("Your code is ready for commit/push."); - process.exit(0); + console.log(`\n${colors.red}Validation failed; dependent checks stopped.${colors.reset}`); } + process.exitCode = execution.exitCode; } -// Run main and handle errors -main().catch((error) => { - console.error(`${colors.red}Fatal error:${colors.reset}`, error); - process.exit(1); -}); +const isDirectRun = + process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isDirectRun) { + main().catch((error) => { + console.error(`${colors.red}${error.message}${colors.reset}`); + process.exitCode = 1; + }); +} diff --git a/scripts/dev/ci-local.test.mjs b/scripts/dev/ci-local.test.mjs new file mode 100644 index 0000000000..83b620ed68 --- /dev/null +++ b/scripts/dev/ci-local.test.mjs @@ -0,0 +1,379 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + applyCompatibilityFilters, + executePlan, + loadPassingReceiptStore, + parseArguments, + savePassingReceiptStore, +} from "./ci-local.js"; + +function plan(checks, status = "ready") { + return { + status, + policyVersion: 1, + requestedIntent: "checkpoint", + effectiveIntent: "checkpoint", + risk: "routine", + base: "base", + head: "head", + workingCopyFingerprint: "working-copy-1", + changedPaths: ["scripts/dev/ci-local.js"], + testPaths: {}, + requestedChecks: [], + environment: { profile: "test", toolchain: {}, capabilities: {} }, + checks: checks.map((id) => ({ + id, + command: `run ${id}`, + cwd: ".", + state: "pending", + blockedBy: [], + freshness: "exact-inputs", + stopRule: "stop-dependent-checks", + budgetSeconds: 1, + mandatory: false, + })), + }; +} + +test("local execution fails fast by default", async () => { + const calls = []; + const result = await executePlan(plan(["first", "second"]), { + runCheck: async (check) => { + calls.push(check.id); + return { ok: false, exitCode: 7, durationSeconds: 0.01 }; + }, + }); + + assert.equal(result.status, "failed"); + assert.deepEqual(calls, ["first"]); + assert.equal(result.results[0].receiptInputs.cacheReuse.allowed, true); +}); + +test("explicit no-fail-fast continues independent checks", async () => { + const calls = []; + const result = await executePlan(plan(["first", "second"]), { + failFast: false, + runCheck: async (check) => { + calls.push(check.id); + return { ok: check.id === "second", exitCode: check.id === "second" ? 0 : 1 }; + }, + }); + + assert.equal(result.status, "failed"); + assert.deepEqual(calls, ["first", "second"]); +}); + +test("cancellation is terminal and starts no checks", async () => { + const calls = []; + const cancelledPlan = { ...plan(["first"]), status: "cancelled", stopReason: "user-cancelled" }; + const result = await executePlan(cancelledPlan, { + runCheck: async (check) => calls.push(check.id), + }); + + assert.equal(result.status, "cancelled"); + assert.equal(result.exitCode, 130); + assert.deepEqual(calls, []); +}); + +test("blocked checks remain explicit while runnable evidence is collected", async () => { + const input = plan(["format", "contracts-test"], "blocked"); + input.checks[1].state = "blocked"; + input.checks[1].blockedBy = ["foundry"]; + const calls = []; + const result = await executePlan(input, { + runCheck: async (check) => { + calls.push(check.id); + return { ok: true, exitCode: 0 }; + }, + }); + + assert.equal(result.status, "blocked"); + assert.equal(result.exitCode, 2); + assert.deepEqual(calls, ["format"]); + assert.deepEqual(result.blocked, [{ id: "contracts-test", blockedBy: ["foundry"] }]); +}); + +test("passing receipt reuse is opt-in, exact, and never stores failures", async () => { + const receiptStore = new Map(); + let calls = 0; + const input = plan(["first"]); + const first = await executePlan(input, { + reusePassingReceipts: true, + receiptStore, + runCheck: async () => { + calls += 1; + return { ok: true, exitCode: 0 }; + }, + }); + assert.equal(first.status, "passed"); + assert.equal(receiptStore.size, 1); + + const second = await executePlan(input, { + reusePassingReceipts: true, + receiptStore, + runCheck: async () => { + throw new Error("exact receipt should have been reused"); + }, + }); + assert.equal(second.results[0].reused, true); + assert.equal(calls, 1); + + const dirtyChanged = { ...input, workingCopyFingerprint: "working-copy-2" }; + await executePlan(dirtyChanged, { + reusePassingReceipts: true, + receiptStore, + runCheck: async () => { + calls += 1; + return { ok: true, exitCode: 0 }; + }, + }); + assert.equal(calls, 2); + + const failureStore = new Map(); + await executePlan(input, { + reusePassingReceipts: true, + receiptStore: failureStore, + runCheck: async () => ({ ok: false, exitCode: 1 }), + }); + assert.equal(failureStore.size, 0); +}); + +test("persisted receipt store rejects tampered receipt inputs", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "validation-receipts-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const path = join(directory, "receipts.json"); + const receiptStore = new Map(); + await executePlan(plan(["first"]), { + reusePassingReceipts: true, + receiptStore, + runCheck: async () => ({ ok: true, exitCode: 0 }), + }); + savePassingReceiptStore(receiptStore, path); + assert.equal(loadPassingReceiptStore(path).size, 1); + + const stored = JSON.parse(readFileSync(path, "utf8")); + const [fingerprint] = Object.keys(stored.receipts); + stored.receipts[fingerprint].receiptInputs.command = "tampered command"; + writeFileSync(path, JSON.stringify(stored)); + assert.equal(loadPassingReceiptStore(path).size, 0); +}); + +test("legacy and selector arguments remain parseable", () => { + const parsed = parseArguments([ + "--quick", + "--intent", + "readiness", + "--skip-contracts", + "--plan-json", + "--test-path", + "client:src/foo.test.tsx", + "--changed", + "packages/client/src/foo.tsx", + ]); + + assert.equal(parsed.intent, "readiness"); + assert.equal(parsed.skipContracts, true); + assert.equal(parsed.planJson, true); + assert.deepEqual(parsed.testPaths.client, ["src/foo.test.tsx"]); +}); + +// Independent package suites declare a concurrency group in the policy. Only +// checks adjacent in plan order may batch, so printed order and the stop rule +// survive untouched. +function groupedPlan(specs) { + const base = plan(specs.map((spec) => spec.id)); + base.checks = base.checks.map((check, index) => ({ + ...check, + ...(specs[index].group ? { concurrencyGroup: specs[index].group } : {}), + ...(specs[index].blocked ? { state: "blocked", blockedBy: ["toolchain.node"] } : {}), + })); + return base; +} + +function overlapTracker() { + const state = { active: 0, peak: 0, captured: new Map(), order: [] }; + const runCheck = async (check, options = {}) => { + state.order.push(check.id); + state.captured.set(check.id, options.captureOutput === true); + state.active += 1; + state.peak = Math.max(state.peak, state.active); + await new Promise((resolve) => setTimeout(resolve, 15)); + state.active -= 1; + return { ok: true, exitCode: 0, durationSeconds: 0.01 }; + }; + return { state, runCheck }; +} + +test("adjacent checks sharing a concurrency group run together", async () => { + const { state, runCheck } = overlapTracker(); + const result = await executePlan( + groupedPlan([ + { id: "client-test", group: "package-tests-surface" }, + { id: "admin-test", group: "package-tests-surface" }, + ]), + { runCheck }, + ); + + assert.equal(result.status, "passed"); + assert.equal(state.peak, 2); + assert.equal(state.captured.get("client-test"), true); + assert.equal(state.captured.get("admin-test"), true); +}); + +test("different groups, ungrouped checks, and blocked members never batch", async () => { + for (const specs of [ + [ + { id: "shared-test", group: "package-tests-core" }, + { id: "client-test", group: "package-tests-surface" }, + ], + [{ id: "format" }, { id: "client-test", group: "package-tests-surface" }], + [ + { id: "client-test", group: "package-tests-surface" }, + { id: "admin-test", group: "package-tests-surface", blocked: true }, + ], + ]) { + const { state, runCheck } = overlapTracker(); + await executePlan(groupedPlan(specs), { runCheck }); + assert.equal(state.peak, 1, JSON.stringify(specs)); + } +}); + +test("a check running alone still streams instead of capturing", async () => { + const { state, runCheck } = overlapTracker(); + await executePlan(groupedPlan([{ id: "format" }]), { runCheck }); + assert.equal(state.captured.get("format"), false); +}); + +test("a failing batch reports every member and stops the checks after it", async () => { + const started = []; + const result = await executePlan( + groupedPlan([ + { id: "client-test", group: "package-tests-surface" }, + { id: "admin-test", group: "package-tests-surface" }, + { id: "docs-build" }, + ]), + { + runCheck: async (check) => { + started.push(check.id); + const ok = check.id !== "client-test"; + return { ok, exitCode: ok ? 0 : 3, durationSeconds: 0.01 }; + }, + }, + ); + + assert.equal(result.status, "failed"); + assert.equal(result.exitCode, 3); + // both in-flight members report, and nothing after the batch starts + assert.deepEqual([...started].sort(), ["admin-test", "client-test"]); + assert.deepEqual( + result.results.map((entry) => entry.id), + ["client-test", "admin-test"], + ); +}); + +test("concurrency can be turned off without changing results", async () => { + const { state, runCheck } = overlapTracker(); + const result = await executePlan( + groupedPlan([ + { id: "client-test", group: "package-tests-surface" }, + { id: "admin-test", group: "package-tests-surface" }, + ]), + { runCheck, concurrency: false }, + ); + + assert.equal(result.status, "passed"); + assert.equal(state.peak, 1); + assert.deepEqual(state.order, ["client-test", "admin-test"]); +}); + +test("a reusable receipt keeps its member out of the batch", async () => { + const receiptStore = new Map(); + const grouped = groupedPlan([ + { id: "client-test", group: "package-tests-surface" }, + { id: "admin-test", group: "package-tests-surface" }, + ]); + + await executePlan(grouped, { + reusePassingReceipts: true, + receiptStore, + runCheck: async () => ({ ok: true, exitCode: 0, durationSeconds: 0.01 }), + }); + + const { state, runCheck } = overlapTracker(); + const second = await executePlan(grouped, { + reusePassingReceipts: true, + receiptStore, + runCheck, + }); + + assert.equal(second.status, "passed"); + assert.equal(state.peak, 0); + assert.ok(second.results.every((entry) => entry.reused === true)); +}); + +// Regression: the plan inherited its blocked status even after the compatibility +// filter removed the only blocked check, so a run whose remaining checks all +// passed still reported blocked and exited 2. +function filterablePlan(checks) { + return { + status: checks.some((c) => c.state === "blocked") ? "blocked" : "ready", + environmentBlockers: [], + budget: { targetSeconds: 180, automatedSeconds: 0, manualSeconds: 0 }, + checks, + }; +} + +test("dropping the only blocked check unblocks the plan", () => { + const plan = filterablePlan([ + { id: "format", state: "pending", mandatory: false, budgetSeconds: 5 }, + { id: "indexer-test", state: "blocked", mandatory: false, budgetSeconds: 60 }, + ]); + assert.equal(plan.status, "blocked"); + + const filtered = applyCompatibilityFilters(plan, { skipIndexer: true }); + + assert.deepEqual( + filtered.checks.map((check) => check.id), + ["format"], + ); + assert.equal(filtered.status, "ready"); +}); + +test("a blocked check that survives the filter keeps the plan blocked", () => { + const plan = filterablePlan([ + { id: "format", state: "pending", mandatory: false, budgetSeconds: 5 }, + { id: "indexer-test", state: "blocked", mandatory: false, budgetSeconds: 60 }, + ]); + + const filtered = applyCompatibilityFilters(plan, { skipDocs: true }); + + assert.equal(filtered.status, "blocked"); +}); + +test("a mandatory blocked check is never dropped by a compatibility filter", () => { + const plan = filterablePlan([ + { id: "indexer-test", state: "blocked", mandatory: true, budgetSeconds: 60 }, + ]); + + const filtered = applyCompatibilityFilters(plan, { skipIndexer: true }); + + assert.deepEqual( + filtered.checks.map((check) => check.id), + ["indexer-test"], + ); + assert.equal(filtered.status, "blocked"); +}); + +test("environment blockers keep the plan blocked even with no blocked checks", () => { + const plan = filterablePlan([ + { id: "format", state: "pending", mandatory: false, budgetSeconds: 5 }, + ]); + plan.environmentBlockers = ["toolchain.bun"]; + + assert.equal(applyCompatibilityFilters(plan, {}).status, "blocked"); +}); diff --git a/scripts/quality/ci-gate.mjs b/scripts/quality/ci-gate.mjs index a40661ae55..ac1eb12990 100644 --- a/scripts/quality/ci-gate.mjs +++ b/scripts/quality/ci-gate.mjs @@ -1,226 +1,9 @@ #!/usr/bin/env node -const WORKFLOW_MATCHERS = new Map([ - [ - "Admin", - (path) => - matchesCommonWeb(path) || - path.startsWith("packages/admin/") || - path === ".github/workflows/admin.yml", - ], - [ - "Agent", - (path) => - matchesCommonPackage(path) || - path.startsWith("packages/agent/") || - path.startsWith("packages/shared/") || - path === "scripts/quality/check-source-structure.js" || - path === ".github/workflows/agent.yml", - ], - [ - "Client", - (path) => - matchesCommonWeb(path) || - path.startsWith("packages/client/") || - path === ".github/workflows/client.yml", - ], - [ - "Contracts", - (path) => - path === "package.json" || - path === "bun.lock" || - path === ".env.schema" || - path.startsWith("packages/contracts/") || - path.startsWith("scripts/contracts/") || - path === "scripts/lib/git-guardrails.mjs" || - path === "scripts/quality/check-source-structure.js" || - path === ".github/workflows/contracts.yml", - ], - [ - "Design", - (path) => - [ - "AGENTS.md", - "CLAUDE.md", - "DESIGN.md", - "package.json", - "bun.lock", - "docs/DESIGN.md", - "docs/docs/builders/packages/admin.mdx", - "docs/docs/builders/testing/storybook.mdx", - "docs/docs/reference/banned-vocabulary.json", - "packages/admin/AGENTS.md", - "packages/admin/DESIGN.md", - "packages/shared/AGENTS.md", - "packages/shared/package.json", - "packages/shared/vitest.storybook.config.ts", - "vercel.json", - ".github/workflows/design.yml", - ].includes(path) || - path.startsWith(".claude/skills/design/") || - path.startsWith("packages/admin/src/") || - (path.startsWith("packages/client/DESIGN") && path.endsWith(".md")) || - path.startsWith("packages/client/src/") || - path.startsWith("packages/shared/.storybook/") || - path.startsWith("packages/shared/src/") || - path.startsWith("scripts/design/") || - path === "scripts/data/design-token-usage-baseline.tsv" || - path === "scripts/quality/check-story-coverage.ts" || - path === "scripts/quality/check-story-quality.ts", - ], - [ - "Docs", - (path) => - path.startsWith("docs/") || - ["package.json", "bun.lock", ".github/workflows/docs.yml"].includes(path), - ], - [ - "Indexer", - (path) => - matchesCommonPackage(path) || - path.startsWith("packages/indexer/") || - matchesContractConsumer(path) || - path === "scripts/quality/check-source-structure.js" || - path === ".github/workflows/indexer.yml", - ], - [ - // Mirrors .github/workflows/ontology.yml path filters exactly. package.json - // and bun.lock are deliberately excluded: check-ontology.mjs is zero-dep - // (node stdlib only) and the workflow installs nothing, so dependency bumps - // cannot affect it. Keep that invariant if the checker ever grows imports. - "Ontology", - (path) => - path.startsWith("packages/shared/src/") || - path.startsWith("packages/contracts/src/") || - path.startsWith("packages/contracts/deployments/") || - path.startsWith("packages/client/src/components/Public/") || - path.startsWith("packages/client/src/views/Public/") || - [ - "packages/contracts/config/schemas.json", - "packages/client/src/views/Home/Garden/Assessment.tsx", - "packages/client/src/views/Home/Garden/Work.tsx", - "packages/indexer/schema.graphql", - "docs/docs/reference/glossary-community.md", - "docs/docs/reference/ontology.generated.mdx", - "docs/docs/reference/ontology-human.generated.mdx", - "docs/docs/community/green-goods-claims.generated.mdx", - "docs/docs/builders/integrations/entity-matrix.mdx", - "scripts/quality/check-ontology.mjs", - "scripts/quality/ontology-render.mjs", - "scripts/quality/check-ontology.test.mjs", - "scripts/data/ontology-drift-baseline.json", - ".github/workflows/ontology.yml", - ".plans/active/commitment-pooling/contract-spec.md", - ".plans/active/commitment-pooling/status.json", - ".plans/active/commitment-pooling/standing-commitments-spec.md", - ".plans/active/commitment-credit-follow-on/spec.md", - ".plans/active/community-interface/spec.md", - ".plans/active/community-interface/status.json", - ".plans/active/commitment-pooling/settlement-spec.md", - "docs/docs/builders/architecture/erd.mdx", - "docs/docs/builders/specs/v1-0.mdx", - "packages/contracts/script/DeployBadgeSchema.s.sol", - ].includes(path), - ], - [ - "Shared", - (path) => - ["package.json", "bun.lock", "biome.json", ".env.schema"].includes(path) || - path.startsWith(".github/workflows/") || - matchesScriptSource(path) || - matchesPackageSource(path) || - path.endsWith("/package.json") || - matchesContractConsumer(path), - ], - [ - "Supply Chain Guardrails", - matchesSupplyChainGuardrails, - ], -]); - -function matchesSupplyChainGuardrails(path) { - return ( - path === "package.json" || - path.endsWith("/package.json") || - ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"].includes(path) || - ["bunfig.toml", ".npmrc", "pnpm-workspace.yaml", ".yarnrc.yml"].includes(path) || - path.startsWith(".github/workflows/") || - ["AGENTS.md", "CLAUDE.md", "ONBOARDING.md"].includes(path) || - path.startsWith(".codex/") || - path.startsWith(".claude/") || - path.startsWith(".plans/") || - path.startsWith("docs/routines/") || - path.startsWith("scripts/quality/") || - path.startsWith("scripts/harness/") || - [ - ".css", - ".scss", - ".js", - ".json", - ".jsx", - ".mjs", - ".cjs", - ".md", - ".mdx", - ".sh", - ".sol", - ".ts", - ".tsx", - ".yaml", - ".yml", - ].some((extension) => path.endsWith(extension)) - ); -} - -function matchesCommonPackage(path) { - return ["package.json", "bun.lock", "biome.json", ".env.schema"].includes(path); -} - -function matchesContractConsumer(path) { - return ( - path.startsWith("packages/contracts/src/") || - path.startsWith("packages/contracts/abis/") || - path.startsWith("packages/contracts/deployments/") - ); -} - -function matchesCommonWeb(path) { - return ( - matchesCommonPackage(path) || - ["playwright.config.ts", "scripts/ops/upload-sourcemaps.js"].includes(path) || - path.startsWith("tests/") || - path.startsWith("packages/shared/") || - matchesContractConsumer(path) || - [ - "scripts/lib/env-schema.mjs", - "scripts/lib/env-schema.test.mjs", - "scripts/lib/env-parity.mjs", - "scripts/lib/env-parity.d.mts", - "scripts/dev/env-check.js", - "scripts/quality/check-source-structure.js", - ].includes(path) - ); -} - -function matchesScriptSource(path) { - return ( - path.startsWith("scripts/") && - [".cjs", ".js", ".mjs", ".ts"].some((extension) => path.endsWith(extension)) - ); -} - -function matchesPackageSource(path) { - return ( - path.startsWith("packages/") && - [".js", ".jsx", ".ts", ".tsx"].some((extension) => path.endsWith(extension)) - ); -} +import { selectExpectedWorkflows } from "./select-validation.mjs"; export function expectedWorkflowNames(files) { - return [...WORKFLOW_MATCHERS] - .filter(([, matches]) => files.some((file) => matches(file))) - .map(([name]) => name) - .sort(); + return selectExpectedWorkflows({ changedPaths: files, intent: "merge", ci: true }); } async function githubJson(token, path) { @@ -252,7 +35,7 @@ async function changedFiles(token, repository, pullNumber) { } } -function latestRunsByName(runs) { +export function latestRunsByName(runs) { const latest = new Map(); for (const run of runs) { if (run.name === "CI Gate") continue; @@ -270,57 +53,70 @@ async function workflowRuns(token, repository, headSha) { return latestRunsByName(data.workflow_runs); } -export async function runGate({ - token, - repository, - pullNumber, - headSha, - maxAttempts = 110, - intervalMs = 20_000, -}) { +export async function runGate( + { + token, + repository, + pullNumber, + headSha, + // 96 * 20s = 32 minutes of polling. ci-gate.yml sets timeout-minutes: 40, so + // this leaves ~8 minutes for checkout, setup, and the fixture test step and + // the gate still reports its own timeout error instead of being cancelled. + maxAttempts = 96, + intervalMs = 20_000, + }, + dependencies = {}, +) { if (!token || !repository || !pullNumber || !headSha) { throw new Error("GITHUB_TOKEN, REPO, PR_NUMBER, and HEAD_SHA are required"); } - const files = await changedFiles(token, repository, pullNumber); - const expected = expectedWorkflowNames(files); - console.log(`Changed files: ${files.length}`); - console.log(`Expected workflows: ${expected.length > 0 ? expected.join(", ") : "(none)"}`); + const { + loadChangedFiles = changedFiles, + selectWorkflows = expectedWorkflowNames, + loadWorkflowRuns = workflowRuns, + wait = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + logger = console, + } = dependencies; + + const files = await loadChangedFiles(token, repository, pullNumber); + const expected = selectWorkflows(files); + logger.log(`Changed files: ${files.length}`); + logger.log(`Expected workflows: ${expected.length > 0 ? expected.join(", ") : "(none)"}`); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const runs = await workflowRuns(token, repository, headSha); + const runs = await loadWorkflowRuns(token, repository, headSha); const missing = expected.filter((name) => !runs.has(name)); const pending = expected .map((name) => runs.get(name)) .filter((run) => run && run.status !== "completed"); + const failed = expected + .map((name) => runs.get(name)) + .filter((run) => run?.status === "completed" && run.conclusion !== "success"); + + if (failed.length > 0) { + for (const run of failed) { + logger.error(`::error::${run.name} concluded ${run.conclusion}: ${run.html_url}`); + } + throw new Error(`${failed.length} expected workflow(s) did not succeed`); + } if (missing.length > 0 || pending.length > 0) { - console.log( + logger.log( `Attempt ${attempt}/${maxAttempts}: missing ${missing.length}, pending ${pending.length}` ); - for (const name of missing) console.log(` - missing: ${name}`); - for (const run of pending) console.log(` - pending: ${run.name} [${run.status}]`); - if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)); + for (const name of missing) logger.log(` - missing: ${name}`); + for (const run of pending) logger.log(` - pending: ${run.name} [${run.status}]`); + if (attempt < maxAttempts) await wait(intervalMs); continue; } - const failed = expected - .map((name) => runs.get(name)) - .filter((run) => run.conclusion !== "success"); - for (const name of expected) { const run = runs.get(name); - console.log(` - ${name}: ${run.conclusion}`); - } - - if (failed.length > 0) { - for (const run of failed) { - console.error(`::error::${run.name} concluded ${run.conclusion}: ${run.html_url}`); - } - throw new Error(`${failed.length} expected workflow(s) did not succeed`); + logger.log(` - ${name}: ${run.conclusion}`); } - console.log("CI Gate passed: every expected path-filtered workflow reported success."); + logger.log("CI Gate passed: every expected path-filtered workflow reported success."); return; } diff --git a/scripts/quality/ci-gate.test.mjs b/scripts/quality/ci-gate.test.mjs index bd8a2ea669..b7d9f8901b 100644 --- a/scripts/quality/ci-gate.test.mjs +++ b/scripts/quality/ci-gate.test.mjs @@ -1,7 +1,67 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import test from "node:test"; -import { expectedWorkflowNames } from "./ci-gate.mjs"; +import { expectedWorkflowNames, latestRunsByName, runGate } from "./ci-gate.mjs"; + +const silentLogger = { + log() {}, + error() {}, +}; + +const ciGateWorkflow = readFileSync( + resolve(import.meta.dirname, "../../.github/workflows/ci-gate.yml"), + "utf8", +); +const supplyChainWorkflow = readFileSync( + resolve(import.meta.dirname, "../../.github/workflows/supply-chain-guardrails.yml"), + "utf8", +); + +function workflowRun(name, { id, status = "completed", conclusion = "success" } = {}) { + return { + id, + name, + status, + conclusion: status === "completed" ? conclusion : null, + html_url: `https://example.test/runs/${id}`, + }; +} + +function gateFixture(snapshots) { + let runRequests = 0; + let waits = 0; + + return { + dependencies: { + loadChangedFiles: async () => ["fixture.txt"], + selectWorkflows: () => ["Alpha", "Beta"], + loadWorkflowRuns: async () => { + const snapshot = snapshots[Math.min(runRequests, snapshots.length - 1)]; + runRequests += 1; + return new Map(snapshot.map((run) => [run.name, run])); + }, + wait: async () => { + waits += 1; + }, + logger: silentLogger, + }, + calls() { + return { runRequests, waits }; + }, + }; +} + +const gateOptions = { + token: "test-token", + repository: "greenpill-dev-guild/green-goods", + pullNumber: 123, + headSha: "abc123", + maxAttempts: 3, + intervalMs: 0, +}; test("package changes require every package and supply-chain workflow", () => { assert.deepEqual(expectedWorkflowNames(["package.json"]), [ @@ -20,13 +80,28 @@ test("package changes require every package and supply-chain workflow", () => { test("guidance-consumer source changes also require supply-chain guardrails", () => { assert.deepEqual(expectedWorkflowNames(["packages/agent/src/index.ts"]), [ "Agent", - "Shared", "Supply Chain Guardrails", ]); }); -test("contract source changes include every contract consumer", () => { +test("raw contract source stays on contract, ontology, and global guardrail workflows", () => { assert.deepEqual(expectedWorkflowNames(["packages/contracts/src/Gardens.sol"]), [ + "Contracts", + "Ontology", + "Supply Chain Guardrails", + ]); +}); + +test("ABI and deployment artifacts fan out to actual contract consumers", () => { + assert.deepEqual(expectedWorkflowNames(["packages/contracts/abis/GardenAccount.json"]), [ + "Admin", + "Client", + "Contracts", + "Indexer", + "Shared", + "Supply Chain Guardrails", + ]); + assert.deepEqual(expectedWorkflowNames(["packages/contracts/deployments/42161-latest.json"]), [ "Admin", "Client", "Contracts", @@ -40,7 +115,6 @@ test("contract source changes include every contract consumer", () => { test("contract naming helper changes require contracts CI", () => { assert.deepEqual(expectedWorkflowNames(["scripts/lib/git-guardrails.mjs"]), [ "Contracts", - "Shared", "Supply Chain Guardrails", ]); }); @@ -52,6 +126,7 @@ test("ontology-scoped changes require the ontology workflow", () => { "Client", "Design", "Ontology", + "Shared", "Supply Chain Guardrails", ]); assert.deepEqual(expectedWorkflowNames(["docs/docs/reference/glossary-community.md"]), [ @@ -97,17 +172,73 @@ test("docs-only changes require docs and guidance-consumer checks", () => { ]); }); -test("workflow changes require their workflow plus shared and supply-chain checks", () => { +test("ordinary source keeps global formatting ownership and live package routing", () => { + assert.deepEqual(expectedWorkflowNames(["packages/client/src/views/Home/Garden/Work.tsx"]), [ + "Client", + "Design", + "Ontology", + "Supply Chain Guardrails", + ]); + assert.deepEqual(expectedWorkflowNames(["packages/shared/src/index.ts"]), [ + "Admin", + "Agent", + "Client", + "Design", + "Ontology", + "Shared", + "Supply Chain Guardrails", + ]); +}); + +test("workflow changes require their workflow and supply-chain checks", () => { assert.deepEqual(expectedWorkflowNames([".github/workflows/docs.yml"]), [ "Docs", + "Supply Chain Guardrails", + ]); +}); + +test("package and toolchain changes match every live workflow trigger", () => { + assert.deepEqual(expectedWorkflowNames(["packages/client/package.json"]), [ + "Client", + "Supply Chain Guardrails", + ]); + assert.deepEqual(expectedWorkflowNames(["biome.json"]), [ + "Admin", + "Agent", + "Client", "Shared", "Supply Chain Guardrails", ]); + assert.deepEqual(expectedWorkflowNames([".github/actions/setup-js/action.yml"]), [ + "Admin", + "Agent", + "Client", + "Contracts", + "Design", + "Docs", + "Indexer", + "Shared", + "Supply Chain Guardrails", + ]); +}); + +test("specialized workflow inputs stay synchronized with live path filters", () => { + assert.deepEqual(expectedWorkflowNames(["scripts/ops/upload-sourcemaps.js"]), [ + "Admin", + "Client", + "Supply Chain Guardrails", + ]); + assert.deepEqual(expectedWorkflowNames(["vercel.json"]), [ + "Design", + "Supply Chain Guardrails", + ]); + assert.deepEqual(expectedWorkflowNames([".npmrc"]), ["Supply Chain Guardrails"]); + assert.deepEqual(expectedWorkflowNames([".mise.toml"]), ["Supply Chain Guardrails"]); + assert.match(supplyChainWorkflow, /- ["']\.mise\.toml["']/); }); -test("CI Gate script changes require shared and supply-chain workflows", () => { +test("CI Gate script changes require supply-chain guardrails", () => { assert.deepEqual(expectedWorkflowNames(["scripts/quality/ci-gate.mjs"]), [ - "Shared", "Supply Chain Guardrails", ]); }); @@ -129,3 +260,102 @@ test("source-structure changes require every workflow that runs the check", () = "Supply Chain Guardrails", ]); }); + +test("CI Gate pins Node and runs every standard-library validation fixture", () => { + assert.match( + ciGateWorkflow, + /uses:\s*actions\/setup-node@820762786026740c76f36085b0efc47a31fe5020/, + ); + assert.match(ciGateWorkflow, /node-version:\s*["']22\.22\.1["']/); + assert.match( + ciGateWorkflow, + /node --test scripts\/quality\/select-validation\.test\.mjs scripts\/dev\/ci-local\.test\.mjs scripts\/quality\/ci-gate\.test\.mjs/, + ); +}); + +test("direct CLI execution preserves required-input failure behavior", () => { + const result = spawnSync(process.execPath, [resolve(import.meta.dirname, "ci-gate.mjs")], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_TOKEN: "", + REPO: "", + PR_NUMBER: "", + HEAD_SHA: "", + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /::error::GITHUB_TOKEN, REPO, PR_NUMBER, and HEAD_SHA are required/); +}); + +test("a completed failure fails immediately while another workflow is pending", async () => { + const fixture = gateFixture([ + [ + workflowRun("Alpha", { id: 2, conclusion: "failure" }), + workflowRun("Beta", { id: 3, status: "in_progress" }), + ], + ]); + + await assert.rejects( + runGate(gateOptions, fixture.dependencies), + /1 expected workflow\(s\) did not succeed/, + ); + assert.deepEqual(fixture.calls(), { runRequests: 1, waits: 0 }); +}); + +test("all completed successes pass the gate", async () => { + const fixture = gateFixture([ + [workflowRun("Alpha", { id: 2 }), workflowRun("Beta", { id: 3 })], + ]); + + await runGate(gateOptions, fixture.dependencies); + assert.deepEqual(fixture.calls(), { runRequests: 1, waits: 0 }); +}); + +test("a missing workflow may register on a later poll", async () => { + const fixture = gateFixture([ + [workflowRun("Alpha", { id: 2 })], + [workflowRun("Alpha", { id: 2 }), workflowRun("Beta", { id: 3 })], + ]); + + await runGate(gateOptions, fixture.dependencies); + assert.deepEqual(fixture.calls(), { runRequests: 2, waits: 1 }); +}); + +test("a workflow that never registers keeps missing-workflow protection strict", async () => { + const fixture = gateFixture([ + [workflowRun("Alpha", { id: 2 })], + ]); + + await assert.rejects( + runGate({ ...gateOptions, maxAttempts: 2 }, fixture.dependencies), + /timed out before every expected workflow registered and completed/, + ); + assert.deepEqual(fixture.calls(), { runRequests: 2, waits: 1 }); +}); + +for (const conclusion of ["cancelled", "timed_out", "action_required", "skipped"]) { + test(`a ${conclusion} workflow is a terminal non-success`, async () => { + const fixture = gateFixture([ + [ + workflowRun("Alpha", { id: 2, conclusion }), + workflowRun("Beta", { id: 3 }), + ], + ]); + + await assert.rejects( + runGate(gateOptions, fixture.dependencies), + /1 expected workflow\(s\) did not succeed/, + ); + assert.deepEqual(fixture.calls(), { runRequests: 1, waits: 0 }); + }); +} + +test("the newest workflow run wins regardless of API ordering", () => { + const olderSuccess = workflowRun("Alpha", { id: 10 }); + const newerFailure = workflowRun("Alpha", { id: 20, conclusion: "failure" }); + + assert.equal(latestRunsByName([newerFailure, olderSuccess]).get("Alpha"), newerFailure); + assert.equal(latestRunsByName([olderSuccess, newerFailure]).get("Alpha"), newerFailure); +}); diff --git a/scripts/quality/select-validation.mjs b/scripts/quality/select-validation.mjs new file mode 100755 index 0000000000..4f3ebb6576 --- /dev/null +++ b/scripts/quality/select-validation.mjs @@ -0,0 +1,714 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readlinkSync } from "node:fs"; +import { dirname, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(scriptDirectory, "../.."); +const defaultPolicyPath = resolve(projectRoot, "scripts/data/validation-policy.json"); + +export function loadPolicy(policyPath = defaultPolicyPath) { + const policy = JSON.parse(readFileSync(policyPath, "utf8")); + validatePolicy(policy); + return policy; +} + +function validatePolicy(policy) { + if (!Number.isInteger(policy.version) || policy.version < 1) { + throw new Error("Validation policy must have a positive integer version"); + } + if (!Array.isArray(policy.checks) || policy.checks.length === 0) { + throw new Error("Validation policy must define checks"); + } + + const ids = new Set(); + for (const check of policy.checks) { + if (!check.id || ids.has(check.id)) { + throw new Error(`Validation policy contains an invalid or duplicate check id: ${check.id}`); + } + ids.add(check.id); + for (const field of ["risk", "expectedSignal", "freshness", "stopRule"]) { + if (!check[field]) throw new Error(`Validation check ${check.id} is missing ${field}`); + } + if (!Number.isFinite(check.budgetSeconds) || check.budgetSeconds <= 0) { + throw new Error(`Validation check ${check.id} must have a positive budgetSeconds`); + } + } + + for (const rule of [...(policy.conditionalRules ?? []), ...(policy.criticalOverrides ?? [])]) { + for (const id of rule.checks ?? [rule.check]) { + if (!ids.has(id)) throw new Error(`Validation rule references unknown check ${id}`); + } + } +} + +function normalizePath(path) { + return String(path).replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function normalizePaths(paths = []) { + return [...new Set(paths.filter(Boolean).map(normalizePath))].sort(); +} + +function groupMatches(path, rule) { + const groups = []; + if (rule.exact?.length) groups.push(rule.exact.includes(path)); + if (rule.prefixes?.length) groups.push(rule.prefixes.some((prefix) => path.startsWith(prefix))); + if (rule.contains?.length) groups.push(rule.contains.some((part) => path.includes(part))); + if (rule.extensions?.length) groups.push(rule.extensions.some((extension) => path.endsWith(extension))); + if (groups.length === 0) return false; + return rule.match === "all" ? groups.every(Boolean) : groups.some(Boolean); +} + +function ruleMatches(paths, rule) { + return paths.some((path) => groupMatches(path, rule)); +} + +function maxRisk(policy, risks) { + const order = policy.riskOrder; + return risks.reduce((current, candidate) => { + if (!order.includes(candidate)) throw new Error(`Unknown validation risk: ${candidate}`); + return order.indexOf(candidate) > order.indexOf(current) ? candidate : current; + }, "routine"); +} + +function effectiveIntent(policy, requestedIntent, ci) { + if (!policy.intentOrder.includes(requestedIntent)) { + throw new Error(`Unknown validation intent: ${requestedIntent}`); + } + if (ci && !["merge", "release"].includes(requestedIntent)) return "merge"; + return requestedIntent; +} + +function impactedSurfaces(policy, paths, fullRepository) { + const all = ["contracts", "shared", "indexer", "client", "admin", "agent", "docs"]; + if (fullRepository) return new Set(all); + if (paths.length === 0) return new Set(); + + const surfaces = new Set(); + for (const rule of policy.surfaceRules ?? []) { + if (!ruleMatches(paths, rule)) continue; + if (rule.surface === "all") all.forEach((surface) => surfaces.add(surface)); + else surfaces.add(rule.surface); + } + return surfaces; +} + +function addSurfaceChecks(ids, surface, { includeBuilds, intent }) { + const checks = { + contracts: ["contracts-build", "contracts-test"], + shared: ["shared-typecheck", "shared-test"], + indexer: ["indexer-test"], + client: ["client-test"], + admin: ["admin-test"], + agent: ["agent-typecheck", "agent-test"], + docs: intent === "qa" ? ["docs-build"] : ["docs-test", "docs-build"], + }; + const builds = { + shared: "shared-build", + indexer: "indexer-build", + client: "client-build", + admin: "admin-build", + agent: "agent-build", + }; + + for (const id of checks[surface] ?? []) ids.add(id); + if (includeBuilds && builds[surface]) ids.add(builds[surface]); +} + +function isUiBuildImpact(paths, surface) { + const prefix = `packages/${surface}/src/`; + return paths.some( + (path) => + (path.startsWith(prefix) && + /(^|\/)(app|index|main|route|router|routes)(\.[^/]+)?$/i.test(path)) || + path === `packages/${surface}/package.json` || + path.startsWith(`packages/${surface}/vite.config.`), + ); +} + +function normalizeTestPaths(testPaths = {}) { + const result = {}; + for (const [surface, paths] of Object.entries(testPaths)) { + result[surface] = normalizePaths(paths).map((path) => { + if (path.startsWith("-") || !/^[A-Za-z0-9_./@+-]+$/.test(path)) { + throw new Error(`Unsafe focused test path: ${path}`); + } + return path; + }); + } + return result; +} + +export function selectValidation(input = {}, options = {}) { + const policy = options.policy ?? loadPolicy(options.policyPath); + const requestedIntent = input.intent ?? "checkpoint"; + const ci = input.ci === true; + const intent = effectiveIntent(policy, requestedIntent, ci); + const changedPaths = normalizePaths(input.changedPaths); + const testPaths = normalizeTestPaths(input.testPaths); + const requestedChecks = normalizePaths(input.checkIds); + const baseRisk = input.risk ?? "routine"; + + const planIdentity = { + policyVersion: policy.version, + requestedIntent, + effectiveIntent: intent, + ci, + base: input.base ?? null, + head: input.head ?? null, + workingCopyFingerprint: input.workingCopyFingerprint ?? null, + changedPaths, + testPaths, + requestedChecks, + }; + + if (input.cancelled === true) { + return { + ...planIdentity, + status: "cancelled", + stopReason: "user-cancelled", + risk: maxRisk(policy, [baseRisk]), + surfaces: [], + checks: [], + environment: normalizeEnvironment(input.environment), + environmentBlockers: [], + budget: summarizeBudget(intent, []), + receiptPolicy: { + cacheReuseAllowed: true, + optInRequired: true, + failuresCacheable: false, + note: "Only opt-in exact-fingerprint passing receipts may be reused.", + }, + }; + } + + const hardRules = (policy.criticalOverrides ?? []).filter((rule) => ruleMatches(changedPaths, rule)); + const pathRiskRules = (policy.riskRules ?? []).filter((rule) => ruleMatches(changedPaths, rule)); + const risk = maxRisk(policy, [ + baseRisk, + ...pathRiskRules.map((rule) => rule.risk), + ...hardRules.map((rule) => rule.risk), + ]); + const fullRepository = + ["readiness", "push", "ship", "release"].includes(intent) || + (intent === "merge" && !ci); + const surfaces = impactedSurfaces(policy, changedPaths, fullRepository); + const selected = new Set(); + const evidenceOnly = ["diagnose", "review"].includes(intent); + const hasAutomaticScope = changedPaths.length > 0 || fullRepository; + + const knownCheckIds = new Set(policy.checks.map((check) => check.id)); + for (const id of requestedChecks) { + if (!knownCheckIds.has(id)) throw new Error(`Unknown requested validation check: ${id}`); + selected.add(id); + } + + if (!evidenceOnly && hasAutomaticScope) selected.add("format"); + if ( + intent === "qa" && + changedPaths.some((path) => [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"].some((extension) => path.endsWith(extension))) + ) { + selected.add("lint"); + } + if ( + hasAutomaticScope && + ["checkpoint", "readiness", "push", "ship", "merge", "release"].includes(intent) + ) { + selected.add("lint"); + } + + const includeBuilds = ["readiness", "push", "ship", "merge", "release"].includes(intent); + if (evidenceOnly) { + for (const surface of Object.keys(testPaths)) { + const testId = `${surface}-test`; + if (!knownCheckIds.has(testId)) throw new Error(`Unknown focused-test surface: ${surface}`); + selected.add(testId); + } + } else { + for (const surface of surfaces) { + addSurfaceChecks(selected, surface, { includeBuilds, intent }); + if ( + intent === "qa" && + ["client", "admin"].includes(surface) && + isUiBuildImpact(changedPaths, surface) + ) { + selected.add(`${surface}-build`); + } + if ( + intent === "qa" && + surface === "agent" && + changedPaths.some((path) => path.startsWith("packages/agent/src/")) + ) { + selected.add("agent-build"); + } + } + } + + if (["readiness", "push", "ship", "release"].includes(intent)) { + selected.add("abi-artifacts"); + selected.add("contracts-verify-fast"); + } + for (const rule of evidenceOnly ? [] : (policy.conditionalRules ?? [])) { + if (!ruleMatches(changedPaths, rule)) continue; + if (intent === "qa" && !["browser-proof", "ontology"].includes(rule.check)) continue; + selected.add(rule.check); + } + + const mandatory = new Set(); + for (const rule of evidenceOnly ? [] : hardRules) { + for (const id of rule.checks) { + selected.add(id); + mandatory.add(id); + } + } + if (["readiness", "push", "ship", "merge", "release"].includes(intent)) { + for (const id of selected) mandatory.add(id); + } + + const environment = normalizeEnvironment(input.environment); + let checks = policy.checks + .filter((check) => selected.has(check.id)) + .map((check) => + materializeCheck(check, environment, mandatory.has(check.id), testPaths, { + intent, + ci, + changedPaths, + }), + ); + const toolchainBlockers = compareToolchain(policy.toolchain, environment.toolchain, checks); + if (toolchainBlockers.length > 0) { + const capabilities = toolchainBlockers.map((blocker) => blocker.capability); + checks = checks.map((check) => ({ + ...check, + state: "blocked", + blockedBy: [...new Set([...check.blockedBy, ...capabilities])], + })); + } + const blockedChecks = checks.filter((check) => check.state === "blocked"); + const budget = summarizeBudget(intent, checks); + + return { + ...planIdentity, + status: blockedChecks.length > 0 || toolchainBlockers.length > 0 ? "blocked" : "ready", + stopReason: + blockedChecks.length > 0 || toolchainBlockers.length > 0 + ? "required-environment-unavailable" + : null, + risk, + surfaces: [...surfaces], + environment, + environmentBlockers: toolchainBlockers, + checks, + budget, + receiptPolicy: { + cacheReuseAllowed: true, + optInRequired: true, + failuresCacheable: false, + note: "Only opt-in exact-fingerprint passing receipts may be reused.", + }, + }; +} + +function normalizeEnvironment(environment = {}) { + return { + profile: environment.profile ?? "unspecified", + toolchain: { ...(environment.toolchain ?? {}) }, + capabilities: { ...(environment.capabilities ?? {}) }, + }; +} + +function compareToolchain(expected, actual, checks) { + const blockers = []; + if (checks.length === 0 || Object.keys(actual).length === 0) return blockers; + const requiredTools = new Set(["node"]); + if (checks.some((check) => check.command?.includes("bun"))) requiredTools.add("bun"); + if (checks.some((check) => check.capabilities?.includes("foundry"))) requiredTools.add("foundry"); + for (const tool of requiredTools) { + const expectedVersion = expected[tool]; + const actualVersion = actual[tool]; + if (!actualVersion) { + blockers.push({ capability: `toolchain.${tool}`, expected: expectedVersion, actual: null }); + } else if (actualVersion !== expectedVersion) { + blockers.push({ capability: `toolchain.${tool}`, expected: expectedVersion, actual: actualVersion }); + } + } + return blockers; +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'"'"'`)}'`; +} + +function materializeCheck(check, environment, mandatory, testPaths, context) { + const blockedBy = (check.capabilities ?? []).filter( + (capability) => environment.capabilities[capability] === false, + ); + const surface = check.id.endsWith("-test") ? check.id.slice(0, -5) : null; + const focusedPaths = surface ? testPaths[surface] ?? [] : []; + let command = + focusedPaths.length > 0 ? `${check.command} ${focusedPaths.join(" ")}` : check.command; + if ( + check.id === "format" && + !context.ci && + ["push", "ship", "release"].includes(context.intent) + ) { + command = "bun format"; + } + if ( + check.id === "format" && + ["diagnose", "review", "qa"].includes(context.intent) && + context.changedPaths.length > 0 + ) { + // Biome exits non-zero when every supplied path is one it does not handle, + // which a Markdown-only or Solidity-only change always is. Without this the + // scoped format check fails and fail-fast stops the rest of the plan. + command = `bunx @biomejs/biome format --no-errors-on-unmatched ${context.changedPaths.map(shellQuote).join(" ")}`; + } + if (check.id === "lint" && context.intent === "qa") { + const sourcePaths = context.changedPaths.filter((path) => + [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"].some((extension) => + path.endsWith(extension), + ), + ); + command = `bun --bun run oxlint ${sourcePaths.map(shellQuote).join(" ")} --deny-warnings`; + } + let budgetSeconds = focusedPaths.length > 0 ? Math.min(check.budgetSeconds, 60) : check.budgetSeconds; + if (check.id === "format" && ["diagnose", "review", "qa"].includes(context.intent)) { + budgetSeconds = Math.min(budgetSeconds, 10); + } + if (check.id === "lint" && context.intent === "qa") budgetSeconds = Math.min(budgetSeconds, 15); + return { + ...check, + command, + focusedPaths, + budgetSeconds, + mandatory, + state: blockedBy.length > 0 ? "blocked" : "pending", + blockedBy, + }; +} + +function summarizeBudget(intent, checks) { + const targetSeconds = { qa: 90, checkpoint: 180, push: 180 }[intent] ?? null; + const automatedSeconds = checks + .filter((check) => !check.manual) + .reduce((total, check) => total + check.budgetSeconds, 0); + const manualSeconds = checks + .filter((check) => check.manual) + .reduce((total, check) => total + check.budgetSeconds, 0); + return { + targetSeconds, + automatedSeconds, + manualSeconds, + withinTarget: targetSeconds === null ? null : automatedSeconds <= targetSeconds, + mandatoryChecksMayExceedTarget: checks.some((check) => check.mandatory) && + targetSeconds !== null && + automatedSeconds > targetSeconds, + rule: "Budgets warn and profile; they never skip selected or mandatory checks.", + }; +} + +export function selectExpectedWorkflows(input = {}, options = {}) { + const policy = options.policy ?? loadPolicy(options.policyPath); + if (input.cancelled === true) return []; + effectiveIntent(policy, input.intent ?? "merge", input.ci === true); + const paths = normalizePaths(input.changedPaths); + return Object.entries(policy.workflowRules ?? {}) + .filter(([, rule]) => ruleMatches(paths, rule)) + .map(([name]) => name) + .sort(); +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableValue(entry)]), + ); + } + return value; +} + +export function buildReceiptInputs(plan, check) { + const inputs = { + policyVersion: plan.policyVersion, + requestedIntent: plan.requestedIntent, + effectiveIntent: plan.effectiveIntent, + risk: plan.risk, + base: plan.base, + head: plan.head, + workingCopyFingerprint: plan.workingCopyFingerprint, + changedPaths: [...plan.changedPaths], + testPaths: plan.testPaths, + requestedChecks: [...plan.requestedChecks], + checkId: check.id, + command: check.command, + cwd: check.cwd ?? ".", + environment: plan.environment, + freshness: check.freshness, + cacheReuse: { + allowed: true, + optInRequired: true, + failuresCacheable: false, + }, + }; + const fingerprint = fingerprintReceiptInputs(inputs); + return { ...inputs, fingerprint }; +} + +export function fingerprintReceiptInputs(receiptInputs) { + const { fingerprint: _ignored, ...inputs } = receiptInputs; + return createHash("sha256").update(JSON.stringify(stableValue(inputs))).digest("hex"); +} + +export function parseCliArgs(argv) { + const options = { + intent: "checkpoint", + changedPaths: [], + capabilities: {}, + testPaths: {}, + checkIds: [], + json: false, + ci: false, + cancelled: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = () => { + const value = argv[++index]; + if (!value) throw new Error(`${arg} requires a value`); + return value; + }; + switch (arg) { + case "--intent": + options.intent = next(); + break; + case "--base": + options.base = next(); + break; + case "--head": + options.head = next(); + break; + case "--changed": + options.changedPaths.push(...next().split(",").filter(Boolean)); + break; + case "--changed-file": + options.changedPaths.push( + ...readFileSync(resolve(process.cwd(), next()), "utf8").split(/\r?\n/).filter(Boolean), + ); + break; + case "--risk": + options.risk = next(); + break; + case "--test-path": { + const value = next(); + const separator = value.indexOf(":"); + if (separator < 1) throw new Error("--test-path must use surface:path"); + const surface = value.slice(0, separator); + const path = value.slice(separator + 1); + (options.testPaths[surface] ??= []).push(path); + break; + } + case "--check": + options.checkIds.push(next()); + break; + case "--environment": + options.environmentProfile = next(); + break; + case "--capability": { + const [name, rawValue] = next().split("=", 2); + if (!name || !["true", "false"].includes(rawValue)) { + throw new Error("--capability must use name=true or name=false"); + } + options.capabilities[name] = rawValue === "true"; + break; + } + case "--ci": + options.ci = true; + break; + case "--cancelled": + options.cancelled = true; + break; + case "--json": + options.json = true; + break; + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function gitOutput(args, cwd = projectRoot) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function gitRawOutput(args, cwd = projectRoot) { + return execFileSync("git", args, { cwd }); +} + +function lines(value) { + return value.split(/\r?\n/).filter(Boolean); +} + +function workingCopyFingerprint(cwd, committedPatch, stagedPatch, unstagedPatch, untrackedPaths) { + const hash = createHash("sha256"); + hash.update("validation-working-copy-v1\0"); + for (const [label, patch] of [ + ["committed", committedPatch], + ["staged", stagedPatch], + ["unstaged", unstagedPatch], + ]) { + hash.update(`${label}\0`); + hash.update(patch); + hash.update("\0"); + } + for (const path of [...untrackedPaths].sort()) { + hash.update(`untracked\0${path}\0`); + const absolutePath = resolve(cwd, path); + if (absolutePath !== cwd && !absolutePath.startsWith(`${cwd}${sep}`)) { + throw new Error(`Untracked path escaped repository root: ${path}`); + } + const stat = lstatSync(absolutePath); + if (stat.isSymbolicLink()) hash.update(`symlink:${readlinkSync(absolutePath)}`); + else if (stat.isFile()) hash.update(readFileSync(absolutePath)); + else hash.update(`unsupported:${stat.mode}`); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function resolveGitInputs(options, { cwd = projectRoot } = {}) { + const base = options.base ?? "origin/develop"; + const head = options.head ?? "HEAD"; + const resolvedBase = gitOutput(["rev-parse", base], cwd); + const resolvedHead = gitOutput(["rev-parse", head], cwd); + const committedPatch = gitRawOutput( + ["diff", "--binary", `${resolvedBase}...${resolvedHead}`], + cwd, + ); + const stagedPatch = gitRawOutput(["diff", "--cached", "--binary"], cwd); + const unstagedPatch = gitRawOutput(["diff", "--binary"], cwd); + const committedPaths = lines( + gitOutput(["diff", "--name-only", `${resolvedBase}...${resolvedHead}`], cwd), + ); + const stagedPaths = lines(gitOutput(["diff", "--cached", "--name-only"], cwd)); + const unstagedPaths = lines(gitOutput(["diff", "--name-only"], cwd)); + const untrackedPaths = lines(gitOutput(["ls-files", "--others", "--exclude-standard"], cwd)); + const changedPaths = + options.changedPaths.length > 0 + ? normalizePaths(options.changedPaths) + : normalizePaths([...committedPaths, ...stagedPaths, ...unstagedPaths, ...untrackedPaths]); + return { + base: resolvedBase, + head: resolvedHead, + changedPaths, + workingCopyFingerprint: workingCopyFingerprint( + cwd, + committedPatch, + stagedPatch, + unstagedPatch, + untrackedPaths, + ), + }; +} + +export function detectCliToolchain(options = {}) { + const execute = options.execFileSync ?? execFileSync; + const version = (command, args) => { + try { + return String( + execute(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }), + ).trim(); + } catch { + return null; + } + }; + const bunOutput = version("bun", ["--version"]); + const foundryOutput = version("forge", ["--version"]); + const foundryVersion = foundryOutput?.match(/\d+\.\d+\.\d+/)?.[0] ?? null; + return { + node: options.nodeVersion ?? process.versions.node, + ...(bunOutput ? { bun: bunOutput } : {}), + ...(foundryVersion ? { foundry: foundryVersion } : {}), + }; +} + +function showHelp() { + console.log(`Usage: node scripts/quality/select-validation.mjs [options] + +Options: + --intent diagnose|qa|review|checkpoint|readiness|push|ship|merge|release + --base Base revision (default: origin/develop) + --head Head revision (default: HEAD) + --changed Comma-separated changed paths; repeatable + --changed-file Read changed paths from a newline-delimited file + --risk routine|sensitive|critical (paths can only escalate it) + --test-path Use a focused package-relative test path; repeatable + --check Add an explicit acceptance check; repeatable + --environment Environment profile label + --capability k=true Record an available/unavailable environment capability + --ci Make merge intent authoritative + --cancelled Emit a terminal cancelled plan + --json Emit JSON (default output is a readable summary) + --help, -h Show this help`); +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + if (options.help) { + showHelp(); + return; + } + const git = options.cancelled + ? { base: options.base ?? null, head: options.head ?? null, changedPaths: options.changedPaths } + : resolveGitInputs(options); + const plan = selectValidation({ + intent: options.intent, + risk: options.risk, + testPaths: options.testPaths, + checkIds: options.checkIds, + ci: options.ci, + cancelled: options.cancelled, + ...git, + environment: { + profile: options.environmentProfile, + toolchain: detectCliToolchain(), + capabilities: options.capabilities, + }, + }); + if (options.json) { + console.log(JSON.stringify(plan, null, 2)); + return; + } + console.log( + `Validation plan: ${plan.status} · ${plan.effectiveIntent} · ${plan.risk} · ${plan.changedPaths.length} changed path(s)`, + ); + for (const check of plan.checks) { + const suffix = check.blockedBy.length > 0 ? ` (blocked: ${check.blockedBy.join(", ")})` : ""; + console.log(`- ${check.id}: ${check.state}${suffix}`); + } +} + +const isDirectRun = + process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isDirectRun) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/quality/select-validation.test.mjs b/scripts/quality/select-validation.test.mjs new file mode 100644 index 0000000000..f949b996e0 --- /dev/null +++ b/scripts/quality/select-validation.test.mjs @@ -0,0 +1,550 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + buildReceiptInputs, + detectCliToolchain, + resolveGitInputs, + selectExpectedWorkflows, + selectValidation, +} from "./select-validation.mjs"; + +function ids(plan) { + return plan.checks.map((check) => check.id); +} + +test("the durable Bun caller re-enters the selector under real Node", () => { + const packageJson = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ); + + assert.equal( + packageJson.scripts["validation:plan"], + "node scripts/dev/node-cli.js scripts/quality/select-validation.mjs", + ); +}); + +test("docs-only QA stays on the docs surface", () => { + const plan = selectValidation({ + intent: "qa", + changedPaths: ["docs/docs/builders/getting-started.mdx"], + }); + + assert.equal(plan.status, "ready"); + assert.deepEqual(ids(plan), ["format", "docs-build"]); + assert.equal( + plan.checks[0].command, + "bunx @biomejs/biome format --no-errors-on-unmatched 'docs/docs/builders/getting-started.mdx'", + ); + assert.equal(plan.budget.withinTarget, true); +}); + +// Regression: Biome exits non-zero when it handles none of the supplied paths. +// A Markdown-, Solidity-, or YAML-only change is exactly that case, so without +// the flag the scoped format check failed and fail-fast killed the whole plan +// on the most common lightweight edit in this repository. +test("scoped format tolerates paths Biome does not handle", () => { + for (const changedPath of [ + "docs/docs/reference/glossary-community.md", + "packages/contracts/src/Garden.sol", + ".github/workflows/client.yml", + ]) { + for (const intent of ["diagnose", "review", "qa"]) { + const plan = selectValidation({ intent, changedPaths: [changedPath] }); + const format = plan.checks.find((check) => check.id === "format"); + if (!format) continue; + assert.match( + format.command, + /--no-errors-on-unmatched/, + `${intent} on ${changedPath} must not fail on an unmatched path`, + ); + } + } +}); + +test("a clean checkpoint is an explicit no-op", () => { + const plan = selectValidation({ intent: "checkpoint", changedPaths: [] }); + assert.deepEqual(plan.surfaces, []); + assert.deepEqual(plan.checks, []); + assert.equal(plan.budget.automatedSeconds, 0); +}); + +test("validation tooling paths escalate to sensitive risk", () => { + const plan = selectValidation({ + intent: "diagnose", + changedPaths: ["scripts/dev/ci-local.js"], + }); + assert.equal(plan.risk, "sensitive"); + assert.deepEqual(plan.checks, []); +}); + +test("isolated client behavior accepts focused proof without forcing a package build", () => { + const plan = selectValidation({ + intent: "qa", + changedPaths: ["packages/client/src/views/Home/Garden/Work.tsx"], + testPaths: { client: ["src/views/Home/Garden/Work.test.tsx"] }, + }); + + assert.deepEqual(ids(plan), [ + "format", + "lint", + "client-test", + "browser-proof", + ]); + assert.equal( + plan.checks.find((check) => check.id === "client-test").command, + "bun run test src/views/Home/Garden/Work.test.tsx", + ); + assert.equal( + plan.checks.find((check) => check.id === "lint").command, + "bun --bun run oxlint 'packages/client/src/views/Home/Garden/Work.tsx' --deny-warnings", + ); + assert.equal(plan.budget.targetSeconds, 90); + assert.equal(plan.budget.withinTarget, true); + assert.equal(plan.checks.at(-1).state, "pending"); +}); + +test("routing changes add the package build in QA", () => { + const plan = selectValidation({ + intent: "qa", + changedPaths: ["packages/client/src/router.tsx"], + testPaths: { client: ["src/router.test.tsx"] }, + }); + + assert.ok(ids(plan).includes("client-build")); + assert.ok(ids(plan).includes("lint")); +}); + +test("critical contract paths cannot be downgraded by QA intent", () => { + const plan = selectValidation({ + intent: "qa", + risk: "routine", + changedPaths: ["packages/contracts/src/Garden.sol"], + }); + + assert.equal(plan.risk, "critical"); + assert.deepEqual(ids(plan), [ + "format", + "abi-artifacts", + "contracts-build", + "contracts-test", + "contracts-verify-fast", + "ontology", + ]); + assert.ok(plan.checks.filter((check) => check.mandatory).length >= 3); +}); + +test("mutation-rich shared hooks retain the critical override", () => { + for (const changedPath of [ + "packages/shared/src/hooks/garden/useCreateGarden.ts", + "packages/shared/src/hooks/assessment/useAssessment.ts", + "packages/shared/src/modules/work/submit.ts", + "packages/shared/src/workflows/approve.ts", + ]) { + const plan = selectValidation({ intent: "qa", changedPaths: [changedPath] }); + assert.equal(plan.risk, "critical", changedPath); + assert.ok(plan.checks.find((check) => check.id === "shared-test")?.mandatory, changedPath); + assert.ok(plan.checks.find((check) => check.id === "client-test")?.mandatory, changedPath); + } +}); + +test("shared public API changes include direct consumers", () => { + const plan = selectValidation({ + intent: "checkpoint", + changedPaths: ["packages/shared/src/index.ts"], + }); + + assert.deepEqual(ids(plan), [ + "format", + "lint", + "shared-typecheck", + "shared-test", + "client-test", + "admin-test", + "agent-typecheck", + "agent-test", + "source-structure", + "design-guardrails", + ]); +}); + +test("CI owns merge intent and cannot be downgraded", () => { + const plan = selectValidation({ + intent: "qa", + ci: true, + changedPaths: ["packages/agent/src/index.ts"], + }); + + assert.equal(plan.requestedIntent, "qa"); + assert.equal(plan.effectiveIntent, "merge"); + assert.ok(ids(plan).includes("agent-build")); +}); + +test("diagnose and review classify critical risk without inventing broad proof", () => { + for (const intent of ["diagnose", "review"]) { + const unrequested = selectValidation({ + intent, + changedPaths: ["packages/contracts/src/Garden.sol"], + }); + assert.equal(unrequested.risk, "critical"); + assert.deepEqual(unrequested.checks, []); + + const requested = selectValidation({ + intent, + changedPaths: ["packages/shared/src/hooks/garden/useCreateGarden.ts"], + testPaths: { shared: ["src/hooks/garden/useCreateGarden.test.ts"] }, + }); + assert.deepEqual(ids(requested), ["shared-test"]); + assert.equal( + requested.checks[0].command, + "bun run test src/hooks/garden/useCreateGarden.test.ts", + ); + } +}); + +test("missing required environment capability is explicitly blocked", () => { + const plan = selectValidation({ + intent: "qa", + changedPaths: ["packages/contracts/src/Garden.sol"], + environment: { capabilities: { dependencies: true, foundry: false } }, + }); + + assert.equal(plan.status, "blocked"); + const blocked = plan.checks.filter((check) => check.state === "blocked"); + assert.ok(blocked.length > 0); + assert.ok(blocked.every((check) => check.blockedBy.includes("foundry"))); +}); + +test("exact toolchain parity is enforced only for tools selected checks need", () => { + const matching = selectValidation({ + intent: "qa", + changedPaths: ["docs/docs/builders/getting-started.mdx"], + environment: { + profile: "local", + toolchain: { node: "22.22.1", bun: "1.3.14" }, + capabilities: { dependencies: true }, + }, + }); + assert.equal(matching.status, "ready"); + + const mismatched = selectValidation({ + intent: "qa", + changedPaths: ["docs/docs/builders/getting-started.mdx"], + environment: { + profile: "local", + toolchain: { node: "24.19.0" }, + capabilities: { dependencies: true }, + }, + }); + assert.equal(mismatched.status, "blocked"); + assert.deepEqual( + mismatched.environmentBlockers.map((entry) => entry.capability), + ["toolchain.node", "toolchain.bun"], + ); + assert.ok(mismatched.checks.every((check) => check.state === "blocked")); +}); + +test("direct CLI toolchain detection blocks a stale Bun plan", () => { + const toolchain = detectCliToolchain({ + nodeVersion: "22.22.1", + execFileSync(command) { + if (command === "bun") return "1.3.10\n"; + if (command === "forge") return "forge Version: 1.7.1-stable\n"; + throw new Error(`unexpected command: ${command}`); + }, + }); + const plan = selectValidation({ + intent: "qa", + changedPaths: ["docs/docs/builders/getting-started.mdx"], + environment: { toolchain, capabilities: { dependencies: true } }, + }); + + assert.equal(plan.status, "blocked"); + assert.deepEqual(plan.environmentBlockers, [ + { capability: "toolchain.bun", expected: "1.3.14", actual: "1.3.10" }, + ]); +}); + +test("cancellation is terminal and selects no checks", () => { + const plan = selectValidation({ + intent: "ship", + cancelled: true, + changedPaths: ["packages/contracts/src/Garden.sol"], + }); + + assert.equal(plan.status, "cancelled"); + assert.deepEqual(plan.checks, []); + assert.equal(plan.stopReason, "user-cancelled"); + assert.equal(plan.budget.automatedSeconds, 0); +}); + +test("ship remains strict and includes the complete build surface", () => { + const plan = selectValidation({ + intent: "ship", + changedPaths: ["docs/README.md"], + }); + + for (const checkId of [ + "contracts-test", + "shared-test", + "indexer-test", + "client-build", + "admin-build", + "agent-build", + "docs-build", + "contracts-verify-fast", + ]) { + assert.ok(ids(plan).includes(checkId), checkId); + assert.equal(plan.checks.find((check) => check.id === checkId).mandatory, true, checkId); + } +}); + +test("readiness is strict, mandatory, and non-mutating while local ship formats", () => { + const readiness = selectValidation({ + intent: "readiness", + changedPaths: ["packages/client/src/index.ts"], + }); + assert.equal( + readiness.checks.find((check) => check.id === "format").command, + "bun run format:check", + ); + assert.ok(readiness.checks.every((check) => check.mandatory)); + for (const checkId of [ + "contracts-build", + "contracts-test", + "shared-test", + "shared-build", + "indexer-test", + "indexer-build", + "client-test", + "client-build", + "admin-test", + "admin-build", + "agent-test", + "agent-build", + "docs-test", + "docs-build", + "contracts-verify-fast", + ]) { + assert.ok(ids(readiness).includes(checkId), checkId); + } + + const ship = selectValidation({ intent: "ship", changedPaths: [] }); + assert.equal(ship.checks.find((check) => check.id === "format").command, "bun format"); + const merge = selectValidation({ + intent: "merge", + ci: true, + changedPaths: ["package.json"], + }); + assert.equal( + merge.checks.find((check) => check.id === "format").command, + "bun run format:check", + ); +}); + +test("ordinary source checkpoints do not invent the full supply-chain suite", () => { + for (const changedPath of [ + "packages/client/src/components/Panel.tsx", + "packages/shared/src/components/Button.tsx", + ]) { + const plan = selectValidation({ intent: "checkpoint", changedPaths: [changedPath] }); + assert.ok(ids(plan).includes("format"), changedPath); + assert.ok(ids(plan).includes("lint"), changedPath); + assert.ok(!ids(plan).includes("supply-chain"), changedPath); + } +}); + +test("receipt inputs authorize only opt-in passing reuse", () => { + const plan = selectValidation({ + intent: "checkpoint", + base: "base-sha", + head: "head-sha", + changedPaths: ["packages/agent/src/index.ts"], + environment: { + profile: "local", + toolchain: { node: "22.22.1", bun: "1.3.14" }, + capabilities: { dependencies: true }, + }, + }); + const receipt = buildReceiptInputs(plan, plan.checks[0]); + + assert.deepEqual(receipt.changedPaths, ["packages/agent/src/index.ts"]); + assert.equal(receipt.base, "base-sha"); + assert.equal(receipt.head, "head-sha"); + assert.equal(receipt.checkId, "format"); + assert.equal(receipt.cacheReuse.allowed, true); + assert.equal(receipt.cacheReuse.optInRequired, true); + assert.equal(receipt.cacheReuse.failuresCacheable, false); + assert.match(receipt.fingerprint, /^[a-f0-9]{64}$/); +}); + +test("git inputs include dirty and untracked paths and fingerprint their content", (t) => { + const directory = mkdtempSync(join(tmpdir(), "validation-selector-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const git = (...args) => execFileSync("git", args, { cwd: directory, stdio: "ignore" }); + git("init"); + git("config", "user.email", "validation@example.com"); + git("config", "user.name", "Validation Test"); + git("config", "commit.gpgsign", "false"); + mkdirSync(join(directory, "packages/client/src"), { recursive: true }); + writeFileSync(join(directory, "packages/client/src/app.ts"), "export const value = 1;\n"); + git("add", "."); + git("commit", "-m", "test: seed fixture"); + + const clean = resolveGitInputs({ changedPaths: [], base: "HEAD", head: "HEAD" }, { cwd: directory }); + assert.deepEqual(clean.changedPaths, []); + + writeFileSync(join(directory, "packages/client/src/app.ts"), "export const value = 2;\n"); + mkdirSync(join(directory, "docs")); + writeFileSync(join(directory, "docs/new.md"), "new\n"); + const dirty = resolveGitInputs({ changedPaths: [], base: "HEAD", head: "HEAD" }, { cwd: directory }); + assert.deepEqual(dirty.changedPaths, ["docs/new.md", "packages/client/src/app.ts"]); + assert.notEqual(dirty.workingCopyFingerprint, clean.workingCopyFingerprint); + + writeFileSync(join(directory, "docs/new.md"), "changed again\n"); + const changedAgain = resolveGitInputs( + { changedPaths: [], base: "HEAD", head: "HEAD" }, + { cwd: directory }, + ); + assert.notEqual(changedAgain.workingCopyFingerprint, dirty.workingCopyFingerprint); + + writeFileSync(join(directory, "packages/client/src/app.ts"), "export const value = 2;\n \n"); + const trailingWhitespace = resolveGitInputs( + { changedPaths: [], base: "HEAD", head: "HEAD" }, + { cwd: directory }, + ); + writeFileSync(join(directory, "packages/client/src/app.ts"), "export const value = 2;\n \n"); + const changedTrailingWhitespace = resolveGitInputs( + { changedPaths: [], base: "HEAD", head: "HEAD" }, + { cwd: directory }, + ); + assert.notEqual( + changedTrailingWhitespace.workingCopyFingerprint, + trailingWhitespace.workingCopyFingerprint, + ); +}); + +test("workflow mapping follows observable contract artifacts", () => { + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: ["packages/contracts/src/Garden.sol"], + intent: "merge", + ci: true, + }), + ["Contracts", "Ontology", "Supply Chain Guardrails"], + ); + + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: ["packages/contracts/abis/GardenAccount.json"], + intent: "merge", + ci: true, + }), + [ + "Admin", + "Client", + "Contracts", + "Indexer", + "Shared", + "Supply Chain Guardrails", + ], + ); +}); + +test("workflow mapping includes global formatting ownership for ordinary source", () => { + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: ["packages/client/src/views/Home/Garden/Work.tsx"], + intent: "merge", + ci: true, + }), + ["Client", "Design", "Ontology", "Supply Chain Guardrails"], + ); + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: ["packages/shared/src/index.ts"], + intent: "merge", + ci: true, + }), + ["Admin", "Agent", "Client", "Design", "Ontology", "Shared", "Supply Chain Guardrails"], + ); + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: ["docs/docs/builders/getting-started.mdx"], + intent: "merge", + ci: true, + }), + ["Docs", "Supply Chain Guardrails"], + ); +}); + +test("shared JS setup changes select every dependent workflow", () => { + assert.deepEqual( + selectExpectedWorkflows({ + changedPaths: [".github/actions/setup-js/action.yml"], + intent: "merge", + ci: true, + }), + [ + "Admin", + "Agent", + "Client", + "Contracts", + "Design", + "Docs", + "Indexer", + "Shared", + "Supply Chain Guardrails", + ], + ); +}); + +test("workflow mapping preserves exact live and intended trigger parity", () => { + const cases = [ + ["scripts/ops/upload-sourcemaps.js", ["Admin", "Client", "Supply Chain Guardrails"]], + ["scripts/lib/env-schema.mjs", ["Admin", "Client", "Supply Chain Guardrails"]], + ["scripts/lib/env-schema.test.mjs", ["Admin", "Client", "Supply Chain Guardrails"]], + ["scripts/lib/env-parity.mjs", ["Admin", "Client", "Supply Chain Guardrails"]], + ["scripts/lib/env-parity.d.mts", ["Admin", "Client"]], + ["scripts/dev/env-check.js", ["Admin", "Client", "Supply Chain Guardrails"]], + [ + "scripts/quality/check-source-structure.js", + ["Admin", "Agent", "Client", "Contracts", "Indexer", "Shared", "Supply Chain Guardrails"], + ], + ["scripts/lib/git-guardrails.mjs", ["Contracts", "Supply Chain Guardrails"]], + ["docs/docs/builders/testing/storybook.mdx", ["Design", "Docs", "Supply Chain Guardrails"]], + ["packages/client/DESIGN-pwa.md", ["Client", "Design", "Supply Chain Guardrails"]], + [ + "packages/shared/.storybook/preview.ts", + ["Admin", "Agent", "Client", "Design", "Shared", "Supply Chain Guardrails"], + ], + ["scripts/data/design-token-usage-baseline.tsv", ["Design"]], + ["scripts/quality/check-story-quality.ts", ["Design", "Supply Chain Guardrails"]], + ["vercel.json", ["Design", "Supply Chain Guardrails"]], + ["packages/contracts/config/schemas.json", ["Contracts", "Ontology", "Supply Chain Guardrails"]], + ["packages/client/src/views/Home/Garden/Assessment.tsx", ["Client", "Design", "Ontology", "Supply Chain Guardrails"]], + ["packages/indexer/schema.graphql", ["Indexer", "Ontology"]], + ["docs/docs/reference/ontology.generated.mdx", ["Docs", "Ontology", "Supply Chain Guardrails"]], + ["scripts/quality/ontology-render.mjs", ["Ontology", "Supply Chain Guardrails"]], + ["scripts/data/ontology-drift-baseline.json", ["Ontology", "Supply Chain Guardrails"]], + [".plans/active/commitment-pooling/contract-spec.md", ["Ontology", "Supply Chain Guardrails"]], + ["docs/docs/builders/architecture/erd.mdx", ["Docs", "Ontology", "Supply Chain Guardrails"]], + ["packages/contracts/script/DeployBadgeSchema.s.sol", ["Contracts", "Ontology", "Supply Chain Guardrails"]], + ["bunfig.toml", ["Supply Chain Guardrails"]], + [".npmrc", ["Supply Chain Guardrails"]], + [".mise.toml", ["Supply Chain Guardrails"]], + ["biome.json", ["Admin", "Agent", "Client", "Shared", "Supply Chain Guardrails"]], + ]; + + for (const [changedPath, expected] of cases) { + assert.deepEqual( + selectExpectedWorkflows({ changedPaths: [changedPath], intent: "merge", ci: true }), + expected, + changedPath, + ); + } +}); diff --git a/scripts/quality/workflow-performance-parity.test.mjs b/scripts/quality/workflow-performance-parity.test.mjs new file mode 100644 index 0000000000..943e9db8fd --- /dev/null +++ b/scripts/quality/workflow-performance-parity.test.mjs @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(import.meta.dirname, "../.."); +const workflowsDir = join(root, ".github/workflows"); + +function read(relativePath) { + return readFileSync(join(root, relativePath), "utf8"); +} + +function workflowSources() { + return readdirSync(workflowsDir) + .filter((file) => file.endsWith(".yml") && file !== "ci-gate.yml") + .sort() + .map((file) => [file, read(`.github/workflows/${file}`)]); +} + +test("shared JS setup pins the toolchain and installs from the frozen lockfile", () => { + const action = read(".github/actions/setup-js/action.yml"); + + assert.match(action, /node-version:\s*["']22\.22\.1["']/); + assert.match(action, /bun-version:\s*["']1\.3\.14["']/); + assert.match(action, /uses:\s*actions\/setup-node@[0-9a-f]{40}/); + assert.match(action, /uses:\s*oven-sh\/setup-bun@[0-9a-f]{40}/); + assert.match(action, /bun install --frozen-lockfile/); +}); + +// Measured regression guard, not a style preference. Restoring the Bun download +// store cost more than the install it replaced and evicted the Foundry caches +// that do pay off, so the shared setup stays cacheless until new evidence says +// otherwise. See the rationale comment in the action itself. +test("shared JS setup does not restore a dependency cache without fresh evidence", () => { + const action = read(".github/actions/setup-js/action.yml"); + + assert.doesNotMatch(action, /uses:\s*actions\/cache@/); + assert.doesNotMatch(action, /~\/\.bun\/install\/cache/); + const cachedPaths = [...action.matchAll(/^\s*path:\s*(.+)$/gm)].map( + (match) => match[1], + ); + assert.deepEqual(cachedPaths, []); +}); + +test("dependency-installing workflow jobs use shared JS setup", () => { + for (const [file, source] of workflowSources()) { + assert.doesNotMatch( + source, + /run:\s*bun install --frozen-lockfile/, + `${file} must not duplicate dependency setup`, + ); + } + + const expectedUsers = [ + "admin.yml", + "agent.yml", + "client.yml", + "contracts-nightly.yml", + "contracts.yml", + "design.yml", + "docs.yml", + "indexer.yml", + "shared.yml", + "supply-chain-guardrails.yml", + ]; + for (const file of expectedUsers) { + const source = read(`.github/workflows/${file}`); + assert.match( + source, + /uses:\s*\.\/\.github\/actions\/setup-js/, + `${file} must use the shared setup action`, + ); + if (!file.includes("nightly")) { + assert.match( + source, + /\.github\/actions\/setup-js\/action\.yml/, + `${file} must rerun when the shared action changes`, + ); + } + } +}); + +test("workflow parity is an early durable Supply Chain guard", () => { + const source = read(".github/workflows/supply-chain-guardrails.yml"); + const callerIndex = source.indexOf("name: Run workflow performance parity tests"); + const formatIndex = source.indexOf("name: Check repository formatting"); + + assert.ok(callerIndex >= 0 && callerIndex < formatIndex); + assert.match( + source.slice(callerIndex, callerIndex + 180), + /node --test scripts\/quality\/workflow-performance-parity\.test\.mjs/, + ); + assert.equal(source.match(/- "\.mise\.toml"/g)?.length, 2); +}); + +test("every direct Node and Bun setup uses the exact repository versions", () => { + for (const [file, source] of workflowSources()) { + for (const match of source.matchAll(/node-version:\s*["']?([^\s"']+)/g)) { + assert.equal(match[1], "22.22.1", `${file} has a drifting Node pin`); + } + for (const match of source.matchAll(/bun-version:\s*["']?([^\s"']+)/g)) { + assert.equal(match[1], "1.3.14", `${file} has a drifting Bun pin`); + } + } +}); + +test("raw Solidity source does not fan out to mocked consumer workflows", () => { + for (const file of ["admin.yml", "client.yml", "indexer.yml"]) { + const source = read(`.github/workflows/${file}`); + assert.doesNotMatch(source, /packages\/contracts\/src\/\*\*/); + assert.match(source, /packages\/contracts\/abis\/\*\*/); + assert.match(source, /packages\/contracts\/deployments\/\*\*/); + } +}); + +test("Shared outer routing matches the internal shared-impact detector", () => { + const source = read(".github/workflows/shared.yml"); + const outer = source.slice(0, source.indexOf("permissions:")); + + for (const required of [ + "package.json", + "bun.lock", + "biome.json", + ".env.schema", + ".github/actions/setup-js/action.yml", + ".github/workflows/shared.yml", + "scripts/quality/check-source-structure.js", + "packages/shared/**", + "packages/contracts/abis/**", + "packages/contracts/deployments/**", + ]) { + assert.equal( + outer.match(new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) + ?.length, + 2, + `Shared push and pull_request routing must include ${required}`, + ); + } + + for (const forbidden of [ + ".github/workflows/**", + "scripts/**/*.ts", + "packages/**/*.ts", + "packages/**/package.json", + ]) { + assert.ok(!outer.includes(forbidden), `Shared outer routing is too broad: ${forbidden}`); + } + assert.match(source, /schedule:\s*\n\s*- cron:/); + assert.match(source, /id:\s*filter/); +}); + +test("CI coverage drops HTML generation without weakening local reports or thresholds", () => { + const configs = { + "packages/admin/vitest.config.ts": [70, 70, 70, 70], + "packages/agent/vitest.config.ts": [10, 20, 20, 20], + "packages/client/vitest.config.ts": [75, 80, 80, 80], + "packages/shared/vitest.config.ts": [70, 70, 70, 70], + }; + + for (const [file, thresholds] of Object.entries(configs)) { + const source = read(file); + assert.match(source, /process\.env\.CI/); + assert.match(source, /\["text", "json"\]/); + assert.match(source, /\["text", "json", "html"\]|\["text", "html", "json"\]/); + const actualThresholds = [ + ...source.matchAll( + /(?:branches|functions|lines|statements):\s*(\d+)(?:,|\n)/g, + ), + ].map((match) => Number(match[1])); + assert.deepEqual(actualThresholds, thresholds, `${file} thresholds drifted`); + } + + const c8 = JSON.parse(read("packages/indexer/.c8rc.json")); + assert.deepEqual(c8.reporter, ["text", "json", "html"]); + assert.deepEqual( + [c8.branches, c8.functions, c8.lines, c8.statements], + [50, 50, 50, 50], + ); + assert.match( + read("packages/indexer/package.json"), + /"test:coverage:ci":\s*"c8 --reporter text --reporter json bun run mocha"/, + ); + assert.match( + read(".github/workflows/indexer.yml"), + /run:\s*bun run test:coverage:ci/, + ); +}); + +test("contracts realism remains equivalent without unrelated tool setup", () => { + const source = read(".github/workflows/contracts.yml"); + const realism = source.slice( + source.indexOf(" realism-audit:"), + source.indexOf(" fork-readiness-core:"), + ); + + assert.doesNotMatch( + realism, + /Install Foundry|setup-bun|bun install|submodules:\s*recursive/, + ); + assert.match(realism, /node-version:\s*["']22\.22\.1["']/); + assert.match(realism, /bash scripts\/contracts\/validate-test-realism-tooling\.sh/); + assert.match(realism, /bash scripts\/contracts\/check-test-realism\.sh/); +}); + +test("repository formatting runs once, early in the broad guardrail", () => { + for (const file of ["admin.yml", "agent.yml", "client.yml", "shared.yml"]) { + assert.doesNotMatch( + read(`.github/workflows/${file}`), + /bun run format:check/, + `${file} must not duplicate repository formatting`, + ); + } + + const guardrails = read(".github/workflows/supply-chain-guardrails.yml"); + const formatIndex = guardrails.indexOf("name: Check repository formatting"); + const guidanceIndex = guardrails.indexOf("name: Check Codex guidance parity"); + assert.ok(formatIndex >= 0 && formatIndex < guidanceIndex); + assert.match( + guardrails.slice(formatIndex, formatIndex + 120), + /bun run format:check/, + ); +});