Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
04ba028
chore: reorganize game/ + test/ into modules, modular concurrent CI, …
Maudfer Jul 11, 2026
bb84e25
ci: per-module coverage forcing function (report per module, gate rea…
Maudfer Jul 11, 2026
f46dea9
ci: add broad lint & problems check (ESLint + markdownlint) as an adv…
Maudfer Jul 11, 2026
c23f6df
fix: declare @types/node and modernize tsconfig (drop baseUrl, module…
Maudfer Jul 11, 2026
cc4bf78
chore: pin editor TypeScript to the workspace version (.vscode/settin…
Maudfer Jul 11, 2026
8cdfe8f
lint: clear the ESLint + markdownlint backlog and make lint a blockin…
Maudfer Jul 11, 2026
77ba1b3
chore: bump TypeScript to 6.0.3 (+ ts-jest); tsconfig types field fix…
Maudfer Jul 11, 2026
5e39bef
test(perf): unit perf-regression gates for the generation spine
Maudfer Jul 12, 2026
75a74f7
test(perf): per-phase cost FRACTIONS + promote perf to a blocking check
Maudfer Jul 12, 2026
d28a30b
test: raise per-module coverage bar to 80% and fill the gaps
Maudfer Jul 12, 2026
caac4e7
test(perf): enforce the dominant cost fractions against CI-measured b…
Maudfer Jul 12, 2026
fee7bd0
chore(ci,deps): remove bogus `app` dep (kills mongoose vulns), gate a…
Maudfer Jul 12, 2026
40de9d8
test(perf): center the enforced cost-fraction baselines on two CI runs
Maudfer Jul 12, 2026
0ba8d67
docs: document the test architecture + add tests-per-task and revert-…
Maudfer Jul 12, 2026
c32c5fc
test(perf): convert the perf gate to deterministic operation counts
Maudfer Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Generated files are compared byte-for-byte by checked-diff tests (test/simulationDocs.test.ts);
# keep them LF on every platform so local runs match CI and the generator output.
docs/simulation-relationships.md text eol=lf
docs/event-classification.md text eol=lf
# Generated files are compared byte-for-byte by checked-diff tests (test/util/simulationDocs.test.ts,
# test/events/eventClassification.test.ts); keep them LF on every platform so local runs match CI and
# the generator output.
docs/generated/simulation-relationships.md text eol=lf
docs/generated/event-classification.md text eol=lf
248 changes: 218 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,59 +1,247 @@
name: CI

# Runs on every PR targeting main and on every push to main (post-merge validation), plus manual dispatch.
# CI only — no deploy/publish steps. These checks are intended to be configured as required status checks on
# `main` so PRs can't merge red (see docs/tasks/009-github-actions-ci_DONE.md for the branch-protection notes).
# Runs on every PR targeting main and on every push to main (post-merge validation), plus manual
# dispatch. CI only — no deploy/publish steps.
#
# The suite is split into concurrent, independently-reported checks: a `changes` job detects which
# test modules a PR touched (path-based skipping), a dynamic `test` matrix runs one job per affected
# module (each mirrors a src/app/game/<group> folder — see jest.config.js `projects`), a
# `coverage-gate` merges every module's coverage and enforces the global threshold, and `ci-success`
# is the single stable aggregate check. Point branch protection's required check at `ci-success`.

on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:

# Cancel superseded runs on the same ref (e.g. a new push to a PR branch).
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-and-test:
# Detect which test modules this change affects. `shared` (foundational code imported everywhere)
# fans out to ALL modules — the sim is tightly coupled, so a change to types/util/json/the tick
# spine/root orchestrators must run everything. Otherwise a module runs when its own
# src/app/game/<group>/ or test/<module>/ changed. Output `modules` is a JSON array consumed by the
# dynamic test matrix.
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
modules: ${{ steps.detect.outputs.modules }}
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: detect
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
MODULES="world agents population events actions execution economy skills objects history save data util"

if [ "$EVENT_NAME" = "pull_request" ]; then BASE="$PR_BASE_SHA"; else BASE="$PUSH_BEFORE_SHA"; fi
if [ -z "${BASE:-}" ] || ! git cat-file -e "$BASE" 2>/dev/null; then BASE="HEAD~1"; fi
CHANGED=$(git diff --name-only "$BASE" HEAD || true)
echo "changed files:"; echo "$CHANGED"

# Shared foundations → run everything. (Root config, shared types/util/json, the execution
# spine, and the root orchestrators GameManager/City/Clock are imported across all modules.)
SHARED_RE='^(src/types/|src/util/|src/json/|src/app/game/execution/|src/app/game/GameManager\.ts|src/app/game/City\.ts|src/app/game/Clock\.ts|package\.json|package-lock\.json|tsconfig\.json|jest\.config\.js|\.babelrc|babel\.config\.js|scripts/|\.github/workflows/)'

SELECTED=""
if echo "$CHANGED" | grep -qE "$SHARED_RE"; then
SELECTED="$MODULES"
else
for m in $MODULES; do
# util's source lives in the shared src/util (handled above); trigger it only on its tests.
if [ "$m" = "util" ]; then
echo "$CHANGED" | grep -qE "^test/util/" && SELECTED="$SELECTED $m" || true
else
echo "$CHANGED" | grep -qE "^(src/app/game/$m/|test/$m/)" && SELECTED="$SELECTED $m" || true
fi
done
fi

- name: Set up Node.js
uses: actions/setup-node@v4
# Emit a JSON array (empty array when nothing test-relevant changed → the test job is skipped).
JSON=$(printf '%s\n' $SELECTED | grep -v '^$' | sort -u \
| jq -R . | jq -cs .)
echo "selected modules: $JSON"
echo "modules=$JSON" >> "$GITHUB_OUTPUT"

typecheck:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run typecheck

- name: Install dependencies
run: npm ci

- name: Type check
run: npm run typecheck
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build-prod

- name: Unit tests + coverage gate
run: npm run test:coverage
# Broad "lint & problems" check — reproduces the VS Code Problems panel (ESLint for TS/React/import
# hygiene, markdownlint for docs). Both linters run even if the first fails, so all problems surface.
# The backlog is cleared, so this is BLOCKING (in `ci-success`'s needs) — keep it green.
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Markdown lint
run: npm run lint:md
- name: ESLint
if: always()
run: npm run lint:js

- name: Production build
run: npm run build-prod
# Unit perf-regression gates for the offline generation spine (the `perf` jest project). Runs on EVERY change
# — a per-agent / subcomponent perf regression can originate anywhere in the sim — with --runInBand and NO
# coverage (istanbul instrumentation would skew the one timing check). Well under 10s. The gate is
# DETERMINISTIC OPERATION COUNTS (util/perfMeter): the sim does identical work per fixed seed on every machine,
# so the counts are byte-identical everywhere and are asserted EXACTLY against test/perf/baselines.json — no
# drift tolerance, no machine-class caveat, no PERF_ENFORCE flag (it enforces the same on a dev box and here).
# A count moves only when the sim's work moves, which forces a conscious re-baseline. generationPerf covers
# the whole spine (the sum of parts); regressionGuards pins the specific 078/079 wins (invoke agent-list = 0
# scanned, cache-miss counts, instance pruning) plus the ONE exception counts can't see — predicate
# precompilation — via a within-run timing RATIO (still machine-independent). BLOCKING (in ci-success needs).
# Re-baseline after an intentional perf change: PERF_UPDATE_BASELINES=1 npx jest --selectProjects perf, commit
# baselines.json (deterministic, so a local box suffices — CI will match).
perf:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Generation perf-regression gates
run: npx jest --selectProjects perf --runInBand

- name: Upload coverage report
if: always()
# One concurrent job per affected test module (dynamic matrix) — fast, granular pass/fail. Each runs
# with --coverage and uploads its own per-module coverage report (measured by its own tests) for the
# `coverage` job to gate.
test:
needs: changes
if: needs.changes.outputs.modules != '[]'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
module: ${{ fromJSON(needs.changes.outputs.modules) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run ${{ matrix.module }} tests with coverage
run: npx jest --selectProjects ${{ matrix.module }} --coverage --coverageReporters=json --coverageReporters=lcovonly --coverageDirectory coverage
- name: Upload ${{ matrix.module }} coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: coverage/
name: coverage-${{ matrix.module }}
path: |
coverage/coverage-final.json
coverage/lcov.info
if-no-files-found: ignore

# Advisory only — surfaces known vulnerabilities without blocking the merge.
- name: Dependency audit (advisory)
if: always()
# Coverage gate: does NOT run tests — it downloads every module's report from the `test` jobs and
# fails if ANY module's OWNED-file statement coverage is below COVERAGE_THRESHOLD (jest.config.js,
# currently 80%). scripts/coverage-gate.mjs filters each report to the module's own files first —
# Jest's collectCoverageFrom is additive, so an unfiltered report is diluted by transitively-required
# files from other modules. All modules currently clear 80%, so this is BLOCKING (in ci-success's needs).
coverage:
needs: [changes, test]
if: always() && needs.changes.outputs.modules != '[]'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Download module coverage reports
uses: actions/download-artifact@v4
with:
path: coverage-artifacts
pattern: coverage-*
continue-on-error: true
run: npm audit --audit-level=high
- name: Per-module coverage gate
run: node scripts/coverage-gate.mjs coverage-artifacts

# Advisory — surfaces known vulnerabilities without blocking the merge. `--omit=dev` gates only the
# dependencies that actually SHIP: dev-only tooling (browser-sync's transitive ws/socket.io) carries
# advisories that never reach the bundle. Run a plain `npm audit` locally for the full dev picture.
audit:
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=high --omit=dev

# Single stable aggregate check — make THIS the required status check in branch protection. Fails if
# any required upstream job failed or was cancelled (skipped is fine). `audit` is intentionally NOT
# here (advisory — dev-tooling advisories shouldn't block).
ci-success:
needs: [changes, typecheck, build, lint, perf, test, coverage]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify no required job failed
run: |
set -euo pipefail
for r in \
"${{ needs.changes.result }}" \
"${{ needs.typecheck.result }}" \
"${{ needs.build.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.perf.result }}" \
"${{ needs.test.result }}" \
"${{ needs.coverage.result }}"; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "A required job did not succeed (result: $r)"; exit 1
fi
done
echo "All required jobs succeeded."

# The Playwright integration suite (task 008) is not implemented yet. When it lands, add a job here that
# installs Chromium (`npx playwright install --with-deps chromium`) and runs `npm run test:integration`,
# uploading the Playwright report/trace on failure. Kept out for now so CI stays green and fast.
# The Playwright integration suite (task 008) is not implemented yet. When it lands, add a job here
# that installs Chromium (`npx playwright install --with-deps chromium`) and runs the integration
# suite, gated on `changes` and wired into `ci-success`'s needs.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ lib-cov

# Coverage directory used by tools like istanbul
coverage
coverage-artifacts
*.lcov

# nyc test coverage
Expand Down
32 changes: 32 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// markdownlint config (task: lint & problems CI check). Matches the VS Code markdownlint extension so
// the Markdown "Problems" you see locally reproduce in CI. A forcing function — fix over time.
{
"config": {
"default": true,
// Line length: prose docs (CLAUDE.md/README) are intentionally long — off (also off-by-default noise
// in most editor setups).
"MD013": false,
// Inline HTML: the docs use <HUD>, <br/>, alignment tags deliberately.
"MD033": false,
// First-line-heading: some docs open with a blockquote/badge.
"MD041": false,
// Table pipe alignment: a very new, purely-cosmetic rule (not in most editor extensions). The tables
// here use compact/unpadded pipes deliberately — enforcing column alignment is churn with no value.
"MD060": false
},
"globs": ["**/*.md"],
"ignores": [
"node_modules",
"bin",
"dist",
"coverage",
"coverage-artifacts",
// Worktree-isolated spawned-agent checkouts (gitignored) — don't lint the nested repo copies.
".claude",
// Generated docs are produced by the doc generators, not hand-edited — fix the generator, not the output.
"docs/generated",
// The task backlog is a frozen historical record (each file is a completed/archived ticket); not worth
// reformatting to lint rules.
"docs/tasks"
]
}
9 changes: 9 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
// Recommended extensions so the VS Code "Problems" panel matches CI's `lint` job. After `npm install`
// these use the repo's eslint.config.mjs and .markdownlint-cli2.jsonc, so local and CI report the same
// problems (no more local-only lint findings).
"recommendations": [
"dbaeumer.vscode-eslint",
"DavidAnson.vscode-markdownlint"
]
}
10 changes: 10 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
// Use the project's own TypeScript (node_modules/typescript, currently 6.0.3) for the editor's
// language features — NOT the newer one VS Code bundles and self-updates. tsconfig.json only sets
// compiler *options*, not the compiler *version*, so without this the editor's Problems panel can
// report errors (e.g. deprecation warnings) that CI's pinned tsc never emits. This makes the editor
// match CI. VS Code prompts once to trust the workspace version — accept it (or run
// "TypeScript: Select TypeScript Version" -> "Use Workspace Version").
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}
Loading
Loading