Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ updates:
- "StyleCop.Analyzers*"
- "Microsoft.CodeAnalysis.*Analyzers"

- package-ecosystem: npm
directory: /src/Anichron.UI
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
ui-lint:
patterns:
- "eslint"
- "@eslint/*"
- "eslint-plugin-*"
- "typescript-eslint"
- "prettier"
- "dependency-cruiser"
npm:
patterns:
- "*"
exclude-patterns:
- "eslint"
- "@eslint/*"
- "eslint-plugin-*"
- "typescript-eslint"
- "prettier"
- "dependency-cruiser"

- package-ecosystem: docker
directory: /src/Anichron.API
schedule:
Expand Down
67 changes: 66 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,71 @@ jobs:
- name: Integration tests
run: dotnet test src/ -p:TestSuite=Integration

ui:
name: UI
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/Anichron.UI
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

# Installs pnpm from packageManager and Node from .nvmrc, then runs a frozen install.
- uses: pnpm/setup@v3
with:
working-directory: src/Anichron.UI
require-lockfile: true
cache: true

- name: Typecheck
run: pnpm typecheck

- name: Lint
run: pnpm lint

- name: Format
run: pnpm format

- name: Dependency rules
run: pnpm depcruise

- name: Unit tests
run: pnpm test

- name: Test UI helper scripts
working-directory: .
run: |
scripts/ui-mutation-changed.test.sh
scripts/ui-mutation-guard.test.sh
scripts/ui-pre-push.test.sh

- name: Mutation setup matches its rules
working-directory: .
run: scripts/ui-mutation-guard.sh

# A pull request mutates what it changed; master mutates the whole scope.
- name: Mutation test
working-directory: .
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.base_ref }}
run: |
if [ "$EVENT" = pull_request ]; then
scripts/ui-mutation-changed.sh --base "origin/$BASE"
else
scripts/ui-mutation-changed.sh --all
fi

- name: Build
run: pnpm build

- name: Bundle budget
run: pnpm size

# One Stryker run per mutation target project, in parallel (ADR 0010): in
# solution mode Stryker captures the same coverage once per project. The list
# comes from the IsMutationTarget convention, so a new project is mutated
Expand Down Expand Up @@ -324,7 +389,7 @@ jobs:
gate:
name: CI gate
runs-on: ubuntu-latest
needs: [checks, integration, mutation, docker]
needs: [checks, integration, mutation, ui, docker]
if: always()
permissions:
contents: read
Expand Down
19 changes: 17 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,40 @@ on:

jobs:
analyze:
name: Analyze (C#)
name: Analyze (${{ matrix.name }})
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: C#
language: csharp
build-mode: manual
- name: TypeScript
language: javascript-typescript
build-mode: none

steps:
- uses: actions/checkout@v7

- uses: github/codeql-action/init@v4
with:
languages: csharp
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- uses: actions/setup-dotnet@v6
if: matrix.language == 'csharp'
with:
dotnet-version: '10.0.x'

- name: Build
if: matrix.language == 'csharp'
run: dotnet build src/

- uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}
38 changes: 38 additions & 0 deletions .husky/task-runner.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,49 @@
"command": "dotnet",
"args": ["build", "src/", "--no-incremental", "-v", "q"]
},
{
"name": "ui-format",
"group": "pre-commit",
"command": "pnpm",
"args": ["--dir", "src/Anichron.UI", "format"],
"filteringRule": "staged",
"include": ["src/Anichron.UI/**"]
},
{
"name": "ui-lint",
"group": "pre-commit",
"command": "pnpm",
"args": ["--dir", "src/Anichron.UI", "lint"],
"filteringRule": "staged",
"include": ["src/Anichron.UI/**"]
},
{
"name": "ui-typecheck",
"group": "pre-commit",
"command": "pnpm",
"args": ["--dir", "src/Anichron.UI", "typecheck"],
"filteringRule": "staged",
"include": ["src/Anichron.UI/**"]
},
{
"name": "ui-depcruise",
"group": "pre-commit",
"command": "pnpm",
"args": ["--dir", "src/Anichron.UI", "depcruise"],
"filteringRule": "staged",
"include": ["src/Anichron.UI/**"]
},
{
"name": "test",
"group": "pre-push",
"command": "dotnet",
"args": ["test", "src/", "-v", "q"]
},
{
"name": "ui-test",
"group": "pre-push",
"command": "bash",
"args": ["scripts/ui-pre-push.sh"]
}
]
}
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Install local tools (Husky) and wire up the pre-commit hook
dotnet tool restore
dotnet husky install

