diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c0e83746..0ec54093 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,15 +1,13 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json # # CodeRabbit config for Axis. Purpose: make automated review cite this repo's -# canonical rules (CLAUDE.md + docs/playbooks) instead of generic advice, so -# every finding maps to a section a human (or agent) can act on — and so the -# review-findings ledger (docs/review-findings-ledger.md) can track which -# finding classes have been mechanized away vs. still need human review. +# canonical rules (CLAUDE.md + docs/playbooks) instead of generic advice. +# Every finding should map to a section a human or agent can act on. The +# enforcement taxonomy lives in docs/REVIEW_FINDINGS.md. # -# Deterministic rules (no var, one-type-per-file, using-not-FQCN, forward -# CancellationToken) are enforced by analyzers in .editorconfig — they should -# fail the build, not wait for review. The instructions below are for the -# judgment-based classes that an analyzer cannot reliably catch. +# Deterministic rules like no-var locals and CancellationToken forwarding are +# enforced by analyzers/.editorconfig and should fail the build, not wait for +# review. The instructions below are for judgment-based classes or guidance. language: en-US reviews: @@ -20,14 +18,14 @@ reviews: path_instructions: - path: "**/*.cs" instructions: | - Project rules: CLAUDE.md (machine rules P0-P2) and .editorconfig are - authoritative. Style/naming (no `var` for locals, `using` not - fully-qualified names) and CancellationToken forwarding (CA2016, escalated - in .editorconfig + TreatWarningsAsErrors) are analyzer-enforced and fail - the build — only flag them if the build somehow did not. (One-public-type- - per-file is deliberately NOT enforced — it conflicts with intentional - groupings like Result/Result, ICommand/ICommand, and polymorphic - value-object hierarchies; see review-findings-ledger.md. Do not flag it.) + Project rules: CLAUDE.md (machine rules P0-P2), .editorconfig, and + docs/REVIEW_FINDINGS.md are authoritative. No `var` for locals and + CancellationToken forwarding (CA2016) are analyzer-enforced and fail + the build; only flag them if the build somehow did not. Inline + fully-qualified type names are style guidance, not a hard gate. + One-public-type-per-file is deliberately NOT enforced because it + conflicts with intentional groupings; see docs/REVIEW_FINDINGS.md. + Do not flag it. Focus review on: - Result / Result for business failures; exceptions only for infrastructure faults (CLAUDE.md P1). Flag bespoke bool/tuple/throw diff --git a/.editorconfig b/.editorconfig index c35b3b02..79531c8b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,6 +18,7 @@ root = true [*] +# Enforced by `python scripts/axis.py check text-encoding`. charset = utf-8 end_of_line = lf indent_style = space @@ -345,7 +346,7 @@ dotnet_analyzer_diagnostic.category-Reliability.severity = suggestion # ── Escalated to warning (→ build error via TreatWarningsAsErrors) ── # Recurring review findings that are mechanical and deterministic: cheaper to -# fail the build than to re-flag in review. See docs/review-findings-ledger.md. +# fail the build than to re-flag in review. See docs/REVIEW_FINDINGS.md. # CA2016: forward the CancellationToken parameter to methods that take one. # Caught dropped tokens in schedulers/middleware/gRPC that review kept finding. dotnet_diagnostic.CA2016.severity = warning diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 743763ad..1b38d6fc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,13 +8,13 @@ ## Requirements & rules followed - + - [ ] **Spec → code** — changes match use-case file ACs (or gaps documented in `> **Implementation status**` callouts; deferred items have `**Deferred (PR #N follow-up):**` lines) -- [ ] **Gate 0** — AC map complete; use-case/domain docs identified (when shipping code) +- [ ] **Ready review** — AC map complete; use-case/domain docs identified (when shipping behavior) - [ ] **Path coverage matrix** — for each touched implementation surface, happy/validation/auth/isolation/dependency paths are tested or explicitly marked `N/A` (see `docs/playbooks/agent-checklist.md` § AC coverage) -- [ ] **Gate 1** — local fast gate (`python scripts/axis.py verify`: build + vulnerability scan + format + unit test projects + frontend checks + doc drift) ran green for paths touched; full `dotnet test Axis.sln` is enforced by CI/branch protection (N/A with reason) -- [ ] **Gate 2** — docs updated in same PR (use-case callout, domain README, `PROGRESS.md`, `TECH_STACK` / patterns as triggered); `python scripts/axis.py check doc-drift` ran green locally -- [ ] **Gate 3** — retrospective done; `patterns.md` / use-case file / `TECH_STACK.md` / `CLAUDE.md` updated if a new durable rule emerged +- [ ] **Verification gate** — local fast gate (`python scripts/axis.py verify`: build + vulnerability scan + format + unit test projects + frontend checks + policy tests + doc drift) ran green for paths touched; full `dotnet test Axis.sln` is enforced by CI/branch protection (N/A with reason) +- [ ] **Docs review** — owning docs updated when behavior/spec/status changed; pure refactor/style/test-only changes marked N/A +- [ ] **Retrospective review** — `REVIEW_FINDINGS.md` / patterns / use-case / `TECH_STACK.md` / `CLAUDE.md` updated if a durable rule or repeat finding emerged - [ ] **Workarounds** — this PR neither introduces nor resolves a P0/P1 violation **OR** [`docs/WORKAROUNDS.md`](../docs/WORKAROUNDS.md) was updated in the same commit and the violation site has a `// WORKAROUND: see docs/WORKAROUNDS.md#` comment - [ ] **No new `TODO` / `FIXME` / `NotImplementedException` / placeholder / stub** under `src/`, `tests/`, `frontend/src/` diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 24550c7c..15cd18a1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -42,7 +42,6 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} - doc-drift-scope: ${{ steps.filter.outputs['doc-drift-scope'] }} protobuf: ${{ steps.filter.outputs.protobuf }} markdown: ${{ steps.filter.outputs.markdown }} steps: @@ -77,19 +76,6 @@ jobs: # when no frontend/** file is touched in the same PR. - 'openapi.json' - '.github/workflows/build-and-test.yml' - doc-drift-scope: - - 'src/**' - - 'tests/**' - # The umbrella's full-tree checks (link targets, use-case - # structure, code-fence integrity) validate every doc, so any - # doc change must trigger them — not just use-cases. - - 'docs/**' - # Globbed, not enumerated: the umbrella invokes several checker - # scripts, and a new one must trigger the job without anyone - # remembering to add it here. - - 'scripts/**' - - 'buf.yaml' - - '.github/workflows/build-and-test.yml' protobuf: - '**/*.proto' - 'buf.yaml' @@ -138,7 +124,7 @@ jobs: - name: Build run: dotnet build --no-restore - # Format gate — see CLAUDE.md § Gate 1. Runs after build so analyzer + # Format gate — see CLAUDE.md § Verification gate. Runs after build so analyzer # diagnostics are populated. Fails the job on any whitespace / charset # / style violation the local `dotnet format` would also catch. - name: Verify formatting (dotnet format) @@ -210,8 +196,6 @@ jobs: doc-drift: name: Doc drift - needs: detect - if: needs.detect.outputs['doc-drift-scope'] == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -220,6 +204,9 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Test policy gates + run: python scripts/axis.py check policy-tests + - name: Check doc drift run: python scripts/axis.py check doc-drift env: diff --git a/Axis.sln b/Axis.sln index 49d3d550..2d6d27c2 100644 --- a/Axis.sln +++ b/Axis.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 diff --git a/CLAUDE.md b/CLAUDE.md index ae8ba340..08ce88d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,12 +57,12 @@ Stack, versions, and ADRs are owned by [`docs/TECH_STACK.md`](docs/TECH_STACK.md - Tech stack immutable without explicit user approval. - Spec → code only; never rewrite specs to match shortcuts. -- Never weaken tests, `.Skip()`, or mock away behavior under test. +- Never weaken tests, add test `Skip = ...`, or mock away behavior under test. - Never bypass auth, skip an AC silently, or mark ✅ to avoid a hard gap. - Domain: zero external dependencies. - No implementation of a non-trivial change without completing the **Design Gate** ([design-gate.md](docs/playbooks/design-gate.md)); high-risk surfaces require user sign-off before code. -- Never commit with failing Gate 1; docs and requirements satisfied before merge (agent-checklist + PR template). -- When `src/`, `tests/`, or `docs/use-cases/` change: run `python scripts/axis.py check doc-drift` before push; CI **Doc drift** must be green. Tick **Gate 2** in the PR template — do not paste drift-script output. +- Never commit with a failing Verification gate; docs and requirements satisfied before merge (agent-checklist + PR template). +- Run `python scripts/axis.py check policy-tests` and `python scripts/axis.py check doc-drift` before push when touching docs, scripts, repo layout, handlers, endpoints, or generated-contract surfaces; CI **Doc drift** runs on every PR and must be green. Tick **Docs review** in the PR template when docs were reviewed — do not paste drift-script output. **P1 — confirm with user before deviating:** @@ -77,7 +77,7 @@ Stack, versions, and ADRs are owned by [`docs/TECH_STACK.md`](docs/TECH_STACK.md **P2 — every commit:** - Zero build warnings and zero test failures. -- Docs in the same PR as code (see [agent-checklist](docs/playbooks/agent-checklist.md)). +- Owning docs in the same PR when behavior/spec/status changes; pure refactor/style/test-only changes do not need a token docs edit (see [agent-checklist](docs/playbooks/agent-checklist.md)). - No new TODO/FIXME/placeholder/stub code. - For **wireframe** changes (`docs/wireframes/`, `docs/use-cases/**` screen `.excalidraw`/`.svg`): run [`docs/playbooks/visual-artifact-checklist.md`](docs/playbooks/visual-artifact-checklist.md) before commit. **Diagrams** are Mermaid in `docs/README.md` and use-case `README.md` — one theme in [`docs/diagrams/mermaid-theme.mjs`](docs/diagrams/mermaid-theme.mjs); run `node docs/scripts/sync-mermaid-theme.mjs` after editing `MERMAID_INIT` ([`docs/playbooks/mermaid.md`](docs/playbooks/mermaid.md)). @@ -118,18 +118,18 @@ Response header for multi-file / new-layer tasks: Skip for trivial single-file fixes and doc-only edits. -### Gates +### Reviews And Gates -**Gate 0/1 ownership:** detailed AC-map/path-coverage requirements and the authoritative Gate 1 command matrix are owned by +**Ready review / Verification gate ownership:** detailed AC-map/path-coverage requirements and the authoritative Verification gate command matrix are owned by [agent-checklist.md](docs/playbooks/agent-checklist.md) (single source). This file keeps policy-level requirements only: -- **Gate 0 policy:** no blank AC map rows for in-scope bullets; no happy-path-only completion claims. -- **Gate 1 policy:** follow [agent-checklist.md § Gate 1](docs/playbooks/agent-checklist.md#gate-1--verify-before-push-fast-local-gate), the single owner for local fast-gate commands and CI full-gate expectations. Never present a unit-only/local-fast run as a full-suite run. +- **Ready review policy:** no blank AC map rows for in-scope bullets; no happy-path-only completion claims. +- **Verification gate policy:** follow [agent-checklist.md § Verification Gate](docs/playbooks/agent-checklist.md#verification-gate--verify-before-push), the single owner for local fast-gate commands and CI full-gate expectations. Never present a unit-only/local-fast run as a full-suite run. -**Gate 2** — docs in same PR ([agent-checklist.md § Gate 2](docs/playbooks/agent-checklist.md)). **Doc drift** — run script before push when code/use-cases change; CI job must be green. +**Docs review** — docs walkthrough when behavior/spec/status changes ([agent-checklist.md](docs/playbooks/agent-checklist.md)). **Doc drift** — CI-enforced deterministic policy/doc checks; it does not require a token docs edit for every code diff. -**Gate 3** — retrospective ([agent-checklist.md § Gate 3](docs/playbooks/agent-checklist.md)); update docs on any "yes". +**Retrospective review** — update docs, tests, or [REVIEW_FINDINGS.md](docs/REVIEW_FINDINGS.md) when a durable rule or repeat finding emerges. ### Git @@ -190,7 +190,7 @@ Add navigation back-links per [docs/README.md](docs/README.md) (playbooks, use-c **Per layer / module:** all use-case callouts updated; domain README table; [`PROGRESS.md`](docs/PROGRESS.md) (layer summary only — not per-class detail). -**Per PR before merge:** PR description = Summary + Linked spec + Requirements only (no CI status, no commit list — Checks tab covers that). Run `python scripts/axis.py check doc-drift` before push when `src/`, `tests/`, or `docs/use-cases/` change — the command enforces use-case-docs same-PR, new-handler tests, the no-new `TODO`/`FIXME`/`stub` rule, script-standard enforcement, and new raw-SQL call review (cross-module guard). +**Per PR before merge:** PR description = Summary + Linked spec + Requirements only (no CI status, no commit list — Checks tab covers that). Run `python scripts/axis.py check policy-tests` and `python scripts/axis.py check doc-drift` before push when touching docs, scripts, repo layout, handlers, endpoints, generated-contract surfaces, or bulk file rewrites. The CI job runs on every PR and enforces deterministic policy/doc checks: text encoding (`UTF-8` without BOM + LF), changed-handler test files, no-new `TODO`/`FIXME`/`stub`, no new test `Skip = ...`, no `EnsureCreated`, script standards, layout checks, and docs integrity. Diagrams/wireframes: regenerate `.svg` in same PR when source `.excalidraw` changes. Agents must pass [`docs/playbooks/visual-artifact-checklist.md`](docs/playbooks/visual-artifact-checklist.md) before commit. @@ -212,7 +212,7 @@ Diagrams/wireframes: regenerate `.svg` in same PR when source `.excalidraw` chan | [TECH_STACK.md](docs/TECH_STACK.md) | Libraries + ADRs | | [PROGRESS.md](docs/PROGRESS.md) | Module layer status | | [WORKAROUNDS.md](docs/WORKAROUNDS.md) | Intentional rule violations + cleanup triggers (**read when touching legacy or shipping a known shortcut**) | -| [REVIEW_FINDINGS.md](docs/REVIEW_FINDINGS.md) | Recurring review finding classes → the gate that prevents each (or why manual); wired to Gate 3 | +| [REVIEW_FINDINGS.md](docs/REVIEW_FINDINGS.md) | Recurring review finding classes → Enforced / Partial / Review-only / Guidance status; wired to Retrospective review | | [docs/playbooks/visual-artifact-checklist.md](docs/playbooks/visual-artifact-checklist.md) | **Required when changing diagrams/wireframes/use-case visuals** | | [Architecture tests README](tests/Architecture/Axis.Architecture.Tests/README.md) | What's mechanically enforced + how to add a new rule | | [docs/use-cases/](docs/use-cases/README.md) | Features + ACs | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 752e15dc..7e959817 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,13 +13,13 @@ Docs-first development: feature specs in `docs/use-cases/` are the contract; cod ## Before you push -Install the local hook once with `python scripts/axis.py bootstrap`, then use `python scripts/axis.py verify` for the fast pre-push gate. During implementation, prefer targeted checks for the surface you are editing; the hook is the local enforcement point before push. The authoritative Gate 1 policy and command matrix live in [agent-checklist.md § Gate 1](docs/playbooks/agent-checklist.md#gate-1--verify-before-push-fast-local-gate); unit-only feedback is available via `python scripts/axis.py test unit`. Script standards live in [scripts.md](docs/playbooks/scripts.md). +Install the local hook once with `python scripts/axis.py bootstrap`, then use `python scripts/axis.py verify` for the fast pre-push gate. During implementation, prefer targeted checks for the surface you are editing; the hook is the local enforcement point before push. The authoritative Verification gate policy and command matrix live in [agent-checklist.md § Verification Gate](docs/playbooks/agent-checklist.md#verification-gate--verify-before-push); unit-only feedback is available via `python scripts/axis.py test unit`. Script standards live in [scripts.md](docs/playbooks/scripts.md). -1. Walk **Gates 0-3** in [docs/playbooks/agent-checklist.md](docs/playbooks/agent-checklist.md) locally; tick the matching boxes in the PR body. +1. Walk Ready review, Verification gate, Docs review, and Retrospective review in [docs/playbooks/agent-checklist.md](docs/playbooks/agent-checklist.md) locally; tick the matching boxes in the PR body. 2. When you touch C# under `src/` or `tests/`, run `dotnet format Axis.sln` - style and naming rules live in [`.editorconfig`](.editorconfig) (CI runs `dotnet format --verify-no-changes`). -3. Run `python scripts/axis.py check doc-drift` when `src/`, `tests/`, or `docs/use-cases/` change. **New module, endpoint, Kafka event, or proto?** Follow [docs/playbooks/repo-layout-discovery.md](docs/playbooks/repo-layout-discovery.md) (checklists A-E - what CI auto-checks vs what you still edit by hand). Use-case layout: [USE_CASE_TEMPLATE.md](docs/use-cases/USE_CASE_TEMPLATE.md). If `docker-compose.yml` changes, update [local-dev.md](docs/playbooks/local-dev.md). CI job **Doc drift** must be green. +3. Run `python scripts/axis.py check policy-tests` and `python scripts/axis.py check doc-drift` when touching docs, scripts, repo layout, handlers, endpoints, or generated-contract surfaces. **New module, endpoint, Kafka event, or proto?** Follow [docs/playbooks/repo-layout-discovery.md](docs/playbooks/repo-layout-discovery.md) (checklists A-E - what CI auto-checks vs what you still edit by hand). Use-case layout: [USE_CASE_TEMPLATE.md](docs/use-cases/USE_CASE_TEMPLATE.md). If `docker-compose.yml` changes, update [local-dev.md](docs/playbooks/local-dev.md). CI job **Doc drift** runs on every PR and must be green. 4. PR description: **Summary + Linked spec + Requirements only** - no commit list, no CI status (the Checks tab covers that). GitHub auto-fills [.github/PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md); CI job **PR body guard** enforces the required sections and checklist state. -5. When adding or changing `.proto` files: run `python scripts/axis.py generate buf-yaml` (updates [`buf.yaml`](buf.yaml) module paths), then `buf lint` - see [repo-layout-discovery.md section D](docs/playbooks/repo-layout-discovery.md). CI **Protobuf** job runs on proto/`buf.yaml` changes. +5. When adding or changing `.proto` files: run `python scripts/axis.py generate buf-yaml` (updates [`buf.yaml`](buf.yaml) module paths), then `buf lint` - see [repo-layout-discovery.md § Auto-discovered](docs/playbooks/repo-layout-discovery.md#auto-discovered-do-not-duplicate-lists-elsewhere). CI **Protobuf** job runs on proto/`buf.yaml` changes. ## Dependency updates (Dependabot) @@ -54,7 +54,7 @@ When you change [`docker-compose.yml`](docker-compose.yml), update that playbook | Doc | Purpose | |-----|---------| | [CLAUDE.md](CLAUDE.md) | Architecture rules, P0 stops, machine rules | -| [docs/playbooks/agent-checklist.md](docs/playbooks/agent-checklist.md) | Daily workflow + Gates 0-3 | +| [docs/playbooks/agent-checklist.md](docs/playbooks/agent-checklist.md) | Daily workflow, Verification gate, and review checks | | [docs/playbooks/process.md](docs/playbooks/process.md) | Layer-by-layer implementation + deferred follow-ups | | [docs/playbooks/patterns-index.md](docs/playbooks/patterns-index.md) | Jump table into patterns | | [docs/README.md](docs/README.md) | Documentation hub + single source of truth per topic | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb9c4b09..f62d5404 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ Each module *is* a service contract — modulith mode collocates them as in-proc Per-message transport selection follows the suffix convention in [ADR-025](./TECH_STACK.md#adr-025-transport-selection-rule-by-message-name-suffix): `*Command`/`*Job`/`*SagaStep` → RabbitMQ, `*Event`/`*Snapshot` → Kafka. -Forbidden: shared `DbContext`, direct C# method calls into another module's Application services, cross-module SQL, in-process `IMediator` for cross-module dispatch. The drift script and CI enforce these — see [CLAUDE.md § Service boundaries](../CLAUDE.md) and [playbooks/patterns.md § Cross-module communication](./playbooks/patterns.md#cross-module-communication-pattern). +Forbidden: shared `DbContext`, direct C# method calls into another module's Application services, cross-module SQL, in-process `IMediator` for cross-module dispatch. Architecture tests and CI enforce the structural subset; cross-module raw SQL and runtime DI gaps remain review-owned. See [CLAUDE.md § Service boundaries](../CLAUDE.md), [REVIEW_FINDINGS.md](./REVIEW_FINDINGS.md), and [playbooks/patterns.md § Cross-module communication](./playbooks/patterns.md#cross-module-communication-pattern). --- diff --git a/docs/README.md b/docs/README.md index 9c3f990c..4c7b6c26 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,7 @@ | [Visual artifact checklist](./playbooks/visual-artifact-checklist.md) | Required review checklist for diagrams/wireframes/use-case visuals before commit | | [Mermaid theme](./playbooks/mermaid.md) | One `%%{init}%%` for every diagram in `docs/` | | [Docs style](./playbooks/docs-style.md) | Anti-patterns for `.md` files — single-owner rule, size budgets, when to create vs absorb | -| [Review findings](./REVIEW_FINDINGS.md) | Recurring review finding classes → the analyzer/test/codegen/CI guard that prevents each (or why it stays manual); wired to Gate 3 | +| [Review findings](./REVIEW_FINDINGS.md) | Recurring review finding classes → Enforced / Partial / Review-only / Guidance status; wired to Retrospective review | --- diff --git a/docs/REVIEW_FINDINGS.md b/docs/REVIEW_FINDINGS.md index 0e24b468..2589a01f 100644 --- a/docs/REVIEW_FINDINGS.md +++ b/docs/REVIEW_FINDINGS.md @@ -1,72 +1,85 @@ # Review findings -> **Navigation**: [← docs/README.md](./README.md) · [← CLAUDE.md](../CLAUDE.md) +> **Navigation**: [<- docs/README.md](./README.md) . [<- CLAUDE.md](../CLAUDE.md) -A register of recurring code-review finding **classes** and how each is -prevented. The goal: a given finding class is reviewed by a human (or -CodeRabbit) **once**; after that the project either mechanizes it away -(analyzer / fitness test / codegen / CI guard) or records a deliberate -decision to keep catching it in review. This turns review feedback into -prevention instead of re-explaining the same rule every PR. +Recurring review finding classes and how each is prevented. A finding class +should be explained once; after that it is either mechanized, kept explicitly +review-only, downgraded to guidance, or deleted as noise. -Sibling register: [WORKAROUNDS.md](./WORKAROUNDS.md) (intentional rule -violations). This file is the inverse — rules we are progressively making -impossible to violate. +Sibling register: [WORKAROUNDS.md](./WORKAROUNDS.md) tracks intentional rule +violations. This file tracks the rules we are trying to make hard to violate. + +--- + +## Enforcement taxonomy + +Use these labels consistently in docs, PR templates, and review comments: + +| Status | Meaning | Allowed language | +|---|---|---| +| **Enforced** | CI/build/tooling fails the PR for this class. Custom repo gates have a counterexample test proving the rule fires. | "gate", "enforced", "must" | +| **Partial** | Tooling blocks a deterministic subset and the known gap is documented. | "partially enforced"; name the gap | +| **Review-only** | Human/CodeRabbit judgment is required; CI cannot prove it without false positives. | "review checkpoint", not "gate" | +| **Guidance** | Useful convention or example, but not a defect class. | "prefer", "pattern", "example" | +| **Not a rule** | Deliberately not enforced or reviewed. | Avoid rule language | + +Custom gates are not trusted just because they pass on a clean repo. Before a +custom rule can be marked **Enforced**, add a negative test under +[`scripts/tests`](../scripts/tests/test_policy_gates.py) or the relevant test +project showing the bad example fails. Analyzer/compiler/tool rules can rely on +the tool, but the repo-specific wiring still needs to run in CI. --- ## When to add a row -Wired into **Gate 3** ([agent-checklist.md § Gate 3](./playbooks/agent-checklist.md)): -when a review finding **repeats a class already seen** in a prior PR, it must -land here — either pointed at a mechanism or recorded as deliberately manual. +Wired into **Retrospective review** ([agent-checklist.md](./playbooks/agent-checklist.md)): +when a review finding repeats a class already seen, record it here. Pick a +status honestly; do not upgrade review-only guidance into a fake gate. -Before building a gate, the class must pass all three tests. If any is "no", -leave it in the **manual** tier with the reason; an unreliable gate costs more -than the review it replaces. +Before building a gate, the class must pass all three tests. If any answer is +"no", leave it review-only or guidance. -1. **Deterministic?** Can a rule decide it without understanding intent? (A - dropped `CancellationToken` is; "this should be a `Result`" often is not.) -2. **Recurred?** Seen ≥ 2–3 times across PRs — a real pattern, not a one-off. -3. **Cheaper than re-review?** Gate build + maintenance (incl. false-positive - noise) is less than the cost of flagging it forever. +1. **Deterministic?** Can a rule decide it without understanding intent? +2. **Recurred?** Seen at least 2-3 times across PRs? +3. **Cheaper than re-review?** Gate build and maintenance cost less than + catching it by review? -Mechanism tiers, cheapest first: **existing analyzer rule** (just escalate -severity in [`.editorconfig`](../.editorconfig)) → **architecture fitness test** -([tests/Architecture](../tests/Architecture/Axis.Architecture.Tests/README.md)) -→ **codegen / source generator** → **CI guard** (workflow step or paths-filter) -→ **manual** (CodeRabbit path-instruction in `.coderabbit.yaml`, added by the -CodeRabbit-config PR, + reviewer judgment). +Mechanism tiers, cheapest first: analyzer severity in [`.editorconfig`](../.editorconfig) +-> architecture fitness test ([tests/Architecture](../tests/Architecture/Axis.Architecture.Tests/README.md)) +-> codegen/source generator -> CI guard -> review-only. --- ## Ledger -| Finding class | Mechanism | Tier | Status | +| Finding class | Mechanism | Proof / scope | Status | |---|---|---|---| -| FE/BE casing & wire-shape drift | `gen:api-types` from `openapi.json` + `OpenApiDocumentTests` + paths-filter on `openapi.json` | codegen + CI | **Closed** ([#165](https://github.com/phuongnse/axis/pull/165)) | -| `CancellationToken` not forwarded to a callee that accepts one | `CA2016` escalated to `warning` → build error via `TreatWarningsAsErrors` | analyzer | **Closed** (`.editorconfig`) | -| Cross-module in-process call / illegal project ref | Architecture fitness tests + CodeRabbit path-instruction | arch-test + manual | **Partial** — fitness tests miss runtime DI resolved via `Contracts`; CodeRabbit flags the rest | -| One public type per `.cs` file | — (kept at `suggestion`) | manual | **Won't mechanize** — not a defect class; a hard rule fights intentional groupings (`Result`, `ICommand`, polymorphic VO hierarchies, query+DTO colocation). 2026-06 scan: 22 files, mostly idiomatic | -| `using` not fully-qualified names | `IDE0001`/`IDE0002` (currently `suggestion`) | analyzer | **Planned** (low ROI — no real defect) | -| Auth/permission check before input validation | CodeRabbit path-instruction (`src/Axis.Api/Endpoints`) | manual | **Manual** — ordering intent not reliably analyzable | -| Wrong status code on bad input (400 vs 500) | CodeRabbit path-instruction | manual | **Manual** | -| Side effects committed before the DB transaction they depend on | CodeRabbit path-instruction | manual | **Manual** — needs data-flow judgment | -| Test asserts something other than its name claims (e.g. `Returns403` asserts 401) | CodeRabbit path-instruction; analyzer pilot under consideration | manual → analyzer? | **Manual** — green-but-wrong test; pilot a name↔asserted-status analyzer if it recurs | -| `Result`/`Result` vs bespoke bool/tuple/throw for business failures | CodeRabbit path-instruction | manual | **Manual** (P1, design judgment) | -| Endpoint returns `object`/anonymous JSON instead of an Application-layer DTO | `python scripts/axis.py check doc-drift` added-lines ratchet: bans new `.Produces` / `Results.Ok(new { … })` in `Axis.Api/Endpoints` | CI guard | **Closed** — 30 endpoints converted to named DTOs (`CreatedResponse`/`MessageResponse`, query DTOs, `UserSessionResponse`); `openapi.json`/`api-types.ts` regenerated. Flagged on [#155](https://github.com/phuongnse/axis/pull/155) | -| Minimal-API endpoint orchestrates >1 `mediator.Send` (logic in endpoint; side-effect consumed before a later step can fail) | `python scripts/axis.py check doc-drift` full-state guard: fails if any endpoint handler (returning `Task`) has >1 `.Send(`/`.Publish(` | CI guard | **Closed** — baseline 0; fails build on any new. Drift guard over a Roslyn analyzer: cheaper, no new dependency for one rule. Limit: named handlers only, not inline lambdas. Flagged on [#155](https://github.com/phuongnse/axis/pull/155) | - -Status: **Closed** = a mechanism fails the build/CI; **Partial** = mechanized -with a documented gap; **Planned** = agreed, not yet built; **Manual** = kept in -review on purpose (reason in the row). +| FE/BE casing and wire-shape drift | `OpenApiDocumentTests` + frontend `gen:api-types` diff on `openapi.json` | CI .NET/frontend jobs run when `openapi.json` or relevant source changes | **Enforced** | +| .NET test name convention | `python scripts/axis.py check test-naming` | `scripts/tests/test_policy_gates.py` negative test + CI policy tests | **Enforced** | +| Python policy gates still fire | `python scripts/axis.py check policy-tests` | CI `Doc drift` job runs on every PR | **Enforced** | +| Tracked text file encoding drift | `python scripts/axis.py check text-encoding` | `scripts/tests/test_policy_gates.py` rejects BOM, CRLF, invalid UTF-8, and common mojibake markers | **Enforced** | +| `CancellationToken` not forwarded to a callee that accepts one | `CA2016` escalated to `warning`, build fails via `TreatWarningsAsErrors` | Analyzer/compiler wiring in `.editorconfig` + `Directory.Build.props` | **Enforced** | +| New skipped tests | Added-line ratchet rejects `Skip =` under `tests/` | `scripts/tests/test_policy_gates.py` negative test | **Enforced** | +| `EnsureCreated` reintroduced | Added-line ratchet rejects `EnsureCreated` / `EnsureCreatedAsync` under `src/` and `tests/` | `scripts/tests/test_policy_gates.py` negative test | **Enforced** | +| New or modified Application handler without matching test file | Diff ratchet checks changed `Commands/*Handler.cs` and `Queries/*Handler.cs` | `scripts/tests/test_policy_gates.py` negative test; untouched legacy files are not swept | **Partial** | +| Endpoint returns `object`/anonymous JSON instead of an Application-layer DTO | Added-line ratchet bans new `.Produces` / `Results.Ok(new { ... })` in `Axis.Api/Endpoints` | `scripts/tests/test_policy_gates.py` covers the ratchet class | **Enforced** | +| Minimal-API endpoint orchestrates more than one mediator call | Full-state guard counts `.Send(`/`.Publish(` in named endpoint handlers | CI `Doc drift`; known gap: inline lambdas are not parsed | **Partial** | +| Cross-module in-process call / illegal project ref | Architecture fitness tests + CodeRabbit path instruction | Tests catch project/type graph violations; runtime DI via `Contracts` still needs review | **Partial** | +| Cross-module raw SQL | CodeRabbit path instruction + review | Path-only grep cannot know table ownership safely | **Review-only** | +| Auth/permission check before input validation | CodeRabbit path instruction | Requires endpoint intent and failure-order judgment | **Review-only** | +| Wrong status code on bad input | CodeRabbit path instruction | Requires semantic assertion and error taxonomy judgment | **Review-only** | +| Side effects committed before the DB transaction they depend on | CodeRabbit path instruction | Requires data-flow and transaction-boundary judgment | **Review-only** | +| Test asserts something other than its name claims | CodeRabbit path instruction; analyzer pilot only if it recurs | Green-but-wrong tests require assertion semantics | **Review-only** | +| `Result`/`Result` vs bespoke bool/tuple/throw for business failures | CodeRabbit path instruction | Design judgment; exceptions are valid for infrastructure faults | **Review-only** | +| One public type per `.cs` file | None | Intentional groupings are common and valid | **Not a rule** | +| Inline fully-qualified type names instead of `using` directives | IDE suggestions / `dotnet format` cleanup | Low defect value; keep as style guidance unless it becomes recurring review noise | **Guidance** | --- ## Metric -The point is a downward trend in **findings per PR for already-closed classes** -— ideally zero (a closed class reappearing means the gate has a hole, like the -`openapi.json` paths-filter gap [#165] closed). Note repeat-of-closed-class -occurrences in the Gate 3 retrospective so holes surface instead of being -re-fixed by hand. +Track repeat findings for already **Enforced** classes. A repeat means either +the gate has a hole, the CI trigger is wrong, or the docs overstated the rule. +Record that in Retrospective review so the fix is a stronger mechanism, not more +prose. diff --git a/docs/playbooks/agent-checklist.md b/docs/playbooks/agent-checklist.md index a653f0d9..272fd61b 100644 --- a/docs/playbooks/agent-checklist.md +++ b/docs/playbooks/agent-checklist.md @@ -2,15 +2,15 @@ > **Navigation**: [← docs/README.md](../README.md) · [← CLAUDE.md](../../CLAUDE.md) -**Daily workflow.** Walk Gates 0–3 **locally** while implementing; reflect outcomes in the [PR template](../../.github/PULL_REQUEST_TEMPLATE.md) checkboxes. **PR description = Summary + Linked spec + Requirements only** — no Gate paste blocks, no commit list, no CI/Doc-drift status (GitHub Checks tab covers that). +**Daily workflow.** Walk the Ready review, Docs review, and Retrospective review while implementing; run the Verification gate before push. Reflect outcomes in the [PR template](../../.github/PULL_REQUEST_TEMPLATE.md) checkboxes. **PR description = Summary + Linked spec + Requirements only** — no review/gate paste blocks, no commit list, no CI/Doc-drift status (GitHub Checks tab covers that). -**Large use cases:** split into **genuinely isolated PRs** (each branch from `main`, each passing the two-sided isolation test). See [pr-slicing.md](./pr-slicing.md) — never stack slice B on slice A's branch, never claim "Gate 1 green" you did not run, and assign one owner per shared seam. +**Large use cases:** split into **genuinely isolated PRs** (each branch from `main`, each passing the two-sided isolation test). See [pr-slicing.md](./pr-slicing.md) — never stack slice B on slice A's branch, never claim the Verification gate is green when you did not run it, and assign one owner per shared seam. The paste-block templates below are for *your own* walk-through (agent reasoning, scratchpad, or PR thread comment if asked) — not for the PR description. --- -## Gate 0 — Ready (before code) +## Ready Review — before code - **Design Gate** ([design-gate.md](./design-gate.md)): re-derive the rules governing the surface you touch and produce the dossier (rules quoted, blast-radius `grep`, contract+casing, gate plan); **high-risk surfaces require user sign-off before code** - AC map: every row has layer + file/test — **no blank cells** @@ -20,7 +20,7 @@ The paste-block templates below are for *your own* walk-through (agent reasoning - End of PR: [process.md § PR wrap-up](process.md) — deferred lines, host wiring, callouts (no user reminder) ```markdown -## Gate 0 +## Ready review | AC / use case | Layer | File / test | |---------|-------|-------------| | … | … | … | @@ -64,7 +64,7 @@ Use-case files group ACs under **Happy path**, **Validation & errors**, **Edge c ### Anti-pattern: `Gaps vs spec: none` after happy path only -Do **not** mark a layer ✅ or write `Gaps vs spec: none for backend` because the main API flow works. That is not Gate 0 complete. +Do **not** mark a layer ✅ or write `Gaps vs spec: none for backend` because the main API flow works. That is not Ready review complete. | Wrong | Right | |-------|--------| @@ -89,21 +89,22 @@ Do **not** mark a layer ✅ or write `Gaps vs spec: none for backend` because th --- -## Gates (every PR) +## Automated gates and review checkpoints -**Doc drift:** when `src/`, `tests/`, or `docs/use-cases/` change — run `python scripts/axis.py check doc-drift` **before push** (P0); CI job **Doc drift** must be green. When `docker-compose.yml` changes, update [local-dev.md](./local-dev.md) in the same PR (`check-local-dev-docs.py` runs inside the drift command). Script output is not a PR artefact — walk Gate 2 rows below mentally and tick the Gate 2 checkbox. +**Doc drift:** CI runs `python scripts/axis.py check policy-tests` and `python scripts/axis.py check doc-drift` on every PR. Run them locally when touching docs, scripts, repo layout, handlers, endpoints, generated-contract surfaces, or bulk file rewrites. This job enforces deterministic policy/doc checks; it does not require a docs edit for every code diff. -| Gate | Action | -|------|--------| -| **0** | AC map + docs touched (when `src/`, `tests/`, or `frontend/` change) | -| **1** | Local fast verification (table below); CI/branch protection owns the full suite | -| **2** | Doc walk-through (rows below) | -| **3** | Retrospective (questions below) | +| Item | Type | Action | +|------|------|--------| +| **Ready review** | Review-only | AC map + docs identified when shipping behavior | +| **Verification gate** | Enforced | Local fast verification; CI/branch protection owns the full suite | +| **Docs review** | Review-only | Docs walkthrough when behavior/spec/status changes | +| **Retrospective review** | Review-only | Retrospective and REVIEW_FINDINGS update when a rule/finding repeats | -**CI-only gates** (run automatically on PR, no local action required): +**CI-enforced checks** (run automatically on PR, no local action required unless debugging): -- **PR guard** — [`scripts/check-pr.py`](../../scripts/check-pr.py) validates PR metadata in one place: the PR title must use Conventional Commit style (`type(scope): subject` or `type: subject`), and the PR body must keep the template contract: `Summary`, `Linked spec`, and `Requirements & rules followed` are present; requirement checkboxes are checked or explicitly marked `N/A` with a concrete reason. The template in [`.github/PULL_REQUEST_TEMPLATE.md`](../../.github/PULL_REQUEST_TEMPLATE.md) auto-fills the body; this CI job is the enforcement. -- **Doc drift** — enforces same-PR docs, new-handler tests, no-new TODO/FIXME, new raw-SQL review, [WORKAROUND comment ↔ inventory sync](../WORKAROUNDS.md), [speculation guard](./docs-style.md#anti-patterns-dont-ship-these), [incident/lesson framing guard](./docs-style.md#keep-practice-docs-general), `GetAwaiter().GetResult()` ban, hardcoded connection-string ban, `DateTime.Now` ban (use `UtcNow`), script-standard enforcement, and a stale-terminology guard (current pattern list lives in [`scripts/axis.py`](../../scripts/axis.py)). **Module/API → use-case domain** — [`doc_drift_domains.py`](../../scripts/doc_drift_domains.py) + [`axis_repo.py`](../../scripts/axis_repo.py). **Layout drift** in the same job: `python scripts/axis.py check buf-modules`, `python scripts/axis.py check kafka-wiring`, `python scripts/axis.py check domain-readme-index`. +- **PR guard** — [`scripts/check-pr.py`](../../scripts/check-pr.py) validates PR metadata shape and checkbox self-attestation. It cannot prove that review-only checkpoints happened. +- **Policy gate tests** — `python scripts/axis.py check policy-tests` runs counterexample tests for custom Python gates so a regex/path change cannot silently disable enforcement. +- **Doc drift** — validates text encoding (`UTF-8` without BOM + LF), module/API discovery, changed-handler test files, no-new TODO/FIXME/stub markers, no new test `Skip = ...`, no `EnsureCreated`, endpoint named DTO ratchets, [WORKAROUND comment ↔ inventory sync](../WORKAROUNDS.md), [speculation guard](./docs-style.md#anti-patterns-dont-ship-these), [incident/lesson framing guard](./docs-style.md#keep-practice-docs-general), `GetAwaiter().GetResult()` ban, hardcoded connection-string ban, `DateTime.Now` ban, script standards, stale terminology, and layout checks (`buf`, Kafka wiring, domain README index). - **Markdown link check** — `lychee` verifies internal links and `#anchors`. **Relative file/image targets** (`![alt](./asset.svg)`, `[text](./file.md)`) are double-checked by `python scripts/axis.py check doc-link-targets` inside the drift command — catches the broken-image class lychee misses. - **Doc navigation** — `python scripts/axis.py check doc-navigation` requires every `docs/**/*.md` file to start with an H1 and a `> **Navigation**:` block so docs never become dead ends. - **Code-fence integrity** — `python scripts/axis.py check doc-code-fences` (inside the drift command) flags code-block lines with collapsed indentation (a lone leading space). Catches the bulk-find-replace corruption class that lychee, prettier, and the structural checks all let through. @@ -111,16 +112,16 @@ Do **not** mark a layer ✅ or write `Gaps vs spec: none for backend` because th - **Secret scanning** — TruffleHog scans the full PR diff for committed secrets (API keys, passwords, tokens) and verifies each finding against the alleged service before reporting (`--only-verified` cuts false positives). - **Vulnerable packages** — `python scripts/axis.py check vulnerable-packages` wraps `dotnet list package --vulnerable --include-transitive` and fails on any known CVE in the dep tree (covers transitive packages too). - **Architecture fitness tests** run as part of `dotnet test` — failures there mean a CLAUDE.md P0/P1 rule got violated structurally. See [tests README](../../tests/Architecture/Axis.Architecture.Tests/README.md). -- **EF migrations** — only `dotnet ef migrations add` (no hand-written `.cs` / orphan `.Designer.cs`). Each `{Name}.cs` needs `{Name}.Designer.cs`. See [local-dev.md § EF Core migrations](./local-dev.md#ef-core-migrations-dotnet-ef). +- **EF migrations** — drift verifies each migration `.cs` has its `.Designer.cs`. The command used to create the migration remains review-owned. See [local-dev.md § EF Core migrations](./local-dev.md#ef-core-migrations-dotnet-ef). - **Local dev docs** — [`docker-compose.yml`](../../docker-compose.yml) changes require [`docs/playbooks/local-dev.md`](./local-dev.md) in the same PR; CI runs `python scripts/axis.py check local-dev-docs`. - **Async-safety analyzers** (`Microsoft.VisualStudio.Threading.Analyzers`) — type-aware checks at build time for sync-over-async (VSTHRD002), async-void (VSTHRD100), unobserved async results (VSTHRD110). Rule selection rationale in [patterns.md § Async patterns](./patterns.md#async-patterns). - **Coverage report** uploaded as artifact (`dotnet-coverage`). No threshold yet — see [CONTRIBUTING.md § Coverage](../../CONTRIBUTING.md#coverage). **Adding new CI checks — verify GitHub plan support first.** Some GitHub-native security workflows require **GitHub Advanced Security (GHAS)** on private repos (a paid add-on). On `phuong-labs/axis` this includes `actions/dependency-review-action` and CodeQL code-scanning *upload* (analysis runs, only the SARIF upload fails). Verify GHAS provisioning before adding such checks; otherwise the PR will fail and need a follow-up to disable. Dependabot security updates work on any plan and cover the same threat model with a publish-time delay — use it as the baseline. The disabled-job comment in [`.github/workflows/build-and-test.yml`](../../.github/workflows/build-and-test.yml) lists the specific jobs to restore when GHAS is provisioned. -**Priority:** Gate **1** blocks commit (failing build/tests). Gate **2** keeps docs in the same PR — required before merge, not a substitute for Gate 1. The [PR template](../../.github/PULL_REQUEST_TEMPLATE.md) lists Gate 1 before Gate 2. +**Priority:** the Verification gate and CI-enforced checks block merge. Ready, Docs, and Retrospective reviews are review-only self-audits captured by the PR checklist. -### Gate 1 — verify before push (fast local gate) +### Verification Gate — verify before push **One command:** `python scripts/axis.py verify` runs the fast local gate — build + vulnerable package scan + `dotnet format --verify` + **unit test projects only** + frontend `ci`/test + drift. It only runs the layers whose files changed (so doc-only and frontend-only work stays quick). Run `python scripts/axis.py bootstrap` once to install the committed **pre-push hook** explicitly (`core.hooksPath = scripts/hooks`); build commands must not mutate Git config. CI/branch protection remains the authoritative full gate and runs full `dotnet test` including Testcontainers before merge. @@ -145,17 +146,19 @@ The .NET branch of `python scripts/axis.py verify` also runs the enforced | `src/` or `tests/` | `dotnet format --verify-no-changes` | | `frontend/` | `npm run ci` then `npm run test` | | `src/Axis.Api/Endpoints/` or API contract | Update + run `tests/Api/Axis.Api.Tests/` | -| Any of the above + `docs/use-cases/` | `python scripts/axis.py check doc-drift` — also runs `check use-case-docs` and `check local-dev-docs`, enforces no-new `TODO`/`FIXME`/`stub`, reviews new raw-SQL calls | +| Docs/scripts/layout/policy change | `python scripts/axis.py check policy-tests` then `python scripts/axis.py check doc-drift` | | `docker-compose.yml` | Update [local-dev.md](./local-dev.md) in same PR; `python scripts/axis.py check doc-drift` | ```text -Gate 1 self-check: +Verification gate self-check: - test naming → ran / not triggered (reason) - dotnet build → ran / not triggered (reason) - vulnerable package scan → ran / not triggered (reason) - unit test projects → ran / not triggered (reason) - dotnet format --verify-no-changes → ran / not triggered (reason) - npm run ci + npm run test → ran / not triggered (reason) +- policy gate tests → ran / not triggered (reason) +- python scripts/axis.py check text-encoding → ran / not triggered (reason) - python scripts/axis.py check doc-drift → ran / not triggered (reason) ``` @@ -163,14 +166,14 @@ Example (docs-only): every line `not triggered — no src/, tests/, or frontend/ **Full suite:** integration and API tests run in CI as part of full `dotnet test`; Docker/Testcontainers is required there. Run full local `dotnet test Axis.sln --nologo` when debugging CI, changing Infrastructure/API behavior, or preparing a high-risk backend PR. Use the Docker endpoint available to the shell running the suite when `docker info` works. If it does not and Docker Engine lives inside WSL2, set `DOCKER_HOST` to the exported daemon instead (prefer `tcp://127.0.0.1:2375`). -### Gate 2 — docs walk-through +### Docs Review -Paste block format: header `Gate 2:` then one `-` line per row (Gate 3 uses the same bullet style). +Paste block format: header `Docs review:` then one `-` line per row (Retrospective review uses the same bullet style). Mark pure refactor/style/test-only changes as `not triggered` instead of inventing docs churn. ```text -Gate 2: +Docs review: - Library → TECH_STACK.md / not triggered -- New pattern → patterns.md / not triggered +- New pattern → patterns.md or REVIEW_FINDINGS.md / not triggered - Use-case layer callout → docs/use-cases/{domain}/… (layout per [docs-style § Use-case visual artifacts](./docs-style.md#use-case-files--wireframes--implementation-status); multi-screen example [register-org](../use-cases/platform-foundation/register-org/README.md)) / not triggered - Use-case wireframes/diagrams README → Screen flow + full wireframes inventory + Diagrams owned in-folder only ([docs-style](./docs-style.md#use-case-files--wireframes--implementation-status)) / not triggered - Domain README + PROGRESS → … / not triggered @@ -204,12 +207,12 @@ Do **not** ship the first diff that only makes CI green or closes the thread. Fo **Examples** (illustrative — not an exhaustive list): splitting an invariant across “mutate then query”; external calls with catch-only patches; duplicating logic to silence a linter. In those cases, look for a single owner of the invariant or parity with existing guards/handlers in the repo. -### Gate 3 — retrospective +### Retrospective Review -Answer **Yes** or **No** on **each line** (same `-` bullet style as Gate 2 — do not collapse to a single `No`). If **Yes**, name the doc updated in this PR. +Answer **Yes** or **No** on **each line** (same `-` bullet style as Docs review — do not collapse to a single `No`). If **Yes**, name the doc, test, or ledger row updated in this PR. ```text -Gate 3: +Retrospective review: - New rule from test failure? → No - Invented invariant without AC? → No - Infrastructure footgun? → No @@ -221,7 +224,7 @@ Gate 3: - Repeat of a prior review finding class? → No ``` -If the last line is **Yes**, record the class in [REVIEW_FINDINGS.md](../REVIEW_FINDINGS.md): point it at a mechanism (analyzer / fitness test / codegen / CI guard) or mark it deliberately manual with a reason. A finding class should be reviewed once, then prevented — not re-flagged every PR. +If the last line is **Yes**, record the class in [REVIEW_FINDINGS.md](../REVIEW_FINDINGS.md): mark it Enforced, Partial, Review-only, Guidance, or Not a rule. A finding class should be reviewed once, then prevented or explicitly kept human-owned. --- @@ -243,37 +246,32 @@ Never ✅ and "pending …" in the same callout. Checkboxes in use-case files ar | **2 — Domain** | A layer is complete for the module | Domain `README.md` implementation table + **Open work (agents)** section (remove or reword items you closed) | | **3 — Platform** | Module-wide summary changed | `docs/PROGRESS.md` — layer status only | -Updating only `PROGRESS.md` while changing `src/` without `docs/use-cases/` → drift fails. Domain README `| API | ⏳` after endpoints ship → drift fails. +Updating only `PROGRESS.md` while changing `src/` without a use-case callout still fails because platform status needs an owning source. Domain README pending API status after endpoints ship also fails. **Agents starting a task:** read [use cases README § How agents find open work](../use-cases/README.md#how-agents-find-open-work) — checkboxes in use-case files are not progress. -**Chore/style PRs that touch module code:** drift still applies — add one small, accurate detail to the matching domain doc (a chunk size, a behavior nuance, a deferral note already true). Don't propose loosening the script, don't strand the format gunk waiting for a "real" PR, and don't invent fake content. The script's intent is *prompt the developer to look at docs*, not *require rewrite proportional to code change*. - --- -## P0 (CI + culture) +## Review-only project expectations + +These expectations still matter, but do not call them CI gates unless [REVIEW_FINDINGS.md](../REVIEW_FINDINGS.md) marks the class **Enforced**. -- Spec → code, never the reverse -- No cross-module SQL / shared `DbContext` / `IMediator` for domain events -- New `*Handler.cs` → `*HandlerTests.cs` (drift script) -- Module code → `docs/use-cases/{module}/` in **same PR** -- Frontend screen → wireframe row in use-case `## Wireframes` table (every `*.excalidraw` **screen** in the folder; use `## Screen flow` + `#`/`Role` columns when >3 screens — [docs-style § Use-case visual artifacts](./docs-style.md#use-case-files--wireframes--implementation-status), example [register-org](../use-cases/platform-foundation/register-org/README.md)) -- Use-case diagram → row only if the `.excalidraw` lives **in that use-case folder**; link other use cases in `**Related:**` prose, not in `## Diagrams` table -- No `.Skip()`, weakened tests, or ✅ when ACs are open -- **Full suite honesty:** local pre-push uses the fast Gate 1 command matrix; CI/branch protection runs full `dotnet test Axis.sln`. If you claim the full suite ran locally, it must be full `Axis.sln` with integration/API tests, not a solution filter or unit-only run. +- Spec → code, never the reverse. +- No cross-module SQL / shared `DbContext` / `IMediator` for domain events. Structural subsets are enforced; semantic SQL and runtime DI remain review-only. +- Changed `*Handler.cs` → matching `*HandlerTests.cs`. The diff ratchet enforces changed Application handlers; untouched legacy files are not swept. +- Behavior/spec/status changes → update the owning docs in the same PR. Pure refactor, style, dependency, and test-only changes do not need a token docs edit. +- Frontend screen → wireframe row in the owning use-case `## Wireframes` table when the screen changes. +- Use-case diagram → row only if the `.excalidraw` lives **in that use-case folder**; link other use cases in `**Related:**` prose, not in `## Diagrams` table. +- No test `Skip = ...`, weakened tests, or completed layer status when ACs are open. New test skips are enforced; weakened assertions/status honesty remain review-only. +- **Full suite honesty:** local pre-push uses the fast Verification gate command matrix; CI/branch protection runs full `dotnet test Axis.sln`. If you claim the full suite ran locally, it must be full `Axis.sln` with integration/API tests, not a solution filter or unit-only run. --- -## Domain map (code → docs) +## Domain layout discovery **Full rules + agent checklists:** [repo-layout-discovery.md](./repo-layout-discovery.md) (auto vs manual tables, commands, checklists A–E). -**Summary:** [`doc_drift_domains.py`](../../scripts/doc_drift_domains.py) maps `src/Modules/*` and `*Endpoints.cs` → `docs/use-cases/{slug}/`. New module → create domain folder (or `MODULE_DOMAIN_SLUG_OVERRIDES` in [`axis_repo.py`](../../scripts/axis_repo.py) for `Identity` → `identity-access`). Cross-cutting only in `EXTRA_CODE_TO_DOC_RULES`. - -| Manual exception | Docs folder | -|------------------|-------------| -| `OrganizationVerifiedHandler` in any module | `docs/use-cases/platform-foundation/` | -| `frontend/src/features/auth`, `routes/`, `AppShell` | `docs/use-cases/identity-access/` | +**Summary:** [`doc_drift_domains.py`](../../scripts/doc_drift_domains.py) validates that module folders and endpoint groups map to existing `docs/use-cases/{slug}/` domains. It does not require a token docs edit for every module-code change; behavior/spec/status doc accuracy is Docs review. --- diff --git a/docs/playbooks/design-gate.md b/docs/playbooks/design-gate.md index 23cec6ff..7e43b18a 100644 --- a/docs/playbooks/design-gate.md +++ b/docs/playbooks/design-gate.md @@ -6,7 +6,7 @@ Defects are **born before code is written** — when the agent edits a surface w The discipline: **re-derive the governing rules for the exact surface you are about to touch, before touching it** — the rigor an independent reviewer applies, applied up front. -> **This gate fails the same way "Gate 1 green" failed** — by being ticked, not done. So it is **artifact-producing**, not a feeling: you quote rules with `file:section`, you paste the blast-radius `grep`, you name the contract. "I thought carefully" is not a Design Gate. +> **This gate fails the same way "Verification gate green" fails** — by being ticked, not done. So it is **artifact-producing**, not a feeling: you quote rules with `file:section`, you paste the blast-radius `grep`, you name the contract. "I thought carefully" is not a Design Gate. --- @@ -61,7 +61,7 @@ Present the dossier through **plan mode** and **do not write code until the user ## Close the loop (after implementing) 1. **Self-review the diff against the dossier** — was every governing rule honored, every caller in the blast radius updated, the contract emitted as decided? -2. **Run the local gate before push** — `python scripts/axis.py verify` runs build, vulnerable package scan, format, unit test projects, frontend checks, and doc drift. CI/branch protection is the required full gate and runs full `dotnet test` including Testcontainers before merge. "Build passed" ≠ "PR is mergeable" — formatting, integration, and casing only fully surface in the CI gate. See [agent-checklist § Gate 1](./agent-checklist.md) and [pr-slicing § Gate 1 honesty](./pr-slicing.md#gate-1-honesty). +2. **Run the local gate before push** — `python scripts/axis.py verify` runs build, vulnerable package scan, format, unit test projects, frontend checks, policy tests, and doc drift. CI/branch protection is the required full gate and runs full `dotnet test` including Testcontainers before merge. "Build passed" ≠ "PR is mergeable" — formatting, integration, and casing only fully surface in the CI gate. See [agent-checklist § Verification Gate](./agent-checklist.md#verification-gate--verify-before-push) and [pr-slicing § Verification Gate Honesty](./pr-slicing.md#verification-gate-honesty). 3. If you claim the **full suite** ran locally, that means full `dotnet test Axis.sln` ran — including the integration tests. Docker is required for that full local run; if Docker is unavailable, rely on CI for the authoritative full gate instead of presenting a partial run as full. --- diff --git a/docs/playbooks/docs-style.md b/docs/playbooks/docs-style.md index 7cdf3176..0151253b 100644 --- a/docs/playbooks/docs-style.md +++ b/docs/playbooks/docs-style.md @@ -2,7 +2,7 @@ > **Navigation**: [← docs/README.md](../README.md) · [← CLAUDE.md](../../CLAUDE.md) -Short anti-pattern checklist for everything under `docs/`. Read once; come back when adding a new file. Most of these are enforced by `python scripts/axis.py check doc-drift` and the **Markdown link check** CI job — the doctrine here exists so the rules feel justified, not arbitrary. +Short anti-pattern checklist for everything under `docs/`. Read once; come back when adding a new file. Some items are CI-enforced, some are review-only guidance; the enforcement status lives in [REVIEW_FINDINGS.md](../REVIEW_FINDINGS.md). Do not call a docs convention a gate unless CI actually blocks it. --- @@ -41,7 +41,8 @@ Reference and practice docs are **scanned, not read**. Optimize for a reader who | **Duplicating compose ports / service URLs** | Playbooks drift from `docker-compose.yml` | Owner: [local-dev.md](./local-dev.md) + compose file; enforced by `python scripts/axis.py check local-dev-docs` | | **Aspirational metrics** in engineering docs (e.g. "50 customers in 6 months") | Nobody measures or tests against them; they age into embarrassment | Keep in pitch deck / `PRODUCT_VISION.md` if anywhere; do not pollute technical reference | | **Empty "TODO: fill later" sections** | Look authoritative, contain nothing, lie to readers | Delete the section. Add it when there's content to add. | -| **"Process about process"** docs > 100 lines | Nobody reads them; the rules don't get followed | Embed the rule into the **drift script** or a **template**. Doctrine without enforcement is decoration. | +| **Accidental encoding rewrites** | Review diffs become unreadable; PowerShell/default tool output can turn Unicode into mojibake | Tracked text files must be UTF-8 without BOM and LF line endings. `python scripts/axis.py check text-encoding` enforces this and still allows real Unicode such as Vietnamese, arrows, and status icons. | +| **"Process about process"** docs > 100 lines | Nobody reads them; the rules don't get followed | If deterministic, embed the rule into a tested gate or template. If not, keep it short and label it review-only guidance. | | **New file for content that fits in an existing file** | Doc graph fragments; agents have to read more files to get less | Absorb into the closest existing file. New file only when topic is genuinely separate **and** ≥ ~50 lines worth. | | **Incident / lesson detail baked into a general rule** | Reads as universal guidance but only fits the one case it came from; ages into noise and is hard to apply elsewhere | State the **general principle** in the playbook; keep the instance specifics in the use-case file / `PROGRESS.md` / PR retro. See § Keep practice docs general. | @@ -71,7 +72,7 @@ When you learn something from a specific incident: | The general rule, phrased so it applies to any future case | The incident specifics — which feature, which fields, which error code | | (optional) one labeled example link | Detail lives in the use-case file, `PROGRESS.md`, or the PR retrospective | -**Test:** read the rule as if you'd never seen the originating feature. If it only makes sense with that one use case in mind, generalize it and move the specifics out. (This is what [agent-checklist § Gate 3](./agent-checklist.md) means by "Incident-level detail in rule text? → No".) +**Test:** read the rule as if you'd never seen the originating feature. If it only makes sense with that one use case in mind, generalize it and move the specifics out. (This is what [agent-checklist Retrospective review](./agent-checklist.md) means by "Incident-level detail in rule text? → No".) Enforced by the incident/lesson-framing guard in `python scripts/axis.py check doc-drift`, which flags lesson-style callouts (a bold *Lesson* heading, or a rule tagged with one specific feature in parentheses) in practice docs. The guard is deliberately narrow — it catches the recurring callout class, not every over-fit; the rest is on review and this section. @@ -228,7 +229,7 @@ Avoid writing engineering process constraints as end-user use cases. Keep those 1. Add the back-link header (per [`docs/README.md`](../README.md)): `> **Navigation**: [← parent.md](...)` so future readers can climb back up. 2. If it belongs in the docs hub: add a playbook row, a [Key Diagrams](../README.md#key-diagrams) index link, or a [Wireframes](../README.md#wireframes) domain pointer — **not** a per-screen wireframe file row (those stay in the owning use-case `## Wireframes` only). 3. If it owns a topic, add it to the **Single source of truth** table. -4. If the topic could be enforced mechanically, add a rule to `scripts/axis.py check doc-drift` — that is what makes the convention survive. +4. If the topic could be enforced mechanically, add a tested rule to `scripts/axis.py` and document it in [REVIEW_FINDINGS.md](../REVIEW_FINDINGS.md). If it cannot be tested without noisy false positives, label it review-only guidance. --- diff --git a/docs/playbooks/pr-slicing.md b/docs/playbooks/pr-slicing.md index 05fc6246..5aa805a7 100644 --- a/docs/playbooks/pr-slicing.md +++ b/docs/playbooks/pr-slicing.md @@ -14,7 +14,7 @@ A slice is isolated only if **both** sides hold. Verify both before marking the | Side | Definition | How to verify | |------|------------|---------------| -| **A — Stands alone** | A fresh checkout of the branch compiles, the local Gate 1 fast gate is green, CI is expected to run the full suite, and every route / endpoint / contract / symbol the slice *references* already exists on its merge target. | Switch to the branch on a clean tree → run `python scripts/axis.py verify`; rely on CI/branch protection for full `dotnet test Axis.sln`. | +| **A — Stands alone** | A fresh checkout of the branch compiles, the local Verification gate is green, CI is expected to run the full suite, and every route / endpoint / contract / symbol the slice *references* already exists on its merge target. | Switch to the branch on a clean tree → run `python scripts/axis.py verify`; rely on CI/branch protection for full `dotnet test Axis.sln`. | | **B — Integrates** | After **rebasing onto current `main`**, it still compiles and is green, and merging it requires **no unmerged sibling**. | Rebase onto `origin/main`, re-run `python scripts/axis.py verify`; CI must rerun on the rebased branch before merge. | If a slice references something an **unmerged sibling owns** (a route it navigates to, an endpoint it calls, a symbol it imports, a constant it reads), it fails side A — it is stacked in disguise, not isolated. Either pull that dependency into this slice, or ship a fallback that compiles and degrades gracefully on the current `main`. @@ -26,7 +26,7 @@ If a slice references something an **unmerged sibling owns** (a route it navigat | Rule | Meaning | |------|---------| | **Branch from `main`** | Each slice starts from `origin/main`. Never stack slice B on slice A's branch. | -| **Prove both sides before "ready"** | Run the local Gate 1 fast gate on the branch **and** after a trial rebase onto `origin/main`; CI/branch protection owns the full suite. | +| **Prove both sides before "ready"** | Run the local Verification gate on the branch **and** after a trial rebase onto `origin/main`; CI/branch protection owns the full suite. | | **Vertical when a contract changes** | If an endpoint adds a **required** field or changes a response/contract already used on `main`, ship backend + minimal caller in the **same** PR so `main` never breaks. | | **Horizontal when additive** | New UI on existing APIs can be frontend-only; a new API with no caller on `main` yet can be backend-only. | | **One owner per shared seam** | A file or symbol touched by more than one slice has exactly **one owning slice**; the others consume it, never re-add it. See below. | @@ -58,9 +58,9 @@ Common seam categories: --- -## Gate 1 honesty +## Verification Gate Honesty -"Gate 1 green" in a PR body is a factual claim that you ran the local Gate 1 fast gate on this branch: `python scripts/axis.py verify` with the command matrix in [agent-checklist § Gate 1](./agent-checklist.md#gate-1--verify-before-push-fast-local-gate). Do not present unit-only output, a one-file test, or a partial command as Gate 1. +"Verification gate green" in a PR body is a factual claim that you ran the local Verification gate on this branch: `python scripts/axis.py verify` with the command matrix in [agent-checklist § Verification Gate](./agent-checklist.md#verification-gate--verify-before-push). Do not present unit-only output, a one-file test, or a partial command as the Verification gate. The full suite is a separate claim: full local verification means full `dotnet test Axis.sln --nologo` plus the applicable frontend and drift checks. CI/branch protection is the authoritative full gate before merge. @@ -100,7 +100,7 @@ an unmerged sibling owns? | Symptom at merge time | Rule | |-----------------------|------| | Branch does not compile because it references something only a sibling adds | Two-sided test, side A · shared-seam ownership | -| "Gate 1 green" in the body but CI is red | Gate 1 honesty | +| "Verification gate green" in the body but CI is red | Verification gate honesty | | Two open PRs add the same new file → add/add conflict | No duplicate new files across siblings | | Two slices add the same member/migration → broken model snapshot | One owner per shared seam | | Two slices disagree on a shared constant/contract value | One source for shared values/contracts | @@ -127,4 +127,4 @@ an unmerged sibling owns? - [ ] Shared values/contracts imported from their single owner, not re-hardcoded - [ ] Any partially-done spec bullet listed under `**Deferred (PR #N follow-up):**` - [ ] `python scripts/axis.py check doc-drift` when `src/`, `tests/`, or `docs/use-cases/` change -- [ ] "Gate 1 green" in the PR body reflects commands you actually ran (anything skipped is stated) +- [ ] "Verification gate green" in the PR body reflects commands you actually ran (anything skipped is stated) diff --git a/docs/playbooks/repo-layout-discovery.md b/docs/playbooks/repo-layout-discovery.md index 815bcc86..b21118a3 100644 --- a/docs/playbooks/repo-layout-discovery.md +++ b/docs/playbooks/repo-layout-discovery.md @@ -10,8 +10,8 @@ | What changes | How CI knows | Agent fix when check fails | |--------------|--------------|----------------------------| -| `src/Modules/{Module}/` | Folder name → `docs/use-cases/{slug}/` ([`doc_drift_domains.py`](../../scripts/doc_drift_domains.py)) | Add domain folder + use-case docs in **same PR** as module code | -| `src/Axis.Api/Endpoints/*Endpoints.cs` | `using Axis.{Module}.Application` → same domain as module | Same as row above | +| `src/Modules/{Module}/` | Folder name → `docs/use-cases/{slug}/` ([`doc_drift_domains.py`](../../scripts/doc_drift_domains.py)) | Add or map the domain folder when adding a module | +| `src/Axis.Api/Endpoints/*Endpoints.cs` | `using Axis.{Module}.Application` → same domain as module | Ensure the endpoint group maps to an existing domain; update behavior docs via Docs review when behavior changes | | `*Event.avsc` under `Contracts/Schemas/` | `python scripts/axis.py register avro-schemas --dry-run` globs `*Event.avsc` | No script edit; optional local: `python scripts/axis.py register avro-schemas` | | `Contracts/Protos/*.proto` | `python scripts/axis.py check buf-modules` vs `buf.yaml` | `python scripts/axis.py generate buf-yaml` then `buf lint` | | `*KafkaTopics.cs` constants | `python scripts/axis.py check kafka-wiring` vs `Program.cs` | Add `PublishAndListenWithAvro` + `PublishLocally` for new `{Class}.{Const}` | @@ -22,10 +22,6 @@ **Slug override (rare):** when module folder name ≠ `docs/use-cases/` folder (only `Identity` → `identity-access` today), add one line to `MODULE_DOMAIN_SLUG_OVERRIDES` in [`axis_repo.py`](../../scripts/axis_repo.py). -**Cross-cutting doc rules (rare):** paths that are not owned by a single module (e.g. auth `AppShell`, `OrganizationVerifiedHandler`) stay in `EXTRA_CODE_TO_DOC_RULES` inside [`doc_drift_domains.py`](../../scripts/doc_drift_domains.py). - ---- - ## Still manual (CI catches omissions) | Item | Why not generated | Agent must | @@ -40,19 +36,19 @@ ## One command before push (layout + docs) -When `src/`, `tests/`, or `docs/use-cases/` change: +When touching docs, scripts, repo layout, handlers, endpoints, or generated-contract surfaces: ```bash +python scripts/axis.py check policy-tests python scripts/axis.py check doc-drift ``` -That runs (among others): domain doc rules on the PR diff, `check buf-modules`, `check kafka-wiring`, `check domain-readme-index`, `check use-case-docs`, link targets, local-dev sync, and script-standard enforcement. +That runs (among others): module/API layout discovery, changed-handler test ratchets, `check buf-modules`, `check kafka-wiring`, `check domain-readme-index`, `check use-case-docs`, link targets, local-dev sync, script-standard enforcement, and policy counterexample tests. When only validating discovery (no PR diff yet): ```bash -python scripts/axis.py check doc-drift -python scripts/doc_drift_domains.py --list # debug: code → docs rules +python scripts/doc_drift_domains.py --list # debug: module/API → docs mapping python scripts/axis.py check buf-modules python scripts/axis.py check kafka-wiring python scripts/axis.py check domain-readme-index @@ -82,12 +78,12 @@ python scripts/axis.py check domain-readme-index ### C — New REST surface (`*Endpoints.cs` or handler in existing module) -- [ ] Spec under correct domain in `docs/use-cases/` (drift enforces same-PR doc touch on module/API change). +- [ ] Spec under correct domain in `docs/use-cases/` when shipping behavior; this is Docs review, not a path-only gate. - [ ] `Map*Endpoints` in `Program.cs` ([process.md § Host wiring](./process.md)). -- [ ] Domain README: set API row to ⚠️ or ✅ when endpoints ship (not `| API | ⏳`). -- [ ] Handler tests: `*Handler.cs` → `*HandlerTests.cs` (drift). -- [ ] **Regenerate the OpenAPI contract** — a new/changed route, request, or response shape changes the wire. Run `python scripts/axis.py generate api-contracts` and **commit both** `openapi.json` and `frontend/src/lib/api-types.ts`. `openapi.json` and `api-types.ts` are generated, never hand-edited. CI's `OpenApiDocumentTests` plus `python scripts/axis.py check frontend-api-contracts` fail when contracts drift or frontend code defines hand-authored `*Request`/`*Response`/`*Dto` API models. -- [ ] `python scripts/axis.py check doc-drift`. +- [ ] Domain README: set API row away from pending when endpoints ship. +- [ ] Handler tests: changed `*Handler.cs` → matching `*HandlerTests.cs` (diff ratchet). +- [ ] **Regenerate the OpenAPI contract** when a route, request, or response shape changes. Run `python scripts/axis.py generate api-contracts` and commit both `openapi.json` and `frontend/src/lib/api-types.ts`. +- [ ] `python scripts/axis.py check policy-tests` and `python scripts/axis.py check doc-drift`. ### D — New Kafka event @@ -106,10 +102,10 @@ python scripts/axis.py check domain-readme-index ## Rules (P0/P1 for agents) 1. **Do not** add parallel hardcoded module/endpoint lists to `python scripts/axis.py check doc-drift` — extend [`axis_repo.py`](../../scripts/axis_repo.py) / the dedicated checker, then wire it into drift. -2. **Do not** skip `docs/use-cases/` when changing `src/Modules/` or `*Endpoints.cs` — drift fails by design. -3. **Do** run `python scripts/axis.py generate domain-readme-index` after changing use-case titles/Purpose or adding a use-case folder. -4. **Do** treat `TenantModuleNames` and `Program.cs` Kafka wiring as contract edits — architecture + kafka checks must pass. -5. **Chore PRs** that touch module code still need a minimal accurate doc touch in the matching domain ([agent-checklist § Chore/style PRs](./agent-checklist.md)). +2. **Do** create or map the domain folder when adding a new module or endpoint group; discovery fails if the owning domain is missing. +3. **Do** update owning docs in the same PR when behavior/spec/status changes. Pure refactor, style, dependency, and test-only changes do not need a token docs edit. +4. **Do** run `python scripts/axis.py generate domain-readme-index` after changing use-case titles/Purpose or adding a use-case folder. +5. **Do** treat `TenantModuleNames` and `Program.cs` Kafka wiring as contract edits — architecture + kafka checks must pass. --- @@ -117,7 +113,7 @@ python scripts/axis.py check domain-readme-index | Doc | Topic | |-----|--------| -| [agent-checklist.md](./agent-checklist.md) | Gates 0–3, domain map summary | +| [agent-checklist.md](./agent-checklist.md) | Verification gate, review checks, domain layout summary | | [process.md](./process.md) | Layer order, host wiring, deferred follow-ups | | [patterns.md § gRPC](./patterns.md) | Buf, proto layout | | [Architecture tests README](../../tests/Architecture/Axis.Architecture.Tests/README.md) | Fitness tests + new module | diff --git a/docs/playbooks/scripts.md b/docs/playbooks/scripts.md index cb11524f..15edf569 100644 --- a/docs/playbooks/scripts.md +++ b/docs/playbooks/scripts.md @@ -9,6 +9,8 @@ Top-level maintenance scripts under `scripts/` should be Python only. ```bash python scripts/axis.py verify +python scripts/axis.py check policy-tests +python scripts/axis.py check text-encoding python scripts/axis.py check doc-drift python scripts/axis.py check scripts-standard python scripts/axis.py check doc-navigation diff --git a/docs/playbooks/testing.md b/docs/playbooks/testing.md index 5bc71015..0d6ea157 100644 --- a/docs/playbooks/testing.md +++ b/docs/playbooks/testing.md @@ -58,7 +58,7 @@ Testing playbook responsibility here is implementation technique: ### Pre-commit gate -See [agent-checklist.md § Gate 1](./agent-checklist.md) and CLAUDE.md. When `src/` or `tests/` change: +See [agent-checklist.md § Verification Gate](./agent-checklist.md#verification-gate--verify-before-push) and CLAUDE.md. When `src/` or `tests/` change: ```bash dotnet build diff --git a/docs/wireframes/generate-screens.mjs b/docs/wireframes/generate-screens.mjs index 14ccdea8..724dcade 100644 --- a/docs/wireframes/generate-screens.mjs +++ b/docs/wireframes/generate-screens.mjs @@ -1,4 +1,4 @@ -/** +/** * Axis Screen Wireframes — generate-screens.mjs * Run: node docs/wireframes/generate-screens.mjs * Filter: node docs/wireframes/generate-screens.mjs identity-access/login,identity-access/register-user diff --git a/scripts/axis.py b/scripts/axis.py index a78e8833..5c842a3e 100644 --- a/scripts/axis.py +++ b/scripts/axis.py @@ -166,8 +166,134 @@ def iter_files(root: Path, suffixes: tuple[str, ...]) -> Iterable[Path]: ) -def git_ls_files(pattern: str) -> list[str]: - return [line for line in git(["ls-files", pattern]).splitlines() if line.strip()] +def git_ls_files(pattern: str | None = None) -> list[str]: + args = ["ls-files"] + if pattern is not None: + args.append(pattern) + return [line for line in git(args).splitlines() if line.strip()] + + +TEXT_ENCODING_SUFFIXES = { + ".avsc", + ".cs", + ".cshtml", + ".csproj", + ".css", + ".dockerignore", + ".editorconfig", + ".env", + ".excalidraw", + ".gitattributes", + ".gitignore", + ".graphql", + ".html", + ".http", + ".js", + ".json", + ".jsonc", + ".jsx", + ".md", + ".mjs", + ".props", + ".proto", + ".ps1", + ".py", + ".runsettings", + ".scss", + ".sh", + ".sln", + ".sql", + ".svg", + ".targets", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +} +TEXT_ENCODING_FILENAMES = { + ".coderabbit.yaml", + ".editorconfig", + ".gitattributes", + ".gitignore", + "Dockerfile", + "Makefile", +} +TEXT_ENCODING_SKIP_PARTS = {".git", "bin", "coverage", "dist", "node_modules", "obj"} +UTF8_BOM = b"\xef\xbb\xbf" + + +def mojibake_marker(text: str) -> str: + return text.encode("utf-8").decode("cp1252", errors="ignore") + + +MOJIBAKE_MARKERS = ( + "\ufffd", + mojibake_marker("’"), + mojibake_marker("“"), + mojibake_marker("”"), + mojibake_marker("–"), + mojibake_marker("—"), + mojibake_marker("→"), + mojibake_marker("←"), + mojibake_marker("✓"), + mojibake_marker("✅"), + mojibake_marker("⚠"), + mojibake_marker("⏳"), +) + + +def should_check_text_encoding(path: str) -> bool: + normalized = path.replace("\\", "/") + if any(part in TEXT_ENCODING_SKIP_PARTS for part in normalized.split("/")): + return False + p = Path(normalized) + return p.suffix.lower() in TEXT_ENCODING_SUFFIXES or p.name in TEXT_ENCODING_FILENAMES + + +def text_encoding_issues(paths: Iterable[Path], *, root: Path = ROOT) -> list[str]: + issues: list[str] = [] + for path in sorted(paths): + if not path.is_file(): + continue + normalized = str(path.relative_to(root)).replace("\\", "/") + if not should_check_text_encoding(normalized): + continue + + data = path.read_bytes() + if data.startswith(UTF8_BOM): + issues.append(f"{normalized}: UTF-8 BOM found - save as UTF-8 without BOM") + + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + issues.append(f"{normalized}: invalid UTF-8 byte at offset {exc.start}") + continue + + if b"\r" in data: + issues.append(f"{normalized}: CRLF/CR line ending found - use LF") + + for line_number, line in enumerate(text.splitlines(), 1): + if any(marker in line for marker in MOJIBAKE_MARKERS): + snippet = line.strip() + if len(snippet) > 160: + snippet = f"{snippet[:157]}..." + issues.append(f"{normalized}:{line_number}: mojibake marker found: {snippet}") + return issues + + +def check_text_encoding(_args: argparse.Namespace | None = None) -> int: + paths = [ROOT / path for path in git_ls_files() if should_check_text_encoding(path)] + issues = text_encoding_issues(paths) + if issues: + print("check-text-encoding FAIL:", file=sys.stderr) + for issue in issues: + print(f" - {issue}", file=sys.stderr) + print("\nUse UTF-8 without BOM and LF line endings for tracked text files.", file=sys.stderr) + return 1 + print(f"check-text-encoding: OK ({len(paths)} files scanned)") + return 0 TEST_ATTRIBUTE_RE = re.compile(r"^\s*\[(?:Xunit[.])?(?:Fact|Theory)(?:Attribute)?(?:\s*[(]|\s*\])") @@ -463,6 +589,22 @@ def check_scripts_standard(_args: argparse.Namespace | None = None) -> int: return 0 +def check_policy_tests(_args: argparse.Namespace | None = None) -> int: + return run( + [ + sys.executable, + "-m", + "unittest", + "discover", + "-s", + "scripts/tests", + "-p", + "test_*.py", + ], + check=False, + ).returncode + + def added_lines(range_spec: str, include: callable[[str], bool]) -> Iterable[tuple[str, str]]: result = run([exe("git"), "diff", "--unified=0", range_spec], capture=True, check=False) if result.returncode != 0: @@ -495,6 +637,79 @@ def fail(issues: list[str], message: str) -> None: print(f"check-doc-drift FAIL: {message}", file=sys.stderr) +DOC_DRIFT_ADDED_LINE_RULES = [ + ( + r"GetAwaiter[(][)][.]GetResult[(][)]", + lambda p: p.startswith("src/") and p.endswith(".cs"), + "Sync-over-async introduced - make the caller async instead", + ), + ( + r'"(Host=|Server=|Data Source=)', + lambda p: p.startswith("src/") and p.endswith(".cs"), + "Hardcoded connection string introduced - use configuration/options", + ), + ( + r"DateTime[.]Now", + lambda p: (p.startswith("src/") or p.startswith("tests/")) and p.endswith(".cs"), + "DateTime.Now introduced - use DateTimeOffset.UtcNow / TimeProvider", + ), + ( + r"[.]Produces|Results[.](Ok|Json|Created|Accepted)[(]new[ ]*[{]", + lambda p: p.startswith("src/Axis.Api/Endpoints/") and p.endswith(".cs"), + "Endpoint returns object/anonymous JSON - use a named Application-layer DTO (REVIEW_FINDINGS.md)", + ), + ( + r"\bSkip\s*=", + lambda p: p.startswith("tests/") and p.endswith(".cs"), + "Skipped test introduced - fix or remove the test instead", + ), + ( + r"[.]EnsureCreated(?:Async)?[(]", + lambda p: (p.startswith("src/") or p.startswith("tests/")) and p.endswith(".cs"), + "EnsureCreated introduced - use the owning DbContext migration chain", + ), + ( + r"TODO|FIXME|NotImplementedException|placeholder|stub", + lambda p: (p.startswith("src/") or p.startswith("tests/") or p.startswith("frontend/src/")) + and "/obj/" not in p + and "/node_modules/" not in p, + "New TODO/FIXME/stub marker introduced - resolve or open an issue", + ), +] + + +def doc_drift_added_line_issues(rows: Iterable[tuple[str, str]]) -> list[str]: + issues: list[str] = [] + for path, line in rows: + for pattern, include, message in DOC_DRIFT_ADDED_LINE_RULES: + if include(path) and re.search(pattern, line): + issues.append(f"{message}: {path}: {line}") + return issues + + +def missing_handler_test_issues(changes: Iterable[list[str]], *, root: Path | None = None) -> list[str]: + root = root or ROOT + issues: list[str] = [] + for parts in changes: + status = parts[0] + if status == "D" or not (status in {"A", "M"} or status.startswith("R")): + continue + handler = (parts[2] if status.startswith("R") and len(parts) > 2 else parts[1]).replace("\\", "/") + if not re.match(r"^src/Modules/.*/(Commands|Queries)/.*Handler[.]cs$", handler): + continue + module_match = re.match(r"src/Modules/([^/]+)/", handler) + if not module_match: + continue + module = module_match.group(1) + subdir = "Commands" if "/Commands/" in handler else "Queries" + handler_name = Path(handler).stem + test_file = root / "tests" / "Modules" / module / f"Axis.{module}.Application.Tests" / subdir / f"{handler_name}Tests.cs" + if not test_file.is_file(): + relative_test = str(test_file.relative_to(root)).replace("\\", "/") + issues.append(f"Handler {handler} - create {relative_test}") + return issues + + def endpoint_mediator_hits() -> list[str]: hits: list[str] = [] for ep in sorted((ROOT / "src" / "Axis.Api" / "Endpoints").glob("*.cs")): @@ -568,60 +783,17 @@ def check_doc_drift(_args: argparse.Namespace | None = None) -> int: print(f"check-doc-drift: no diff in {range_spec} - skip") return 1 if issues else 0 - rules = doc_drift_domains.discover_rules() - domain_errors = doc_drift_domains.check_domain_docs(paths, rules) - domain_errors.extend(doc_drift_domains.check_readme_api_status(paths)) + domain_errors = doc_drift_domains.check_readme_api_status(paths) for err in domain_errors: fail(issues, err) - frontend_generated = re.compile(r"^frontend/src/lib/api-types[.]ts$") - frontend_authored = any(path.startswith("frontend/src/") and not frontend_generated.search(path) for path in paths) - if frontend_authored and not docs_changed_under(paths, "docs/use-cases/"): - fail(issues, "frontend/src/ changed but no files under docs/use-cases/ in this PR") - if any(path.startswith("src/") for path in paths) and docs_changed_under(paths, "docs/PROGRESS.md"): if not any(path.startswith("docs/use-cases/") for path in paths): fail(issues, "docs/PROGRESS.md updated but no docs/use-cases/ change while src/ changed") - rule_sets = [ - ( - r"GetAwaiter[(][)][.]GetResult[(][)]", - lambda p: p.startswith("src/") and p.endswith(".cs"), - "Sync-over-async introduced - make the caller async instead", - ), - ( - r'"(Host=|Server=|Data Source=)', - lambda p: p.startswith("src/") and p.endswith(".cs"), - "Hardcoded connection string introduced - use configuration/options", - ), - ( - r"DateTime[.]Now", - lambda p: (p.startswith("src/") or p.startswith("tests/")) and p.endswith(".cs"), - "DateTime.Now introduced - use DateTimeOffset.UtcNow / TimeProvider", - ), - ( - r"SqlQueryRaw|ExecuteSqlRaw|FromSqlRaw|ExecuteSqlInterpolated|FromSqlInterpolated", - lambda p: p.startswith("src/Modules/") and p.endswith(".cs"), - "New raw-SQL call in module code - confirm same-module tables only", - ), - ( - r"[.]Produces|Results[.](Ok|Json|Created|Accepted)[(]new[ ]*[{]", - lambda p: p.startswith("src/Axis.Api/Endpoints/") and p.endswith(".cs"), - "Endpoint returns object/anonymous JSON - use a named Application-layer DTO (REVIEW_FINDINGS.md)", - ), - ( - r"TODO|FIXME|NotImplementedException|placeholder|stub", - lambda p: (p.startswith("src/") or p.startswith("tests/") or p.startswith("frontend/src/")) - and "/obj/" not in p - and "/node_modules/" not in p, - "New TODO/FIXME/stub marker introduced - resolve or open an issue", - ), - ] - for pattern, include, message in rule_sets: - rx = re.compile(pattern) - for path, line in added_lines(range_spec, include): - if rx.search(line): - fail(issues, f"{message}: {path}: {line}") + all_added_lines = added_lines(range_spec, lambda _path: True) + for issue in doc_drift_added_line_issues(all_added_lines): + fail(issues, issue) for hit in endpoint_mediator_hits(): fail( @@ -630,22 +802,8 @@ def check_doc_drift(_args: argparse.Namespace | None = None) -> int: f"command/handler or saga (REVIEW_FINDINGS.md): {hit}", ) - for parts in changed_name_status(range_spec): - status = parts[0] - if not (status == "A" or status.startswith("R")): - continue - handler = (parts[2] if status.startswith("R") and len(parts) > 2 else parts[1]).replace("\\", "/") - if not re.match(r"^src/Modules/.*/(Commands|Queries)/.*Handler[.]cs$", handler): - continue - module_match = re.match(r"src/Modules/([^/]+)/", handler) - if not module_match: - continue - module = module_match.group(1) - subdir = "Commands" if "/Commands/" in handler else "Queries" - handler_name = Path(handler).stem - test_file = ROOT / "tests" / "Modules" / module / f"Axis.{module}.Application.Tests" / subdir / f"{handler_name}Tests.cs" - if not test_file.is_file(): - fail(issues, f"Handler {handler} - create {rel(test_file)}") + for issue in missing_handler_test_issues(changed_name_status(range_spec)): + fail(issues, issue) check_workarounds(issues) @@ -687,6 +845,7 @@ def check_doc_drift(_args: argparse.Namespace | None = None) -> int: fail(issues, f"EF migration missing .Designer.cs - regenerate with dotnet ef: {rel(migration)}") checkers = [ + ("check-text-encoding", check_text_encoding), ("check-scripts-standard", check_scripts_standard), ("check-ef-domain-mapping", check_ef_domain_mapping), ("check-frontend-api-contracts", check_frontend_api_contracts), @@ -762,6 +921,7 @@ def step(name: str, fn: callable[[], int]) -> None: step("frontend ci (tsc + biome)", lambda: run([exe("npm"), "run", "ci"], cwd=ROOT / "frontend", check=False).returncode) step("frontend test", lambda: run([exe("npm"), "run", "test"], cwd=ROOT / "frontend", check=False).returncode) + step("policy gate tests", lambda: check_policy_tests()) step("doc drift", lambda: check_doc_drift()) if api_surface_drift: @@ -875,6 +1035,8 @@ def main(argv: list[str] | None = None) -> int: check = sub.add_parser("check") check_sub = check.add_subparsers(dest="check_command", required=True) check_sub.add_parser("doc-drift").set_defaults(func=check_doc_drift) + check_sub.add_parser("policy-tests").set_defaults(func=check_policy_tests) + check_sub.add_parser("text-encoding").set_defaults(func=check_text_encoding) check_sub.add_parser("scripts-standard").set_defaults(func=check_scripts_standard) check_sub.add_parser("test-naming").set_defaults(func=check_test_naming) check_sub.add_parser("test-project-classification").set_defaults(func=check_test_project_classification) diff --git a/scripts/doc_drift_domains.py b/scripts/doc_drift_domains.py index 20f94adb..9b15ea43 100644 --- a/scripts/doc_drift_domains.py +++ b/scripts/doc_drift_domains.py @@ -1,15 +1,11 @@ #!/usr/bin/env python3 -"""Discover code-path → use-case domain mappings for doc-drift checks. +"""Validate module/API to use-case-domain layout discovery. -Mappings are derived from the repo layout, not a hand-maintained list: - - - ``src/Modules/{Module}/`` → ``docs/use-cases/{domain}/`` (PascalCase → kebab-case, - with a small override table for names that do not match the folder slug). - - ``src/Axis.Api/Endpoints/*Endpoints.cs`` → same domain as the module referenced - by ``using Axis.{Module}.Application`` (first non-Shared Application import). - -Extra rules (cross-cutting paths that are not module-scoped) live in -``EXTRA_CODE_TO_DOC_RULES`` — keep that list minimal. +Mappings are derived from the repo layout so new modules and endpoint groups +cannot silently point at a missing domain. This checker does not require an +unrelated use-case doc edit for every code change; behavioral doc accuracy is a +review checkpoint because a path-only rule cannot determine whether behavior +changed. Run ``python3 scripts/doc_drift_domains.py --validate`` after adding a module, endpoint group, or domain folder. Agent checklists: docs/playbooks/repo-layout-discovery.md @@ -20,16 +16,14 @@ import argparse import re import sys -from dataclasses import dataclass, field from pathlib import Path _SCRIPTS_DIR = Path(__file__).resolve().parent if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) -from axis_repo import ( +from axis_repo import ( # noqa: E402 ENDPOINTS_DIR, - MODULES_DIR, ROOT, USE_CASES_DIR, iter_module_names, @@ -37,70 +31,9 @@ primary_application_module, ) -# Cross-cutting paths: (code regex, doc prefix under repo root, label). -EXTRA_CODE_TO_DOC_RULES: list[tuple[str, str, str]] = [ - ( - r"src/Modules/.*/.*OrganizationVerifiedHandler", - "docs/use-cases/platform-foundation", - "platform-foundation tenant provisioning", - ), - ( - r"frontend/src/(features/auth|routes/|components/layout/AppShell)", - "docs/use-cases/identity-access", - "identity-access auth frontend", - ), -] - - -@dataclass -class DomainDriftRule: - """Require docs under doc_prefix when any code_pattern matches a changed path.""" - - doc_prefix: str - label: str - code_patterns: list[str] = field(default_factory=list) - - -def discover_rules() -> list[DomainDriftRule]: - """Build merged rules (one per doc_prefix) from modules, endpoints, and extras.""" - by_doc: dict[str, DomainDriftRule] = {} - - def add_pattern(doc_prefix: str, label: str, pattern: str) -> None: - rule = by_doc.get(doc_prefix) - if rule is None: - rule = DomainDriftRule(doc_prefix=doc_prefix, label=label, code_patterns=[]) - by_doc[doc_prefix] = rule - if pattern not in rule.code_patterns: - rule.code_patterns.append(pattern) - - for module in iter_module_names(): - domain = module_to_domain_slug(module) - doc_prefix = f"docs/use-cases/{domain}" - add_pattern( - doc_prefix, - f"{domain} module", - f"src/Modules/{module}/", - ) - - for endpoint_file in sorted(ENDPOINTS_DIR.glob("*Endpoints.cs")): - module = primary_application_module(endpoint_file) - domain = module_to_domain_slug(module) - doc_prefix = f"docs/use-cases/{domain}" - stem = endpoint_file.stem[: -len("Endpoints")] - add_pattern( - doc_prefix, - f"{domain} API", - f"src/Axis\\.Api/Endpoints/{re.escape(stem)}", - ) - - for pattern, doc_prefix, label in EXTRA_CODE_TO_DOC_RULES: - add_pattern(doc_prefix, label, pattern) - - return sorted(by_doc.values(), key=lambda r: r.doc_prefix) - def validate_discovery() -> list[str]: - """Fail fast when the tree and mapping table are out of sync.""" + """Fail fast when module/endpoint discovery points at missing docs.""" issues: list[str] = [] for module in iter_module_names(): @@ -115,12 +48,12 @@ def validate_discovery() -> list[str]: for endpoint_file in sorted(ENDPOINTS_DIR.glob("*Endpoints.cs")): try: - primary_application_module(endpoint_file) + module_to_domain_slug(primary_application_module(endpoint_file)) except ValueError as exc: issues.append(str(exc)) for domain_dir in sorted(USE_CASES_DIR.iterdir()): - if not domain_dir.is_dir() or domain_dir.name.startswith("_"): + if not domain_dir.is_dir() or domain_dir.name.startswith((".", "_")): continue if domain_dir.name == "page-builder": continue @@ -134,37 +67,8 @@ def path_matches_pattern(path: str, pattern: str) -> bool: return re.search(pattern, path) is not None -def is_csproj_only_match(changed_paths: list[str], pattern: str) -> bool: - """True when every changed path matching pattern is a .csproj (Dependabot noise).""" - matches = [p for p in changed_paths if path_matches_pattern(p, pattern)] - if not matches: - return False - return all(p.endswith(".csproj") for p in matches) - - -def check_domain_docs(changed_paths: list[str], rules: list[DomainDriftRule]) -> list[str]: - errors: list[str] = [] - for rule in rules: - triggered = False - for pattern in rule.code_patterns: - if not any(path_matches_pattern(p, pattern) for p in changed_paths): - continue - if is_csproj_only_match(changed_paths, pattern): - continue - triggered = True - break - if not triggered: - continue - doc_prefix = rule.doc_prefix + "/" - if not any(p.startswith(doc_prefix) for p in changed_paths): - errors.append( - f"{rule.label}: code changed but no files under {rule.doc_prefix}/ in this PR" - ) - return errors - - def check_readme_api_status(changed_paths: list[str]) -> list[str]: - """Domain README must not keep '| API | ⏳' after API endpoint files change.""" + """Domain README must not keep '| API | pending' after endpoint files change.""" errors: list[str] = [] domains_to_check: set[str] = set() for endpoint_file in ENDPOINTS_DIR.glob("*Endpoints.cs"): @@ -178,13 +82,19 @@ def check_readme_api_status(changed_paths: list[str]) -> list[str]: readme = USE_CASES_DIR / domain / "README.md" if not readme.is_file(): continue - if re.search(r"\| API \| ⏳", readme.read_text(encoding="utf-8")): - errors.append( - f"docs/use-cases/{domain}/README.md still '| API | ⏳' — set ⚠️ or ✅" - ) + if re.search(r"\| API \| \u23f3", readme.read_text(encoding="utf-8")): + errors.append(f"docs/use-cases/{domain}/README.md still has pending API status") return errors +def print_mappings() -> None: + for module in iter_module_names(): + print(f"src/Modules/{module}/ -> docs/use-cases/{module_to_domain_slug(module)}/") + for endpoint_file in sorted(ENDPOINTS_DIR.glob("*Endpoints.cs")): + module = primary_application_module(endpoint_file) + print(f"{endpoint_file.relative_to(ROOT)} -> docs/use-cases/{module_to_domain_slug(module)}/") + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -200,7 +110,7 @@ def main() -> int: parser.add_argument( "--list", action="store_true", - help="print discovered rules (debug)", + help="print discovered module/API to docs mappings (debug)", ) args = parser.parse_args() @@ -211,27 +121,20 @@ def main() -> int: print(f" - {issue}", file=sys.stderr) return 1 - rules = discover_rules() - if args.list: - for rule in rules: - print(f"{rule.doc_prefix} ({rule.label})") - for pat in rule.code_patterns: - print(f" {pat}") + print_mappings() return 0 if args.validate: print( f"doc-drift-domains: OK ({len(iter_module_names())} modules, " - f"{len(list(ENDPOINTS_DIR.glob('*Endpoints.cs')))} endpoint groups, " - f"{len(rules)} doc rules)" + f"{len(list(ENDPOINTS_DIR.glob('*Endpoints.cs')))} endpoint groups)" ) return 0 if args.check: changed = [line.strip() for line in sys.stdin if line.strip()] - errors = check_domain_docs(changed, rules) - errors.extend(check_readme_api_status(changed)) + errors = check_readme_api_status(changed) if errors: print("check-doc-domain-drift failed:", file=sys.stderr) for err in errors: diff --git a/scripts/tests/test_policy_gates.py b/scripts/tests/test_policy_gates.py new file mode 100644 index 00000000..b6b87149 --- /dev/null +++ b/scripts/tests/test_policy_gates.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import axis # noqa: E402 +import doc_drift_domains # noqa: E402 + + +def load_script(script_name: str): + path = SCRIPTS / script_name + module_name = f"_test_{script_name.replace('-', '_').replace('.', '_')}" + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise AssertionError(f"Cannot load {script_name}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +check_pr = load_script("check-pr.py") + + +class TestTestNamingGate(unittest.TestCase): + def run_test_naming(self, source: str) -> int: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + test_dir = root / "tests" / "Example" + test_dir.mkdir(parents=True) + (test_dir / "ExampleTests.cs").write_text(source, encoding="utf-8") + + original_root = axis.ROOT + axis.ROOT = root + try: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + return axis.check_test_naming() + finally: + axis.ROOT = original_root + + def test_rejects_non_three_segment_test_names(self) -> None: + rc = self.run_test_naming( + """ +public sealed class ExampleTests +{ + [Fact] + public void BadName() {} +} +""" + ) + self.assertNotEqual(0, rc) + + def test_accepts_subject_condition_outcome_names(self) -> None: + rc = self.run_test_naming( + """ +public sealed class ExampleTests +{ + [Fact] + public void Widget_WhenInputIsValid_ReturnsSuccess() {} +} +""" + ) + self.assertEqual(0, rc) + + def test_current_repository_test_names_still_pass(self) -> None: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(0, axis.check_test_naming()) + + +class TestPrGuard(unittest.TestCase): + def test_rejects_unchecked_requirement_without_na_reason(self) -> None: + body = """## Summary +This summary is long enough. + +## Linked spec +docs/use-cases/example/README.md + +## Requirements & rules followed +- [ ] **Verification gate** - local checks +""" + self.assertTrue(check_pr.validate("feat(example): improve gates", body)) + + def test_accepts_checked_requirement(self) -> None: + body = """## Summary +This summary is long enough. + +## Linked spec +docs/use-cases/example/README.md + +## Requirements & rules followed +- [x] **Verification gate** - local checks +""" + self.assertEqual([], check_pr.validate("feat(example): improve gates", body)) + + +class TestDocDriftRatchets(unittest.TestCase): + def issue_text(self, rows: list[tuple[str, str]]) -> str: + return "\n".join(axis.doc_drift_added_line_issues(rows)) + + def test_rejects_skipped_tests(self) -> None: + issues = self.issue_text([("tests/ExampleTests.cs", '[Fact(Skip = "later")]')]) + self.assertIn("Skipped test introduced", issues) + + def test_rejects_ensure_created(self) -> None: + issues = self.issue_text([("tests/Fixture.cs", "await db.Database.EnsureCreatedAsync();")]) + self.assertIn("EnsureCreated introduced", issues) + + def test_rejects_datetime_now_in_src_or_tests(self) -> None: + issues = self.issue_text([("src/Example.cs", "var now = DateTime.Now;")]) + self.assertIn("DateTime.Now introduced", issues) + + def test_ignores_todo_in_docs(self) -> None: + issues = axis.doc_drift_added_line_issues([("docs/example.md", "TODO in docs is not this gate")]) + self.assertEqual([], issues) + + +class TestTextEncodingGate(unittest.TestCase): + def issues_for_file(self, name: str, content: bytes) -> str: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return "\n".join(axis.text_encoding_issues([path], root=root)) + + def test_rejects_utf8_bom(self) -> None: + issues = self.issues_for_file("docs/example.md", b"\xef\xbb\xbf# Title\n") + self.assertIn("UTF-8 BOM found", issues) + + def test_rejects_invalid_utf8(self) -> None: + issues = self.issues_for_file("docs/example.md", b"# Title\n\xff\n") + self.assertIn("invalid UTF-8 byte", issues) + + def test_rejects_crlf_line_endings(self) -> None: + issues = self.issues_for_file("docs/example.md", b"# Title\r\n") + self.assertIn("CRLF/CR line ending", issues) + + def test_rejects_common_mojibake_markers(self) -> None: + mojibake_dash = "—".encode("utf-8").decode("cp1252") + issues = self.issues_for_file("docs/example.md", f"Broken {mojibake_dash} dash\n".encode("utf-8")) + self.assertIn("mojibake marker found", issues) + + def test_accepts_utf8_unicode_without_bom_and_lf(self) -> None: + issues = self.issues_for_file("docs/example.md", "Tiếng Việt → ✅\n".encode("utf-8")) + self.assertEqual("", issues) + + def test_accepts_valid_latin_capital_a_with_circumflex(self) -> None: + issues = self.issues_for_file("docs/example.md", "Ângström\n".encode("utf-8")) + self.assertEqual("", issues) + + def test_current_repository_text_encoding_still_passes(self) -> None: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(0, axis.check_text_encoding()) + + +class TestHandlerTestRatchet(unittest.TestCase): + def test_modified_handler_requires_matching_test_file(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + changes = [["M", "src/Modules/Billing/Axis.Billing.Application/Commands/CreateInvoiceHandler.cs"]] + issues = axis.missing_handler_test_issues(changes, root=root) + self.assertEqual( + [ + "Handler src/Modules/Billing/Axis.Billing.Application/Commands/CreateInvoiceHandler.cs - " + "create tests/Modules/Billing/Axis.Billing.Application.Tests/Commands/CreateInvoiceHandlerTests.cs" + ], + issues, + ) + + def test_modified_handler_passes_when_matching_test_file_exists(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + test_file = ( + root + / "tests" + / "Modules" + / "Billing" + / "Axis.Billing.Application.Tests" + / "Commands" + / "CreateInvoiceHandlerTests.cs" + ) + test_file.parent.mkdir(parents=True) + test_file.write_text("", encoding="utf-8") + changes = [["M", "src/Modules/Billing/Axis.Billing.Application/Commands/CreateInvoiceHandler.cs"]] + issues = axis.missing_handler_test_issues(changes, root=root) + self.assertEqual([], issues) + + def test_deleted_handler_does_not_require_test_file(self) -> None: + changes = [["D", "src/Modules/Billing/Axis.Billing.Application/Commands/CreateInvoiceHandler.cs"]] + self.assertEqual([], axis.missing_handler_test_issues(changes)) + + +class TestDocDomainDiscovery(unittest.TestCase): + def test_module_code_change_alone_does_not_force_doc_activity(self) -> None: + self.assertEqual( + [], + doc_drift_domains.check_readme_api_status(["src/Modules/Identity/Axis.Identity.Domain/User.cs"]), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Axis.Api/Properties/launchSettings.json b/src/Axis.Api/Properties/launchSettings.json index 5822df4a..eebdba12 100644 --- a/src/Axis.Api/Properties/launchSettings.json +++ b/src/Axis.Api/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, diff --git a/src/Modules/DataModeling/Axis.DataModeling.Application/Axis.DataModeling.Application.csproj b/src/Modules/DataModeling/Axis.DataModeling.Application/Axis.DataModeling.Application.csproj index e873cf30..7cff5d18 100644 --- a/src/Modules/DataModeling/Axis.DataModeling.Application/Axis.DataModeling.Application.csproj +++ b/src/Modules/DataModeling/Axis.DataModeling.Application/Axis.DataModeling.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/DataModeling/Axis.DataModeling.Domain/Axis.DataModeling.Domain.csproj b/src/Modules/DataModeling/Axis.DataModeling.Domain/Axis.DataModeling.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/DataModeling/Axis.DataModeling.Domain/Axis.DataModeling.Domain.csproj +++ b/src/Modules/DataModeling/Axis.DataModeling.Domain/Axis.DataModeling.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Axis.DataModeling.Infrastructure.csproj b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Axis.DataModeling.Infrastructure.csproj index 802084fc..1155bd57 100644 --- a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Axis.DataModeling.Infrastructure.csproj +++ b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Axis.DataModeling.Infrastructure.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/20260526031047_InitialCreate.Designer.cs b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/20260526031047_InitialCreate.Designer.cs index 0d48b21c..1c0e7626 100644 --- a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/20260526031047_InitialCreate.Designer.cs +++ b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/20260526031047_InitialCreate.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.DataModeling.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/DataModelingDbContextModelSnapshot.cs b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/DataModelingDbContextModelSnapshot.cs index d136bc7f..efb2f491 100644 --- a/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/DataModelingDbContextModelSnapshot.cs +++ b/src/Modules/DataModeling/Axis.DataModeling.Infrastructure/Migrations/DataModelingDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.DataModeling.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/FormBuilder/Axis.FormBuilder.Application/Axis.FormBuilder.Application.csproj b/src/Modules/FormBuilder/Axis.FormBuilder.Application/Axis.FormBuilder.Application.csproj index b53b8e9a..a26914ff 100644 --- a/src/Modules/FormBuilder/Axis.FormBuilder.Application/Axis.FormBuilder.Application.csproj +++ b/src/Modules/FormBuilder/Axis.FormBuilder.Application/Axis.FormBuilder.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/FormBuilder/Axis.FormBuilder.Domain/Axis.FormBuilder.Domain.csproj b/src/Modules/FormBuilder/Axis.FormBuilder.Domain/Axis.FormBuilder.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/FormBuilder/Axis.FormBuilder.Domain/Axis.FormBuilder.Domain.csproj +++ b/src/Modules/FormBuilder/Axis.FormBuilder.Domain/Axis.FormBuilder.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Axis.FormBuilder.Infrastructure.csproj b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Axis.FormBuilder.Infrastructure.csproj index c1b4ff71..deb6e04d 100644 --- a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Axis.FormBuilder.Infrastructure.csproj +++ b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Axis.FormBuilder.Infrastructure.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/20260526031056_InitialCreate.Designer.cs b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/20260526031056_InitialCreate.Designer.cs index 58242e77..79c70f24 100644 --- a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/20260526031056_InitialCreate.Designer.cs +++ b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/20260526031056_InitialCreate.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.FormBuilder.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/FormBuilderDbContextModelSnapshot.cs b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/FormBuilderDbContextModelSnapshot.cs index 79bcb843..13e947ac 100644 --- a/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/FormBuilderDbContextModelSnapshot.cs +++ b/src/Modules/FormBuilder/Axis.FormBuilder.Infrastructure/Migrations/FormBuilderDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.FormBuilder.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Identity/Axis.Identity.Application/Axis.Identity.Application.csproj b/src/Modules/Identity/Axis.Identity.Application/Axis.Identity.Application.csproj index fcac8610..25021d5f 100644 --- a/src/Modules/Identity/Axis.Identity.Application/Axis.Identity.Application.csproj +++ b/src/Modules/Identity/Axis.Identity.Application/Axis.Identity.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/Identity/Axis.Identity.Domain/Axis.Identity.Domain.csproj b/src/Modules/Identity/Axis.Identity.Domain/Axis.Identity.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/Identity/Axis.Identity.Domain/Axis.Identity.Domain.csproj +++ b/src/Modules/Identity/Axis.Identity.Domain/Axis.Identity.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/20260603100509_InitialIdentitySchema.Designer.cs b/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/20260603100509_InitialIdentitySchema.Designer.cs index ecd9fa0b..75a99638 100644 --- a/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/20260603100509_InitialIdentitySchema.Designer.cs +++ b/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/20260603100509_InitialIdentitySchema.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using System.Collections.Generic; using Axis.Identity.Infrastructure.Persistence; diff --git a/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs index ba7381d4..25734a77 100644 --- a/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs +++ b/src/Modules/Identity/Axis.Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using System.Collections.Generic; using Axis.Identity.Infrastructure.Persistence; diff --git a/src/Modules/PageBuilder/Axis.PageBuilder.Application/Axis.PageBuilder.Application.csproj b/src/Modules/PageBuilder/Axis.PageBuilder.Application/Axis.PageBuilder.Application.csproj index 00f4dffa..92297907 100644 --- a/src/Modules/PageBuilder/Axis.PageBuilder.Application/Axis.PageBuilder.Application.csproj +++ b/src/Modules/PageBuilder/Axis.PageBuilder.Application/Axis.PageBuilder.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/PageBuilder/Axis.PageBuilder.Domain/Axis.PageBuilder.Domain.csproj b/src/Modules/PageBuilder/Axis.PageBuilder.Domain/Axis.PageBuilder.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/PageBuilder/Axis.PageBuilder.Domain/Axis.PageBuilder.Domain.csproj +++ b/src/Modules/PageBuilder/Axis.PageBuilder.Domain/Axis.PageBuilder.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/PageBuilder/Axis.PageBuilder.Infrastructure/Axis.PageBuilder.Infrastructure.csproj b/src/Modules/PageBuilder/Axis.PageBuilder.Infrastructure/Axis.PageBuilder.Infrastructure.csproj index 7f8fd166..86374c50 100644 --- a/src/Modules/PageBuilder/Axis.PageBuilder.Infrastructure/Axis.PageBuilder.Infrastructure.csproj +++ b/src/Modules/PageBuilder/Axis.PageBuilder.Infrastructure/Axis.PageBuilder.Infrastructure.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Application/Axis.WorkflowBuilder.Application.csproj b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Application/Axis.WorkflowBuilder.Application.csproj index 67987655..b8fd9c18 100644 --- a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Application/Axis.WorkflowBuilder.Application.csproj +++ b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Application/Axis.WorkflowBuilder.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Domain/Axis.WorkflowBuilder.Domain.csproj b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Domain/Axis.WorkflowBuilder.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Domain/Axis.WorkflowBuilder.Domain.csproj +++ b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Domain/Axis.WorkflowBuilder.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Axis.WorkflowBuilder.Infrastructure.csproj b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Axis.WorkflowBuilder.Infrastructure.csproj index 06b7b648..fef2651e 100644 --- a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Axis.WorkflowBuilder.Infrastructure.csproj +++ b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Axis.WorkflowBuilder.Infrastructure.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/20260526031052_InitialCreate.Designer.cs b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/20260526031052_InitialCreate.Designer.cs index acf70c5a..55e2845d 100644 --- a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/20260526031052_InitialCreate.Designer.cs +++ b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/20260526031052_InitialCreate.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.WorkflowBuilder.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/WorkflowBuilderDbContextModelSnapshot.cs b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/WorkflowBuilderDbContextModelSnapshot.cs index 294f4a50..6182730c 100644 --- a/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/WorkflowBuilderDbContextModelSnapshot.cs +++ b/src/Modules/WorkflowBuilder/Axis.WorkflowBuilder.Infrastructure/Migrations/WorkflowBuilderDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.WorkflowBuilder.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Domain/Axis.WorkflowEngine.Domain.csproj b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Domain/Axis.WorkflowEngine.Domain.csproj index 25740d05..26d4d7eb 100644 --- a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Domain/Axis.WorkflowEngine.Domain.csproj +++ b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Domain/Axis.WorkflowEngine.Domain.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/20260526031100_InitialCreate.Designer.cs b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/20260526031100_InitialCreate.Designer.cs index df703582..d5ed12d3 100644 --- a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/20260526031100_InitialCreate.Designer.cs +++ b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/20260526031100_InitialCreate.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.WorkflowEngine.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/WorkflowEngineDbContextModelSnapshot.cs b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/WorkflowEngineDbContextModelSnapshot.cs index 13202e54..453c874a 100644 --- a/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/WorkflowEngineDbContextModelSnapshot.cs +++ b/src/Modules/WorkflowEngine/Axis.WorkflowEngine.Infrastructure/Migrations/WorkflowEngineDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Axis.WorkflowEngine.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/Shared/Axis.Shared.Application/Axis.Shared.Application.csproj b/src/Shared/Axis.Shared.Application/Axis.Shared.Application.csproj index 3056820b..86322a8c 100644 --- a/src/Shared/Axis.Shared.Application/Axis.Shared.Application.csproj +++ b/src/Shared/Axis.Shared.Application/Axis.Shared.Application.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Shared/Axis.Shared.Domain/Axis.Shared.Domain.csproj b/src/Shared/Axis.Shared.Domain/Axis.Shared.Domain.csproj index fa71b7ae..30402ac0 100644 --- a/src/Shared/Axis.Shared.Domain/Axis.Shared.Domain.csproj +++ b/src/Shared/Axis.Shared.Domain/Axis.Shared.Domain.csproj @@ -1,4 +1,4 @@ - + net8.0