Compare commits

...
8 Commits
Author SHA1 Message Date
kpcto aaa7489566 Merge pull request '[E00-S05-T01] Implement CI quality baseline (required PR stages)' (#405) from feature/187 into main
CI / Stage 2 — Typecheck (E00-S05-T01) (push) Successful in 1m9s
CI / Stage 5 — Architecture tests (E00-S05-T01) (push) Successful in 3m7s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (push) Successful in 1m2s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (push) Successful in 44s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (push) Successful in 1m8s
CI / Stage 4 — Unit tests (E00-S05-T01) (push) Successful in 1m36s
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (push) Successful in 2m32s
2026-08-31 00:07:02 +00:00
implementer 10ea1ef3db test: lock in the action SHA pins and workflow-level permissions with the ci-stages suite (E00-S05-T01)
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 4m11s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m24s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 51s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m49s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 4m2s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m15s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m43s
- assertHardening: every uses: ref is a full 40-char commit SHA equal to the
  committed PINNED_ACTIONS table (no floating tags) and the workflow declares
  a top-level permissions: contents: read block
- mutation probes: reverting a pin to @v4, swapping a pinned SHA or removing
  the permissions block all fail
2026-08-30 06:29:43 +00:00
implementer d0c3b6a83d ci: pin third-party actions to full commit SHAs and declare minimal workflow permissions (E00-S05-T01)
- actions/checkout pinned to 11bd71901bbe5b1630ceea73d27597364c9af683 (v4.2.2)
- actions/setup-node pinned to 1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a (v4.2.0)
- workflow-level permissions: contents: read (the pipeline only reads the repo)
- no floating @v4 tags remain anywhere in the workflow
2026-08-30 06:29:43 +00:00
implementer 13b1548df8 ci: build the config and database-postgres packages before the architecture suites (E00-S05-T01)
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 53s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m33s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 1m20s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 2m46s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 4m55s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m22s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m22s
The strict-tsconfig suite typechecks apps/server with tsc --noEmit, which
resolves @personal-blog/config and @personal-blog/database-postgres through
their compiled dist/ type declarations. On a fresh checkout dist/ does not
exist, so the architecture stage must build those two packages first (the
same prerequisite the unit and postgres-integration stages already declare).
2026-08-30 06:22:26 +00:00
implementer f3cd70e45d docs: document the lint command and the CI quality baseline stages (E00-S05-T01)
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 46s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m15s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 44s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m44s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Failing after 56s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Skipped
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Skipped
The non-container guide now covers pnpm lint (the formatting/lint policy) in
the Test and clean-clone smoke sections and describes the ordered PR CI
stages locked in by tests/ci-stages.test.mjs.
2026-08-30 06:12:25 +00:00
implementer 89fe0b52e6 ci: run the required PR quality stages in order (E00-S05-T01)
Restructure .gitea/workflows/ci.yml from a flat list of per-suite jobs into
the ordered stage baseline: frozen install -> typecheck -> formatting/lint ->
unit -> architecture -> PostgreSQL integration -> build of the admin and
server applications. Each stage gates on its predecessor through needs, so
the frozen install runs before every later stage and the pipeline halts on
the first failing stage. The docker-gated real-stack probes in the
postgres-integration (and container) suites keep running where a Docker
daemon is available and skipping cleanly otherwise.
2026-08-30 06:12:25 +00:00
implementer d9b493498e test: lock in the CI quality baseline stage order with the ci-stages suite (E00-S05-T01)
tests/ci-stages.test.mjs asserts that .gitea/workflows/ci.yml declares the
seven required PR stages (frozen-install, typecheck, formatting-lint, unit,
architecture, postgres-integration, build-apps) in order, that every later
stage gates on its predecessor through needs, that each stage runs its
expected command (frozen install, pnpm typecheck, pnpm lint, the unit /
architecture / postgres-integration node --test runs, the apps-group build
with the server artifact check), and that every committed test suite is
wired into exactly one stage. Mutation probes prove the assertions are
non-vacuous.
2026-08-30 06:12:25 +00:00
implementer e6d28f94f0 feat: add dependency-free formatting/lint policy suite and root lint script (E00-S05-T01)
The formatting-policy suite (tests/formatting-policy.test.mjs) locks in the
workspace formatting and lint policy: LF line endings, no BOM, no trailing
whitespace, no tab indentation, exactly one final newline, and valid JSON
with 2-space indentation and no duplicate keys. Every rule has a mutation
probe. The root `lint` script runs the suite; the CI formatting-lint stage
executes it.
2026-08-30 06:12:25 +00:00
5 changed files with 840 additions and 297 deletions
+204 -293
View File
@@ -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
+16 -4
View File
@@ -143,11 +143,22 @@ node <package-dir>/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
```
+1
View File
@@ -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"
},
+370
View File
@@ -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@<sha>". */
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 ` <jobName>:` 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/);
});
+249
View File
@@ -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, []);
});