diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b92c349..b24f95f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,14 +5,48 @@ on: push: branches: [main] +# Minimal workflow token: the pipeline only reads repository contents +# (checkout, frozen install, typecheck, lint, tests, build) — nothing writes +# back, so the token is scoped to contents: read (E00-S05-T01). +permissions: + contents: read + +# E00-S05-T01 — CI quality baseline (required PR stages). +# +# Every pull request runs the required quality stages in order, each gated on +# the previous stage through `needs`: +# +# 1. frozen-install — the committed lockfile installs cleanly +# 2. typecheck — every workspace package passes `tsc --noEmit` +# 3. formatting-lint — the dependency-free formatting/lint policy (`pnpm lint`) +# 4. unit — deterministic unit suites (health, config, env example…) +# 5. architecture — static workspace/container structure and policy suites +# 6. postgres-integration — PostgreSQL adapter suites (docker-gated real-stack +# probes run where a Docker daemon is available and +# skip cleanly otherwise) +# 7. build-apps — builds the workspace applications (apps/*: server +# today, admin when E06-S01 lands) and verifies the +# compiled artifact +# +# The stage order, the `needs` chain and the tests/ coverage are locked in by +# tests/ci-stages.test.mjs (architecture stage). Third-party actions +# (actions/checkout, actions/setup-node) are pinned to full commit SHAs — no +# floating tags — and the workflow token is scoped to `contents: read` +# (E00-S05-T01 hardening). Container/Compose smoke on main/release branches +# and the Docker Compose baseline stack (E00-S02) stay out of scope for this +# stage list. + jobs: + # Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the + # committed lockfile must install cleanly and be up to date with the + # manifests before any stage proceeds. frozen-install: - name: Frozen lockfile install + name: Stage 1 — Frozen lockfile install (E00-S05-T01) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -22,308 +56,185 @@ jobs: - name: Verify workspace groups run: pnpm -r list --depth -1 - # E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs - # gate every PR (the docker-gated layer-scan probe inside the same file runs - # where a Docker daemon is available and skips cleanly otherwise). - secrets-not-embedded: - name: Secrets not embedded (E00-S02-T08) + # Stage 2 — typecheck (E00-S05-T01). + typecheck: + name: Stage 2 — Typecheck (E00-S05-T01) + needs: frozen-install runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - - name: Run secrets-not-embedded test suite + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Typecheck every workspace package + run: pnpm typecheck + + # Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the + # dependency-free formatting-policy suite (tests/formatting-policy.test.mjs): + # LF line endings, no BOM, no trailing whitespace, no tab indentation, final + # newline, and valid JSON with 2-space indentation and no duplicate keys. + formatting-lint: + name: Stage 3 — Formatting/lint policy (E00-S05-T01) + needs: typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Node.js 24 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Run the formatting/lint policy + run: pnpm lint + + # Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every + # PR without external services: the app health endpoint, the secrets scan + # and the configuration service suites (schema, startup error, log + # redaction, env adapter, .env.example). The config suites boot the + # committed server, so the config and database-postgres packages are built + # first. + unit: + name: Stage 4 — Unit tests (E00-S05-T01) + needs: formatting-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Node.js 24 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run the health-endpoint unit suite + run: node --test tests/health-endpoint.test.mjs + - name: Run the secrets-not-embedded unit suite run: node --test tests/secrets-not-embedded.test.mjs - - # E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs - # gate every PR — the scan proves pg/Kysely imports live only in - # packages/database-postgres and the mutation probes prove the scan catches - # a driver import injected into any other package. - database-postgres-imports: - name: Database-postgres import isolation (E00-S03-T02) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Run database-postgres import isolation suite - run: node --test tests/database-postgres-imports.test.mjs - - # E00-S03-T03: the static assertions of tests/database-postgres-ledger.test.mjs - # gate every PR — the suite locks in the migration ledger (schema_migrations - # table DDL, idempotent parameterized record, driver-boundary re-export) - # with mutation probes, and the docker-gated real-stack probe (migrate an - # empty database and confirm the ledger exists) runs where a Docker daemon - # is available and skips cleanly otherwise. The job installs the frozen - # workspace because the real-stack probe executes the committed ledger - # module from the host (it imports `pg` through the package's own links). - database-postgres-ledger: - name: Migration ledger (E00-S03-T03) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Run migration ledger test suite - run: node --test tests/database-postgres-ledger.test.mjs - - # E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs - # gate every PR — the suite locks in the migration advisory lock (session- - # scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a - # dedicated connection, re-entrant-safe in-flight acquire so concurrent - # acquire() calls share one connection, driver-boundary re-export) with - # mutation probes, and the docker-gated real-stack concurrent probe (a - # second runner waits or fails while the first holds the lock; concurrent - # acquire() checks out exactly one connection; the lock releases when the - # holding session ends) runs where a Docker daemon is available and skips - # cleanly otherwise. The job installs the frozen workspace because the - # real-stack probe executes the committed lock module from the host (it - # imports `pg` through the package's own links). - database-postgres-lock: - name: Migration advisory lock (E00-S03-T04) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Run migration advisory lock test suite - run: node --test tests/database-postgres-lock.test.mjs - - # E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs - # gate every PR — the suite locks in the migration failure diagnostic (a - # structured MigrationFailedError whose diagnostic identifies the failing - # migration, the failure phase, the underlying cause, and the applied/pending - # ledger state, serializable via toJSON) with mutation probes, and a - # deterministic stub-pool behavioral probe (intentionally failing migration - # fixture -> structured diagnostic naming the failing migration) runs on - # Node 24; the docker-gated real-stack probe (the issue's test plan: "run an - # intentionally failing migration fixture and confirm the diagnostic") runs - # where a Docker daemon is available and skips cleanly otherwise. The job - # installs the frozen workspace because the probes execute the committed - # runner module from the host (it imports `pg` through the package's own - # links). - database-postgres-diagnostic: - name: Migration failure diagnostic (E00-S03-T05) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Run migration failure diagnostic test suite - run: node --test tests/database-postgres-diagnostic.test.mjs - - # E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate - # every PR — the suite locks in the readiness gate (the app answers - # GET /health with 503 {"status":"not ready"} until the startup migration - # run completes, then 200 {"status":"ok"}) with mutation probes, the - # deterministic probes (boot the committed server: no DATABASE_URL -> - # ready immediately; unreachable DATABASE_URL -> stays not-ready) run on - # Node 24, and the docker-gated real-stack probe (the issue's test plan: - # "start with pending migrations and confirm readiness waits" — the app's - # migration run is blocked behind a held ACCESS EXCLUSIVE lock on the - # migration ledger, /health stays not-ready, then flips ready once the lock - # releases) runs where a Docker daemon is available and skips cleanly - # otherwise. The job installs the frozen workspace and builds the config - # and database-postgres packages because the probes boot the committed - # server from the host (it imports @personal-blog/config and - # @personal-blog/database-postgres through the packages' own links; the - # required EPPP_SESSION_SECRET is provided by the probe's boot env). - app-readiness: - name: App readiness after migrations (E00-S03-T06) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) - run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build - - name: Run app readiness test suite - run: node --test tests/app-readiness.test.mjs - - # E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs - # gate every PR — the suite locks in the field-specific startup error (a - # missing required setting fails startup with an error naming the missing - # field: packages/config's MissingRequiredSettingError/assertValidConfig, - # wired into the committed server before it binds) with mutation probes, and - # the deterministic probes execute the issue's test plan ("start with a - # missing required field and confirm the error names it"): booting the - # committed server without EPPP_SESSION_SECRET exits non-zero naming - # sessionSecret, while a valid secret boots to GET /health 200. The job - # installs the frozen workspace and builds the config and database-postgres - # packages because the probes boot the committed server which imports them. - config-startup-error: - name: Field-specific startup errors (E00-S04-T02) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) - run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build - - name: Run config startup error test suite - run: node --test tests/config-startup-error.test.mjs - - # E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs - # gate every PR — the suite locks in automatic secret redaction from logs - # (packages/config's redactConfig/redactText + the server's redacting - # logger: every log line is scrubbed of the config's secret values) with - # mutation probes, and the deterministic probes execute the issue's test - # plan ("log configuration and confirm secret values are redacted"): - # booting the committed server logs its resolved configuration with the - # secret values replaced by [REDACTED], and no secret value appears in the - # log output. The job installs the frozen workspace and builds the config - # and database-postgres packages because the probes boot the committed - # server which imports them. - config-log-redaction: - name: Secret redaction from logs (E00-S04-T03) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) - run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build - - name: Run config log redaction test suite - run: node --test tests/config-log-redaction.test.mjs - - # E00-S04-T04: the static assertions of tests/config-env-adapter.test.mjs - # gate every PR — the suite locks in the environment adapter (packages/config - # is the single owner of process.env reads; the server and every other module - # read no process.env, all settings flow through loadConfigFromEnv into the - # validated config) with a comment-stripped workspace scan, mutation probes - # (injecting a direct process.env read into any other module fails the scan), - # a deterministic boundary probe (full env mapping, defaults, bad-PORT - # fallback, HOST validated as hostname/IP with invalid values throwing a - # field-specific startup error, missing required secret -> - # MissingRequiredSettingError) and server-boot probes (a PORT/HOST override - # shows up in the resolved configuration; HOST=127.0.0.1 binds loopback only - # and the startup log reflects the actual bind; an invalid HOST fails startup - # naming the field without echoing the raw value; a missing required secret - # still fails startup). The job installs the frozen workspace and builds the - # config and database-postgres packages because the probes boot the committed - # server which imports them. - config-env-adapter: - name: Env adapter owns process.env (E00-S04-T04) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) - run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build - - name: Run config env adapter test suite - run: node --test tests/config-env-adapter.test.mjs - - # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate - # every PR — the suite locks in the TypeBox/Ajv configuration schema - # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + - # ajv@8.20.0) with mutation probes, and the deterministic probe executes - # the issue's test plan ("validate a full config against the TypeBox/Ajv - # schema") against the committed schema through Ajv. The job installs the - # frozen workspace and builds the config package because the probe also - # exercises the compiled package boundary (@personal-blog/config) exactly - # as the later configuration adapter will consume it. - config-schema: - name: TypeBox/Ajv config schema (E00-S04-T01) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) - run: corepack enable - - name: Install dependencies (frozen lockfile) - run: pnpm install --frozen-lockfile - - name: Build the config package (the probe exercises the compiled package boundary) - run: pnpm --filter @personal-blog/config build - - name: Run config schema test suite + - name: Run the config-schema unit suite run: node --test tests/config-schema.test.mjs - - # E00-S04-T05: the static assertions of tests/env-example.test.mjs gate every - # PR — the suite locks in the committed `.env.example` template: it exists at - # the repo root, is un-ignored in .gitignore (real `.env` files stay ignored - # while the example is tracked), documents every configuration environment - # source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains - # placeholder values only — no credential URI, no long secret-looking value, - # and no compose default credential — with mutation probes proving the - # assertions are non-vacuous. It also locks the fail-closed EPPP_SESSION_SECRET - # placeholder (shorter than the schema's 32-character minimum), builds the - # secret-shaped probe at runtime so the branch stays gitleaks-clean, and - # masks raw values in assertion messages. The test needs no dependencies, so - # the job only installs Node. - env-example: - name: .env.example placeholders only (E00-S04-T05) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Node.js 24 - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Run .env.example test suite + - name: Run the config-startup-error unit suite + run: node --test tests/config-startup-error.test.mjs + - name: Run the config-log-redaction unit suite + run: node --test tests/config-log-redaction.test.mjs + - name: Run the config-env-adapter unit suite + run: node --test tests/config-env-adapter.test.mjs + - name: Run the env-example unit suite run: node --test tests/env-example.test.mjs - # E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db - # image pinned to postgres:18.6-bookworm, health gate, volume persistence, - # build platforms) gate every PR (the docker-gated real-stack probes inside - # the same file run where a Docker daemon is available and skip cleanly - # otherwise). - compose-config: - name: Compose config (E00-S03-T01) + # Stage 5 — architecture tests (E00-S05-T01). Static structure and policy + # suites: dependency boundaries, workspace layout/configuration, strict + # TypeScript base, engine/TypeScript pins, root commands, frozen-install + # clean clone, container definition structure, and the CI baseline itself. + architecture: + name: Stage 5 — Architecture tests (E00-S05-T01) + needs: unit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - - name: Run compose-config test suite + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (strict-tsconfig typechecks apps/server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run the architecture-import suite + run: node --test tests/architecture-import.test.mjs + - name: Run the no-core-extension-imports suite + run: node --test tests/no-core-extension-imports.test.mjs + - name: Run the workspace-layout suite + run: node --test tests/workspace-layout.test.mjs + - name: Run the workspace-config suite + run: node --test tests/workspace-config.test.mjs + - name: Run the strict-tsconfig suite + run: node --test tests/strict-tsconfig.test.mjs + - name: Run the typescript-pin suite + run: node --test tests/typescript-pin.test.mjs + - name: Run the node-engine suite + run: node --test tests/node-engine.test.mjs + - name: Run the frozen-install suite + run: node --test tests/frozen-install.test.mjs + - name: Run the root-commands suite + run: node --test tests/root-commands.test.mjs + - name: Run the compose-config suite run: node --test tests/compose-config.test.mjs + - name: Run the build-targets suite + run: node --test tests/build-targets.test.mjs + - name: Run the non-root-user suite + run: node --test tests/non-root-user.test.mjs + - name: Run the readonly-rootfs suite + run: node --test tests/readonly-rootfs.test.mjs + - name: Run the database-postgres-imports suite + run: node --test tests/database-postgres-imports.test.mjs + - name: Run the ci-stages baseline suite + run: node --test tests/ci-stages.test.mjs + + # Stage 6 — PostgreSQL integration tests (E00-S05-T01). The PostgreSQL + # adapter suites (migration ledger, advisory lock, failure diagnostic) and + # the app readiness suite: their docker-gated real-stack probes (migrate an + # empty database, hold/release the advisory lock, readiness waits on the + # startup migration run) run where a Docker daemon is available and skip + # cleanly otherwise; the static and deterministic probes always gate. The + # app-readiness probes boot the committed server, so the config and + # database-postgres packages are built first. + postgres-integration: + name: Stage 6 — PostgreSQL integration tests (E00-S05-T01) + needs: architecture + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Node.js 24 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run the database-postgres-ledger suite + run: node --test tests/database-postgres-ledger.test.mjs + - name: Run the database-postgres-lock suite + run: node --test tests/database-postgres-lock.test.mjs + - name: Run the database-postgres-diagnostic suite + run: node --test tests/database-postgres-diagnostic.test.mjs + - name: Run the app-readiness suite + run: node --test tests/app-readiness.test.mjs + + # Stage 7 — build the applications (E00-S05-T01). Builds every workspace + # application under apps/ (apps/server today; apps/admin when E06-S01 lands + # — the pnpm apps-group glob picks it up automatically) and verifies the + # compiled server artifact. + build-apps: + name: Stage 7 — Build the admin and server applications (E00-S05-T01) + needs: postgres-integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Node.js 24 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands) + run: pnpm --filter "./apps/**" run build + - name: Verify the compiled server application artifact + run: test -f apps/server/dist/index.js diff --git a/docs/development/non-container.md b/docs/development/non-container.md index f85c1a5..d172635 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -143,11 +143,22 @@ node /dist/index.js # e.g. node packages/core/dist/index.js ```sh pnpm test +pnpm lint ``` -`pnpm test` runs the `node:test` suites under `tests/` (currently -`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies. -This is also the suite that enforces the dependency-boundary rule. +`pnpm test` runs the `node:test` suites under `tests/` with zero extra +dependencies (this is also the suite that enforces the dependency-boundary +rule). `pnpm lint` runs the formatting/lint policy suite +(`tests/formatting-policy.test.mjs`): every tracked text file must use LF +line endings, no BOM, no trailing whitespace, no tab indentation and exactly +one final newline; JSON files must additionally parse, carry no duplicate +keys and use 2-space indentation. + +Pull requests run these checks as CI stages, in order — frozen lockfile +install → typecheck → formatting/lint → unit → architecture → PostgreSQL +integration → build of the applications (E00-S05-T01); the stage order, +the `needs` chain and the `tests/` coverage are locked in by +`tests/ci-stages.test.mjs`. ## Smoke check from a clean clone @@ -157,7 +168,8 @@ corepack enable pnpm install --frozen-lockfile # exit 0, lockfile untouched pnpm build # 5/5 packages emit dist/, exit 0 pnpm typecheck # 5/5 packages pass --noEmit, exit 0 -pnpm test # 10/10 pass, exit 0 +pnpm lint # formatting/lint policy passes, exit 0 +pnpm test # all node:test suites pass, exit 0 pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up ``` diff --git a/package.json b/package.json index 8b9a001..f5a4b48 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "pnpm -r run build", + "lint": "node --test tests/formatting-policy.test.mjs", "test": "node --test \"tests/**/*.test.mjs\"", "typecheck": "pnpm -r run typecheck" }, diff --git a/tests/ci-stages.test.mjs b/tests/ci-stages.test.mjs new file mode 100644 index 0000000..0103250 --- /dev/null +++ b/tests/ci-stages.test.mjs @@ -0,0 +1,370 @@ +/** + * CI quality baseline test — locks in the [E00-S05-T01] required PR stages of + * the committed workflow (`.gitea/workflows/ci.yml`). + * + * The baseline (issue #187 acceptance criteria): + * - "CI runs frozen install before later stages" → `frozen-install` is the + * first job and every later stage declares `needs` on its predecessor, so + * the pipeline runs the required stages strictly in order and nothing + * proceeds past a failed stage. + * - "CI runs typecheck, formatting/lint, unit, architecture and PostgreSQL + * integration tests" → the five stage jobs exist with the expected + * commands: `pnpm typecheck`, `pnpm lint`, and the unit / architecture / + * postgres-integration `node --test` suite runs. + * - "CI builds the admin and server applications" → the `build-apps` stage + * compiles the whole apps group (`./apps/**` — apps/server today, the + * admin app when E06-S01 lands) and verifies the compiled server + * artifact. + * - "CI workflow pins third-party actions (actions/checkout, + * actions/setup-node) to full commit SHAs, not floating tags" → every + * `uses:` ref is a 40-char commit SHA pinned to the committed + * PINNED_ACTIONS values — no `@v4`-style floating tags. + * - "CI workflow declares minimal permissions (`permissions: contents: + * read`) at the workflow level" → the workflow declares a top-level + * `permissions:` block granting exactly `contents: read`. + * - every committed test suite under tests/ is wired into exactly one stage + * (`pnpm lint` runs the formatting-policy suite; every other suite is + * named by a `node --test` run in the unit, architecture or + * postgres-integration stage). + * + * Mutation probes prove the assertions are non-vacuous: removing a stage, + * breaking the `needs` chain, or dropping a suite from its stage all fail. + * + * Run: `node --test tests/ci-stages.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The workflow file under test. */ +const WORKFLOW = '.gitea/workflows/ci.yml'; + +/** The required PR stages, in the order the pipeline must run them. */ +const REQUIRED_STAGES = [ + 'frozen-install', + 'typecheck', + 'formatting-lint', + 'unit', + 'architecture', + 'postgres-integration', + 'build-apps', +]; + +/** The root lint command the formatting-lint stage must run. */ +const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs'; + +/** + * The third-party actions the workflow may use, pinned to the full commit + * SHA of a released version (E00-S05-T01 hardening). Updating a pin means + * updating this table and the workflow together, in the same PR. + */ +const PINNED_ACTIONS = { + 'actions/checkout': '11bd71901bbe5b1630ceea73d27597364c9af683', // v4.2.2 + 'actions/setup-node': '1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a', // v4.2.0 +}; + +/** + * Parses the workflow's `jobs:` section (the committed file is 2-space + * indented YAML) into `{ order, jobs }` where `order` lists job keys in + * document order and each job carries its `needs` value and `run:` commands. + * Comments and blank lines are skipped; unknown keys under a job are ignored. + */ +function parseWorkflowJobs(yamlText) { + const lines = yamlText.split('\n'); + const jobsIndex = lines.findIndex((line) => line === 'jobs:'); + assert.ok(jobsIndex >= 0, 'the workflow must declare a top-level jobs: section'); + + const order = []; + const jobs = {}; + let current = null; + + for (let i = jobsIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + const indent = line.length - line.trimStart().length; + if (indent === 2) { + const key = /^([A-Za-z0-9_-]+):/.exec(trimmed); + assert.ok(key, `unexpected jobs: entry at indent 2: "${line}"`); + current = key[1]; + order.push(current); + jobs[current] = { needs: null, runs: [] }; + continue; + } + if (current && indent > 2) { + const needs = /^needs:\s*(.+)$/.exec(trimmed); + if (needs) jobs[current].needs = needs[1].trim().replace(/^\[|\]$/g, ''); + const run = /^run:\s*(.+)$/.exec(trimmed); + if (run) jobs[current].runs.push(run[1].trim()); + } + } + + return { order, jobs }; +} + +/** Extracts the tests/*.test.mjs suite names named by `node --test` runs. */ +function suitesNamedInRuns(runs) { + const out = []; + for (const run of runs) { + for (const token of run.split(/\s+/)) { + if (token.startsWith('tests/') && token.endsWith('.test.mjs')) { + out.push(token.slice('tests/'.length)); + } + } + } + return out; +} + +/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@". */ +function usesRefs(yamlText) { + const refs = []; + for (const line of yamlText.split('\n')) { + // `uses:` appears either as a bare key or as a sequence item ("- uses:"). + const match = /^\s*(?:-\s+)?uses:\s*(\S+)/.exec(line); + if (match) refs.push(match[1]); + } + return refs; +} + +/** + * Asserts the E00-S05-T01 hardening criteria: every third-party `uses:` ref + * is pinned to a full 40-char commit SHA (exactly the committed PINNED_ACTIONS + * values — no floating tags) and the workflow declares `permissions: + * contents: read` at the top level. Throws an AssertionError describing the + * first violated invariant. + */ +function assertHardening(yamlText) { + const refs = usesRefs(yamlText); + assert.ok(refs.length > 0, 'the workflow must use at least one third-party action'); + for (const ref of refs) { + const match = /^([\w.-]+\/[\w.-]+)@([0-9a-f]{40})$/.exec(ref); + assert.ok( + match, + `every third-party action must be pinned to a full 40-char commit SHA, not a floating tag (got "${ref}")`, + ); + assert.ok( + Object.hasOwn(PINNED_ACTIONS, match[1]), + `unexpected third-party action "${match[1]}" — add it to the PINNED_ACTIONS policy table if it is approved`, + ); + assert.equal( + match[2], + PINNED_ACTIONS[match[1]], + `"${match[1]}" must be pinned to the committed full commit SHA ${PINNED_ACTIONS[match[1]]} (got ${match[2]})`, + ); + } + for (const action of Object.keys(PINNED_ACTIONS)) { + assert.ok( + refs.some((ref) => ref.startsWith(`${action}@`)), + `the workflow must use "${action}" pinned to a full commit SHA`, + ); + } + assert.match( + yamlText, + /^permissions:\n[ \t]+contents: read$/m, + 'the workflow must declare a top-level "permissions: contents: read" block', + ); +} + +/** + * Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an + * AssertionError describing the first violated invariant. + */ +function assertBaseline({ order, jobs }) { + // Every required stage exists. + for (const stage of REQUIRED_STAGES) { + assert.ok(jobs[stage], `required CI stage "${stage}" is missing from the workflow`); + } + + // The required stages run in order (their relative order is preserved). + const present = order.filter((name) => REQUIRED_STAGES.includes(name)); + assert.deepEqual( + present, + REQUIRED_STAGES, + `the required CI stages must run in order: ${REQUIRED_STAGES.join(' -> ')}`, + ); + + // Frozen install runs before later stages: every later stage gates on its + // predecessor, so the pipeline is strictly ordered. + for (let i = 1; i < REQUIRED_STAGES.length; i++) { + assert.equal( + jobs[REQUIRED_STAGES[i]].needs, + REQUIRED_STAGES[i - 1], + `stage "${REQUIRED_STAGES[i]}" must gate on the previous stage "${REQUIRED_STAGES[i - 1]}"`, + ); + } + + // Stage commands. + assert.ok( + jobs['frozen-install'].runs.some((run) => run.includes('pnpm install --frozen-lockfile')), + 'frozen-install must run the frozen lockfile install', + ); + assert.ok( + jobs['typecheck'].runs.some((run) => run.includes('pnpm typecheck')), + 'the typecheck stage must run pnpm typecheck', + ); + assert.ok( + jobs['formatting-lint'].runs.some((run) => run.includes('pnpm lint')), + 'the formatting-lint stage must run pnpm lint', + ); + + // The build stage compiles the apps group and verifies the server artifact. + const buildRuns = jobs['build-apps'].runs; + assert.ok( + buildRuns.some((run) => run.includes('run build') && run.includes('./apps/**')), + 'build-apps must build the apps group (pnpm --filter "./apps/**" run build)', + ); + assert.ok( + buildRuns.some((run) => run.includes('apps/server/dist/index.js')), + 'build-apps must verify the compiled server application artifact', + ); + + // Every committed test suite is wired into exactly one stage. + const staged = [ + ...suitesNamedInRuns(jobs['unit'].runs), + ...suitesNamedInRuns(jobs['architecture'].runs), + ...suitesNamedInRuns(jobs['postgres-integration'].runs), + ]; + const allSuites = readdirSync(path.join(REPO_ROOT, 'tests')) + .filter((file) => file.endsWith('.test.mjs')) + .sort(); + // The formatting-policy suite is run by the formatting-lint stage via the + // root lint script rather than by a node --test run in a test stage. + const expected = allSuites.filter((file) => file !== 'formatting-policy.test.mjs'); + assert.equal( + staged.length, + expected.length, + 'each test suite must be wired into exactly one CI stage run ' + + `(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`, + ); + assert.deepEqual( + [...new Set(staged)].sort(), + expected, + 'every committed test suite must be wired into exactly one CI stage ' + + `(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`, + ); +} + +/** Removes the whole ` :` block from a workflow text (probe helper). */ +function removeJobBlock(yamlText, jobName) { + const lines = yamlText.split('\n'); + const start = lines.findIndex((line) => line === ` ${jobName}:`); + assert.ok(start >= 0, `job ${jobName} must exist in the workflow text`); + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if ( + trimmed !== '' && + !trimmed.startsWith('#') && + lines[i].length - lines[i].trimStart().length === 2 && + /^[A-Za-z0-9_-]+:/.test(trimmed) + ) { + end = i; + break; + } + } + return [...lines.slice(0, start), ...lines.slice(end)].join('\n'); +} + +// --------------------------------------------------------------------------- +// Real-workflow baseline +// --------------------------------------------------------------------------- + +test('the committed CI workflow runs the required PR stages in order', () => { + assertBaseline(parseWorkflowJobs(read(WORKFLOW))); +}); + +test('the workflow triggers on pull requests and pushes to main', () => { + const text = read(WORKFLOW); + assert.match(text, /^on:$/m, 'the workflow must declare an on: trigger block'); + assert.match(text, /pull_request:/, 'PRs must trigger the CI workflow'); + assert.match(text, /push:/, 'pushes must trigger the CI workflow'); + assert.match(text, /branches:\s*\[main\]/, 'the push trigger must cover main'); +}); + +test('the root lint script runs the formatting-policy suite', () => { + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.lint, + LINT_SCRIPT, + `root package.json must declare scripts.lint exactly as "${LINT_SCRIPT}"`, + ); +}); + +test('the workflow pins third-party actions to full commit SHAs and declares minimal permissions', () => { + assertHardening(read(WORKFLOW)); +}); + +// --------------------------------------------------------------------------- +// Mutation probes — the baseline assertions are non-vacuous +// --------------------------------------------------------------------------- + +test('removing a required stage fails the baseline (mutation probe)', () => { + const mutated = removeJobBlock(read(WORKFLOW), 'unit'); + assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /required CI stage "unit"/); +}); + +test('breaking the needs chain fails the baseline (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace('needs: formatting-lint', 'needs: typecheck'); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws( + () => assertBaseline(parseWorkflowJobs(mutated)), + /must gate on the previous stage/, + ); +}); + +test('dropping a suite from its stage fails the coverage assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace('tests/config-schema.test.mjs', ''); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws( + () => assertBaseline(parseWorkflowJobs(mutated)), + /must be wired into exactly one CI stage/, + ); +}); + +test('reordering the stages fails the baseline (mutation probe)', () => { + const text = read(WORKFLOW); + // Swap the typecheck and formatting-lint job blocks so their document order + // no longer matches the required stage order. + const typecheckBlock = text.slice(text.indexOf(' typecheck:'), text.indexOf(' formatting-lint:')); + const lintBlock = text.slice(text.indexOf(' formatting-lint:'), text.indexOf(' unit:')); + const mutated = text.replace(typecheckBlock + lintBlock, lintBlock + typecheckBlock); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/); +}); + +test('reverting an action pin to a floating tag fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace( + `actions/checkout@${PINNED_ACTIONS['actions/checkout']}`, + 'actions/checkout@v4', + ); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /full 40-char commit SHA/); +}); + +test('changing a pinned action SHA fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace( + `actions/setup-node@${PINNED_ACTIONS['actions/setup-node']}`, + `actions/setup-node@${'a'.repeat(40)}`, + ); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /must be pinned to the committed full commit SHA/); +}); + +test('removing the workflow-level permissions block fails the hardening assertion (mutation probe)', () => { + const text = read(WORKFLOW); + const mutated = text.replace(/^permissions:\n contents: read\n\n/m, ''); + assert.notEqual(mutated, text, 'the probe must mutate the workflow'); + assert.throws(() => assertHardening(mutated), /permissions: contents: read/); +}); diff --git a/tests/formatting-policy.test.mjs b/tests/formatting-policy.test.mjs new file mode 100644 index 0000000..34a8b44 --- /dev/null +++ b/tests/formatting-policy.test.mjs @@ -0,0 +1,249 @@ +/** + * Formatting/lint policy test — locks in the workspace formatting and lint + * policy (E00-S05-T01, CI stage 3: formatting/lint). + * + * Policy (every file tracked by git, i.e. every committed text file): + * - LF line endings: no carriage returns (no CRLF, no lone CR) + * - no UTF-8 byte-order mark + * - no trailing whitespace on any line + * - no tab characters anywhere (indentation is spaces) + * - exactly one final newline: the file must end with `\n`, with no blank + * line left at the end of the file + * JSON files additionally must: + * - parse as strict JSON (no trailing commas, no comments) + * - contain no duplicate object keys + * - use 2-space indentation (every line's leading spaces are an even count) + * + * The scan is scoped to files tracked by git (`git ls-files`), so ignored and + * generated files (node_modules/, dist/, .env, probe scratch files) never + * enter the policy. Binary files (containing a NUL byte) are skipped. + * + * Mutation probes prove every rule is non-vacuous: each violation below is + * injected into a temp file and must be reported. + * + * Run: `pnpm lint` (== `node --test tests/formatting-policy.test.mjs`) + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** True for binary files: the policy applies to text files only. */ +const isBinary = (content) => content.includes('\0'); + +/** + * Returns the list of tracked files (git ls-files, NUL-delimited) under the + * given repo root, or fails the suite when git is unavailable. + */ +function trackedFiles(root = REPO_ROOT) { + const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' }); + assert.equal(result.status, 0, `git ls-files must succeed in ${root}`); + return result.stdout.split('\0').filter((p) => p.length > 0); +} + +/** + * Collects the duplicate object keys of a JSON document. JSON.parse collapses + * duplicate keys (last value wins) before any reviver or post-parse walker can + * see them, so this scans the raw text: a string literal immediately followed + * by `:` is an object key, and keys are tracked per enclosing `{…}` frame, so + * same-named keys in different objects stay legal while a repeated key inside + * one object is reported. + */ +function collectDuplicateKeys(text, out) { + const frames = []; + let i = 0; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === '"') { + let j = i + 1; + while (j < n && text[j] !== '"') { + if (text[j] === '\\') j += 1; + j += 1; + } + const key = text.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + let k = j + 1; + while (k < n && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1; + if (text[k] === ':' && frames.length > 0) { + const seen = frames[frames.length - 1]; + if (seen.has(key)) out.push(`duplicate JSON key: ${key}`); + seen.add(key); + } + i = k; + continue; + } + if (ch === '{') { + frames.push(new Set()); + i += 1; + continue; + } + if (ch === '}') { + frames.pop(); + i += 1; + continue; + } + i += 1; + } +} + +/** + * Returns the formatting/lint violations for one file relative to `root`, or + * [] when the file conforms. Binary files are out of policy scope. + */ +function violationsForFile(root, relPath) { + let content; + try { + content = readFileSync(path.join(root, relPath), 'utf8'); + } catch (err) { + return [`${relPath}: unreadable: ${err.message}`]; + } + if (isBinary(content)) return []; + + const label = (message) => `${relPath}: ${message}`; + const out = []; + + if (content.includes('\r')) out.push(label('carriage return (use LF line endings)')); + if (content.startsWith('\uFEFF')) out.push(label('UTF-8 byte-order mark')); + if (content.includes('\t')) out.push(label('tab character (indentation must be spaces)')); + + const lines = content.split('\n'); + lines.forEach((line, i) => { + if (/[ \t]+$/.test(line)) out.push(label(`trailing whitespace on line ${i + 1}`)); + }); + + if (content.length > 0) { + if (lines[lines.length - 1] !== '') out.push(label('missing final newline')); + else if (lines[lines.length - 2] === '') out.push(label('blank line at end of file')); + } + + if (relPath.endsWith('.json')) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (err) { + out.push(label(`invalid JSON: ${err.message}`)); + return out; + } + const duplicateKeys = []; + collectDuplicateKeys(content, duplicateKeys); + for (const message of duplicateKeys) out.push(label(message)); + lines.forEach((line, i) => { + const indent = /^([ ]*)\S/.exec(line); + if (indent && indent[1].length % 2 !== 0) { + out.push(label(`JSON indentation must be 2 spaces per level (line ${i + 1})`)); + } + }); + } + + return out; +} + +/** Writes a probe file into a fresh temp dir and returns its violations. */ +function probeViolations(filename, content) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-format-policy-')); + try { + writeFileSync(path.join(dir, filename), content); + return violationsForFile(dir, filename); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Real-tree scan +// --------------------------------------------------------------------------- + +test('every tracked text file conforms to the formatting/lint policy', () => { + const files = trackedFiles(); + assert.ok( + files.length >= 25, + `expected a meaningful tracked file set to scan (got ${files.length})`, + ); + const violations = []; + for (const file of files) violations.push(...violationsForFile(REPO_ROOT, file)); + assert.deepEqual(violations, [], `formatting/lint violations:\n${violations.join('\n')}`); +}); + +test('the scan covers the workspace source, docs, manifests and workflow', () => { + const files = trackedFiles(); + for (const expected of [ + 'apps/server/src/index.ts', + 'packages/config/src/env.ts', + 'docs/development/non-container.md', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'compose.yaml', + '.gitea/workflows/ci.yml', + ]) { + assert.ok(files.includes(expected), `tracked file set must include ${expected}`); + } +}); + +// --------------------------------------------------------------------------- +// Mutation probes — every rule is non-vacuous +// --------------------------------------------------------------------------- + +test('a trailing whitespace is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line with trailing space \n'); + assert.ok(violations.some((v) => /trailing whitespace/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a tab character is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\twith tab\n'); + assert.ok(violations.some((v) => /tab character/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a CRLF line ending is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\r\n'); + assert.ok(violations.some((v) => /carriage return/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a UTF-8 byte-order mark is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', '\uFEFFline\n'); + assert.ok(violations.some((v) => /byte-order mark/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a missing final newline is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'no final newline'); + assert.ok(violations.some((v) => /missing final newline/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a blank line at the end of the file is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\n\n'); + assert.ok(violations.some((v) => /blank line at end of file/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('invalid JSON is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{"a": 1,}\n'); + assert.ok(violations.some((v) => /invalid JSON/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a duplicate JSON key is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{"a": 1, "a": 2}\n'); + assert.ok(violations.some((v) => /duplicate JSON key/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('odd JSON indentation is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{\n "a": 1,\n "b": 2\n}\n'); + assert.ok(violations.some((v) => /2 spaces per level/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('binary files are out of policy scope (mutation probe)', () => { + const violations = probeViolations('probe.bin', 'a\0b'); + assert.deepEqual(violations, []); +}); + +test('a conforming file yields no violations (mutation probe)', () => { + const violations = probeViolations('probe.json', '{\n "a": 1\n}\n'); + assert.deepEqual(violations, []); +});