# The UI: only pnpm is needed (Node 26 has no corepack); pnpm downloads the pinned Node itself (devEngines.runtime)
pnpm --dir src/Anichron.UI install
```

## Build & Run
Expand Down Expand Up @@ -88,6 +91,18 @@ Done when the scoped run reports `100.00%` (or nothing to mutate) and every new

Mutants that flip between runs come from `static readonly` initialisers, EF's cached model, and EF's compiled-query cache (a killing assertion that does not depend on the soft-delete filter reads through `IgnoreQueryFilters()`, #312): build such data in a property or method, and build model-asserting test contexts with `EnableServiceProviderCaching(false)` (`AnichronDbContexts.NpgsqlModel()`, or `InMemory(freshModel: true)`). An assertion behind `?.` (`x?.Value.Should()...`) never runs when `x` is null; assert presence first (`ContainSingle(...).Which`). Rationale for every existing exclusion: `docs/adr/0005-mutation-scope-excludes-unkillable-and-wiring-code.md`.

## The UI (`src/Anichron.UI`)

Commands are the `package.json` scripts, run from `src/Anichron.UI` (`pnpm dev`, `typecheck`, `lint`, `format`, `depcruise`, `test`, `build`, `size`, `mutate`). `pnpm dev` proxies `/api` to the API on `http://localhost:5207`. The hooks run `format`, `lint`, `typecheck` and `depcruise` before a commit that stages a UI file, and `scripts/ui-pre-push.sh` before a push (a frozen install, then `test`, when the branch changes the UI); CI's `UI` job runs all of them plus the scoped mutation run and the bundle budget (150 kB gzip initial JS, 15 kB CSS in `.size-limit.json`; a PR that raises one says why).

**Layout.** `src/app/` (entry, providers, the code-based route tree in `router.tsx`), `src/api/`, `src/i18n/`, `src/shared/`, `src/features/<name>/`, and `src/test/` for test support. dependency-cruiser (`.dependency-cruiser.cjs`) fails the build when a feature imports another feature, when `shared/` or `api/` import `features/` or `app/`, when anything but `app/` imports a feature, on an import cycle, when production code imports test code, and when a `.ts` file imports a `.tsx` file. Only `src/api/` touches the network: ESLint's `no-restricted-globals` forbids `fetch`, `EventSource`, `XMLHttpRequest` and `WebSocket` elsewhere, since dependency-cruiser cannot see globals.

**Logic in `.ts`, views in `.tsx`.** Branching, computed values, timers and custom hooks without JSX go in `.ts`. ESLint caps `.tsx` at `complexity` 4 and `max-depth` 2, so a view that grows a branch moves it into a hook. Every user-facing string comes from the English catalog `src/i18n/locales/en.json` through `t(...)` (keys are typed from it; lint fails on a literal in JSX text and in user-visible attributes such as `aria-label`, `alt`, `title` and `placeholder`).

**Tests.** Seam 2 is the default: `renderApp(path)` (`src/test/render-app.tsx`) renders the whole app through its real route tree, MSW (`src/test/server.ts`) is the network boundary and fails on any unhandled request, and assertions are on what the user sees, with `expect(await axeResults(container)).toHaveNoViolations()`. Timers are always fake (`src/test/setup.ts`). Test a `.ts` module directly (seam 3) only for a mutant seam 2 cannot reasonably kill.

**Mutation.** StrykerJS mutates `src/**/*.ts` plus `src/app/router.tsx` at threshold 100; views, tests, declarations, catalogs and test support are out (ADR 0014). Before finishing a UI change, run `scripts/ui-mutation-changed.sh` and work through every survivor with the same rules as the backend above: a test first, a rewrite for an equivalent mutant, a suppression only with an ADR 0014 row. StrykerJS needs no private `TMPDIR`. `scripts/ui-mutation-guard.sh` (a CI step) keeps the config at testRunner `vitest`, thresholds 100, no incremental mode, `--mutate` only in `ui-mutation-changed.sh`, and every mutate entry and directive named in ADR 0014.

## Architecture

Four projects with strict separation of concerns:
Expand All @@ -108,7 +123,7 @@ The API's HTTP surface is `AddApiSurface` plus `MapApiEndpoints`; `Program.cs` a
- **Proxy orphans** (`ProxyOrphans`, a proxy sweep's report) are written only by the Worker; outside it only `EfProxyOrphanReader` depends on the entity (`only-worker-writes-proxy-orphans`). See `docs/architecture/work-queue.md`, "Proxy sweeps".
- Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`).

A planned `Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is not yet in the solution: a React + TypeScript single-page app built with Vite and served by the API on the same origin (ADR 0012). The work is planned in parent issues #344–#349.
`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite, to be served by the API on the same origin (ADR 0012). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below.

### Storage Layout

Expand Down
55 changes: 55 additions & 0 deletions docs/adr/0014-ui-mutation-scope-excludes-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# UI mutation scope: logic in `.ts`, views out

Status: accepted (2026-10-30, #358). The split was decided by the maintainer on #344 (Q4); this
ADR records it, as [ADR 0005](0005-mutation-scope-excludes-unkillable-and-wiring-code.md) does for
the backend.

StrykerJS mutates the UI's **logic**, the `.ts` files under `src/Anichron.UI/src/`, at the same
100% threshold as the backend. **Views**, the `.tsx` files, are out of scope except for the route
tree. Everything in scope that survives is a missing test.

## Why views are excluded

A view's mutants are mostly in its markup: a removed element, an emptied attribute, a swapped
class name. Killing them takes a test per element, which pins the component's structure, and the
UI's tests assert what the user sees instead (#344, "Testing Decisions"). The branching in a view
is what carries real behaviour, and the decision keeps it out of views rather than mutating them:

- Logic, including custom hooks without JSX, lives in `.ts`. dependency-cruiser's
`logic-imports-no-views` rule forbids a `.ts` file importing a `.tsx` file, so logic cannot lean
on a view.
- ESLint caps `complexity` at 4 and `max-depth` at 2 in `.tsx` files, so a view that grows real
branching fails lint and has to move it into a hook.
- Views are still tested: the whole app is rendered through its real route tree, with MSW at the
network boundary and `vitest-axe` on the result (seam 2). That catches a broken screen. It does
not count towards the score.

## What is excluded, and what is brought back

`mutate` in `src/Anichron.UI/stryker.config.json` starts from `src/**/*.ts`. Every other entry is
listed here, and `scripts/ui-mutation-guard.sh` fails on one that is not.

| Entry | Why |
| --- | --- |
| `src/app/router.tsx` | Brought back into scope. The route tree is code-based (Q5) and references the page components, so it is a `.tsx` file under the `.ts` → `.tsx` rule. Its paths and parent links are logic, and seam 2 kills their mutants. Decided by the maintainer on #358. |
| `!src/**/*.test.ts` | Tests. |
| `!src/**/*.d.ts` | Declaration files have no runtime code. |
| `!src/test/**` | Test support (MSW server, the render helper, setup), referenced only by tests; ADR 0005, rule 4. |
| `!src/i18n/locales/**` | The translation catalogs are data. A wrong string fails the seam-2 tests that read it. |
| `!src/api/generated/**` | API types generated from the OpenAPI document (#347): generated code, ADR 0005, rule 1. |

### Individual sites (`// Stryker disable`)

None yet. A directive needs a row here naming its file, and the same proof as ADR 0005: no input
can tell the original and the mutant apart, and no rewrite removes the branch.

## Consequences

- A view that needs a branch, a computed value or a timer gets a hook in a `.ts` file, tested
through seam 2 or directly (seam 3) when seam 2 cannot reasonably kill a mutant.
- An equivalent mutant in a `.ts` file is rewritten away before it is suppressed. The first one,
`initAsync: false` in `createI18n`, was: with inline catalogs i18next initialises synchronously
either way (measured), so the option was dropped.
- The setup is guarded like the backend's: thresholds 100, the Vitest runner, no incremental
mode, `--mutate` only in `scripts/ui-mutation-changed.sh`, and workflows run StrykerJS only
through that script.
23 changes: 23 additions & 0 deletions docs/mutation-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,29 @@ agent works — it diffs the main checkout against the target instead. Measured
untested method: it listed the main checkout's changed files, tested 0 mutants, printed "unable to
calculate a mutation score" and **exited `0`**. `--with-baseline` builds on the same diff.

## The UI

The UI (`src/Anichron.UI`) is mutated by StrykerJS with the Vitest runner, configured in
`src/Anichron.UI/stryker.config.json` at the same 100 thresholds. Its scope is ADR 0014, its
guard `scripts/ui-mutation-guard.sh`, and its scoped run `scripts/ui-mutation-changed.sh`: only
the changed files when every changed UI file is in scope, otherwise the whole scope (a changed
test, view or config can stop a mutant anywhere from being killed, and the scope is small). CI's
`UI` job runs it against the pull request's base, and with `--all` on master.

**The shared-`TMPDIR` hazard does not apply** (checked on StrykerJS 10.1.1). Its sandboxes are
directories under `.stryker-tmp` in the UI project itself, so each worktree has its own, and the
Vitest runner hands the active mutant to the tests in process (Vitest's `provide`/`inject`), not
through a file. Its per-worker setup file is written inside the sandbox. Two runs at once
therefore cannot read each other's mutant, and the scoped run sets no private `TMPDIR`.

**The gate was proven both ways** when it was set up: one survivor (the since-removed
`initAsync: false` in `createI18n`) failed the run at 91.67%, and the seam-2 test,
which sits inside a `describe`, killed the route tree's mutants. The latter matters because of a
StrykerJS bug, closed a day before 10.1.1: no tests ran per mutant for tests inside `describe`
on Vitest 5 (stryker-js#6265). Check a runner upgrade the same way.
Run Stryker at its default log level: `--logLevel debug` crashes the Vitest 5
runner (stryker-js#6272).

## Reading the result

```
Expand Down
Loading
Loading