37 Commits

Author SHA1 Message Date
ad71d2302d Merge pull request 'Update Angular CLI to v22.2.6' (#25) from renovate/minor-patch/angular-cli into main
All checks were successful
Build, test and publish / verify (push) Successful in 9m21s
Reviewed-on: #25
2026-11-13 10:08:34 +01:00
53249055c8 Merge branch 'main' into renovate/minor-patch/angular-cli
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
2026-11-13 08:51:36 +01:00
ec5d7f28d4 Merge pull request 'Update dependency org.jacoco:org.jacoco.cli to v0.8.16' (#30) from renovate/minor-patch/org.jacoco-org.jacoco.cli-0.x into main
All checks were successful
Build, test and publish / verify (push) Successful in 8m27s
Reviewed-on: #30
2026-11-13 08:51:23 +01:00
dad8b0dd59 Merge branch 'main' into renovate/minor-patch/org.jacoco-org.jacoco.cli-0.x
All checks were successful
Build, test and publish / verify (push) Successful in 6m45s
2026-11-13 08:34:35 +01:00
5b4d53f00f Merge pull request 'Update quarkus.platform.version to v3.40.1.1' (#31) from renovate/minor-patch/quarkus.platform.version into main
All checks were successful
Build, test and publish / verify (push) Successful in 8m12s
Reviewed-on: #31
2026-11-13 08:34:23 +01:00
42160b72dc Merge branch 'main' into renovate/minor-patch/quarkus.platform.version
All checks were successful
Build, test and publish / verify (push) Successful in 7m41s
2026-11-13 07:40:40 +01:00
c61e2560dd Merge pull request 'Update dependency com.palantir.javaformat:palantir-java-format to v2.103.0' (#32) from renovate/minor-patch/palantir-java-format.version into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m38s
Reviewed-on: #32
2026-11-13 07:40:23 +01:00
379fd33f95 Merge branch 'main' into renovate/minor-patch/palantir-java-format.version
All checks were successful
Build, test and publish / verify (push) Successful in 7m46s
2026-11-13 07:21:43 +01:00
22a908100e Merge pull request 'Update dependency eslint to v10.14.0' (#33) from renovate/minor-patch/eslint-monorepo into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m13s
Reviewed-on: #33
2026-11-13 07:21:30 +01:00
130b8c93e3 Update dependency eslint to v10.14.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 6m50s
2026-11-13 05:02:04 +00:00
574749a2f9 Update dependency com.palantir.javaformat:palantir-java-format to v2.103.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 7m25s
2026-11-13 05:01:50 +00:00
9c343609ee Update quarkus.platform.version to v3.40.1.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 7m12s
2026-11-13 05:01:34 +00:00
4dd9318b8d Update dependency org.jacoco:org.jacoco.cli to v0.8.16
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 7m30s
2026-11-13 05:01:27 +00:00
dc06353b15 Update Angular CLI to v22.2.6
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 8m19s
2026-11-13 05:01:19 +00:00
8ccffe7b2b Merge pull request 'Update Angular framework to v22.2.4' (#26) from renovate/minor-patch/angular-framework into main
Some checks failed
Build, test and publish / verify (push) Failing after 14m5s
Reviewed-on: #26
2026-11-12 17:00:00 +01:00
4119733bc3 Merge branch 'main' into renovate/minor-patch/angular-framework
All checks were successful
Build, test and publish / verify (push) Successful in 6m43s
2026-11-12 16:35:18 +01:00
821bcece4f Merge pull request 'Update dependency org.codehaus.mojo:exec-maven-plugin to v3.6.4' (#27) from renovate/minor-patch/org.codehaus.mojo-exec-maven-plugin-3.x into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m46s
Reviewed-on: #27
2026-11-12 16:35:03 +01:00
7beeba33ed Merge branch 'main' into renovate/minor-patch/org.codehaus.mojo-exec-maven-plugin-3.x
All checks were successful
Build, test and publish / verify (push) Successful in 6m28s
2026-11-12 16:07:37 +01:00
f809d124e3 Merge pull request 'Update dependency org.jacoco:jacoco-maven-plugin to v0.8.16' (#28) from renovate/minor-patch/org.jacoco-jacoco-maven-plugin-0.x into main
All checks were successful
Build, test and publish / verify (push) Successful in 7m19s
Reviewed-on: #28
2026-11-12 16:07:23 +01:00
369689747d Merge branch 'main' into renovate/minor-patch/org.jacoco-jacoco-maven-plugin-0.x
All checks were successful
Build, test and publish / verify (push) Successful in 6m41s
2026-11-12 15:52:15 +01:00
8dffbdbd6b Merge pull request 'Update dependency org.jacoco:org.jacoco.agent to v0.8.16' (#29) from renovate/minor-patch/org.jacoco-org.jacoco.agent-0.x into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m51s
Reviewed-on: #29
2026-11-12 15:51:56 +01:00
f5ac671b65 Update dependency org.jacoco:org.jacoco.agent to v0.8.16
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 6m26s
2026-11-12 12:38:30 +00:00
583935ae5d Update dependency org.jacoco:jacoco-maven-plugin to v0.8.16
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 6m13s
2026-11-12 12:38:25 +00:00
3fa47582a3 Update dependency org.codehaus.mojo:exec-maven-plugin to v3.6.4
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 6m7s
2026-11-12 12:38:18 +00:00
ab5fe02328 Update Angular framework to v22.2.4
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build, test and publish / verify (push) Successful in 5m30s
2026-11-12 12:38:13 +00:00
2a900982e4 Merge pull request 'Configure Renovate' (#22) from renovate/configure into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m39s
Reviewed-on: #22
2026-11-12 13:28:18 +01:00
cf0e829346 Merge pull request 'Expose Playwright CI image for Renovate' (#23) from s5/playwright-image-declaration into main
Some checks failed
Build, test and publish / verify (push) Has been cancelled
Reviewed-on: #23
2026-11-12 13:27:29 +01:00
5c86b425d5 Expose the Playwright CI image for Renovate detection
All checks were successful
Build, test and publish / verify (push) Successful in 7m13s
2026-11-12 12:58:18 +01:00
a472bd8d08 Add renovate.json
All checks were successful
Build, test and publish / verify (push) Successful in 5m45s
2026-11-12 11:43:46 +00:00
23cb4d408f Merge pull request 'S19: shared mini-pc runner and landscape workflows' (#21) from feature/S19_shared-runner into main
All checks were successful
Build, test and publish / verify (push) Successful in 9m3s
2026-11-10 09:22:52 +01:00
7750819d73 Merge remote-tracking branch 'origin/main' into feature/S19_multi-project
All checks were successful
Build, test and publish / verify (push) Successful in 10m1s
2026-11-10 08:53:20 +01:00
77b7159f0a S19: adopt shared mini-pc runner and landscape workflows 2026-11-10 08:53:20 +01:00
c6a2b70919 Merge pull request 'feature/S19_multi-project' (#20) from feature/S19_multi-project into main
All checks were successful
Build, test and publish / verify (push) Successful in 10m25s
Reviewed-on: #20
2026-11-08 21:31:35 +01:00
c6a495a93c S19: consume versioned engineering resources and move landscape documentation
All checks were successful
Build, test and publish / verify (push) Successful in 8m54s
2026-11-08 15:23:10 +01:00
bd898e92d2 S19 refinement
All checks were successful
Build, test and publish / verify (push) Successful in 8m15s
2026-11-08 13:39:16 +01:00
d2d4713c51 Merge pull request 'git helper tool added and improved coding-standards' (#19) from git-tools into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m46s
Reviewed-on: #19
2026-11-08 11:43:46 +01:00
8546f222fe git helper tool added and improved coding-standards
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
2026-11-08 10:25:37 +01:00
109 changed files with 2932 additions and 5241 deletions

View File

@@ -5,7 +5,7 @@ on:
jobs:
verify:
runs-on: agiledancers-mini-pc
runs-on: mini-pc
env:
CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
@@ -19,21 +19,23 @@ jobs:
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
# container names and diagnostic artifacts, including reruns of one SHA.
run: java ci/Ci.java attempt
run: java generated/ci/Ci.java attempt
- name: Build and test both images
run: java ci/Ci.java verify
run: java generated/ci/Ci.java verify
- name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main'
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: java ci/Ci.java publish
run: java generated/ci/Ci.java publish
- name: Collect diagnostics and clean up test containers
if: always()
run: java ci/Ci.java cleanup
run: java generated/ci/Ci.java cleanup
- name: Retain diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5

View File

@@ -28,7 +28,7 @@ on:
jobs:
deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
@@ -42,8 +42,10 @@ jobs:
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java ci/Ci.java attempt
run: java generated/ci/Ci.java attempt
- name: Release verified images — persistent PostgreSQL storage
env:
RELEASE_MODE: ${{ inputs.mode }}
@@ -57,7 +59,7 @@ jobs:
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java ci/Ci.java deploy
run: java generated/ci/Ci.java deploy
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5

View File

@@ -5,7 +5,7 @@ on:
jobs:
rollback:
if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc
runs-on: mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
@@ -19,8 +19,10 @@ jobs:
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Verify pinned engineering resources
run: python3 scripts/engineering.py --check
- name: Identify workflow attempt
run: java ci/Ci.java attempt
run: java generated/ci/Ci.java attempt
- name: Restore previous successful release — requires a compatible database schema
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@@ -31,7 +33,7 @@ jobs:
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java ci/Ci.java rollback
run: java generated/ci/Ci.java rollback
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5

View File

@@ -1,22 +1,26 @@
# Project skills and specifications
# Shared skills and specifications
- Project skills are stored in `skills/`; specifications belong in `specs/`.
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
dedicated Skill tool is available, load the file directly.
- Use `grill-me` only when the user explicitly requests it, for example
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
files directly and follow their instructions within the available tools.
- Installing or mentioning a skill does not start an interview. Do not apply
`grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
Installing or mentioning a skill starts no interview.
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
# Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use `skills/code-review/SKILL.md` together with
- For reviews, use landscape's `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration.
# Landscape and engineering
- Read current [landscape guidance](http://192.168.2.28:3000/wanjakrah/landscape),
including applicable ADRs and specifications. This repository owns its implementation.
- Engineering resources are pinned by `engineering.lock.json`. Do not edit
`generated/`; change engineering and deliberately adopt a released version.
- Regenerate with `mvn -N -Pregenerate-engineering validate`; check with
`python3 scripts/engineering.py --check`. No sibling checkout is needed.

View File

@@ -1,255 +1,11 @@
# Backend coding standards
# Agile Dancers coding standards
This document is the authoritative policy for handwritten production and test Java
in `agiledancers-be`. Generated code and Java helpers outside that module are out
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
changing Java. Their backend-module placeholder means `agiledancers-be` here.
Frontend conventions remain in README.md and agiledancers-fe.
## Scope and authority
The existing maintainer-approved exception for Group's eight-parameter canonical
constructor remains in `tooling/standards/waivers.tsv`. No new waiver is introduced
by S19. For reviews also read [the local companion](docs/backend-review.md).
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
generated code. Dependency injection and production architecture rules apply
where relevant. Frontend and Java helpers outside the backend are out of scope.
- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/)
supplied source material. Only the rules adopted in this local document apply;
changes to the article do not change project policy.
- The project's pinned Palantir formatter wins formatting conflicts, including
comment layout. S8 remains responsible for formatter integration.
- Do not adopt unrelated static-analysis rule bundles.
## Java conventions
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
use UPPER_SNAKE_CASE; package names are lowercase.
- One top-level type per file; explicit member visibility; `@Override` on
overriding methods. Do not invent visibility modifiers where Java forbids them.
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
and `while` bodies require braces, including enhanced for and do/while bodies.
- No wildcard imports. Static imports are allowed only in test classes.
- Comments explain intent or constraints. Document public contracts with Javadoc
where their meaning is not clear from the declaration; do not require boilerplate
documentation for every accessor.
- Block comments use the following layout, subject to Palantir formatting:
```java
/*
This is an example of a block comment.
It may have more than one line.
*/
```
- Prefer records for immutable data carriers. Classes are allowed when mutation,
framework requirements or inheritance make records unsuitable. Preserve API and
serialization behavior. Record immutability is shallow: protect mutable
components appropriately.
- Require `final` for parameters, local variables and fields wherever they can
remain assigned once, including loop variables and catch parameters where Java
permits it. Field finality must remain compatible with framework injection and
lifecycle requirements. Permit reassignment when the algorithm needs it; do not
introduce artificial holder objects to avoid reassignment. This rule does not
require final classes or methods.
- Use explicit types unless the initializer visibly identifies the type. `var` is
allowed with `new School(...)` or an explicit cast, but not for method results,
literals or other inferred expressions. For example,
`final var school = new School(...)` is allowed, while a repository result uses
`final School school = repository.find(id)`. Borderline cases require review.
- No additional member-ordering or line-width policy beyond Palantir.
## Metrics
Targets guide development. Review signals require assessment, not automatic
failure. Blocking limits fail verification. Strict inequalities matter: a value
exactly at a blocking limit passes.
| Metric | Target | Review signal | Blocking limit |
| --- | ---: | ---: | ---: |
| Cyclomatic complexity per method/constructor | ≤ 5 | > 7 | > 10 |
| Cognitive complexity per method/constructor | ≤ 10 | > 10 | > 15 |
| Method/constructor length, code lines | ≤ 20 | > 30 | > 50 |
| Parameters per method | ≤ 3 | > 4 | > 5 |
| Parameters per explicitly written constructor | ≤ 4 | > 5 | > 7 |
| Injected dependencies per class | ≤ 4 | > 5 | > 7 |
| Fan-out per class, unique used types | ≤ 10 | > 15 | Review only |
| Own superclass depth, excluding Object | ≤ 1 | > 2 | > 3 |
| Control-flow nesting | ≤ 2 | > 3 | > 4 |
| Production duplicated lines density | ≤ 1% | > 1% | > 3% |
| Class length, code lines | ≤ 200 | > 300 | > 500 |
| Declared public methods per class, excluding trivial accessors | ≤ 10 | > 15 | Review only |
### Measurement definitions
- Use the pinned PMD algorithms as authoritative for cyclomatic and cognitive
complexity. Document their exact configuration, including switch and boolean
treatment. Do not accidentally introduce a class-level aggregate complexity gate.
- Measure each method, constructor and explicitly written record constructor
independently. Lambda bodies contribute to their enclosing executable;
methods in nested/local classes are separate executables.
- Parameter limits exclude implicit record constructors and generated members.
Record component counts remain a design review concern.
- Length means physical source lines after Palantir formatting, excluding blank
and comment-only lines. Declaration and brace-only lines count; a line containing
both code and comments counts. Do not substitute statement counts or NCSS.
Measure nested types separately and exclude them from enclosing class length.
- Count distinct injected collaborators by resolved type and qualifier. Include
constructor, field, setter, inherited and implicit constructor injection.
Repeated injection of the same type and qualifier counts once; differently
qualified instances of the same type count separately.
- Fan-out counts distinct explicitly referenced resolved types, including JDK and
framework types. Exclude primitives, the class itself and generated references.
- Inheritance depth counts project-owned superclass layers; exclude Object,
interfaces and external framework ancestry.
- Nesting combines `if`, loops, `switch` and `catch`, with the first structure at
depth one. An `else if` chain stays at one level. `else`, `try` and `finally`
add no level themselves. Lambdas retain surrounding depth; methods in nested
classes start fresh.
- Public-method counts include explicitly declared public instance and static
methods; exclude constructors, inherited methods and trivial accessors. A
trivial accessor only reads or assigns its corresponding field, with no
validation, branching or other work.
- Duplication limits apply only to production code. Duplication in tests is allowed
and has no target, review signal or blocking limit.
- Detect production clones with CPD, minimum 100 tokens, without ignoring
identifier or literal differences. Density is
`100 × distinct duplicated code-line locations / all production code-line locations`.
Exclude blank/comment-only lines from numerator and denominator. Count every
occurrence, including the first; merge overlapping ranges to count each source
line once. This project metric need not equal a future Sonar metric.
## Enforcement and developer workflow
- Use explicitly pinned Java-25-compatible Checkstyle and PMD/CPD versions.
Retain ArchUnit for architecture checks and add small targeted custom checks
where standard rules cannot accurately express the agreed semantics.
- Automate every blocking metric and explicit rule where reliable checking is
available. Identify rules requiring manual review explicitly; do not claim
they are automated. Review signals must appear in reports even when builds pass.
- Run standards checks during `mvn verify`; provide documented standalone commands.
Ordinary compile/test commands retain the current S8 formatting behavior.
- Standards checks report violations without rewriting code. CI passes these
checks before image publication. Parser failures or unsupported Java constructs
fail with the affected path and actionable diagnostics, rather than being skipped.
- Add a root AGENTS.md instruction requiring agents to read CODING_STANDARDS.md
before changing backend Java. Project review instructions must explicitly cover
manual rules, review signals in changed code and approved waivers, without
repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside
`skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds
merely to make verification pass.
## Coverage
- Require at least 80% backend line coverage across handwritten production code
for the module as a whole, not separately per class or package. Compare the
unrounded percentage against 80%.
- Local `mvn verify` must reach 80% using backend unit/integration tests alone.
This intentionally tightens the original combined-tests-only requirement.
- CI additionally instruments the backend exercised by Playwright, merges that
coverage with backend unit/integration coverage and gates the combined result.
The Playwright contribution need not separately reach 80%.
- Exclude generated code only; do not broadly exclude DTOs, configuration or
difficult code. Publish line and branch coverage reports; branch coverage has
no agreed blocking percentage.
- PIT measures mutation-test effectiveness, not line coverage, and is outside
S7's required implementation. Configuring Sonar in the pipeline remains out of
scope for S14. FindBugs/SpotBugs is not an additional required check suite.
## Implemented checks and manual review
Checkstyle **12.3.1**, PMD/CPD **7.28.0**, and JaCoCo **0.8.14** are pinned.
`tooling/standards/checkstyle.xml` selects individual naming, structure, import and brace checks; no general analysis bundle is enabled.
`tooling/standards/src/main/java/Standards.java` supplies the targeted measurements.
ArchUnit remains in the backend test suite for architectural boundaries.
PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the
resolved test dependency classpath. Cyclomatic complexity uses PMD's default
options: boolean paths count, assertions do not. Decisions include if/loops,
catch, ternary and throw; non-default switch alternatives count individually,
including comma-separated labels. There is no aggregate class-complexity gate.
Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch,
recursion and flow rules. The scoped visitors retain those algorithms while
including lambdas in their enclosing executable and excluding nested/local type
bodies, whose methods are measured independently. Compact record constructors
use the same visitors and start with cyclomatic complexity one.
Custom checks measure physical code lines from regular parser tokens, including
brace/declaration lines and mixed code/comment lines. They count multiline literal
lines while excluding physically blank lines. Class length excludes nested-type
tokens, preserving lines shared with enclosing code. Other custom measurements
implement the definitions above: nesting, resolved type fan-out, project-owned
superclass depth, public non-accessor methods, and injected collaborators.
Injection includes inherited fields/setters, explicit `@Inject` constructors,
single constructors on bean classes, records' implicit canonical constructors,
and Lombok required/all-argument constructor injection. Qualifier keys use resolved
annotation names and values, including defaults; repeat sites are deduplicated.
The CPD configuration preserves identifiers/literals, uses 100 tokens and disables
skip blocks. Its line-location union counts overlapping occurrences only once.
Checkstyle and targeted checks enforce import/brace/naming/visibility rules,
`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including
loops/catches and lambdas) and initialized fields. PMD's three selected finality
rules additionally cover control-flow assignment and private constructor-initialized
fields. Framework/lifecycle requirements, generated Lombok setters, and external
writes to public/protected fields need review before changing field finality.
No tool can infer whether a comment explains intent, whether a public contract
needs Javadoc, whether a record is appropriate, or whether mutable components are
sufficiently protected. These are **manual rules**, as are ambiguous type inference
and architecture/DI design decisions. Review all `DESIGN` record-component entries
and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`.
Reports never rewrite Java. Formatter application remains S8's separate validate
execution. Generated output under `target` and generated source directories are
outside the handwritten `src/main/java` and `src/test/java` scope. No maintained
file, DTO, configuration class or package is excluded from standards or coverage.
JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions
are configured. Line and branch results are published, with only lines gated.
Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric,
fully resolved executable signature (or class), exact retained value and the
maintainer agreement/reason. No wildcard waivers are allowed, and a changed value
fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression
annotations/comments cannot silently waive a rule. The only approved waiver is the eight-parameter `Group` canonical constructor,
which preserves its JSON contract while defensively copying both lists. Adding a waiver always requires maintainer agreement.
## Commands and reports
Run from the root with JDK 25:
```sh
mvn -Dformat.mode=check clean verify
# Prepare standalone analysis without running tests:
mvn -pl agiledancers-be test-compile dependency:build-classpath@standards-classpath
mvn -f tooling/standards/pom.xml test exec:java
# Exact local coverage gate after a test/report build:
java tooling/coverage/Coverage.java agiledancers-be/target/site/jacoco/jacoco.xml
```
`verify` runs backend tests, builds resolved analysis inputs, generates JaCoCo
reports, runs checker regression tests and standards analysis, then gates exact
module-wide line coverage. `compile` and `test` retain existing formatting behavior
and do not run the verify-only standards checks. Standalone analysis requires its
classpath preparation; unresolved types fail with the path and preparation advice.
Full metrics and findings: `agiledancers-be/target/standards/report.txt`.
Checker regression results: `tooling/standards/target/surefire-reports`.
Coverage HTML/XML/CSV: `agiledancers-be/target/site/jacoco` (local backend tests)
and `agiledancers-be/target/site/jacoco-combined` (merged browser contribution).
The gate compares integer line counts (`5 * covered >= 4 * total`), rejecting an
empty report and percentages that would round up to 80%.
CI first passes local verify. It mounts the pinned JaCoCo agent into its disposable
backend test container, exposes the agent only on the isolated Docker network,
and uses the pinned CLI to dump `target/playwright.exec` after Playwright. A missing
or empty dump fails. The following commands merge both datasets and gate the result
before the verified-commit marker can authorize publication:
```sh
mvn -pl agiledancers-be jacoco:merge@combined jacoco:report@combined-report exec:exec@combined-coverage
```
CI retains both coverage reports, execution data, standards findings and regression
results with its usual diagnostics artifacts. The agent is mounted only for tests;
published images contain no agent. PIT and Sonar configuration remain outside S7.
See `specs/S7-validation.md` for migration evidence and retained-signal assessment.
Validation evidence: [S7](docs/validation/S7-validation.md).

View File

@@ -2,8 +2,9 @@ FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace
COPY pom.xml ./
COPY .mvn .mvn
COPY generated generated
COPY tooling tooling
COPY ci ci
COPY engineering.properties engineering.properties
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src
# Source builds run the same PostgreSQL tests as local/CI verification.

View File

@@ -1,3 +1,18 @@
# Landscape and shared engineering
Architecture decisions, specifications and project onboarding are maintained in
[landscape](http://192.168.2.28:3000/wanjakrah/landscape). Shared agent skills are maintained in landscape/skills/. Reusable tooling,
standards and the project generator are maintained in
[engineering](http://192.168.2.28:3000/wanjakrah/engineering).
This project commits the engineering version recorded in `engineering.lock.json`
under `generated/`. Ordinary builds need no other repository checkout. Regenerate
with `mvn -N -Pregenerate-engineering validate`; CI checks the selected tag and
commit with `python3 scripts/engineering.py --check`. To upgrade, update both tag
and commit, follow engineering's UPGRADING.md, regenerate and review the diff.
Project configuration in `engineering.properties`, workflows and `deploy/` remains
application-owned. Read CODING_STANDARDS.md and AGENTS.md for project guidance.
# agiledancers
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
@@ -299,7 +314,7 @@ during the one-time transition from an in-memory release, and [S6 evidence](docs
Maven is the authoritative formatting path. The root build runs the actual
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
**8.63.4** text recipe in `tooling/format/Format.java`. The pinned
**8.63.4** text recipe in `generated/tooling/format/Format.java`. The pinned
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
No OpenRewrite AutoFormat or additional refactoring recipes run.
@@ -344,9 +359,9 @@ format-on-save must not override Maven's result. Other editors can invoke the
standalone Maven apply command as an external tool.
Verification and measurements are recorded in
[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure,
[the S8 evidence](docs/validation/S8-formatting-evidence.md). Reproduce the isolated failure,
UTF-8, import, cache and idempotence checks with
`python3 tooling/format/test.py`, and the sequential end-to-end build measurements
the formatter integration test in engineering (`python3 tests/format_integration.py`), and the sequential end-to-end build measurements
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
prerequisites). Results are written under `target/`.
@@ -357,7 +372,7 @@ It defines the agreed rules, exact metric limits, manual review duties, pinned
analysis tools, coverage policy and standalone commands. `mvn verify` runs standards
checks and requires at least 80% module-wide backend line coverage. CI also collects
and merges coverage from Playwright before publication. Reports are retained with
CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status.
CI diagnostics. See [S7 validation](docs/validation/S7-validation.md) for current migration status.
Existing-school mutations require `X-School-Revision` with the revision loaded by

View File

@@ -1,33 +0,0 @@
The adr-folder contains Architecture Decision Records.
An Architecture Decision Record is a file that contains a decision that should be applied to all new and altered code and systems.
ADR's can only be added or altered on specific request by the user. Agents should never add or modify ADR's without explicit request to do so.
ADR's are immutable in essence. This means that the only modifications allowed are in its state, or when it applies to typo's, adding clarifications, or removing ambiguity.
An ADR MUST have the following properties:
- ID
- Title
- Supersedes
- Status (NEW, REFINED, APPROVED, REJECTED, EXPIRED)
- Expiration date
- Context
- Decisions
- Options considered with pro's and cons
- Consequences.
An _ID_ MUST be a unique number, prefixed with ADR (e.g. ADR1, ADR2, ADR3, etc.). This makes it easy to reference an ADR.
A _title_ is a short, unique description of the ADR. It should make it easy to know in a glance what the ADR is about.
The name of the file SHOULD contain the ID and the title, or a short variant of the title, of the ADR.
_Supersedes_ is an optional property that MAY reference another, expired ADR that it supersedes.
When _supersedes_ references another ADR, it MUST do so by referencing its ID.
When _supersedes_ references another ADR, the status of that other ADR MUST equal to EXPIRED.
_Status_ contains the state of the ADR. In the readme, when we mention state, we mean the value of this status-property. ADR's are always in a subfolder of the adr-folder, with the same name as the state, but in lowercase ('adr/new' for ADR's with state NEW, 'adr/refined' for ADR's with state REFINED, etc.).
Each created ADR will start with the state NEW. Apart from the ID and the state, each property can still be modified. Nothing that is in an ADR with state NEW, should be considered when writing or evaluating anything within the software landscape.
An RFD will get the state REFINED, when it is fully worked out. Only RFD's with the state NEW can be changed to REFINED. This means an effort has been made to get to a final version of the RFD. Getting an RFD from NEW to REFINED can be done with or without the help of an agent. However, an agent should always verify mutual understanding of the content with the human owner, before changing the state from NEW to REFINED.
An ADR with the state REFINED can be moved to either APPROVED or REJECTED. This step must be done manually, not by any agent. APPROVED means the decision in the ADR applies to all code and/or software added or altered as far as the content of the ADR applies to the situation. Exceptions are possible with explicit accord. Such an accord should be given by a person. REJECTED means that the ADR should not be considered anywhere when writing or evaluating anything within the software landscape.
An ADR with the state EXPIRED is no longer valid. It should no longer be considered anywhere when writing or evaluating anything within the software landscape, even though it had been the case previously. It MAY have been superseded by a new ADR, but that is not necessary.
An _expiration date_ indicated from when an ADR should be notified for reevaluation. Reaching the _expiration date_ does not mean the ADR is expired. It only means the ADR should be reevaluated by a person, with or without the help of an agent. As a result of the reevaluation, this ADR may get EXPIRED - only after explicit agreement of a person - and it MAY be superseded by a new one. Another option is that only the expiration date itself gets updated to a new expiration date. This also only after explicit agreement of a person.
_Context_ should explain the context of the problem where a decision had to be made for.
The _decision_ describes the decision. This is what all code MUST apply to, unless an explicit exception is approved by a person.
The _options_ describes the various alternatives that were considered, all with its pro's and con's, and why the chosen decision whas chosen.
_Consequences_ describes the consequences to the current state of the application landscape at the time of writing. This could be about a migration timeline, a new process, different tooling, or a change in the stack, or something else.

View File

@@ -1,215 +0,0 @@
**ID:** ADR1 \
**Title:** Application size \
**Supersedes:** - \
**Status:** REFINED \
**Expiration Date:** Six months after approval; record the calendar date upon manual approval.
## Context
Agile Dancers currently keeps its frontend, backend, CI pipeline, skills,
specifications, ADRs, and other documentation in one repository. As the application
grows, humans and agents need more context to understand individual tasks.
Functional dependencies can become entangled, making changes and reuse harder.
The primary goal is to reduce the context required for a task. Supporting goals
are preventing uncontrolled coupling and enabling reuse and replacement of
capabilities. Repository organization, module boundaries, and deployment
boundaries are separate architectural choices.
Business capabilities stand on their own, while an application system may support
multiple capabilities. Loosely coupled capability boundaries allow implementations
to change between custom software, libraries, and externally supplied components.
Scheduling and school administration are candidate areas to examine; their exact
system boundaries have not yet been decided.
Code-level modularity alone is insufficient when shared tables, materialized
views, SQL scripts, or other database access bypass module boundaries. Separate
services would have the same weakness without enforced data ownership.
Systems can initially run on the same physical server. Independent workload
placement is nevertheless useful: computational work, such as runners and future
scheduling calculations, may run on a mini-PC rather than the Synology. Independent
scaling and release cycles are future possibilities rather than universal current
requirements. Colocation reduces network exposure but process restarts, timeouts,
and resource contention still require defined failure behavior.
Reusable engineering resources should also support other projects. Specifications
can affect several projects, and ADRs apply across the application landscape.
## Decisions
### Capabilities and systems
Organize the landscape into independently deployable systems. Group capabilities
into systems according to cohesion, data ownership, replacement needs, and
operational requirements, including workload placement. Capability size alone does
not justify a separate system.
Each system has its own repository and independent deployment lifecycle. Within a
system, use modules to keep its capabilities loosely coupled. A capability does
not automatically require its own service or deployment. Compute workers may be
deployed separately from the business system that delegates work to them.
Capability contracts should allow implementations to be replaced by custom code,
libraries, or external products, using adapters where necessary.
### Data ownership
Each system exclusively owns its data. Other systems access that data through
explicit contracts, never through direct access to its storage. This restriction
also applies to reporting queries, views, migrations, and maintenance scripts.
Cross-system reporting requires explicit contracts or a separately owned
projection.
PostgreSQL-backed systems initially share one PostgreSQL server/container, with
separate databases or schemas and separate credentials whose permissions enforce
ownership. Sharing physical infrastructure does not permit sharing storage access.
Systems may choose other storage technologies when justified. This permits future
CQRS designs without adopting CQRS through this ADR.
### Contracts and communication
Systems communicate through explicit, versioned contracts. Internal domain models
and persistence entities remain private to their owning system and are not shared
through common libraries. Contract definitions and generated clients may be shared.
Choose synchronous APIs or asynchronous messaging for each interaction according
to its needs. Define failure behavior for each interaction. This ADR mandates
neither a landscape-wide communication mechanism nor a message broker.
### Landscape and engineering resources
Maintain a landscape repository containing ADRs, specifications, and the capability
and system map. Specifications identify the projects they affect and may span
multiple systems. ADRs govern the application landscape according to their scope.
Each system retains a README describing its implementation and operation.
Maintain reusable engineering resources, including pipeline components, skills,
and general engineering documentation, separately from the landscape repository
and individual systems. Group these resources according to maintenance and release
needs; a separate repository per resource type is not required. Version shared
resources so consuming projects can adopt updates deliberately.
### Adoption and review
Use this ADR as guidance and the starting point for follow-up specifications.
Detailed system boundaries, contracts, and migration designs are follow-up work.
Migrate incrementally, keeping the application working after each extraction.
Perform an initial review six months after approval. Review earlier if contract
coordination, operational overhead, or persistent cross-system coupling becomes a
significant burden. The expiration date triggers reevaluation and does not
automatically invalidate the decision.
## Options considered
### 1. Improve navigation within the current structure
Keep the existing projects and deployment structure, improving focused
documentation, task guidance, and file organization.
Pros:
- Requires little disruption or migration work.
- Makes relevant context easier to find.
- Retains simple local development and coordinated changes.
Cons:
- Offers limited protection against functional entanglement.
- Does not enforce data ownership or support independent workload placement.
- Leaves reuse and replacement dependent on existing application boundaries.
Not chosen because navigation improvements alone do not establish the ownership
and replacement boundaries needed as the landscape grows.
### 2. Modular monolith in one repository
Organize capabilities into modules with explicit interfaces and dependency rules,
while retaining coordinated deployment and one repository.
Pros:
- Reduces the code area needed to understand a capability.
- Enables dependency checks and straightforward changes across modules.
- Keeps deployment and in-process communication simple.
Cons:
- Shared database access can bypass code-level boundaries through tables, views,
and scripts unless storage ownership is separately enforced.
- Workload placement and deployment remain coupled.
- Independent replacement and reuse may require later extraction.
Not chosen as the landscape-wide approach because independently enforced ownership
and deployment boundaries are desired. Modular design remains part of the chosen
approach inside each system.
### 3. Separate projects and repositories with coordinated deployment
Extract capabilities into separately built libraries or applications with
versioned contracts, while retaining a coordinated deployment lifecycle.
Pros:
- Separates source and build context.
- Gives reusable components their own versioned releases.
- Retains a coordinated operational release process.
Cons:
- Changes spanning repositories require dependency release coordination.
- Deployment remains coupled despite repository separation.
- Repository separation alone does not enforce storage ownership or runtime
independence.
Not chosen because it incurs repository coordination costs while retaining a
deployment constraint that would limit workload placement and replacement.
### 4. Independently deployable systems with internal capability modules — chosen
Give systems their own repositories, deployment lifecycles, contracts, and enforced
data ownership. Keep capabilities loosely coupled through modules within a system.
Place compute workers independently where useful.
Pros:
- Supports focused development context and independent system evolution.
- Enforces data ownership beyond code dependency rules through storage permissions.
- Allows workloads to move between the Synology and mini-PC.
- Supports capability replacement and reuse through explicit contracts.
- Allows storage technology choices per system.
Cons:
- Requires contract compatibility and coordination for changes spanning systems.
- Adds deployment, configuration, and operational maintenance for multiple processes.
- Requires explicit failure handling and cross-system consistency decisions.
- Sharing one server or PostgreSQL instance retains common infrastructure failure
points despite logical isolation.
Chosen because ownership, replaceability, focused context, and independent workload
placement outweigh the additional operational and contract coordination work.
## Consequences
- Write follow-up specifications to map capabilities and dependencies, choose
initial system boundaries, define contracts and data ownership, and plan
extraction. This ADR does not itself settle those boundaries or implement them.
- Establish the landscape repository and the repositories and versioning needed
for reusable engineering resources. Preserve links between specifications and
affected projects, and between system READMEs and landscape guidance.
- Extract systems one at a time, establishing contracts and enforcing storage
ownership as part of each extraction. Moving tables into separate schemas is
insufficient while cross-system SQL access remains possible.
- Adapt build and deployment pipelines to release systems independently and
consume versioned shared engineering resources.
- Add appropriate checks for module dependencies, contract compatibility, and
storage isolation. Include views and scripts in boundary enforcement.
- Specify how systems exchange information, handle failed interactions, and keep
derived information consistent. These choices belong to the relevant follow-up
specifications rather than a universal messaging requirement.
- Keep one PostgreSQL container initially. Detailed backup and failover design is
future work; logical data separation does not provide infrastructure failover.
- Record the calendar review date when a maintainer manually approves this ADR.
REFINED records the agreed proposal; approval remains a separate manual step.

View File

@@ -1,176 +0,0 @@
**ID:** ADR2 \
**Title:** HTTP API contracts and tooling \
**Supersedes:** - \
**Status:** REFINED \
**Expiration Date:** Six months after approval; record the calendar date upon manual approval.
## Context
Agile Dancers currently uses REST without an explicit architectural decision.
The landscape needs agreed defaults for communication across system boundaries,
contract documentation, client generation, compatibility, and error handling.
Interactions include frontend access, requests between business systems,
delegation of long-running calculations to workers, change notifications, and
external consumers. Most clients and servers are developed by the same maintainer.
Consumers must nevertheless be able to upgrade independently. The independently
deployable systems proposed in ADR1 are relevant context; ADR1 remains REFINED
and is not approved through this decision.
The current frontend uses Angular, but future clients may use other frameworks
or languages. Contracts and tooling should support that flexibility.
For now, interactions use a defined alternative path when another system is
offline. Durable queued delivery is not a current requirement. More specialized
communication mechanisms may become appropriate for particular interactions.
## Decisions
### Scope and communication style
Use HTTP APIs with JSON payloads as the default across system boundaries.
Use resource-oriented endpoints where natural and action-oriented endpoints
where they express business intent more clearly. Do not force business actions
into CRUD operations. For example:
```text
GET /api/v1/schedules/{id}
POST /api/v1/schedules/{id}/calculate
```
Action endpoints still follow HTTP method and status semantics. For long-running
work, use an explicit job or operation resource with a status endpoint when the
caller needs to track progress. A notification can initially use a direct HTTP
request with documented failure behavior; this does not promise durable delivery.
These are landscape defaults. Document justified exceptions alongside the
relevant interface, including the reason and the alternative contract and failure
behavior. This ADR does not mandate GraphQL, RPC, or messaging infrastructure.
### Contracts and documentation
Describe HTTP APIs using OpenAPI. Normally generate the description from server
code, adding explicit descriptions and annotations where needed. Review the
resulting contract as a public interface; generation alone does not ensure
completeness or backward compatibility.
Use self-hosted Scalar API Reference as the default interactive documentation UI.
Keep the OpenAPI document independently available so consumers and tooling do not
depend on the UI. Publish a contract artifact for each supported major version.
Choose an OpenAPI specification version supported by the server tooling, Scalar,
and the selected client generators. Do not automatically adopt the latest version
without checking that compatibility.
### Client generation
Generate client transport code and contract types from OpenAPI by default, using
OpenAPI Generator. Select the generator appropriate to the consuming technology;
use `typescript-angular` for Angular clients. This choice does not require future
frontends to use Angular.
Pin generator versions and configuration for reproducible generation. Generate
against a selected contract artifact rather than requiring a running server during
the client build. Keep generated code separate from handwritten application logic
and do not manually edit generated files. Generated static types do not by
themselves provide runtime response validation.
### Versioning and compatibility
Put the major contract version in the URL, such as `/api/v1/schedules`.
Keep backward-compatible changes within that major version. Introduce a new
major version for breaking changes. Software release and generated client package
versions may advance independently of the major contract version.
Allow consumers to upgrade independently. Normally support one contract version;
during transitions, aim to support two. This is a guideline rather than a hard
cap: additional versions may coexist when justified. Document supported versions,
deprecation, and the transition plan, coordinating retirement with affected
consumers. Check compatibility before releasing contract changes.
### Errors and offline behavior
Standardize API error bodies on RFC 9457 Problem Details using
`application/problem+json` and appropriate HTTP status codes. Use stable problem
`type` identifiers for programmatic handling and readable explanations for humans.
Clients must not depend on parsing human-readable messages. Document expected
errors in OpenAPI, including validation details where applicable. Do not expose
internal exception details or sensitive information in error responses.
Define timeouts and the offline alternative path for each interaction. A network
failure may produce no HTTP response, so consumers must handle that separately
from a Problem Details response. Avoid automatic retries of actions unless
repeating them is safe. Do not assume durable delivery or eventual replay.
### Adoption and review
Adopt incrementally for new interfaces and interfaces being substantially changed.
Migrate existing interfaces through planned work while preserving compatibility
for existing consumers. No immediate landscape-wide rewrite is required.
Review six months after approval, or earlier if integration needs expose
limitations. The review date triggers reevaluation rather than automatic expiry.
## Options considered
### Communication approach
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| Resource-oriented HTTP/JSON with action endpoints | Readable resources and business actions; supports different client technologies; retains HTTP semantics. | Requires consistent endpoint conventions and explicit handling of long-running work and failures. | Chosen as the default. |
| GraphQL | Flexible data selection; typed schema; supports different consumer views of related data. | Adds query authorization and performance concerns; requires additional conventions for work delegation and notifications. | Not chosen: current needs and common client/server ownership do not justify the added complexity. |
| RPC, including JSON-RPC and gRPC | Explicit operation contracts; JSON-RPC is lightweight; gRPC supports strongly typed generated clients. | Operation-oriented contracts require careful evolution; gRPC adds browser integration requirements. | Not chosen as the default: resource-oriented HTTP with optional actions better matches the desired interface flexibility. RPC remains a possible justified exception. |
### Contract description and authoring
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| OpenAPI, normally generated from server code | Language-independent HTTP contracts; supports documentation and client generation; avoids separately maintaining a design language. | Needs annotations, review, and compatibility checks; generated output can be incomplete. | Chosen. |
| TypeSpec generating OpenAPI | Dedicated contract authoring language; can generate descriptions and other artifacts. | Adds a language and compiler to maintain. | Not chosen: no current benefit sufficient to justify the additional tooling. |
| RAML | API description language with reusable modeling constructs. | Its specification repository is archived, creating a maintenance concern for a new standard. | Not chosen. |
### Interactive documentation
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| Scalar API Reference | Readable interactive documentation; integrated request testing; self-hosting and configuration options. | Requires integration and maintenance alongside the server framework. | Chosen: readability and testability are the primary purposes of the UI. |
| Swagger UI | Interactive requests and authentication support; direct integration with Quarkus OpenAPI tooling. | Its expandable operation layout is less aligned with the preferred reading experience. | Not chosen as the landscape default. |
### Client generation
| Option | Pros | Cons | Outcome |
| --- | --- | --- | --- |
| OpenAPI Generator | Supports Angular and other client technologies in one tool family; configurable generation. | Generator-specific options and upgrades need management. | Chosen to support future technology changes. |
| ng-openapi-gen | Angular-focused generation; npm-based tooling. | Other client technologies require another generator. | Not chosen as the landscape default. |
| Handwritten clients | Direct control over client code and application integration. | Duplicates contract types and request details; increases drift risk. | Not chosen as the default. |
## Consequences
- Apply the defaults to new and substantially changed interfaces. Plan migration
of existing interfaces incrementally, preserving consumer compatibility.
- Generate and publish versioned OpenAPI artifacts. Add Scalar to provide
interactive documentation and request testing for supported contracts.
- Establish pinned OpenAPI Generator configurations for client technologies,
initially Angular. Keep generated code separate from application logic.
- Add contract validation and compatibility checks to release workflows. Verify
generated clients against the actual contract before adopting generator upgrades.
- Introduce URL major versions and coordinate deprecation with consumers. Normally
maintain one version and temporarily two, documenting justified deviations.
- Align API error responses with Problem Details and document expected failures.
Define timeout and offline behavior per interaction, including safe retry rules.
- Record justified exceptions alongside their interfaces rather than imposing an
unsuitable communication mechanism on every interaction.
- Record the calendar review date upon manual approval. REFINED captures the
agreed proposal; approval remains a separate manual step.
### References
- [OpenAPI specification](https://spec.openapis.org/oas/)
- [Scalar API Reference configuration](https://scalar.com/products/api-references/configuration)
- [OpenAPI Generator: TypeScript Angular](https://openapi-generator.tech/docs/generators/typescript-angular/)
- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html)
- [Swagger UI configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)
- [TypeSpec overview](https://learn.microsoft.com/en-us/azure/developer/typespec/overview/)
- [RAML specification repository](https://github.com/raml-org/raml-spec)
- [JSON-RPC specification](https://www.jsonrpc.org/specification)
- [ng-openapi-gen](https://github.com/cyclosproject/ng-openapi-gen)

View File

@@ -85,38 +85,38 @@
</execution>
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
<configuration><artifactItems>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.16</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.16</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.4</version>
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration>
<executable>${maven.home}/bin/mvn</executable>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
<argument>${maven.multiModuleProjectDirectory}/tooling/standards/pom.xml</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/standards/pom.xml</argument>
<argument>test</argument><argument>exec:java</argument>
</arguments>
</configuration>
</execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
</arguments></configuration>
</execution>
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
</arguments></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.16</version>
<executions>
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>

File diff suppressed because it is too large Load Diff

View File

@@ -14,24 +14,24 @@
},
"private": true,
"dependencies": {
"@angular/common": "22.2.3",
"@angular/compiler": "22.2.3",
"@angular/core": "22.2.3",
"@angular/forms": "22.2.3",
"@angular/platform-browser": "22.2.3",
"@angular/router": "22.2.3",
"@angular/common": "22.2.4",
"@angular/compiler": "22.2.4",
"@angular/core": "22.2.4",
"@angular/forms": "22.2.4",
"@angular/platform-browser": "22.2.4",
"@angular/router": "22.2.4",
"rxjs": "7.8.2",
"tslib": "2.8.1"
},
"devDependencies": {
"@angular/build": "22.2.4",
"@angular/cli": "22.2.4",
"@angular/compiler-cli": "22.2.3",
"@angular/build": "22.2.6",
"@angular/cli": "22.2.6",
"@angular/compiler-cli": "22.2.4",
"@eslint/js": "10.0.1",
"@playwright/test": "1.64.1",
"@types/node": "22.20.5",
"angular-eslint": "22.6.0",
"eslint": "10.13.0",
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"husky": "9.1.7",

View File

@@ -1,19 +0,0 @@
[Unit]
Description=Agiledancers Gitea Actions runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/.local/share/agiledancers-runner
EnvironmentFile=%h/.config/agiledancers-runner/environment
ExecStartPre=/usr/bin/env java %h/.local/share/agiledancers-runner/WaitForDocker.java 120
TimeoutStartSec=150
ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/agiledancers-runner/config.yaml
Restart=on-failure
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=150
[Install]
WantedBy=default.target

View File

@@ -6,6 +6,6 @@ runner:
timeout: 2h
shutdown_timeout: 2m
labels:
- agiledancers-mini-pc:host
- mini-pc:host
cache:
enabled: false

19
ci/runner/runner.service Normal file
View File

@@ -0,0 +1,19 @@
[Unit]
Description=mini-pc Gitea Actions runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/.local/share/mini-pc-runner
EnvironmentFile=%h/.config/mini-pc-runner/environment
ExecStartPre=/usr/bin/env java %h/.local/share/mini-pc-runner/WaitForDocker.java 120
TimeoutStartSec=150
ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/mini-pc-runner/config.yaml
Restart=on-failure
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=150
[Install]
WantedBy=default.target

7
deploy/health.sh Normal file
View File

@@ -0,0 +1,7 @@
# Application-specific readiness, retained with each release by engineering.
application_health() {
frontend=$(compose ps -q frontend)
docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null
}

View File

@@ -0,0 +1,3 @@
# S19 resource ownership
The complete [migration inventory](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/S19-classification.md) records every source resource and its destination.

View File

@@ -0,0 +1,12 @@
# S19 review follow-up — 2026-11-10
Engineering v2.0.0 at `46df72f008113d0ed683a6c7344b1d7309564d84` is pinned and committed.
Regeneration and bootstrap --check passed against the published tag and full commit.
Shared engineering tests passed: 8 bootstrap/generator, 21 CI and 34 release tests;
formatter integration passed. Java launcher documentation, varargs and console API updated.
Workflows use the shared user-scoped mini-pc runner for wanjakrah. It serves current
and future repositories without per-project registration. Skills are maintained in
landscape/skills/ and project agent instructions reference those workflows.
Project deployment credentials and runtime configuration remain application-owned.
No production deployment was performed.

36
docs/S19-validation.md Normal file
View File

@@ -0,0 +1,36 @@
# S19 validation
The [landscape specification](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/specs/S19.md)
and [resource inventory](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/S19-classification.md)
describe the repository split. Engineering owns the shared source; this project
consumes its selected tag and commit through engineering.lock.json.
Validated on 2026-11-08:
- Full Maven verification: 73 backend tests and 19 standards regression tests.
Existing approved waiver retained; no new waiver or threshold change.
- Full shared pipeline: frontend lint/build, 32 frontend tests, 4 Playwright
tests, both prebuilt Docker images and combined backend coverage
1771/1841 (96.20%).
- Standalone backend and frontend Docker source builds passed. The backend used
a disposable PostgreSQL container and BuildKit secrets for test configuration.
- Shared CI/release regression suites: 19 and 34 tests respectively.
- Engineering bootstrap/generator suite: 7 tests, covering all component choices,
actual Maven regeneration with generated/ absent, drift including new files,
obsolete-file removal, moved tags and preservation of project-owned files.
- Formatter integration checks passed. Upstream skills and attribution files
match their source snapshot byte for byte; only the skills index was adapted.
- A separate generated example passed its common pipeline and a real isolated
deployment of two synthetic revisions followed by rollback. Production was
not used. See engineering's VALIDATION.md and tests/acceptance.py.
Gitea build-run evidence for the migrated application commit is recorded with
S19 in landscape. Its workflow checks the pinned release before invoking the
same shared pipeline. Checkout and committed generated resources require no
sibling repository checkout.
Production deployment is a separate action. Live NAS SSH authentication,
production credentials and rollback against pre-S19 retained releases were not
exercised. The existing Compose project, database volume and release-state schema
remain compatible. Legacy releases without retained health.sh use the current
application health function; review that compatibility before production rollback.

View File

@@ -1,16 +1,10 @@
# Backend review companion
# Agile Dancers backend review companion
Use alongside `skills/code-review/SKILL.md` and `CODING_STANDARDS.md`.
Use [the shared review guidance](../generated/docs/backend-review.md),
[code-review skill](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/code-review/SKILL.md), and
[project standards](../CODING_STANDARDS.md).
Review changed code for intent-revealing comments, missing public-contract Javadoc,
appropriate record use, defensive copying of mutable record components, and
compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers
and framework/lifecycle exceptions to field finality. Confirm architecture and
injection decisions make sense for production and tests.
Read standards reports even when verification passes. Assess every review signal
in changed code, including fan-out, public methods and record component counts.
Record a rationale for retained signals in the S7 evidence or change description.
Check narrowly scoped waivers against recorded maintainer agreement and reason.
Never introduce blanket exclusions or weaken thresholds to pass a build.
Do not repeat mechanical checks already enforced by tooling; verify their result.
The backend module is agiledancers-be. The approved Group constructor waiver
remains application-owned in tooling/standards/waivers.tsv. Assess retained
signals against the implementation evidence in docs/validation and the change
description. S19 introduces no standards waiver.

View File

@@ -12,7 +12,7 @@ zoals `[skip ci]`; dit is expliciet toegestaan.
## Java-bronbestanden uitvoeren
De CI- en setuphulpmiddelen zijn Java 25-bronbestanden. Start ze direct met
`java ci/Ci.java <opdracht>` vanuit de repositoryroot. De Java-launcher compileert
`java generated/ci/Ci.java <opdracht>` vanuit de repositoryroot. De Java-launcher compileert
de benodigde bronbestanden in het geheugen. Er is geen aparte `javac`-stap,
Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt
alleen de JDK, inclusief de HTTP-client, process builder en bestandsloten.
@@ -36,7 +36,7 @@ Gitea 1.25.4 en de registry staan op de bestaande Synology,
Linux x86_64 mini-pc. Er is geen extra hardware nodig. De mini-pc moet aanstaan en
wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
runner alleen voor deze vertrouwde repository.
runner alleen voor de vertrouwde repositories van deze eigenaar.
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm
en een werkende Docker-daemon
@@ -76,7 +76,7 @@ Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en
backup via:
```sh
java ci/Ci.java configure-docker "$HOME/.docker/daemon.json"
java generated/ci/Ci.java configure-docker "$HOME/.docker/daemon.json"
```
Voor Docker Engine op Linux is dat dezelfde toevoeging aan
@@ -87,7 +87,7 @@ Docker-clients die images van deze interne HTTP-registry moeten ophalen.
Gebruik de Docker Desktop-context of zet `DOCKER_HOST` expliciet. De installer
neemt de geselecteerde socket over in de gebruikersservice. Als de socket later
wijzigt, pas dan `~/.config/agiledancers-runner/environment` aan en herstart de
wijzigt, pas dan `~/.config/mini-pc-runner/environment` aan en herstart de
runner. Bij Docker Desktop blijft de Desktop-VM eveneens nodig voor builds.
Runner en containers moeten de Synology en internetdownloads kunnen bereiken.
@@ -109,12 +109,13 @@ Stel bij repository **Settings → Actions** in:
| Secret | `REGISTRY_TOKEN` | PAT met `write:package` en `read:repository` voor imagepublicatie en controle van de huidige hoofdbranch |
Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT.
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie.
Gebruik de gedeelde user-scoped runner `mini-pc` voor `wanjakrah`; een nieuwe
repository heeft geen eigen runnerregistratie nodig.
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
Voor reproduceerbare configuratie bestaat `java ci/Ci.java configure-gitea`. Maak twee
Voor reproduceerbare configuratie bestaat `java generated/ci/Ci.java configure-gitea`. Maak twee
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie),
repositorybeheerrechten (repository Actions, secrets en variabelen),
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
bewaard. Vul die bestanden via een lokale editor of verborgen terminalinvoer.
Voer daarna uit met de werkelijke platformwaarde:
@@ -124,14 +125,12 @@ export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/agiledancers-runner/admin-token"
export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
export REGISTRY_USER=wanjakrah
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
java ci/Ci.java configure-gitea
java generated/ci/Ci.java configure-gitea
```
Het script stelt de repository-instellingen in via de API en schrijft het
runnerregistratietoken naar
`~/.config/agiledancers-runner/registration-token` met mode `600`. Het toont geen
tokenwaarden. Voor handmatige inrichting kun je dat registratietoken uit de
repository-instellingen halen en in hetzelfde bestand plaatsen.
Het script stelt alleen de applicatie-instellingen, registrysecrets en platformwaarde
in. De gedeelde runnerregistratie wordt eenmalig beheerd vanuit engineering.
Applicatiespecifieke deploymentcredentials blijven onder ~/.config/agiledancers-runner/.
De bevestigde Gitea 1.25.4-configuratie heeft geen `[actions]`-sectie: de defaults
zijn negentig dagen voor artifacts en 365 dagen voor logs. Een workflow vraagt veertien dagen
@@ -139,43 +138,30 @@ via `retention-days: 14`. Een kortere serverlimiet of het vroegtijdig verwijdere
van workflowruns mag die periode niet inkorten. Artifactretentie geldt voor
testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijderd.
## Runner installeren en starten
## Gedeelde runner gebruiken
De fysieke mini-pc draait `mini-pc-runner.service` met label `mini-pc:host`,
geregistreerd op gebruikersniveau voor `wanjakrah`. Daardoor kunnen bestaande en
nieuwe repositories van deze eigenaar dezelfde runner gebruiken. De gedeelde
capaciteit is één job tegelijk. De workflowbestanden gebruiken `runs-on: mini-pc`.
Voor eenmalige registratie, installatie en migratie volg je de
[engineering-instructies](http://192.168.2.28:3000/wanjakrah/engineering/src/tag/v2.0.0/README.md).
Controleer dit project met `java generated/ci/Ci.java check-runner`; gebruik daarbij
het in engineering beschreven owner-only tokenbestand voor de gedeelde setup.
```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
java ci/Ci.java install-runner --prepare-only
# Na Dockerconfiguratie en het opslaan van het registratietoken:
java ci/Ci.java install-runner
systemctl --user status agiledancers-runner
journalctl --user -u agiledancers-runner -f
systemctl --user status mini-pc-runner
journalctl --user -u mini-pc-runner -f
systemctl --user restart mini-pc-runner
```
De installer controleert tools, installeert checksum-gecontroleerde runner
`v0.2.13`, neemt label `agiledancers-mini-pc:host` over en registreert de runner.
Configuratie en `.runner`-credentials staan in
`~/.local/share/agiledancers-runner`, de service in
`~/.config/systemd/user/agiledancers-runner.service` en de expliciete Java-, PATH-
en Docker-socketinstellingen in `~/.config/agiledancers-runner/environment`.
Een bestaande registratie wordt hergebruikt. Er worden geen secrets in de
repository geschreven. Bewaar het tokenbestand alleen zolang registratie/herstel
dat nodig heeft en behandel `.runner` als credential.
Op de mini-pc starten Docker Desktop en de runner automatisch na grafisch
aanmelden. Bij een Docker Desktop-socket installeert de installer hiervoor
`~/.config/systemd/user/agiledancers-runner.service.d/10-agiledancers-docker-desktop.conf`
en schakelt beide services in voor `graphical-session.target`. De runner wacht
via `~/.local/share/agiledancers-runner/WaitForDocker.java` maximaal 120 seconden
tot de ingestelde Docker-engine bereikbaar is. Bij een mislukte start probeert
systemd het na tien seconden opnieuw. De runner volgt stoppen en herstarten van
Docker Desktop en stopt bij afmelden.
User lingering staat op deze pc uit: vóór aanmelden starten deze services niet.
S4 belooft geen beschikbaarheid wanneer de mini-pc uitstaat. Herstarten/stoppen:
```sh
systemctl --user restart agiledancers-runner
systemctl --user stop agiledancers-runner
```
Runnerstate staat onder ~/.local/share/mini-pc-runner/ en serviceconfiguratie onder
~/.config/mini-pc-runner/. De runner start samen met Docker Desktop na grafisch
aanmelden, wacht maximaal 120 seconden op Docker en probeert een mislukte start
opnieuw. User lingering staat uit: vóór aanmelden of bij een uitgeschakelde mini-pc
is de runner niet beschikbaar. De oude agiledancers-runnerstate blijft voor herstel
bewaard; applicatiespecifieke deploymenttokens en SSH-sleutels blijven op hun eigen paden.
## Images en publicatie
@@ -200,8 +186,8 @@ De frontend luistert op containerpoort 8080, ondersteunt SPA-routes en proxy't
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
de pipeline start geen blijvende applicatie op de NAS.
`java ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests.
`java ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`)
`java generated/ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests.
`java generated/ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`)
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
@@ -238,7 +224,7 @@ blijven de committags bruikbaar en worden de oude `main`-updates overgeslagen.
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
```sh
java ci/Ci.java test
java generated/ci/Ci.java test
```
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
@@ -246,7 +232,7 @@ Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
java ci/Ci.java verify
java generated/ci/Ci.java verify
```
## Operationele oplevering

View File

@@ -136,8 +136,9 @@ sudo -n /usr/local/bin/docker compose version
sudo -n /usr/local/bin/docker info --format '{{.OSType}}/{{.Architecture}}'
```
Als DSM geen `visudo` bevat, is `deploy/enable-docker.sh` het eenmalige alternatief.
Kopieer het naar de eigen NAS-thuismap en voer het daar met sudo uit. Het maakt
Als DSM geen `visudo` bevat, is `generated/deploy/enable-docker.sh` het eenmalige alternatief.
Kopieer het naar de eigen NAS-thuismap en voer daar
`sudo sh enable-docker.sh agiledancers-deploy /usr/local/bin/docker` uit. Het maakt
uitsluitend de bovengenoemde regel, controleert die door als deploymentaccount
Docker te starten en verwijdert een nieuwe regel als die controle faalt. Een
afwijkende bestaande regel wordt niet overschreven. De implementatiesessie heeft
@@ -177,7 +178,7 @@ inrichten. Bewaar `admin-token`, `s6-release-token`, `s6-pull-token` en de priva
`~/.ssh/known_hosts` en voer uit:
```sh
java ci/Ci.java configure-deployment
java generated/ci/Ci.java configure-deployment
```
Defaults zijn de bevestigde NAS/account/SSH-poort, repository
@@ -269,8 +270,9 @@ nieuwe release door te drukken.
controlescript uitvoeren met bestaande lockowner en gekozen release-directory:
```sh
cat deploy/health.sh generated/deploy/remote.sh | \
ssh -i <private-key-path> -p <ssh-port> <deploy-user>@<nas> \
'timeout -k 5 300 sh -s -- apply <production-directory> <lock-owner> <release-directory-id>' < deploy/remote.sh
'COMPOSE_PROJECT=agiledancers-production DEPLOY_ROLES="backend frontend" DEPENDENCY_SERVICES=database REQUIRED_SECRETS=DATABASE_PASSWORD TARGET_PLATFORM=linux/amd64 timeout -k 5 300 sh -s -- apply <production-directory> <lock-owner> <release-directory-id>'
```
Gebruik de eerder gepinde hostkey en gecontroleerde waarden uit het journal.
@@ -294,9 +296,9 @@ apart: imageherstel maakt geen migratie ongedaan. Rollback naar een oude release
van vóór S16 kan de database niet lezen. Backup/restore blijven S17-werk.
```sh
java ci/Ci.java test
java ci/Ci.java release-test
sh -n deploy/remote.sh
java generated/ci/Ci.java test
java generated/ci/Ci.java release-test
sh -n generated/deploy/remote.sh
mvn -N -Dformat.mode=check exec:exec@format
```

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S18 implementation validation
Validated on 2026-11-03 against the confirmed S18 specification.

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S2 implementation evidence
Validated on the development mini-PC on 2026-10-29: AMD Ryzen 7 7730U (8 cores,
@@ -58,7 +60,7 @@ Prettier. The selected configuration uses only Prettier. Staged scopes are:
- All: all 52 maintained frontend files, including non-applicable files that the
hook must skip, plus a small TS preservation-test fixture; generated lockfile
excluded. Exact paths and raw measurements are in
[the timing report](s2-hook-timings.json).
[the timing report](http://192.168.2.28:3000/wanjakrah/agiledancers/src/commit/bd898e92d22f6c412e671908770f4d89c8ae12bc/docs/s2-hook-timings.json).
| Candidate | Staged scope | Run 1 | Run 2 | Run 3 |
| --- | --- | --- | --- | --- |

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S4 verification evidence
Updated: 2026-10-29. S4 is **complete**. Main publication/pullback passed in run

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S6 implementation and operational evidence
Status: IN_REVIEW. Production `v1.0.0` is running on the Synology at

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S12 implementation validation
## Delivered behavior

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S16 implementation validation
## Delivered behavior

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S7 implementation and validation evidence
Status: IN_REVIEW — implementation complete; awaiting project review.

View File

@@ -1,3 +1,5 @@
> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md.
# S8 implementation evidence
Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7

5
engineering.lock.json Normal file
View File

@@ -0,0 +1,5 @@
{
"repository": "http://192.168.2.28:3000/wanjakrah/engineering.git",
"tag": "v2.0.0",
"commit": "46df72f008113d0ed683a6c7344b1d7309564d84"
}

35
engineering.properties Normal file
View File

@@ -0,0 +1,35 @@
# Project-owned configuration; environment variables override these defaults.
PROJECT_NAME=agiledancers
BACKEND_MODULE=agiledancers-be
FRONTEND_MODULE=agiledancers-fe
FORMAT_DIRECTORIES=agiledancers-be/src/main/java,agiledancers-be/src/test/java
BACKEND_CONTEXT=.
BROWSER_TESTS=true
CI_DATABASE=true
DATABASE_NAME=agiledancers
DATABASE_USER=agiledancers
CI_READINESS_URL=http://127.0.0.1:8080/api/dance-schools
COMPOSE_PROJECT=agiledancers-production
DEPENDENCY_SERVICES=database
REQUIRED_DEPLOY_SECRETS=DATABASE_PASSWORD
TARGET_PLATFORM=linux/amd64
GITEA_SERVER_URL=http://192.168.2.28:3000
CI_REPOSITORY=wanjakrah/agiledancers
INSECURE_REGISTRY=192.168.2.28:3000
RUNNER_LABEL=mini-pc
RUNNER_NAME=mini-pc
RUNNER_SCOPE=user
DEPLOY_HOST=192.168.2.28
DEPLOY_USER=agiledancers-deploy
DEPLOY_SSH_PORT=22
DEPLOY_DIRECTORY=/volume1/docker/agiledancers-production
DEPLOY_BIND_IP=192.168.2.28
DEPLOY_FRONTEND_PORT=8088
DEPLOY_REGISTRY=127.0.0.1:3000
DEPLOY_DOCKER_BIN=/usr/local/bin/docker
RELEASE_TOKEN_FILE=${user.home}/.config/agiledancers-runner/s6-release-token
PULL_TOKEN_FILE=${user.home}/.config/agiledancers-runner/s6-pull-token
DEPLOY_SSH_KEY_FILE=${user.home}/.config/agiledancers-runner/s6-deploy-key
# Project-owned browser image; keep aligned with @playwright/test.
PLAYWRIGHT_IMAGE=mcr.microsoft.com/playwright:v1.64.1-noble

View File

@@ -1,28 +1,32 @@
# S7 — Backend coding standards
# Backend coding standards
Status: IMPLEMENTED
Shared understanding confirmed after the grill-me review.
## User story
As a developer, I want developers and agents to follow explicit coding standards
for `agiledancers-be`, so that the code stays readable and maintainable.
This document is the authoritative policy for handwritten production and test Java
in `<backend-module>`. Generated code is out of scope. The Java conventions below also apply to
handwritten Java helpers outside that module. Frontend conventions are owned by each consuming project.
## Scope and authority
- Create root `CODING_STANDARDS.md` with the complete agreed backend rules.
Preserve any existing frontend instructions.
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
- Cover handwritten production and test Java in `<backend-module>`. Exclude
generated code. Dependency injection and production architecture rules apply
where relevant. Frontend and Java helpers outside the backend are out of scope.
- Use https://www.geeksforgeeks.org/java/coding-guidelines-in-java/ as source
material, not as a changing normative dependency. The local document is complete
and authoritative for the adopted rules.
where relevant. The Java conventions also cover Java helpers outside the backend; backend
architecture, metrics, coverage and automated checks remain backend-only.
Frontend code is out of scope.
- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/)
supplied source material. Only the rules adopted in this local document apply;
changes to the article do not change project policy.
- The project's pinned Palantir formatter wins formatting conflicts, including
comment layout. S8 remains responsible for formatter integration.
- Do not adopt unrelated static-analysis rule bundles.
## General language guidance
- Always prefer newer stable official APIs, language features and officially
recommended coding practices over older alternatives when they suit the task
and are supported by the project's configured language/runtime version.
Preserve required behavior and compatibility; document concrete reasons for
retaining an older approach. This guidance applies to every language used in
the project.
## Java conventions
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
@@ -32,9 +36,19 @@ for `agiledancers-be`, so that the code stays readable and maintainable.
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
and `while` bodies require braces, including enhanced for and do/while bodies.
- No wildcard imports. Static imports are allowed only in test classes.
- Comments explain intent or constraints. Document public contracts with Javadoc
where their meaning is not clear from the declaration; do not require boilerplate
documentation for every accessor.
- Prefer `java.nio` APIs (including `java.nio.file` and `java.nio.charset`) over
equivalent `java.io` APIs. Use stream APIs where no suitable NIO equivalent
exists, such as process pipes or APIs requiring streams.
- Use varargs (`Type... values`) when an array is the last method or constructor
parameter, including `main(String... args)`. Preserve required override signatures
and framework/API compatibility.
- Prefer Java 25 `IO.print`/`IO.println` for direct standard output and `IO.readln`
for line-oriented standard input. `IO` has no standard-error variant; retain
`System.err` for error output. Use application logging where appropriate.
- Comments explain intent or constraints. Require Javadoc on public classes and
public methods that do more than basic POJO/data-carrier work. Trivial accessors
and plain data carriers do not need boilerplate documentation. Describe behavior,
constraints and failure conditions where relevant.
- Block comments use the following layout, subject to Palantir formatting:
```java
@@ -139,7 +153,7 @@ exactly at a blocking limit passes.
repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside
`skills/code-review/SKILL.md`.
landscape's `skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds
@@ -162,50 +176,101 @@ exactly at a blocking limit passes.
S7's required implementation. Configuring Sonar in the pipeline remains out of
scope for S14. FindBugs/SpotBugs is not an additional required check suite.
## Existing-code migration
- Bring existing backend production and test Java into compliance, including
structural refactoring where necessary.
- Migration is complete when all blocking rules pass and all review signals have
been assessed, with rationale recorded for retained signals. Reaching every
target is not required.
- Preserve public APIs, JSON contracts, persistence formats and application
behavior. Any necessary contract change requires a separate decision.
- Separate enforcement/configuration changes from code refactoring for review.
Strengthen tests where migration affects behavior-sensitive code.
## Implemented checks and manual review
## Acceptance criteria
Checkstyle **12.3.1**, PMD/CPD **7.28.0**, and JaCoCo **0.8.14** are pinned.
`generated/tooling/standards/checkstyle.xml` selects individual naming, structure, import and brace checks; no general analysis bundle is enabled.
`generated/tooling/standards/src/main/java/Standards.java` supplies the targeted measurements.
ArchUnit remains in the backend test suite for architectural boundaries.
- [x] CODING_STANDARDS.md contains all agreed rules and preserves frontend guidance.
- [x] Agent and project code-review instructions enforce discovery and review of
these standards without modifying upstream skills.
- [x] Pinned tools, configurations and targeted checks implement the exact agreed
metrics and identify manual-only rules.
- [x] Verify and standalone checks report actionable blocking failures and review
signals; CI prevents publication after a failure.
- [x] Representative violations demonstrate that their checks fail, including
relevant threshold boundaries and custom measurement semantics.
- [x] Existing handwritten backend production and test code passes all blocking
rules; retained review signals and approved waivers are documented.
- [x] Local backend tests and combined CI coverage meet the agreed line-coverage
gates; line and branch reports are available.
- [x] Clean verification and behavior-preservation tests pass after migration.
- [x] Evidence records tool versions, commands, scope, exclusions, metric and
coverage reports, approved waivers and verification results.
PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the
resolved test dependency classpath. Cyclomatic complexity uses PMD's default
options: boolean paths count, assertions do not. Decisions include if/loops,
catch, ternary and throw; non-default switch alternatives count individually,
including comma-separated labels. There is no aggregate class-complexity gate.
Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch,
recursion and flow rules. The scoped visitors retain those algorithms while
including lambdas in their enclosing executable and excluding nested/local type
bodies, whose methods are measured independently. Compact record constructors
use the same visitors and start with cyclomatic complexity one.
## Implementation handoff
Custom checks measure physical code lines from regular parser tokens, including
brace/declaration lines and mixed code/comment lines. They count multiline literal
lines while excluding physically blank lines. Class length excludes nested-type
tokens, preserving lines shared with enclosing code. Other custom measurements
implement the definitions above: nesting, resolved type fan-out, project-owned
superclass depth, public non-accessor methods, and injected collaborators.
Injection includes inherited fields/setters, explicit `@Inject` constructors,
single constructors on bean classes, records' implicit canonical constructors,
and Lombok required/all-argument constructor injection. Qualifier keys use resolved
annotation names and values, including defaults; repeat sites are deduplicated.
The CPD configuration preserves identifiers/literals, uses 100 tokens and disables
skip blocks. Its line-location union counts overlapping occurrences only once.
This specification records the agreed design. Updating it does not implement
checks, change agent instructions, refactor application code or establish current
coverage. Those are implementation work, with evidence required above.
Checkstyle and targeted checks enforce import/brace/naming/visibility rules,
`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including
loops/catches and lambdas) and initialized fields. PMD's three selected finality
rules additionally cover control-flow assignment and private constructor-initialized
fields. Framework/lifecycle requirements, generated Lombok setters, and external
writes to public/protected fields need review before changing field finality.
Comment intent, Javadoc adequacy for nontrivial public contracts, record suitability
and protection of mutable components require manual review. These are **manual
rules**, as are ambiguous type inference, architecture/DI design decisions, NIO
API selection, trailing-array varargs, console I/O selection and the POJO
documentation boundary. Review all `DESIGN` record-component entries
and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`.
## Implementation evidence
Reports never rewrite Java. Formatter application remains S8's separate validate
execution. Generated output under `target` and generated source directories are
outside the handwritten `src/main/java` and `src/test/java` scope. No maintained
file, DTO, configuration class or package is excluded from standards or coverage.
JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions
are configured. Line and branch results are published, with only lines gated.
Implementation and verification are recorded in `specs/S7-validation.md`; retained
review signals and the sole approved waiver are recorded in
`specs/S7-review-signals.txt` and `tooling/standards/waivers.tsv`.
Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric,
fully resolved executable signature (or class), exact retained value and the
maintainer agreement/reason. No wildcard waivers are allowed, and a changed value
fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression
annotations/comments cannot silently waive a rule. Adding a waiver always requires maintainer agreement.
The maintainer approved GroupDetails-based Java service signatures and the exact
eight-parameter Group constructor waiver in the implementation conversation.
REST/JSON contracts remain unchanged. Clean verify and browser/combined coverage
checks pass. The implementation is ready for project review.
## Commands and reports
Run from the root with JDK 25:
```sh
mvn -Dformat.mode=check clean verify
# Prepare standalone analysis without running tests:
mvn -pl <backend-module> test-compile dependency:build-classpath@standards-classpath
mvn -f generated/tooling/standards/pom.xml test exec:java
# Exact local coverage gate after a test/report build:
java generated/tooling/coverage/Coverage.java <backend-module>/target/site/jacoco/jacoco.xml
```
`verify` runs backend tests, builds resolved analysis inputs, generates JaCoCo
reports, runs checker regression tests and standards analysis, then gates exact
module-wide line coverage. `compile` and `test` retain existing formatting behavior
and do not run the verify-only standards checks. Standalone analysis requires its
classpath preparation; unresolved types fail with the path and preparation advice.
Full metrics and findings: `<backend-module>/target/standards/report.txt`.
Checker regression results: `generated/tooling/standards/target/surefire-reports`.
Coverage HTML/XML/CSV: `<backend-module>/target/site/jacoco` (local backend tests)
and `<backend-module>/target/site/jacoco-combined` (merged browser contribution).
The gate compares integer line counts (`5 * covered >= 4 * total`), rejecting an
empty report and percentages that would round up to 80%.
CI first passes local verify. It mounts the pinned JaCoCo agent into its disposable
backend test container, exposes the agent only on the isolated Docker network,
and uses the pinned CLI to dump `target/playwright.exec` after Playwright. A missing
or empty dump fails. The following commands merge both datasets and gate the result
before the verified-commit marker can authorize publication:
```sh
mvn -pl <backend-module> jacoco:merge@combined jacoco:report@combined-report exec:exec@combined-coverage
```
CI retains both coverage reports, execution data, standards findings and regression
results with its usual diagnostics artifacts. The agent is mounted only for tests;
published images contain no agent. PIT and Sonar configuration remain outside S7.
Application validation evidence and retained-signal assessments stay in the consuming project.

5
generated/SOURCE.json Normal file
View File

@@ -0,0 +1,5 @@
{
"commit": "46df72f008113d0ed683a6c7344b1d7309564d84",
"repository": "http://192.168.2.28:3000/wanjakrah/engineering.git",
"tag": "v2.0.0"
}

View File

@@ -3,14 +3,20 @@ import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Run from the repository root with JDK 25: java ci/Ci.java <command>. */
/** Run from the repository root with JDK 25: java generated/ci/Ci.java <command>. */
public class Ci {
public static void main(String[] args) {
/**
* Runs a CI, release or workstation setup command from the consumer repository root.
*
* @param args command followed by its optional arguments; configuration comes from the project and environment
* @implNote Requires JDK 25. Failures exit nonzero with redacted diagnostics; setup commands can change local or Gitea state.
*/
public static void main(final String... args) {
try {
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
if (args.length == 0) throw new IllegalArgumentException(usage());
var env = new HashMap<>(System.getenv());
Path root = Path.of("").toAbsolutePath();
var env = Recipe.environment(root, System.getenv());
var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75));
switch (args[0]) {
case "verify", "publish", "cleanup" -> {
@@ -30,9 +36,11 @@ public class Ci {
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
case "configure-gitea" -> Setup.configureGitea(env);
case "configure-runner" -> Setup.configureRunner(env);
case "check-runner" -> Setup.checkRunner(env);
case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]));
Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY"));
}
case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
@@ -56,7 +64,8 @@ public class Ci {
}
static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
return "Usage: java generated/ci/Ci.java"
+ " verify|publish|cleanup|attempt|test|configure-gitea|configure-runner|check-runner|configure-docker"
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
}
}

View File

@@ -14,7 +14,7 @@ class CiTests {
static void test(String name, Test test) throws Exception {
test.run();
count++;
System.out.println("PASS " + name);
IO.println("PASS " + name);
}
static void equal(Object expected, Object actual) {
@@ -32,6 +32,75 @@ class CiTests {
static void run() throws Exception {
count = 0;
test("shared runner scope and identity are independent of the application", () -> {
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "first")));
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "second")));
equal("/api/v1/user/actions/runners/registration-token", Setup.runnerRegistrationPath(Map.of()));
equal(
"/api/v1/admin/actions/runners/registration-token",
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "instance")));
equal(
"/api/v1/orgs/team/actions/runners/registration-token",
Setup.runnerRegistrationPath(
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "team")));
equal(
"/api/v1/repos/owner/app/actions/runners/registration-token",
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "repository", "CI_REPOSITORY", "owner/app")));
failure(() -> Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "unknown")));
failure(() -> Setup.runnerRegistrationPath(
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "../unsafe")));
failure(() -> Setup.runnerName(Map.of("RUNNER_NAME", "../unsafe")));
});
test("runner preflight rejects unavailable offline and differently labelled runners", () -> {
equal(false, Setup.hasRunner(Map.of("runners", List.of()), "mini-pc"));
equal(
false,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of("status", "offline", "labels", List.of(Map.of("name", "mini-pc"))))),
"mini-pc"));
equal(
false,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of("status", "online", "labels", List.of(Map.of("name", "other"))))),
"mini-pc"));
equal(
true,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of(
"status",
"online",
"busy",
true,
"labels",
List.of(Map.of("name", "mini-pc"))))),
"mini-pc"));
});
test("recipe supports backend-only and frontend-only projects", () -> {
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
failure(() -> Recipe.roles(Map.of()));
failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe")));
});
test("project configuration is overridden by environment and rejects escaping modules", () -> {
final Path root = Files.createTempDirectory("recipe-config-");
try {
Files.writeString(
root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n");
equal(
"override",
Recipe.environment(root, Map.of("PROJECT_NAME", "override"))
.get("PROJECT_NAME"));
failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside")));
} finally {
Pipeline.deleteTree(root);
}
});
test("JSON preserves configuration values and escaped text", () -> {
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
Object data = Json.parse(json);
@@ -49,14 +118,14 @@ class CiTests {
Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file, original);
Setup.configureDocker(file);
Setup.configureDocker(file, "registry.example:3000");
String first = Files.readString(file);
Setup.configureDocker(file);
Setup.configureDocker(file, "registry.example:3000");
equal(first, Files.readString(file));
equal(
List.of("existing:5000", "192.168.2.28:3000"),
List.of("existing:5000", "registry.example:3000"),
Json.object(Json.parse(first)).get("insecure-registries"));
equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers")));
equal(original, Files.readString(dir.resolve("daemon.json.before-engineering")));
} finally {
Pipeline.deleteTree(dir);
}
@@ -67,7 +136,7 @@ class CiTests {
Path file = dir.resolve("daemon.json");
String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file, original);
failure(() -> Setup.configureDocker(file));
failure(() -> Setup.configureDocker(file, "registry.example:3000"));
equal(original, Files.readString(file));
} finally {
Pipeline.deleteTree(dir);
@@ -233,7 +302,7 @@ class CiTests {
Pipeline.deleteTree(dir);
}
});
System.out.println(count + " CI tests passed");
IO.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
@@ -253,17 +322,20 @@ class CiTests {
"CI_REF",
"refs/heads/main",
"CI_REPOSITORY",
"wanjakrah/agiledancers",
"owner/recipe-demo",
"TARGET_PLATFORM",
"linux/amd64",
"GITEA_SERVER_URL",
"http://192.168.2.28:3000",
"http://registry.example:3000",
"REGISTRY_USER",
"test",
"REGISTRY_TOKEN",
"dummy-token",
"CI_PUBLISH_LOCK_DIR",
root.resolve("lock").toString()));
env.put("PROJECT_NAME", "recipe-demo");
env.put("BACKEND_MODULE", "backend");
env.put("FRONTEND_MODULE", "frontend");
pipeline = new Pipeline(root, env, fake) {
@Override
String mainHead() {

View File

@@ -14,15 +14,15 @@ class Pipeline {
Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
this.root = root;
this.env = new HashMap<>(environment);
this.env = Recipe.environment(root, environment);
this.commands = commands;
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
runName = "agiledancers-" + id + "-" + attempt;
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
runName = Recipe.project(env) + "-" + id + "-" + attempt;
backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt;
frontendImage = Recipe.project(env) + "-ci-frontend:" + commit + "-" + id + "-" + attempt;
artifacts = root.resolve("ci/artifacts");
Files.createDirectories(artifacts);
}
@@ -71,7 +71,7 @@ class Pipeline {
void isolateDocker() throws Exception {
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
env.remove("DOCKER_CONTEXT");
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
isolatedConfig = Files.createTempDirectory(Recipe.project(env) + "-docker-");
env.put("DOCKER_CONFIG", isolatedConfig.toString());
}
@@ -98,13 +98,15 @@ class Pipeline {
isolateDocker();
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
run("java", "-version");
run("node", "--version");
if (Recipe.roles(env).contains("frontend")) run("node", "--version");
if (Recipe.roles(env).contains("backend"))
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run();
ReleaseTests.run();
Path frontend = root.resolve("agiledancers-fe");
verifyFrontend();
Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", "."));
if (Recipe.roles(env).contains("frontend")) verifyFrontend();
String platform = env.get("TARGET_PLATFORM");
for (String role : Recipe.roles(env)) {
run(
"docker",
"build",
@@ -115,22 +117,15 @@ class Pipeline {
"--label",
"org.opencontainers.image.revision=" + commit,
"-t",
backendImage,
".");
run(
"docker",
"build",
"--platform",
platform,
"--target",
"prebuilt",
"--label",
"org.opencontainers.image.revision=" + commit,
"-t",
frontendImage,
"agiledancers-fe");
role.equals("backend") ? backendImage : frontendImage,
env.getOrDefault(
role.toUpperCase() + "_CONTEXT",
role.equals("backend") ? "." : env.get("FRONTEND_MODULE")));
}
if (Recipe.enabled(env, "BROWSER_TESTS")) {
Files.writeString(artifacts.resolve("containers-started"), "");
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
run("docker", "network", "create", "--label", Recipe.project(env) + ".ci.run=" + runName, runName);
if (Recipe.enabled(env, "CI_DATABASE")) {
run(
"docker",
"run",
@@ -142,18 +137,26 @@ class Pipeline {
"--network-alias",
"database",
"--env",
"POSTGRES_USER=agiledancers",
"POSTGRES_USER=" + env.get("DATABASE_USER"),
"--env",
"POSTGRES_PASSWORD=ci-only",
"--env",
"POSTGRES_DB=agiledancers",
"POSTGRES_DB=" + env.get("DATABASE_NAME"),
"postgres:17.6");
for (int attempt = 0; attempt < 60; attempt++) {
if (result(false, "docker", "exec", runName + "-database", "pg_isready", "-U", "agiledancers")
if (result(
false,
"docker",
"exec",
runName + "-database",
"pg_isready",
"-U",
env.get("DATABASE_USER"))
.code()
== 0) break;
Thread.sleep(1000);
}
}
run(
"docker",
"run",
@@ -161,7 +164,7 @@ class Pipeline {
"--name",
runName + "-backend",
"--label",
"agiledancers.ci.run=" + runName,
Recipe.project(env) + ".ci.run=" + runName,
"--platform",
platform,
"--network",
@@ -173,13 +176,14 @@ class Pipeline {
"--cpus",
"2",
"--volume",
root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro",
root.resolve(env.get("BACKEND_MODULE") + "/target/coverage-tools/agent.jar")
+ ":/jacoco-agent.jar:ro",
"--env",
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
"--env",
"DATABASE_URL=jdbc:postgresql://database:5432/agiledancers",
"DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"),
"--env",
"DATABASE_USER=agiledancers",
"DATABASE_USER=" + env.get("DATABASE_USER"),
"--env",
"DATABASE_PASSWORD=ci-only",
backendImage);
@@ -190,7 +194,7 @@ class Pipeline {
"--name",
runName + "-frontend",
"--label",
"agiledancers.ci.run=" + runName,
Recipe.project(env) + ".ci.run=" + runName,
"--platform",
platform,
"--network",
@@ -210,7 +214,7 @@ class Pipeline {
"--name",
runName + "-browser",
"--label",
"agiledancers.ci.run=" + runName,
Recipe.project(env) + ".ci.run=" + runName,
"--platform",
platform,
"--network",
@@ -232,7 +236,7 @@ class Pipeline {
frontend + ":/workspace",
"--workdir",
"/workspace",
"mcr.microsoft.com/playwright:v1.64.1-noble",
env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"),
"npm",
"run",
"e2e");
@@ -245,7 +249,7 @@ class Pipeline {
"--user",
user,
"--volume",
root.resolve("agiledancers-be/target") + ":/coverage",
root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage",
"eclipse-temurin:25-jre",
"java",
"-jar",
@@ -256,17 +260,18 @@ class Pipeline {
"--destfile",
"/coverage/playwright.exec");
check(
Files.size(root.resolve("agiledancers-be/target/playwright.exec")) > 0,
Files.size(root.resolve(env.get("BACKEND_MODULE") + "/target/playwright.exec")) > 0,
"Playwright backend coverage is missing");
run(
"mvn",
"-B",
"-ntp",
"-pl",
"agiledancers-be",
env.get("BACKEND_MODULE"),
"jacoco:merge@combined",
"jacoco:report@combined-report",
"exec:exec@combined-coverage");
}
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
} catch (Exception e) {
failure = e;
@@ -289,23 +294,17 @@ class Pipeline {
}
void verifyFrontend() throws Exception {
Path frontend = root.resolve("agiledancers-fe");
Path frontend = root.resolve(env.get("FRONTEND_MODULE"));
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
.checked();
commands.execute(
List.of(
"npm",
"test",
"--",
"--reporters=default",
"--reporters=junit",
"--output-file=unit-results/junit.xml"),
frontend,
env,
null,
true)
.checked();
final Object configured = Json.parse(
env.getOrDefault(
"FRONTEND_TEST_COMMAND",
"[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]"));
final List<String> test =
((List<?>) configured).stream().map(Object::toString).toList();
commands.execute(test, frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
.checked();
}
@@ -321,7 +320,7 @@ class Pipeline {
"-q",
"-O",
"-",
"http://127.0.0.1:8080/api/dance-schools")
Setup.required(env, "CI_READINESS_URL"))
.code()
== 0) return;
Thread.sleep(2000);
@@ -334,15 +333,15 @@ class Pipeline {
commands.renew(Duration.ofMinutes(2));
List<String> failures = new ArrayList<>();
for (String path : List.of(
"agiledancers-be/target/surefire-reports",
"agiledancers-be/target/standards",
"tooling/standards/target/surefire-reports",
"agiledancers-be/target/site/jacoco",
"agiledancers-be/target/site/jacoco-combined",
"agiledancers-be/target/failsafe-reports",
"agiledancers-fe/unit-results",
"agiledancers-fe/test-results",
"agiledancers-fe/playwright-report")) {
env.get("BACKEND_MODULE") + "/target/surefire-reports",
env.get("BACKEND_MODULE") + "/target/standards",
"generated/tooling/standards/target/surefire-reports",
env.get("BACKEND_MODULE") + "/target/site/jacoco",
env.get("BACKEND_MODULE") + "/target/site/jacoco-combined",
env.get("BACKEND_MODULE") + "/target/failsafe-reports",
env.get("FRONTEND_MODULE") + "/unit-results",
env.get("FRONTEND_MODULE") + "/test-results",
env.get("FRONTEND_MODULE") + "/playwright-report")) {
if (Files.isDirectory(root.resolve(path))) {
try {
copyTree(root.resolve(path), artifacts.resolve(path));
@@ -352,7 +351,8 @@ class Pipeline {
}
}
for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) {
final Path source = root.resolve("agiledancers-be/target").resolve(data);
final Path source =
root.resolve(env.get("BACKEND_MODULE") + "/target").resolve(data);
if (Files.isRegularFile(source))
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
}
@@ -377,7 +377,7 @@ class Pipeline {
void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
System.out.println("Branch is not main; no publication");
IO.println("Branch is not main; no publication");
return;
}
check(
@@ -404,7 +404,7 @@ class Pipeline {
"CI_PUBLISH_LOCK_DIR",
env.getOrDefault(
"XDG_STATE_HOME",
Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
Setup.home().resolve(".local/state").toString()) + "/" + Recipe.project(env) + "-ci"));
Files.createDirectories(lockDir);
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
try (FileChannel channel = FileChannel.open(
@@ -419,12 +419,13 @@ class Pipeline {
token,
true)
.checked();
String prefix = registry + "/" + owner + "/agiledancers-";
ensureImage(prefix + "backend", backendImage);
ensureImage(prefix + "frontend", frontendImage);
for (String role : List.of("backend", "frontend")) {
String prefix = registry + "/" + owner + "/" + Recipe.project(env) + "-";
for (String role : Recipe.roles(env)) {
ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage);
}
for (String role : Recipe.roles(env)) {
if (!commit.equals(mainHead())) {
System.out.println("Newer main exists; moving tags unchanged");
IO.println("Newer main exists; moving tags unchanged");
return;
}
String image = prefix + role;
@@ -461,7 +462,7 @@ class Pipeline {
void ensureImage(String repository, String source) throws Exception {
String ref = repository + ":" + commit;
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
if (manifest.code() == 0) IO.println("Preserving existing " + ref);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref);
run("docker", "push", ref);

61
generated/ci/Recipe.java Normal file
View File

@@ -0,0 +1,61 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
/** Project-owned configuration for the shared engineering mechanisms. */
final class Recipe {
private Recipe() {}
static Map<String, String> environment(final Path root, final Map<String, String> input) throws Exception {
final var result = new HashMap<String, String>(input);
final Path file = root.resolve("engineering.properties");
if (Files.exists(file)) {
final var properties = new Properties();
try (final var stream = Files.newInputStream(file)) {
properties.load(stream);
}
for (final String key : properties.stringPropertyNames()) {
result.putIfAbsent(
key, properties.getProperty(key).replace("${user.home}", System.getProperty("user.home")));
}
}
if (result.containsKey("PROJECT_NAME")) {
project(result);
for (final String role : roles(result)) {
final Path module = Path.of(result.get(role.toUpperCase() + "_MODULE"));
Pipeline.check(
!module.isAbsolute() && !module.normalize().startsWith(".."),
"Module must stay in the project");
}
Pipeline.check(
!enabled(result, "BROWSER_TESTS") || roles(result).size() == 2,
"Browser coverage requires both backend and frontend");
}
return result;
}
static String project(final Map<String, String> env) {
final String name = Setup.required(env, "PROJECT_NAME");
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid project name");
return name;
}
static List<String> roles(final Map<String, String> env) {
final var roles = new ArrayList<String>();
for (final String role : List.of("backend", "frontend")) {
if (!env.getOrDefault(role.toUpperCase() + "_MODULE", "").isBlank()) {
roles.add(role);
}
}
Pipeline.check(!roles.isEmpty(), "Configure BACKEND_MODULE, FRONTEND_MODULE, or both");
return roles;
}
static boolean enabled(final Map<String, String> env, final String key) {
return Boolean.parseBoolean(env.getOrDefault(key, "false"));
}
}

View File

@@ -75,7 +75,7 @@ class Release {
remote.call("preflight", null);
ReleaseState before = ReleaseState.parse(remote.call("state", null));
selected = select(before);
System.out.println("Selected " + selected.version() + " at " + selected.sha());
IO.println("Selected " + selected.version() + " at " + selected.sha());
// Validate the recovery pair before any replacement too.
if (before.current() != null) ensure(before.current());
if (mode.equals("new")) {
@@ -108,7 +108,7 @@ class Release {
record(outcome);
remote.call("finish", null);
pending = false;
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
IO.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
} finally {
try {
record(outcome);
@@ -153,6 +153,7 @@ class Release {
"A newer release has already succeeded; use explicit rollback for older versions");
String build = api.verified(commit);
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
remote.call("stage-health", Files.readString(root.resolve("deploy/health.sh")));
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
return new ReleaseState.Deployment(
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
@@ -168,7 +169,7 @@ class Release {
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT);
return registry + "/" + owner + "/agiledancers-";
return registry + "/" + owner + "/" + Recipe.project(env) + "-";
}
void ensure(ReleaseState.Deployment release) throws Exception {
@@ -183,6 +184,7 @@ class Release {
}
String digest(String response, String role) {
if (!Recipe.roles(env).contains(role.toLowerCase(java.util.Locale.ROOT))) return "-";
String value = response.lines()
.filter(line -> line.startsWith("S6_" + role + "="))
.map(line -> line.substring(("S6_" + role + "=").length()))

View File

@@ -82,7 +82,7 @@ class ReleaseApi {
"target",
commit,
"message",
"Agiledancers " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
"Release " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
Tag tag = tags().stream()
.filter(t -> t.version().equals(version))
.findFirst()

View File

@@ -32,8 +32,14 @@ class ReleaseRemote implements AutoCloseable {
&& Integer.parseInt(port) > 0
&& Integer.parseInt(port) <= 65535,
"Invalid deployment SSH host, user or port");
script = Files.readString(root.resolve("deploy/remote.sh"));
credentials = Files.createTempDirectory("agiledancers-deploy-ssh-");
script = "COMPOSE_PROJECT=" + quote(Setup.required(env, "COMPOSE_PROJECT")) + "\n"
+ "DEPLOY_ROLES=" + quote(String.join(" ", Recipe.roles(env))) + "\n"
+ "DEPENDENCY_SERVICES=" + quote(env.getOrDefault("DEPENDENCY_SERVICES", "")) + "\n"
+ "TARGET_PLATFORM=" + quote(env.getOrDefault("TARGET_PLATFORM", "linux/amd64")) + "\n"
+ "REQUIRED_SECRETS=" + quote(env.getOrDefault("REQUIRED_DEPLOY_SECRETS", "")) + "\n"
+ Files.readString(root.resolve("deploy/health.sh")) + "\n"
+ Files.readString(root.resolve("generated/deploy/remote.sh"));
credentials = Files.createTempDirectory("engineering-deploy-ssh-");
try {
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");

View File

@@ -10,20 +10,12 @@ import java.util.Map;
class ReleaseSetup {
static void configure(Path root, Map<String, String> input) throws Exception {
var env = new HashMap<>(input);
env.putIfAbsent("GITEA_SERVER_URL", "http://192.168.2.28:3000");
env.putIfAbsent("CI_REPOSITORY", "wanjakrah/agiledancers");
env.putIfAbsent("DEPLOY_HOST", "192.168.2.28");
env.putIfAbsent("DEPLOY_USER", "agiledancers-deploy");
env.putIfAbsent("DEPLOY_SSH_PORT", "22");
env.putIfAbsent("DEPLOY_DIRECTORY", "/volume1/docker/agiledancers-production");
env.putIfAbsent("DEPLOY_BIND_IP", "192.168.2.28");
env.putIfAbsent("DEPLOY_FRONTEND_PORT", "8088");
env.putIfAbsent("DEPLOY_REGISTRY", "127.0.0.1:3000");
Path config = Setup.home().resolve(".config/agiledancers-runner");
Path config = Setup.home().resolve(".config/" + Recipe.project(env) + "-runner");
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("s6-release-token").toString());
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("s6-pull-token").toString());
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("s6-deploy-key").toString());
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("release-token").toString());
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("pull-token").toString());
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("deploy-key").toString());
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE"));
@@ -69,11 +61,17 @@ class ReleaseSetup {
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
System.out.println(remote.executeScript(
Files.readString(root.resolve("deploy/provision.sh")),
IO.println(remote.executeScript(
Files.readString(root.resolve("generated/deploy/provision.sh")),
pull + "\n",
300,
List.of(remote.directory, registryUser, registry, bind, port)));
List.of(
remote.directory,
registryUser,
registry,
bind,
port,
Setup.required(env, "DEPLOY_DOCKER_BIN"))));
remote.acquire();
try {
remote.call("preflight", null);
@@ -88,7 +86,7 @@ class ReleaseSetup {
for (String variable :
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
variable(api, admin, variable, env.get(variable));
System.out.println(
IO.println(
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
}

View File

@@ -17,7 +17,7 @@ class ReleaseShellTests {
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
String calls = Files.readString(f.directory.resolve("calls"));
CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready"));
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/dance-schools"));
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/items"));
CiTests.equal(false, calls.contains(" down"));
CiTests.equal(false, calls.contains("prune"));
}
@@ -111,7 +111,19 @@ class ReleaseShellTests {
static class Fixture implements AutoCloseable {
final Path directory = Files.createTempDirectory("nas-script-test-");
final Map<String, String> env = new HashMap<>(System.getenv());
final String script = Files.readString(Path.of("deploy/remote.sh"));
final String health = "application_health() {\n"
+ "frontend=$(compose ps -q frontend)\n"
+ "docker_cmd exec \"$frontend\" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return"
+ " 1\n"
+ "curl --fail http://127.0.0.1:8088/api/items -o /dev/null\n"
+ "}\n";
final String script = "COMPOSE_PROJECT=recipe-demo-test\n"
+ "DEPLOY_ROLES='backend frontend'\n"
+ "DEPENDENCY_SERVICES=database\n"
+ "TARGET_PLATFORM=linux/amd64\n"
+ "REQUIRED_SECRETS=DATABASE_PASSWORD\n"
+ health + "\n"
+ Files.readString(Path.of("generated/deploy/remote.sh"));
final Commands commands = new Commands(Duration.ofMinutes(1));
Fixture() throws Exception {
@@ -150,19 +162,19 @@ class ReleaseShellTests {
}
String backend() {
return "nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64);
return "nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64);
}
String frontend() {
return "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64);
return "nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64);
}
void prepare() throws Exception {
call("lock", null, "one").checked();
call("preflight", null, "one").checked();
call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one")
.checked();
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-")
call("stage", "services: {}\n", "one").checked();
call("stage-health", health, "one").checked();
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/recipe-demo-")
.checked();
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));

View File

@@ -9,8 +9,11 @@ record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment pre
Deployment {
ReleaseApi.sha(sha);
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
Pipeline.check(!backend.equals("-") || !frontend.equals("-"), "A release must contain an image");
for (String image : new String[] {backend, frontend})
Pipeline.check(image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}"), "Invalid retained image digest");
Pipeline.check(
(image.equals("-") || image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}")),
"Invalid retained image digest");
}
Map<String, Object> json() {

View File

@@ -14,7 +14,7 @@ class ReleaseTests {
static void test(String name, CiTests.Test test) throws Exception {
test.run();
count++;
System.out.println("PASS release: " + name);
IO.println("PASS release: " + name);
}
static Map<String, String> environment() {
@@ -37,7 +37,7 @@ class ReleaseTests {
"GITEA_SERVER_URL",
"http://nas:3000",
"CI_REPOSITORY",
"owner/agiledancers",
"owner/recipe-demo",
"RELEASE_TOKEN",
"test"));
env.putAll(Map.of(
@@ -58,8 +58,8 @@ class ReleaseTests {
return new ReleaseState.Deployment(
ReleaseVersion.parse(version),
sha,
"nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64),
"nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64),
"nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64),
"nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64),
directory,
"10");
}
@@ -252,7 +252,7 @@ class ReleaseTests {
apiTests();
shellTests();
ReleaseShellTests.run();
System.out.println(count + " release behavior tests passed");
IO.println(count + " release behavior tests passed");
}
static class FakeApi extends ReleaseApi {
@@ -353,8 +353,14 @@ class ReleaseTests {
Fixture(String mode) throws Exception {
Files.createDirectories(root.resolve("deploy"));
Files.writeString(root.resolve("deploy/remote.sh"), "");
Files.createDirectories(root.resolve("generated/deploy"));
env.put("PROJECT_NAME", "recipe-demo");
env.put("BACKEND_MODULE", "backend");
env.put("FRONTEND_MODULE", "frontend");
env.put("COMPOSE_PROJECT", "recipe-demo-test");
Files.writeString(root.resolve("generated/deploy/remote.sh"), "");
Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n");
Files.writeString(root.resolve("deploy/health.sh"), "application_health() { true; }\n");
env.put("RELEASE_MODE", mode);
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
api = new FakeApi(env, events);
@@ -379,7 +385,7 @@ class ReleaseTests {
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
try (var f = new Fixture()) {
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
CiTests.equal("127.0.0.1:3000/owner/agiledancers-", f.release.prefix());
CiTests.equal("127.0.0.1:3000/owner/recipe-demo-", f.release.prefix());
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
CiTests.failure(f.release::prefix);
}
@@ -476,7 +482,7 @@ class ReleaseTests {
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
Path directory = Files.createTempDirectory("release-shell-test-");
try {
String script = Files.readString(Path.of("deploy/remote.sh"));
String script = Files.readString(Path.of("generated/deploy/remote.sh"));
var commands = new Commands(Duration.ofSeconds(30));
var env = environment();
var first = commands.execute(

View File

@@ -65,7 +65,7 @@ class Setup {
Pipeline.check(
Set.of("linux/amd64", "linux/arm64").contains(platform),
"Set TARGET_PLATFORM from the verified Synology architecture");
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
String server = required(env, "GITEA_SERVER_URL"), base = "/api/v1/repos/" + required(env, "CI_REPOSITORY");
api(server, admin, "PATCH", base, Map.of("has_actions", true));
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user));
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
@@ -79,31 +79,98 @@ class Setup {
exists = false;
}
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
Map<String, Object> response =
Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null));
Path directory = home().resolve(".config/agiledancers-runner");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
IO.println("Actions, project registry secrets and platform configured; using the shared runner.");
}
static void configureDocker(Path file) throws IOException {
static String runnerName(final Map<String, String> env) {
final String name = env.getOrDefault("RUNNER_NAME", env.getOrDefault("RUNNER_LABEL", "mini-pc"));
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid runner name");
return name;
}
static String runnerRegistrationPath(final Map<String, String> env) {
return switch (env.getOrDefault("RUNNER_SCOPE", "user")) {
case "user" -> "/api/v1/user/actions/runners/registration-token";
case "instance" -> "/api/v1/admin/actions/runners/registration-token";
case "organization" -> {
final String organization = required(env, "RUNNER_ORGANIZATION");
Pipeline.check(organization.matches("[a-zA-Z0-9_-]+"), "Invalid runner organization");
yield "/api/v1/orgs/" + organization + "/actions/runners/registration-token";
}
case "repository" ->
"/api/v1/repos/" + required(env, "CI_REPOSITORY") + "/actions/runners/registration-token";
default ->
throw new IllegalArgumentException("Runner scope must be user, organization, instance or repository");
};
}
static void configureRunner(final Map<String, String> env) throws Exception {
final String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE");
final Map<String, Object> response =
Json.object(api(required(env, "GITEA_SERVER_URL"), admin, "POST", runnerRegistrationPath(env), null));
final Path directory = home().resolve(".config/" + runnerName(env) + "-runner");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
IO.println("Shared runner registration token saved outside Git; run install-runner once on the workstation.");
}
static void checkRunner(final Map<String, String> env) throws Exception {
final String token = secret(env, "GITEA_ADMIN_TOKEN_FILE");
final String repository = required(env, "CI_REPOSITORY");
final String server = required(env, "GITEA_SERVER_URL");
final Map<String, Object> project = Json.object(api(server, token, "GET", "/api/v1/repos/" + repository, null));
Pipeline.check(Boolean.TRUE.equals(project.get("has_actions")), "Enable Actions for the repository first");
if ("user".equals(env.getOrDefault("RUNNER_SCOPE", "user"))) {
final Map<String, Object> user = Json.object(api(server, token, "GET", "/api/v1/user", null));
Pipeline.check(
repository.split("/", 2)[0].equalsIgnoreCase(Objects.toString(user.get("login"))),
"User-scoped runner must belong to the repository owner; use organization scope for an"
+ " organization");
}
// Gitea's repository runner list excludes inherited user/organization runners.
final String base = runnerRegistrationPath(env).replace("/registration-token", "");
final Map<String, Object> response =
Json.object(api(required(env, "GITEA_SERVER_URL"), token, "GET", base, null));
Pipeline.check(
hasRunner(response, env.getOrDefault("RUNNER_LABEL", "mini-pc")),
"No online shared runner with the configured label is available to this repository");
IO.println("Shared runner is online and available to " + required(env, "CI_REPOSITORY"));
}
static boolean hasRunner(final Map<String, Object> response, final String label) {
for (final Object entry : (List<?>) response.getOrDefault("runners", List.of())) {
final Map<String, Object> runner = Json.object(entry);
if (!"online".equals(runner.get("status"))
&& !"idle".equals(runner.get("status"))
&& !"active".equals(runner.get("status"))) {
continue;
}
for (final Object item : (List<?>) runner.getOrDefault("labels", List.of())) {
if (label.equals(Json.object(item).get("name"))) {
return true;
}
}
}
return false;
}
static void configureDocker(Path file, String registry) throws IOException {
Map<String, Object> data =
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
if (!(entry instanceof List<?>))
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>) entry);
if (registries.contains("192.168.2.28:3000")) {
System.out.println("Internal registry already configured; no change");
if (registries.contains(registry)) {
IO.println("Internal registry already configured; no change");
return;
}
registries.add("192.168.2.28:3000");
registries.add(registry);
data.put("insecure-registries", registries);
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
Files.createDirectories(parent);
if (Files.exists(file)) {
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
Path backup = file.resolveSibling(file.getFileName() + ".before-engineering");
if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
}
Path temp = Files.createTempFile(parent, "daemon-", ".json");
@@ -114,7 +181,7 @@ class Setup {
} finally {
Files.deleteIfExists(temp);
}
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
IO.println("Internal registry added; restart/apply Docker Engine configuration to activate");
}
static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
@@ -123,8 +190,8 @@ class Setup {
System.getProperty("os.name").equals("Linux")
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
"Installer targets the existing Linux x86_64 mini-PC");
Path runnerDir = home().resolve(".local/share/agiledancers-runner"),
configDir = home().resolve(".config/agiledancers-runner");
Path runnerDir = home().resolve(".local/share/" + runnerName(env) + "-runner"),
configDir = home().resolve(".config/" + runnerName(env) + "-runner");
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
@@ -164,13 +231,16 @@ class Setup {
String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
.checked();
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml")));
privateWrite(
runnerDir.resolve("config.yaml"),
Files.readString(root.resolve("ci/runner/config.yaml"))
.replace("@RUNNER@", env.getOrDefault("RUNNER_LABEL", "mini-pc")));
privateWrite(
units.resolve(runnerName(env) + "-runner.service"),
Files.readString(root.resolve("ci/runner/runner.service")).replace("@RUNNER_NAME@", runnerName(env)));
Files.copy(
root.resolve("ci/runner/agiledancers-runner.service"),
units.resolve("agiledancers-runner.service"),
StandardCopyOption.REPLACE_EXISTING);
Files.copy(
root.resolve("ci/runner/WaitForDocker.java"),
root.resolve("generated/ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"),
StandardCopyOption.REPLACE_EXISTING);
String dockerHost = env.get("DOCKER_HOST");
@@ -189,8 +259,8 @@ class Setup {
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
+ "\n");
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
Path overrides = units.resolve("agiledancers-runner.service.d");
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
Path overrides = units.resolve(runnerName(env) + "-runner.service.d");
Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
if (desktop) {
Files.createDirectories(overrides);
Files.copy(
@@ -203,7 +273,7 @@ class Setup {
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
.checked();
if (prepareOnly) {
System.out.println("Runner installed; registration and startup still required.");
IO.println("Runner installed; registration and startup still required.");
return;
}
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
@@ -219,11 +289,11 @@ class Setup {
"register",
"--no-interactive",
"--instance",
"http://192.168.2.28:3000",
required(env, "GITEA_SERVER_URL"),
"--name",
"agiledancers-mini-pc",
runnerName(env),
"--labels",
"agiledancers-mini-pc:host",
env.getOrDefault("RUNNER_LABEL", "mini-pc") + ":host",
"--token",
token),
runnerDir,
@@ -243,7 +313,7 @@ class Setup {
.checked();
// Replace any previous default.target link with the desktop-login target.
commands.execute(
List.of("systemctl", "--user", "disable", "agiledancers-runner.service"),
List.of("systemctl", "--user", "disable", runnerName(env) + "-runner.service"),
root,
env,
null,
@@ -251,13 +321,13 @@ class Setup {
.checked();
}
commands.execute(
List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"),
List.of("systemctl", "--user", "enable", "--now", runnerName(env) + "-runner.service"),
root,
env,
null,
true)
.checked();
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
IO.println("Runner registered and started; keep the mini-PC and Docker available.");
}
static Path executable(Map<String, String> env, String name) {

View File

@@ -3,11 +3,18 @@ import java.util.concurrent.TimeUnit;
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
class WaitForDocker {
public static void main(String[] args) throws Exception {
/**
* Waits for the configured Docker daemon before allowing the runner service to start.
*
* @param args optional timeout in seconds, from 1 through 300; defaults to 120
* @throws Exception if the timeout is invalid or the Docker probe cannot run
* @implNote Exits nonzero if Docker does not become ready before the deadline.
*/
public static void main(final String... args) throws Exception {
long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]);
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
System.out.println("Waiting for the configured Docker engine...");
IO.println("Waiting for the configured Docker engine...");
while (System.nanoTime() < deadline) {
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
@@ -17,7 +24,7 @@ class WaitForDocker {
long remaining = Math.max(1, deadline - System.nanoTime());
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
&& process.exitValue() == 0) {
System.out.println("Docker is available; starting the Gitea runner.");
IO.println("Docker is available; starting the Gitea runner.");
return;
}
} finally {

View File

@@ -2,9 +2,11 @@
# One-time DSM administrator action, run with sudo; does not restart any service.
set -eu
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
account=agiledancers-deploy
docker=/usr/local/bin/docker
target=/etc/sudoers.d/agiledancers-deploy
account=${1:?Supply deployment account}
docker=${2:?Supply absolute Docker executable}
case "$account" in ''|*[!a-zA-Z0-9_-]*) exit 1;; esac
case "$docker" in /*) ;; *) exit 1;; esac
target=/etc/sudoers.d/"$account"
rule="$account ALL=(root) NOPASSWD: $docker"
test -x "$docker" && id "$account" >/dev/null
test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; }
@@ -12,7 +14,7 @@ if [ -e "$target" ]; then
test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; }
else
umask 077
temporary=$(mktemp /etc/sudoers.d/agiledancers-deploy.XXXXXX)
temporary=$(mktemp /etc/sudoers.d/"$account".XXXXXX)
trap 'rm -f "$temporary"' EXIT
printf '%s\n' "$rule" > "$temporary"
chmod 440 "$temporary"

View File

@@ -7,7 +7,7 @@ registry_user=$2
registry=$3
bind=$4
port=$5
docker=/usr/local/bin/docker
docker=${6:?Supply the Docker executable path}
test -x "$docker"
sudo -n "$docker" compose version
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'

View File

@@ -7,7 +7,7 @@ action=$1
root=$2
owner=$3
shift 3
die() { echo "S6: $*" >&2; exit 1; }
die() { echo "Deployment: $*" >&2; exit 1; }
case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
test -d "$root" || die 'Provision the deployment directory first'
@@ -72,8 +72,13 @@ docker_cmd() {
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
fi
}
: "${COMPOSE_PROJECT:?}" "${DEPLOY_ROLES:?}" "${TARGET_PLATFORM:?}"
case "$COMPOSE_PROJECT" in ''|*[!a-z0-9_-]*) die 'Invalid Compose project';; esac
case "$DEPENDENCY_SERVICES" in *[!a-zA-Z0-9_\ -]*) die 'Invalid dependency services';; esac
case "$DEPLOY_ROLES" in backend|frontend|'backend frontend') ;; *) die 'Invalid deployment roles';; esac
case "$TARGET_PLATFORM" in linux/amd64|linux/arm64) ;; *) die 'Invalid target platform';; esac
compose() {
docker_cmd compose --project-name agiledancers-production --project-directory "$root" \
docker_cmd compose --project-name "$COMPOSE_PROJECT" --project-directory "$root" \
--env-file "$root/production.env" --env-file "$release/release.env" -f "$release/compose.yaml" "$@"
}
identity() {
@@ -81,8 +86,8 @@ identity() {
sha=$2
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|| die 'Image revision does not match selected SHA'
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \
|| die 'Image platform must be linux/amd64'
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "$TARGET_PLATFORM" \
|| die 'Image platform differs from target'
}
use_release() {
case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac
@@ -90,11 +95,8 @@ use_release() {
test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration'
}
health() {
backend=$(compose ps -q backend)
frontend=$(compose ps -q frontend)
test -n "$backend" && test -n "$frontend" || return 1
# Compare image IDs, revision labels and the container release labels.
for role in backend frontend; do
for role in $DEPLOY_ROLES; do
id=$(compose ps -q "$role")
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1
@@ -102,21 +104,24 @@ health() {
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
done
docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null
if [ -f "$release/health.sh" ]; then . "$release/health.sh"; fi
application_health
}
case "$action" in
preflight)
test -n "${DATABASE_PASSWORD:-}" || die 'Missing DATABASE_PASSWORD in production.env; follow docs/persistent-storage.md before deploying S16'
for secret in ${REQUIRED_SECRETS:-}; do
case "$secret" in ''|*[!A-Z0-9_]*) die 'Invalid required secret name';; esac
eval 'value=${'"$secret"':-}'
test -n "$value" || die "Missing $secret in production.env"
done
command -v timeout >/dev/null || die 'timeout is required for bounded remote operations'
command -v curl >/dev/null || die 'curl is required'
test -x "$DOCKER_BIN" || die 'Docker executable missing'
docker_cmd compose version >/dev/null
test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \
|| test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \
|| die 'Synology Docker platform is not linux/amd64'
actual=$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')
case "$actual" in linux/x86_64) actual=linux/amd64;; linux/aarch64) actual=linux/arm64;; esac
test "$actual" = "$TARGET_PLATFORM" || die 'Docker platform differs from target'
test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first'
# Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication.
case "$BIND_IP" in
@@ -131,13 +136,18 @@ case "$action" in
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
cat > "$release/compose.yaml"
;;
stage-health)
test -f "$release/compose.yaml" || die 'Stage Compose first'
test ! -e "$release/health.sh" || die 'Attempt health configuration already exists'
cat > "$release/health.sh"
;;
prepare)
version=$1; sha=$2; prefix=$3
backend=''; frontend=''
for role in backend frontend; do
backend='-'; frontend='-'
for role in $DEPLOY_ROLES; do
repository="$prefix$role"
image="$repository:$sha"
docker_cmd pull --platform linux/amd64 "$image" >&2
docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2
identity "$image" "$sha"
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
@@ -146,6 +156,8 @@ case "$action" in
done
printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \
"$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env"
printf 'COMPOSE_PROJECT=%s\nDEPLOY_ROLES="%s"\nDEPENDENCY_SERVICES="%s"\nTARGET_PLATFORM=%s\n' \
"$COMPOSE_PROJECT" "$DEPLOY_ROLES" "${DEPENDENCY_SERVICES:-}" "$TARGET_PLATFORM" >> "$release/release.env"
compose config --quiet
printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend"
;;
@@ -156,7 +168,8 @@ case "$action" in
&& test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \
|| die 'Retained configuration differs from recorded successful release'
for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do
docker_cmd pull --platform linux/amd64 "$image" >&2
[ "$image" != - ] || continue
docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2
identity "$image" "$RELEASE_SHA"
done
compose config --quiet
@@ -172,8 +185,10 @@ case "$action" in
end=$(( $(date +%s) + health_seconds ))
# Always recreate the frontend too: nginx must resolve the new backend IP.
# Recreate application containers only; the database and volume survive releases.
compose up -d --no-build --pull missing --timeout 20 database
compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend
if [ -n "${DEPENDENCY_SERVICES:-}" ]; then
compose up -d --no-build --pull missing --timeout 20 $DEPENDENCY_SERVICES
fi
compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 $DEPLOY_ROLES
while ! health; do
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
sleep 1

View File

@@ -0,0 +1,19 @@
# Backend review companion
Use alongside landscape's `skills/code-review/SKILL.md` and `generated/CODING_STANDARDS.md`.
Review changed code for intent-revealing comments, missing public-contract Javadoc,
appropriate record use, defensive copying of mutable record components, and
compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers
and framework/lifecycle exceptions to field finality. Confirm architecture and
injection decisions make sense for production and tests. Check NIO API preference,
varargs for trailing array parameters, and Java 25 IO use for direct console
input/output; retain System.err for diagnostics. Require Javadoc for public
classes and methods beyond basic POJO/data-carrier behavior.
Read standards reports even when verification passes. Assess every review signal
in changed code, including fan-out, public methods and record component counts.
Record a rationale for retained signals in application validation evidence or the change description.
Check narrowly scoped waivers against recorded maintainer agreement and reason.
Never introduce blanket exclusions or weaken thresholds to pass a build.
Do not repeat mechanical checks already enforced by tooling; verify their result.

View File

@@ -0,0 +1,35 @@
import java.nio.file.Path;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Element;
/** Exact module-wide line gate; consumes JaCoCo XML without rounding percentages. */
public class Coverage {
/**
* Gates a JaCoCo XML report at 80 percent module line coverage without rounding.
*
* @param args the report path as the sole argument
* @throws Exception if the report cannot be read, has no line counter, or fails the coverage gate
*/
public static void main(final String... args) throws Exception {
final var factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
final Element report =
factory.newDocumentBuilder().parse(Path.of(args[0]).toFile()).getDocumentElement();
for (var child = report.getFirstChild(); child != null; child = child.getNextSibling()) {
if (child instanceof Element counter
&& counter.getTagName().equals("counter")
&& counter.getAttribute("type").equals("LINE")) {
final long covered = Long.parseLong(counter.getAttribute("covered"));
final long missed = Long.parseLong(counter.getAttribute("missed"));
final long total = covered + missed;
IO.println("Backend line coverage: " + covered + "/" + total + " (" + 100.0 * covered / total + "%)");
if (total == 0 || 5 * covered < 4 * total)
throw new IllegalStateException("Backend line coverage must reach 80%: " + args[0]);
return;
}
}
throw new IllegalStateException("Missing module LINE counter: " + args[0]);
}
}

View File

@@ -9,7 +9,13 @@ import org.openrewrite.text.*;
/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
public class Format {
public static void main(String[] args) throws Exception {
/**
* Checks or atomically formats configured handwritten Java sources with the pinned formatter.
*
* @param args consumer repository root followed by either {@code check} or {@code apply}
* @throws Exception if configuration, parsing or formatting fails, or check mode finds differences
*/
public static void main(final String... args) throws Exception {
Path root = Path.of(args[0]).toAbsolutePath().normalize();
boolean check = switch (args[1]) {
case "check" -> true;
@@ -24,14 +30,19 @@ public class Format {
}
}
String configuration = digest(Files.readString(root.resolve("pom.xml"))
+ Files.readString(root.resolve("tooling/format/Format.java"))
+ Files.readString(root.resolve("generated/tooling/format/Format.java"))
+ System.getProperty("java.version")
+ System.getProperty("java.class.path"));
if (!configuration.equals(cache.getProperty("configuration"))) cache.clear();
cache.setProperty("configuration", configuration);
List<SourceFile> sources = new ArrayList<>();
var settings = new Properties();
try (var input = Files.newInputStream(root.resolve("engineering.properties"))) {
settings.load(input);
}
for (String directory :
List.of("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci", "tooling/format")) {
settings.getProperty("FORMAT_DIRECTORIES", "backend/src").split(",")) {
if (!Files.isDirectory(root.resolve(directory))) continue;
try (var paths = Files.walk(root.resolve(directory))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java"))
.sorted()
@@ -59,7 +70,7 @@ public class Format {
.getChangeset()
.getAllResults();
for (Result result : results) {
System.out.println((check ? "Needs formatting: " : "Formatted: ")
IO.println((check ? "Needs formatting: " : "Formatted: ")
+ result.getAfter().getSourcePath());
}
if (check && !results.isEmpty()) {
@@ -91,8 +102,7 @@ public class Format {
try (var output = Files.newOutputStream(cacheFile)) {
cache.store(output, "Successfully formatted content only");
}
System.out.println(
"Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
IO.println("Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
}
static String digest(String value) throws Exception {

View File

@@ -11,6 +11,6 @@
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.16.0</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.6.1</version></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>Standards</mainClass><arguments><argument>${project.basedir}/../..</argument></arguments></configuration></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>Standards</mainClass><arguments><argument>${project.basedir}/../../..</argument></arguments></configuration></plugin>
</plugins></build>
</project>

View File

@@ -0,0 +1,596 @@
import com.puppycrawl.tools.checkstyle.*;
import com.puppycrawl.tools.checkstyle.api.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import net.sourceforge.pmd.*;
import net.sourceforge.pmd.cpd.*;
import net.sourceforge.pmd.lang.*;
import net.sourceforge.pmd.lang.ast.*;
import net.sourceforge.pmd.lang.java.ast.*;
import net.sourceforge.pmd.lang.java.rule.AbstractJavaRule;
import net.sourceforge.pmd.lang.java.symbols.*;
import net.sourceforge.pmd.lang.java.types.*;
import net.sourceforge.pmd.lang.metrics.MetricOptions;
import net.sourceforge.pmd.lang.rule.RuleSet;
/** Deliberately independent of the application build and its annotation processors. */
public class Standards {
static final List<String> findings = new ArrayList<>();
static final Map<Path, Set<Integer>> codeLines = new HashMap<>();
static final Set<String> ownedTypes = new HashSet<>();
static final Map<String, ASTTypeDeclaration> types = new HashMap<>();
static final List<ASTCompilationUnit> units = new ArrayList<>();
static Path root;
static String module;
record Waiver(String metric, String executable, double value, String reason) {}
static final List<Waiver> waivers = new ArrayList<>();
/**
* Checks configured backend sources and writes standards reports without changing maintained code.
*
* @param args the consumer repository root as the sole argument
* @throws Exception if configuration, resolution or parsing fails, or a blocking violation is found
*/
public static void main(final String... args) throws Exception {
findings.clear();
codeLines.clear();
types.clear();
units.clear();
ownedTypes.clear();
root = Path.of(args[0]).toAbsolutePath().normalize();
final var settings = new Properties();
try (var input = Files.newInputStream(root.resolve("engineering.properties"))) {
settings.load(input);
}
module = settings.getProperty("BACKEND_MODULE");
if (module == null || module.isBlank()) throw new IllegalArgumentException("BACKEND_MODULE is required");
waivers.clear();
final Path waiverFile = root.resolve("tooling/standards/waivers.tsv");
if (Files.exists(waiverFile))
for (String line : Files.readAllLines(waiverFile)) {
if (line.isBlank() || line.startsWith("#")) continue;
final String[] fields = line.split("\t", -1);
if (fields.length != 4 || fields[0].contains("*") || fields[1].contains("*") || fields[3].isBlank())
throw new IllegalArgumentException("Invalid exact waiver in " + waiverFile + ": " + line);
waivers.add(new Waiver(fields[0], fields[1], Double.parseDouble(fields[2]), fields[3]));
}
final Path output = root.resolve(module + "/target/standards");
Files.createDirectories(output);
final List<Path> files = new ArrayList<>();
for (String scope : List.of("main", "test")) {
try (var paths = Files.walk(root.resolve(module + "/src/" + scope + "/java"))) {
paths.filter(p -> p.toString().endsWith(".java")).sorted().forEach(files::add);
}
}
for (Path file : files) {
final var matcher =
java.util.regex.Pattern.compile("package\\s+([\\w.]+)\\s*;").matcher(Files.readString(file));
if (matcher.find())
ownedTypes.add(
matcher.group(1) + "." + file.getFileName().toString().replace(".java", ""));
}
checkstyle(files);
final PMDConfiguration config = new PMDConfiguration();
config.setDefaultLanguageVersion(
LanguageRegistry.PMD.getLanguageById("java").getVersion("25"));
config.setThreads(1);
config.prependAuxClasspath(
Path.of(System.getProperty("java.home"), "lib/jrt-fs.jar").toString());
config.setIgnoreIncrementalAnalysis(true);
// Compiled application/test dependencies permit PMD to resolve external types.
final Path cp = root.resolve(module + "/target/standards-classpath.txt");
if (Files.exists(cp))
config.prependAuxClasspath(
Files.readString(cp) + File.pathSeparator + root.resolve(module + "/target/classes"));
try (var analysis = PmdAnalysis.create(config)) {
analysis.addRuleSet(RuleSet.forSingleRule(new Measurements()));
for (String rule : List.of(
"category/java/codestyle.xml/LocalVariableCouldBeFinal",
"category/java/codestyle.xml/MethodArgumentCouldBeFinal",
"category/java/design.xml/ImmutableField"))
analysis.addRuleSet(analysis.newRuleSetLoader().loadFromResource(rule));
for (Path file : files) analysis.files().addFile(file);
final var report = analysis.performAnalysisAndCollectReport();
for (var error : report.getProcessingErrors())
findings.add("BLOCK parser " + error.getFileId() + " " + error.getMsg());
for (var violation : report.getViolations())
findings.add("BLOCK " + violation.getFileId() + ":" + violation.getBeginLine() + " "
+ violation.getRule().getName() + " " + violation.getDescription());
for (var violation : report.getSuppressedViolations())
findings.add("BLOCK Unapproved suppression: " + violation);
for (var error : report.getConfigurationErrors()) findings.add("BLOCK configuration " + error);
}
duplication(files);
Collections.sort(findings);
Files.write(output.resolve("report.txt"), findings);
findings.stream().filter(f -> !f.startsWith("METRIC ")).forEach(System.out::println);
System.out.println(
"Backend standards: " + files.size() + " source files; full metrics: " + output.resolve("report.txt"));
if (findings.stream().anyMatch(s -> s.startsWith("BLOCK")))
throw new IllegalStateException("Backend standards failed; see " + output.resolve("report.txt"));
}
static void checkstyle(List<Path> files) throws Exception {
final Checker checker = new Checker();
checker.setModuleClassLoader(Standards.class.getClassLoader());
checker.configure(ConfigurationLoader.loadConfiguration(
root.resolve("generated/tooling/standards/checkstyle.xml").toString(),
new PropertiesExpander(new Properties())));
checker.addListener(new AuditListener() {
public void auditStarted(AuditEvent e) {}
public void auditFinished(AuditEvent e) {}
public void fileStarted(AuditEvent e) {}
public void fileFinished(AuditEvent e) {}
public void addError(AuditEvent e) {
findings.add("BLOCK " + e.getFileName() + ":" + e.getLine() + " " + e.getMessage());
}
public void addException(AuditEvent e, Throwable t) {
findings.add("BLOCK " + e.getFileName() + " parser: " + t);
}
});
try {
checker.process(files.stream().map(Path::toFile).toList());
} finally {
checker.destroy();
}
}
public static class Measurements extends AbstractJavaRule {
public Measurements() {
setName("BackendMeasurements");
setMessage("Backend standards");
setLanguage(LanguageRegistry.PMD.getLanguageById("java"));
}
@Override
public Object visit(ASTCompilationUnit unit, Object data) {
units.add(unit);
for (var type : all(unit, ASTTypeDeclaration.class))
types.put(type.getSymbol().getBinaryName(), type);
final Set<Integer> lines = lines(unit);
codeLines.put(path(unit), lines);
for (var node : all(unit, JavaNode.class)) {
if (node instanceof ASTExecutableDeclaration executable) executable(executable);
if (node instanceof ASTCompactConstructorDeclaration compact) {
final var cyclo = new org.apache.commons.lang3.mutable.MutableInt(1);
compact.acceptVisitor(new ScopedCyclo(compact), cyclo);
final var cognitive =
new net.sourceforge.pmd.lang.java.metrics.internal.CognitiveComplexityVisitor.State(
compact);
compact.acceptVisitor(new ScopedCognitive(), cognitive);
metric(compact, "cyclomatic", cyclo.intValue(), 7, 10);
metric(compact, "cognitive", cognitive.getComplexity(), 10, 15);
metric(compact, "executableLines", lines(compact).size(), 30, 50);
metric(compact, "nesting", nesting(compact.getBody(), 0), 3, 4);
metric(
compact,
"parameters",
compact.getEnclosingType().getRecordComponents().size(),
5,
7);
visibility(compact);
}
if (node instanceof ASTVariableId variable) variable(variable);
if (node instanceof ASTTypeDeclaration nested
&& nested.isNested()
&& !nested.isLocal()
&& !nested.isAnonymous()) visibility(nested);
if (node instanceof ASTImportDeclaration imp && imp.isStatic() && production(unit))
block(imp, "Static imports are test-only");
if (node instanceof ASTExecutableDeclaration || node instanceof ASTFieldDeclaration)
visibility((ModifierOwner) node);
if (node instanceof ASTMethodDeclaration method
&& method.isOverride()
&& !method.isAnnotationPresent("java.lang.Override"))
block(method, "Overriding methods require @Override");
}
for (var type : all(unit, ASTTypeDeclaration.class)) typeMetrics(type);
return data;
}
}
static Path path(Node node) {
return Path.of(node.getTextDocument().getFileId().getAbsolutePath())
.toAbsolutePath()
.normalize();
}
static boolean production(Node node) {
return path(node).startsWith(root.resolve(module + "/src/main/java"));
}
static <T> List<T> all(Node node, Class<T> clazz) {
final List<T> result = new ArrayList<>();
if (clazz.isInstance(node)) result.add(clazz.cast(node));
for (int i = 0; i < node.getNumChildren(); i++) result.addAll(all(node.getChild(i), clazz));
return result;
}
static void block(Node node, String message) {
findings.add("BLOCK " + location(node) + " " + message);
}
static String location(Node node) {
return root.relativize(path(node)) + ":" + node.getBeginLine();
}
static void metric(Node node, String name, double value, double review, double limit) {
if (value > limit)
for (Waiver waiver : waivers) {
if (waiver.metric().equals(name)
&& waiver.executable().equals(identity(node))
&& waiver.value() == value) {
findings.add("WAIVER " + location(node) + " " + name + "=" + value + " " + waiver.reason());
return;
}
}
findings.add((value > limit ? "BLOCK " : value > review ? "REVIEW " : "METRIC ") + location(node) + " " + name
+ "=" + value);
}
static String identity(Node node) {
if (node instanceof ASTCompactConstructorDeclaration compact) {
final List<String> parameters = new ArrayList<>();
for (var component : compact.getEnclosingType().getRecordComponents())
parameters.add(component.getTypeMirror().toString());
return compact.getEnclosingType().getBinaryName() + "#<init>(" + String.join(",", parameters) + ")";
}
if (node instanceof ASTExecutableDeclaration executable) {
final List<String> parameters = new ArrayList<>();
for (var parameter : executable.getFormalParameters())
parameters.add(parameter.getTypeMirror().toString());
return executable.getEnclosingType().getBinaryName() + "#"
+ (executable instanceof ASTConstructorDeclaration ? "<init>" : executable.getName())
+ "(" + String.join(",", parameters) + ")";
}
return node instanceof ASTTypeDeclaration type ? type.getBinaryName() : location(node);
}
static Set<Integer> lines(JavaNode node) {
final Set<Integer> result = new HashSet<>();
var token = node.getFirstToken();
while (token != null && !token.isEof()) {
final var loc = token.getReportLocation();
for (int line = loc.getStartLine(); line <= loc.getEndLine(); line++) result.add(line);
if (token == node.getLastToken()) break;
token = token.getNext();
}
final String[] sourceLines = node.getTextDocument().getText().toString().split("\\R", -1);
result.removeIf(line -> line > sourceLines.length || sourceLines[line - 1].isBlank());
return result;
}
static void executable(ASTExecutableDeclaration method) {
{
metric(
method,
"parameters",
method.getArity(),
method instanceof ASTMethodDeclaration ? 4 : 5,
method instanceof ASTMethodDeclaration ? 5 : 7);
}
if (method.getBody() == null) return;
metric(method, "cyclomatic", cyclomatic(method), 7, 10);
metric(method, "cognitive", cognitive(method), 10, 15);
metric(method, "executableLines", lines(method).size(), 30, 50);
metric(method, "nesting", nesting(method.getBody(), 0), 3, 4);
}
static final class ScopedCyclo extends net.sourceforge.pmd.lang.java.metrics.internal.CycloVisitor {
ScopedCyclo(JavaNode node) {
super(MetricOptions.emptyOptions(), node);
}
@Override
public Void visit(ASTLambdaExpression lambda, org.apache.commons.lang3.mutable.MutableInt value) {
for (int i = 0; i < lambda.getNumChildren(); i++) lambda.getChild(i).acceptVisitor(this, value);
return null;
}
}
static final class ScopedCognitive
extends net.sourceforge.pmd.lang.java.metrics.internal.CognitiveComplexityVisitor {
@Override
public Void visit(ASTClassBody body, State state) {
return null;
}
@Override
public Void visitJavaNode(JavaNode node, State state) {
return node instanceof ASTTypeDeclaration || node instanceof ASTTypeBody
? null
: super.visitJavaNode(node, state);
}
}
static int cyclomatic(ASTExecutableDeclaration method) {
final var value = new org.apache.commons.lang3.mutable.MutableInt();
method.acceptVisitor(new ScopedCyclo(method), value);
return value.intValue();
}
static int cognitive(ASTExecutableDeclaration method) {
final var state = new net.sourceforge.pmd.lang.java.metrics.internal.CognitiveComplexityVisitor.State(method);
method.acceptVisitor(new ScopedCognitive(), state);
return state.getComplexity();
}
static int nesting(Node node, int depth) {
if (node instanceof ASTTypeDeclaration) return depth;
final String kind = node.getXPathNodeName();
final boolean control = Set.of(
"IfStatement",
"ForStatement",
"ForeachStatement",
"WhileStatement",
"DoStatement",
"SwitchStatement",
"SwitchExpression",
"CatchClause")
.contains(kind);
final boolean elseIf = node instanceof ASTIfStatement
&& node.getParent() instanceof ASTIfStatement p
&& p.getElseBranch() == node;
final int current = depth + (control && !elseIf ? 1 : 0);
int maximum = current;
for (int i = 0; i < node.getNumChildren(); i++) maximum = Math.max(maximum, nesting(node.getChild(i), current));
return maximum;
}
static void visibility(ModifierOwner member) {
final ASTTypeDeclaration owner = ((JavaNode) member).getEnclosingType();
if (owner == null || owner.isAnnotation()) return;
if (!member.getModifiers().hasAnyExplicitly(JModifier.PUBLIC, JModifier.PROTECTED, JModifier.PRIVATE))
block(member, "Declare explicit member visibility");
}
static void variable(ASTVariableId id) {
if (id.isEnumConstant() && !id.getName().matches("[A-Z][A-Z0-9]*(_[A-Z0-9]+)*"))
block(id, "Enum constants require UPPER_SNAKE_CASE");
if (id.isRecordComponent() || id.isEnumConstant() || id.isUnnamed()) return;
if (id.isTypeInferred() && !id.isLambdaParameter() && !id.isPatternBinding()) {
final ASTExpression initializer = id.getInitializer();
if (!(initializer instanceof ASTConstructorCall)
&& !(initializer instanceof ASTCastExpression)
&& !(initializer instanceof ASTArrayAllocation)) {
block(id, "var requires a visible constructor or cast type");
}
}
if (id.isLambdaParameter()
&& id.getParent() instanceof ASTLambdaParameter parameter
&& parameter.hasVarKeyword()) block(id, "Use an explicit lambda parameter type with final");
if (id.isFinal()) return;
final boolean written =
id.getLocalUsages().stream().anyMatch(u -> u.getAccessType() != ASTAssignableExpr.AccessType.READ);
if (written) return;
if (id.isLambdaParameter()) {
block(id, "Assigned-once lambda parameter requires final: " + id.getName());
return;
}
if (id.isField()) {
final ASTFieldDeclaration field =
id.ancestors(ASTFieldDeclaration.class).first();
if (field == null
|| id.getInitializer() == null
|| field.isAnnotationPresent("jakarta.inject.Inject")
|| field.isAnnotationPresent("lombok.Setter")
|| id.getEnclosingType().isAnnotationPresent("lombok.Setter")
|| id.getEnclosingType().isAnnotationPresent("lombok.Data")) return;
}
block(id, "Assigned-once variable requires final: " + id.getName());
}
static boolean owned(String name) {
return types.containsKey(name) || ownedTypes.stream().anyMatch(n -> name.equals(n) || name.startsWith(n + "$"));
}
static void typeMetrics(ASTTypeDeclaration type) {
final Set<Integer> ownLines = new HashSet<>();
final Set<Integer> scopeLines = codeLines.getOrDefault(path(type), lines(type));
final var nestedTypes = all(type, ASTTypeDeclaration.class).stream()
.filter(t -> t != type)
.toList();
var token = type.getFirstToken();
while (token != null && !token.isEof()) {
final var region = token.getRegion();
if (nestedTypes.stream().noneMatch(t -> t.getTextRegion().contains(region))) {
final var loc = token.getReportLocation();
for (int line = loc.getStartLine(); line <= loc.getEndLine(); line++)
if (scopeLines.contains(line)) ownLines.add(line);
}
if (token == type.getLastToken()) break;
token = token.getNext();
}
metric(type, "classLines", ownLines.size(), 300, 500);
if (type.isRecord())
findings.add("DESIGN " + location(type) + " recordComponents="
+ type.getRecordComponents().size() + " (manual design review)");
int depth = 0;
var superclass = type.getSymbol().getSuperclass();
while (superclass != null && owned(superclass.getBinaryName())) {
depth++;
superclass = superclass.getSuperclass();
}
metric(type, "ownSuperclassDepth", depth, 2, 3);
final Set<String> fanout = new HashSet<>();
for (var ref : all(type, ASTClassType.class)) {
if (ref.getEnclosingType() != type) continue;
final var symbol = ref.getTypeMirror().getSymbol();
if (symbol != null && symbol.isUnresolved())
block(
ref,
"Cannot resolve type " + ref.getSimpleName()
+ "; compile the backend and regenerate target/standards-classpath.txt before"
+ " checking");
if (symbol instanceof JClassSymbol resolved && !resolved.equals(type.getSymbol()))
fanout.add(resolved.getBinaryName());
}
metric(type, "fanOut", fanout.size(), 15, Double.POSITIVE_INFINITY);
int publicMethods = 0;
for (var method : type.getOperations())
if (method instanceof ASTMethodDeclaration
&& method.hasExplicitModifiers(JModifier.PUBLIC)
&& !trivial(method)) publicMethods++;
metric(type, "publicMethods", publicMethods, 15, Double.POSITIVE_INFINITY);
final Set<Dependency> dependencies = new HashSet<>();
injected(type, dependencies, new HashSet<>());
metric(type, "injectedDependencies", dependencies.size(), 5, 7);
}
static boolean trivial(ASTExecutableDeclaration method) {
if (method.getBody() == null || method.getBody().getNumChildren() != 1) return false;
final JavaNode statement = method.getBody().getChild(0);
final String name = method.getName();
String property = name;
if (name.startsWith("get") || name.startsWith("set")) property = name.substring(3);
else if (name.startsWith("is")) property = name.substring(2);
if (property.isEmpty()) return false;
property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
final String fieldName = property;
if (method.getEnclosingType().getSymbol().getDeclaredFields().stream()
.noneMatch(f -> f.getSimpleName().equals(fieldName))) return false;
if (method.getArity() == 0)
return statement instanceof ASTReturnStatement returned
&& accessorField(returned.getExpr(), method, property);
if (method.getArity() != 1 || !name.startsWith("set")) return false;
return statement instanceof ASTExpressionStatement expression
&& expression.getExpr() instanceof ASTAssignmentExpression assignment
&& !assignment.isCompound()
&& accessorField(assignment.getLeftOperand(), method, property)
&& assignment.getRightOperand() instanceof ASTVariableAccess value
&& method.getFormalParameters().get(0).getVarId().getSymbol().equals(value.getReferencedSym());
}
static boolean accessorField(ASTExpression expression, ASTExecutableDeclaration method, String property) {
if (!(expression instanceof ASTAssignableExpr.ASTNamedReferenceExpr reference)) return false;
if (expression instanceof ASTFieldAccess field && !(field.getQualifier() instanceof ASTThisExpression))
return false;
return method.getEnclosingType().getSymbol().getDeclaredFields().stream()
.anyMatch(
field -> field.getSimpleName().equals(property) && field.equals(reference.getReferencedSym()));
}
record Dependency(JTypeMirror type, Set<String> qualifiers) {}
static boolean annotated(AnnotableSymbol symbol, String annotation) {
return symbol.getDeclaredAnnotations().stream()
.anyMatch(a -> a.getBinaryName().equals(annotation));
}
static Dependency dependency(JVariableSymbol variable) {
return dependency(variable, Substitution.EMPTY);
}
static Dependency dependency(JVariableSymbol variable, Substitution substitution) {
final Set<String> qualifiers = new HashSet<>();
for (var annotation : variable.getDeclaredAnnotations()) {
if (annotation.getBinaryName().equals("jakarta.inject.Named")
|| annotated(annotation.getAnnotationSymbol(), "jakarta.inject.Qualifier")) {
final Map<String, String> attributes = new TreeMap<>();
for (String name : annotation.getAttributeNames())
attributes.put(name, String.valueOf(annotation.getAttribute(name)));
qualifiers.add(annotation.getBinaryName() + attributes);
}
}
return new Dependency(variable.getTypeMirror(substitution), qualifiers);
}
static void injected(ASTTypeDeclaration type, Set<Dependency> dependencies, Set<String> visited) {
if (!visited.add(type.getBinaryName())) return;
final var constructors = type.getSymbol().getConstructors();
final boolean bean = type.getDeclaredAnnotations().any(a -> {
final var symbol = a.getTypeMirror().getSymbol();
return Set.of(
"jakarta.inject.Singleton",
"jakarta.enterprise.context.ApplicationScoped",
"jakarta.enterprise.context.RequestScoped",
"jakarta.enterprise.context.Dependent",
"jakarta.enterprise.context.SessionScoped")
.contains(symbol.getCanonicalName())
|| annotated(symbol, "jakarta.inject.Scope")
|| annotated(symbol, "jakarta.enterprise.context.NormalScope")
|| annotated(symbol, "jakarta.enterprise.inject.Stereotype");
});
for (var constructor : constructors) {
if (annotated(constructor, "jakarta.inject.Inject") || bean && constructors.size() == 1) {
for (var parameter : constructor.getFormalParameters()) dependencies.add(dependency(parameter));
}
}
// PMD deliberately sees handwritten source, so account for Lombok's generated constructor.
if (bean
&& type.getOperations()
.filterIs(ASTConstructorDeclaration.class)
.isEmpty()) {
final boolean allArgs = type.isAnnotationPresent("lombok.AllArgsConstructor");
final boolean requiredArgs = type.isAnnotationPresent("lombok.RequiredArgsConstructor");
if (allArgs || requiredArgs)
for (var field : type.getDeclarations(ASTFieldDeclaration.class)) {
if (!field.isStatic())
for (var id : field.getVarIds())
if (allArgs
|| id.getInitializer() == null
&& (id.isFinal() || field.isAnnotationPresent("lombok.NonNull")))
dependencies.add(dependency(id.getSymbol()));
}
}
inheritedInjection(type.getTypeMirror(), dependencies, new HashSet<>());
}
static void inheritedInjection(JClassType resolvedType, Set<Dependency> dependencies, Set<String> visited) {
if (resolvedType == null) return;
final JClassSymbol symbol = resolvedType.getSymbol();
final Substitution substitution = resolvedType.getTypeParamSubst();
if (!visited.add(symbol.getBinaryName())) return;
for (var field : symbol.getDeclaredFields())
if (annotated(field, "jakarta.inject.Inject")) dependencies.add(dependency(field, substitution));
for (var method : symbol.getDeclaredMethods())
if (annotated(method, "jakarta.inject.Inject"))
for (var parameter : method.getFormalParameters())
dependencies.add(dependency(parameter, substitution));
inheritedInjection(resolvedType.getSuperClass(), dependencies, visited);
}
static void duplication(List<Path> files) throws Exception {
final var config = new CPDConfiguration();
config.setMinimumTileSize(100);
config.setIgnoreIdentifiers(false);
config.setIgnoreLiterals(false);
config.setSkipLexicalErrors(false);
config.setNoSkipBlocks(true);
final Set<String> duplicated = new HashSet<>();
try (var cpd = CpdAnalysis.create(config)) {
for (var file : files)
if (file.startsWith(root.resolve(module + "/src/main/java")))
cpd.files().addFile(file);
cpd.performAnalysis(report -> {
for (var error : report.getProcessingErrors()) findings.add("BLOCK CPD parser " + error);
for (var match : report.getMatches())
for (var mark : match) {
final var loc = mark.getLocation();
final Path path = Path.of(loc.getFileId().getAbsolutePath())
.toAbsolutePath()
.normalize();
for (int line = loc.getStartLine(); line <= loc.getEndLine(); line++)
if (codeLines.getOrDefault(path, Set.of()).contains(line))
duplicated.add(path + ":" + line);
findings.add("CLONE " + path + ":" + loc.getStartLine() + "-" + loc.getEndLine());
}
});
}
final long total = codeLines.entrySet().stream()
.filter(e -> e.getKey().startsWith(root.resolve(module + "/src/main/java")))
.mapToLong(e -> e.getValue().size())
.sum();
final double density = total == 0 ? 0 : 100.0 * duplicated.size() / total;
findings.add((density > 3 ? "BLOCK " : density > 1 ? "REVIEW " : "METRIC ") + "productionDuplication=" + density
+ "% (" + duplicated.size() + "/" + total + ")");
}
}

View File

@@ -0,0 +1,372 @@
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import net.sourceforge.pmd.*;
import net.sourceforge.pmd.lang.*;
import net.sourceforge.pmd.lang.document.FileId;
import net.sourceforge.pmd.lang.java.ast.*;
import net.sourceforge.pmd.lang.rule.RuleSet;
import org.junit.jupiter.api.*;
class StandardsTest {
private List<String> analyze(String text) throws Exception {
Standards.root = Path.of("").toAbsolutePath();
Standards.module = "backend";
Standards.ownedTypes.clear();
Standards.findings.clear();
Standards.units.clear();
Standards.types.clear();
Standards.codeLines.clear();
final var config = new PMDConfiguration();
config.setThreads(1);
config.prependAuxClasspath(
Path.of(System.getProperty("java.home"), "lib/jrt-fs.jar").toString());
config.prependAuxClasspath(
System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")));
config.setIgnoreIncrementalAnalysis(true);
config.setDefaultLanguageVersion(
LanguageRegistry.PMD.getLanguageById("java").getVersion("25"));
try (var analysis = PmdAnalysis.create(config)) {
analysis.addRuleSet(RuleSet.forSingleRule(new Standards.Measurements()));
analysis.files().addSourceFile(FileId.fromPath(Standards.root.resolve("Fixture.java")), text);
var report = analysis.performAnalysisAndCollectReport();
assertTrue(
report.getProcessingErrors().isEmpty(),
report.getProcessingErrors().toString());
}
return List.copyOf(Standards.findings);
}
private boolean contains(List<String> findings, String prefix, String metric) {
return findings.stream().anyMatch(s -> s.startsWith(prefix) && s.endsWith(metric));
}
@Test
void cyclomaticAndCognitiveBoundaries() throws Exception {
var at = analyze("class Fixture { public void run(final boolean b) { " + "if (b) {}".repeat(9) + "} }");
assertTrue(contains(at, "REVIEW", "cyclomatic=10.0"));
var over = analyze("class Fixture { public void run(final boolean b) { " + "if (b) {}".repeat(10) + "} }");
assertTrue(contains(over, "BLOCK", "cyclomatic=11.0"));
assertTrue(contains(
analyze("class Fixture { public void run(final boolean b) {" + "if (b) {}".repeat(15) + "} }"),
"REVIEW",
"cognitive=15.0"));
assertTrue(contains(
analyze("class Fixture { public void run(final boolean b) {" + "if (b) {}".repeat(16) + "} }"),
"BLOCK",
"cognitive=16.0"));
}
@Test
void methodAndConstructorParameterBoundariesAndImplicitRecords() throws Exception {
assertTrue(contains(
analyze("class Fixture { public void run(final int a, final int b, final int c, final int d,"
+ " final int e) {} }"),
"REVIEW",
"parameters=5.0"));
assertTrue(contains(
analyze("class Fixture { public void run(final int a, final int b, final int c, final int d,"
+ " final int e, final int f) {} }"),
"BLOCK",
"parameters=6.0"));
var implicit = analyze("record Fixture(int a, int b, int c, int d, int e, int f, int g, int h) {}");
assertFalse(implicit.stream().anyMatch(s -> s.contains("parameters=")));
var compact = analyze(
"record Fixture(int a, int b, int c, int d, int e, int f, int g, int h) { public Fixture" + " {} }");
assertTrue(contains(compact, "BLOCK", "parameters=8.0"));
}
@Test
void physicalLinesExcludeCommentsAndCountBraces() throws Exception {
var findings = analyze("class Fixture {\n"
+ "public void run() {\n"
+ "// comment\n"
+ "/* multi\n"
+ " comment */\n\n"
+ "System.out.println(1); // code\n"
+ "}\n"
+ "}");
assertTrue(contains(findings, "METRIC", "executableLines=3.0"));
assertTrue(contains(findings, "METRIC", "classLines=5.0"));
assertTrue(contains(
analyze("class Fixture {\npublic void run() {\n" + "System.out.println(1);\n".repeat(48) + "}\n}"),
"REVIEW",
"executableLines=50.0"));
assertTrue(contains(
analyze("class Fixture {\npublic void run() {\n" + "System.out.println(1);\n".repeat(49) + "}\n}"),
"BLOCK",
"executableLines=51.0"));
}
@Test
void nestingElseIfCatchSwitchLambdaAndNestedMethods() throws Exception {
var chain = analyze(
"class Fixture { public void run(final boolean b) { if (b) {} else if (b) {} else if (b)" + " {} } }");
assertTrue(contains(chain, "METRIC", "nesting=1.0"));
var nested = analyze("class Fixture { public void run(final boolean b) { if (b) { while (b) { switch (1) {"
+ " default -> { try {} catch (Exception e) { Runnable r = () -> { if (b) {} }; } } }"
+ " } } } }");
assertTrue(contains(nested, "BLOCK", "nesting=5.0"));
var separate = analyze("class Fixture { public void run(final boolean b) { if (b) { class Inner { public void"
+ " go() { if (b) {} } } } } }");
assertFalse(separate.stream().anyMatch(s -> s.endsWith("nesting=2.0")));
}
@Test
void lambdaComplexityContributesAndNestedClassDoesNot() throws Exception {
var findings = analyze("class Fixture { public void run(final boolean b) { Runnable r = () -> { if (b) {} };"
+ " class Inner { public void nested() { if (b) {} } } } }");
assertEquals(
2, findings.stream().filter(s -> s.endsWith("cyclomatic=2.0")).count(), findings.toString());
}
@Test
void varAndFinalRules() throws Exception {
var findings =
analyze("class Fixture { public void run(int p) { var a = new Object(); var b = (Object) a; var c"
+ " = name(); int count = 0; count++; } private String name() { return \"x\"; } }");
assertEquals(
1, findings.stream().filter(s -> s.contains("var requires")).count());
assertTrue(findings.stream().anyMatch(s -> s.contains("requires final: p")));
assertFalse(findings.stream().anyMatch(s -> s.contains("requires final: count")));
}
@Test
void patternBindingsRequireFinalOnlyWhenAssignedOnce() throws Exception {
var findings = analyze("""
record Pair(Object key, Object value) {}
class Fixture {
public void run(final Object input) {
if (input instanceof String unchanged) { System.out.println(unchanged); }
if (input instanceof final String protectedValue) { System.out.println(protectedValue); }
if (input instanceof String reassigned) { reassigned = reassigned.trim(); System.out.println(reassigned); }
switch (input) {
case String switched -> System.out.println(switched);
default -> {}
}
if (input instanceof Pair(var key, var value)) {
System.out.println(key);
System.out.println(value);
}
}
}
""");
for (String binding : List.of("unchanged", "switched", "key", "value"))
assertTrue(findings.stream().anyMatch(s -> s.contains("requires final: " + binding)), findings.toString());
for (String binding : List.of("protectedValue", "reassigned"))
assertFalse(findings.stream().anyMatch(s -> s.contains("requires final: " + binding)), findings.toString());
assertFalse(findings.stream().anyMatch(s -> s.contains("var requires")), findings.toString());
}
@Test
void inheritanceCountsOnlyProjectSuperclasses() throws Exception {
var findings = analyze("class A {} class B extends A {} class C extends B {} class D extends C {} class Fixture"
+ " extends D {} class External extends java.util.ArrayList<String> {}");
assertTrue(contains(findings, "REVIEW", "ownSuperclassDepth=3.0"));
assertTrue(contains(findings, "BLOCK", "ownSuperclassDepth=4.0"));
assertTrue(contains(findings, "METRIC", "ownSuperclassDepth=0.0"));
}
@Test
void injectionUsesResolvedTypesQualifiersInheritedMembersAndImplicitConstructors() throws Exception {
var findings = analyze("""
class Parent {
@jakarta.inject.Inject private String inherited;
@jakarta.inject.Inject public void setValue(final Integer value) {}
}
@jakarta.inject.Singleton class Fixture extends Parent {
private final String value;
public Fixture(@jakarta.inject.Named("a") final String first,
@jakarta.inject.Named("a") final String repeated,
@jakarta.inject.Named("b") final String second) { value = first; }
@jakarta.inject.Inject private String repeatedInherited;
}
""");
assertTrue(contains(findings, "METRIC", "injectedDependencies=4.0"), findings.toString());
String fields = "";
for (int i = 0; i < 7; i++)
fields += "@jakarta.inject.Inject @jakarta.inject.Named(\"" + i + "\") private String d" + i + ";";
assertTrue(contains(analyze("class Fixture {" + fields + "}"), "REVIEW", "injectedDependencies=7.0"));
assertTrue(contains(
analyze("class Fixture {" + fields + "@jakarta.inject.Inject private Integer eighth;}"),
"BLOCK",
"injectedDependencies=8.0"));
}
@Test
void fanoutCountsDistinctJdkTypesAndPublicMethodsExcludeOnlyTrueAccessors() throws Exception {
var findings = analyze("""
class Fixture {
private String name;
private java.util.List<String> values;
private java.util.List<String> repeated;
public String getName() { return name; }
public void setName(final String value) { this.name = value; }
public String getOther() { return name; }
public String getValidatedName() { if (name == null) { throw new IllegalStateException(); } return name; }
public static int calculate() { return 1; }
}
""");
assertTrue(contains(findings, "METRIC", "fanOut=3.0"), findings.toString());
assertTrue(contains(findings, "METRIC", "publicMethods=3.0"), findings.toString());
}
@Test
void commentedAccessorsRemainTrivialWhileAdditionalWorkStillCounts() throws Exception {
var findings = analyze("""
class Fixture {
private String name;
private String trimmed;
private String logged;
private String calculated;
private String appended;
public String getName() { /* Intent comment. */ return name; }
public void setName(final String value) { this./* Field comment. */name = value; }
public String name() { // Read the stored value.
return this.name;
}
public void setName(final String value, final boolean validate) { this.name = value; }
public void setTrimmed(final String value) { this.trimmed = value.trim(); }
public String getLogged() { System.out.println(logged); return logged; }
public String getCalculated() { return calculated.trim(); }
public void setAppended(final String value) { this.appended += value; }
}
""");
assertTrue(contains(findings, "METRIC", "publicMethods=5.0"), findings.toString());
}
@Test
void classLengthBoundaryAndNestedTypeLines() throws Exception {
assertTrue(contains(
analyze("class Fixture {\n" + "private int field;\n".repeat(498) + "}"), "REVIEW", "classLines=500.0"));
assertTrue(contains(
analyze("class Fixture {\n" + "private int field;\n".repeat(499) + "}"), "BLOCK", "classLines=501.0"));
var separate = analyze("class Fixture {\nprivate class Inner {\nprivate int value;\n}\nprivate int other;\n}");
assertEquals(
2, separate.stream().filter(s -> s.endsWith("classLines=3.0")).count());
assertTrue(contains(analyze("class Fixture { private class Inner {} }"), "METRIC", "classLines=1.0"));
}
@Test
void switchBooleanAndThrowTreatmentIsPinned() throws Exception {
var findings = analyze("class Fixture { public int run(final boolean a, final boolean b) { if (a && b) { throw"
+ " new IllegalStateException(); } return switch (1) { case 0, 1 -> 1; default -> 2;"
+ " }; } }");
assertTrue(contains(findings, "METRIC", "cyclomatic=6.0"), findings.toString());
}
private Path project(String production, String tests) throws Exception {
Path directory = Files.createTempDirectory("s7-fixture-");
Files.writeString(directory.resolve("engineering.properties"), "BACKEND_MODULE=backend\n");
Path prod = directory.resolve("backend/src/main/java");
Path test = directory.resolve("backend/src/test/java");
Files.createDirectories(prod);
Files.createDirectories(test);
Path config = directory.resolve("generated/tooling/standards/checkstyle.xml");
Files.createDirectories(config.getParent());
Files.copy(Path.of(System.getProperty("basedir"), "checkstyle.xml"), config);
if (production != null) Files.writeString(prod.resolve("Fixture.java"), production);
if (tests != null) Files.writeString(test.resolve("ExampleTest.java"), tests);
return directory;
}
@Test
void checksFailWithoutRewritingSourcesAndExposeReviewSignals() throws Exception {
String source = "package example;\n"
+ "import java.util.*;\n"
+ "class Fixture { public void run(int x) { if (x == 0) System.out.println(x); } }";
Path directory = project(source, null);
assertThrows(IllegalStateException.class, () -> Standards.main(new String[] {directory.toString()}));
assertEquals(source, Files.readString(directory.resolve("backend/src/main/java/Fixture.java")));
String report = Files.readString(directory.resolve("backend/target/standards/report.txt"));
assertTrue(report.contains("form of import"), report);
assertTrue(report.contains("must use '{}'s"), report);
assertTrue(report.contains("requires final: x"), report);
}
@Test
void productionCpdCountsEveryOccurrenceAndTestClonesDoNotGate() throws Exception {
String method = "public void first() {\n" + "System.out.println(123);\n".repeat(30) + "}\n";
String source = "package example;\nclass Fixture {\n" + method + method.replace("first", "second") + "}";
Path directory = project(source, null);
assertThrows(IllegalStateException.class, () -> Standards.main(new String[] {directory.toString()}));
String report = Files.readString(directory.resolve("backend/target/standards/report.txt"));
assertTrue(report.contains("BLOCK productionDuplication=") && report.contains("(64/67)"), report);
assertTrue(report.lines().filter(l -> l.startsWith("CLONE")).count() >= 2, report);
Path testProject =
project("package example; class Fixture {}", source.replace("class Fixture", "class ExampleTest"));
Standards.main(new String[] {testProject.toString()});
String testReport = Files.readString(testProject.resolve("backend/target/standards/report.txt"));
assertTrue(testReport.contains("productionDuplication=0.0%"), testReport);
}
@Test
void parserFailureNamesTheAffectedFileAndFails() throws Exception {
Path directory = project("package example; class Fixture { public void broken( }", null);
Exception failure = assertThrows(Exception.class, () -> Standards.main(new String[] {directory.toString()}));
assertTrue(
failure.toString().contains("Fixture.java")
|| Standards.findings.stream().anyMatch(f -> f.contains("Fixture.java")),
failure.toString());
}
@Test
void exactCoverageGateAcceptsEightyPercentAndRejectsRoundedNearMiss() throws Exception {
Path directory = Files.createTempDirectory("s7-coverage-");
Path xml = directory.resolve("report.xml");
Path helper = Path.of(System.getProperty("basedir"), "../coverage/Coverage.java")
.normalize();
for (long[] counts : List.of(new long[] {4, 1, 0}, new long[] {799999, 200001, 1}, new long[] {0, 0, 1})) {
Files.writeString(
xml,
"<report><counter type=\"LINE\" covered=\"" + counts[0] + "\" missed=\"" + counts[1]
+ "\"/></report>");
Process process = new ProcessBuilder(
Path.of(System.getProperty("java.home"), "bin/java").toString(),
helper.toString(),
xml.toString())
.redirectErrorStream(true)
.redirectOutput(directory.resolve("output.log").toFile())
.start();
assertEquals(counts[2], process.waitFor(), Files.readString(directory.resolve("output.log")));
}
}
@Test
void overridesAndImplicitRecordInjectionAreMeasured() throws Exception {
var override = analyze("class Fixture extends java.util.ArrayList<String> { public void clear() {} }");
assertTrue(override.stream().anyMatch(f -> f.contains("require @Override")), override.toString());
var record = analyze("@jakarta.inject.Singleton record Fixture(String one, String repeated, Integer two) {}");
assertTrue(contains(record, "METRIC", "injectedDependencies=2.0"), record.toString());
var inherited = analyze("class Parent<T> { @jakarta.inject.Inject private T dependency; } class Fixture extends"
+ " Parent<String> { @jakarta.inject.Inject private String repeated; }");
assertEquals(
2,
inherited.stream()
.filter(f -> f.endsWith("injectedDependencies=1.0"))
.count(),
inherited.toString());
}
@Test
void waiverMatchesOnlyTheExactMetricSignatureAndRetainedValue() throws Exception {
Standards.waivers.add(new Standards.Waiver(
"parameters",
"Fixture#run(int,int,int,int,int,int)",
6,
"Test fixture agreement: preserve an example signature"));
try {
var waived = analyze("class Fixture { public void run(final int a, final int b, final int c, final int d,"
+ " final int e, final int f) {} }");
assertTrue(
waived.stream().anyMatch(f -> f.startsWith("WAIVER") && f.contains("parameters=6.0")),
waived.toString());
var changed = analyze("class Fixture { public void run(final int a, final int b, final int c, final int d,"
+ " final int e, final int f, final int g) {} }");
assertTrue(contains(changed, "BLOCK", "parameters=7.0"));
} finally {
Standards.waivers.clear();
}
}
}

8
git-tools/ATTRIBUTION.md Normal file
View File

@@ -0,0 +1,8 @@
# License and attribution
The Git tools in this directory are maintained by Wanja Krah and distributed
under the MIT License. See [LICENSE.wanjakrah](LICENSE.wanjakrah).
The Java script, integration checks, and documentation were created with
assistance from OpenAI Codex, using a GPT-6-based language model. Wanja Krah
provided the requirements and made the behavior and safety decisions.

49
git-tools/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Local branch cleanup
MIT licensed. See [LICENSE.wanjakrah](LICENSE.wanjakrah) and
[ATTRIBUTION.md](ATTRIBUTION.md).
Requires Git, JDK 25 on `PATH`, and a Unix-like system with `env -S`.
Run from the repository you want to clean (including any subdirectory):
```sh
./git-tools/pruneLocalBranches --check
./git-tools/pruneLocalBranches -d feature/example
./git-tools/pruneLocalBranches -d '*'
./git-tools/pruneLocalBranches --help
```
From another repository, use the absolute path to this script. Its location does
not select the repository; the current working directory does. Always quote `'*'`.
Alternatively launch with `java --source 25 /path/to/pruneLocalBranches ...`.
The script fetches all origin branches and prunes stale **local** origin tracking
references. A failed fetch stops the operation. It never pushes, deletes remote
branches, removes folders, or changes working files.
A local branch is eligible only when no same-name branch exists on origin, it is
not checked out in any worktree, and all its commits are reachable from the current
`HEAD` or local `main`. Configured upstream names do not affect this decision:
`S6-deployment` tracking `origin/main` is not protected by that mapping. If local
`main` is missing, only `HEAD` counts. Neither local branch is updated by fetching.
Squash/rebase merges can leave different commit identities, so these branches may
be refused even when their changes were merged. The refusal lists the commits.
Checked-out branches are always refused, protecting their uncommitted work too.
`--check` prints eligibility and protection reasons. Deletion prints the candidates
and requires exactly `DELETE`; any other response or end of input cancels. Bulk
deletion skips protected branches and reports why. Before each deletion the script
rechecks eligibility and verifies that the branch still points to the displayed
commit. Avoid concurrent Git operations while running it; bulk deletion is not
atomic, so an error can leave earlier candidates already deleted.
Implementation: a Java 25 compact source file runs Git through `ProcessBuilder`
without a shell. After the explicit safety checks, `git branch -D` removes the local
ref, bypassing Git's separate upstream-based merge check. No build or dependencies
are needed. Exit codes: `0` success or cancellation, `1` error/blocked deletion,
`2` invalid arguments.
Run the isolated integration checks with
`python3 git-tools/test-prune-local-branches.py`. They create temporary repositories
under the system temporary directory, print their location for inspection, and
exercise single/bulk deletion, protection rules, cancellation and fetch failures.

167
git-tools/pruneLocalBranches Executable file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env -S java --source 25
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
record Result(int status, String output) {}
record Branch(String name, String oid, String blocker) {}
/** Runs Git, capturing merged standard output/error as UTF-8 and its exit status. */
Result git(String... arguments) throws Exception {
var command = new ArrayList<String>(List.of("git"));
command.addAll(Arrays.asList(arguments));
var process = new ProcessBuilder(command).redirectErrorStream(true).start();
// Process pipes expose stream APIs; there is no equivalent NIO process-pipe API.
String output;
try (var outputStream = process.getInputStream()) {
output = new String(outputStream.readAllBytes(), StandardCharsets.UTF_8).strip();
}
return new Result(process.waitFor(), output);
}
/** Runs Git and returns its output, failing when Git reports an unsuccessful exit status. */
String require(String... arguments) throws Exception {
Result result = git(arguments);
if (result.status() != 0) {
throw new IllegalStateException("git " + String.join(" ", arguments) + ": " + result.output());
}
return result.output();
}
/** Checks an exact ref, distinguishing a missing ref from a Git failure. */
boolean exists(String ref) throws Exception {
Result result = git("show-ref", "--verify", "--quiet", ref);
if (result.status() > 1) {
throw new IllegalStateException("Cannot inspect " + ref + ": " + result.output());
}
return result.status() == 0;
}
/** Checks origin, worktrees and commit reachability for reasons to retain a local branch. */
Branch inspect(String name, String oid) throws Exception {
if (exists("refs/remotes/origin/" + name)) {
return new Branch(name, oid, "same-name branch exists on origin");
}
// NUL-delimited porcelain also handles worktree paths containing newlines.
String worktrees = require("worktree", "list", "--porcelain", "-z");
for (String field : worktrees.split("\u0000")) {
if (field.equals("branch refs/heads/" + name)) {
return new Branch(name, oid, "checked out in a worktree (possibly the current one)");
}
}
var arguments = new ArrayList<String>(List.of("log", "--format=%h %s", oid, "--not", "HEAD"));
if (exists("refs/heads/main")) {
arguments.add("refs/heads/main");
}
String unique = require(arguments.toArray(String[]::new));
if (!unique.isEmpty()) {
return new Branch(name, oid, "commits absent from both HEAD and local main:\n "
+ unique.replace("\n", "\n "));
}
return new Branch(name, oid, null);
}
/** Prints usage, deletion safeguards and exit codes. */
void help() {
IO.println("""
Usage: pruneLocalBranches --check
pruneLocalBranches -d <branch-name>
pruneLocalBranches -d '*'
pruneLocalBranches --help
Operates on the repository in the current directory. Requires Git and JDK 25.
Fetches origin branches and prunes local origin tracking refs; never changes
remote branches. A same-name origin branch protects a local branch.
Checked-out branches and commits absent from both HEAD and local main block
deletion. Missing local main means HEAD alone must contain all commits.
Deletion shows candidates and requires the exact confirmation DELETE.
Quote '*' so your shell does not expand it. No folders are deleted.
Exit codes: 0 success/cancelled, 1 error or blocked deletion, 2 invalid usage.
""");
}
/** Fetches origin, reports candidates and deletes eligible branches only after confirmation. */
void run(String... args) throws Exception {
if (args.length == 1 && args[0].equals("--help")) {
help();
return;
}
boolean check = args.length == 1 && args[0].equals("--check");
boolean delete = args.length == 2 && args[0].equals("-d");
if (!check && !delete) {
help();
System.exit(2);
}
if (!require("rev-parse", "--is-inside-work-tree").equals("true")) {
throw new IllegalStateException("Run inside a working Git repository.");
}
require("rev-parse", "--verify", "HEAD");
// An explicit refspec ignores narrow fetch configuration and fetches every branch.
require("fetch", "--prune", "--no-tags", "--no-recurse-submodules", "origin",
"+refs/heads/*:refs/remotes/origin/*");
String refs = require("for-each-ref", "--format=%(refname:strip=2) %(objectname)", "refs/heads/");
var eligible = new ArrayList<Branch>();
boolean found = false;
boolean blocked = false;
for (String line : refs.lines().toList()) {
String[] fields = line.split(" ", 2);
String name = fields[0];
if (delete && !args[1].equals("*") && !args[1].equals(name)) {
continue;
}
found = true;
Branch branch = inspect(name, fields[1]);
if (branch.blocker() == null) {
eligible.add(branch);
IO.println("ELIGIBLE " + name);
} else {
blocked = true;
IO.println("KEEP " + name + ": " + branch.blocker());
}
}
if (check) {
IO.println(eligible.size() + " local branch(es) eligible for deletion.");
return;
}
if (!found) {
throw new IllegalStateException("No such local branch: " + args[1]);
}
if (eligible.isEmpty()) {
if (blocked) {
throw new IllegalStateException("No eligible branches; see KEEP reasons above.");
}
return;
}
IO.println("Will delete these local branches: "
+ String.join(", ", eligible.stream().map(Branch::name).toList()));
String answer = IO.readln("Are you absolutely sure? Type DELETE to continue: ");
if (!"DELETE".equals(answer)) {
IO.println("Cancelled; no branches deleted.");
return;
}
for (Branch branch : eligible) {
String currentOid = require("rev-parse", "--verify", "refs/heads/" + branch.name());
if (!currentOid.equals(branch.oid())) {
throw new IllegalStateException("Branch changed since confirmation: " + branch.name());
}
Branch fresh = inspect(branch.name(), currentOid);
if (fresh.blocker() != null) {
throw new IllegalStateException("Refusing " + branch.name() + ": " + fresh.blocker());
}
// -D avoids Git's upstream-based merge test; our HEAD/main test above is stricter
// about where commits must be preserved and deliberately ignores upstream names.
IO.println(require("branch", "-D", "--", branch.name()));
}
}
/** Runs the command, reporting failures on standard error with exit status one. */
void main(String... args) {
try {
run(args);
} catch (Exception exception) {
System.err.println("Error: " + exception.getMessage());
System.exit(1);
}
}

View File

@@ -0,0 +1,52 @@
import os, pathlib, subprocess, tempfile
script = str(pathlib.Path(__file__).resolve().with_name('pruneLocalBranches'))
root = pathlib.Path(tempfile.mkdtemp(prefix='git-tools-test-'))
env = dict(os.environ, GIT_AUTHOR_NAME='Test', GIT_AUTHOR_EMAIL='test@example.com', GIT_COMMITTER_NAME='Test', GIT_COMMITTER_EMAIL='test@example.com', GIT_CONFIG_NOSYSTEM='1')
def cmd(*args, cwd=None, expected=0, data=None):
p = subprocess.run(args, cwd=cwd or root, env=env, input=data, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert p.returncode == expected, (args, p.returncode, p.stdout)
return p.stdout
cmd('git','init','--bare','origin.git')
cmd('git','init','-b','main','repo')
repo=root/'repo'
def git(*args): return cmd('git',*args,cwd=repo)
def run(*args,expected=0,data=None): return cmd(script,*args,cwd=repo,expected=expected,data=data)
git('commit','--allow-empty','-m','initial')
git('remote','add','origin',str(root/'origin.git'))
git('push','-u','origin','main')
git('branch','merged')
git('push','origin','merged')
run('--check')
git('push','origin','--delete','merged')
git('branch','S6-deployment')
git('branch','--set-upstream-to=origin/main','S6-deployment')
git('branch','protected')
git('push','origin','protected')
git('checkout','-b','unique')
git('commit','--allow-empty','-m','unpublished')
git('checkout','main')
git('branch','in-worktree')
git('worktree','add',str(root/'linked'),'in-worktree')
out=run('--check')
for value in ['ELIGIBLE merged','ELIGIBLE S6-deployment','KEEP protected: same-name','KEEP main: same-name','KEEP unique: commits absent','unpublished','KEEP in-worktree: checked out']:
assert value in out, (value,out)
assert 'Cancelled' in run('-d','merged',data='no\n')
git('show-ref','--verify','refs/heads/merged')
assert 'Cancelled' in run('-d','merged',data='')
run('-d','protected',expected=1)
run('-d','unique',expected=1)
run('-d','missing',expected=1)
run('-d','merged',data='DELETE\n')
cmd('git','show-ref','--verify','--quiet','refs/heads/merged',cwd=repo,expected=1)
run('-d','*',data='DELETE\n')
cmd('git','show-ref','--verify','--quiet','refs/heads/S6-deployment',cwd=repo,expected=1)
assert 'unique' in git('branch','--list')
assert 'protected' in git('branch','--list')
assert 'in-worktree' in git('branch','--list')
remote=git('ls-remote','--heads','origin')
assert 'refs/heads/main' in remote and 'refs/heads/protected' in remote
run('--bad',expected=2)
git('remote','set-url','origin',str(root/'does-not-exist'))
run('--check',expected=1)
print('PASS: fetch/prune, same-name protection, upstream-name independence, unique commits, worktree protection, cancellation/EOF, single/bulk deletion, remote preservation, invalid arguments, fetch failure')
print('Temporary fixtures:',root)

25
pom.xml
View File

@@ -22,11 +22,11 @@
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.version>3.40.1</quarkus.platform.version>
<quarkus.platform.version>3.40.1.1</quarkus.platform.version>
<format.mode>apply</format.mode>
<format.skip>false</format.skip>
<palantir-java-format.version>2.101.0</palantir-java-format.version>
<palantir-java-format.version>2.103.0</palantir-java-format.version>
<rewrite-core.version>8.63.4</rewrite-core.version>
<ortools-java.version>9.15.6755</ortools-java.version>
@@ -87,7 +87,7 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<version>3.6.4</version>
<inherited>false</inherited>
<executions>
<execution>
@@ -109,7 +109,7 @@
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</argument>
<argument>--class-path</argument><classpath/>
<argument>${maven.multiModuleProjectDirectory}/tooling/format/Format.java</argument>
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/format/Format.java</argument>
<argument>${maven.multiModuleProjectDirectory}</argument>
<argument>${format.mode}</argument>
</arguments>
@@ -187,4 +187,21 @@
</plugins>
</build>
<profiles>
<profile>
<id>regenerate-engineering</id>
<properties><format.skip>true</format.skip></properties>
<build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.4</version>
<inherited>false</inherited>
<executions><execution><id>regenerate-engineering</id><phase>validate</phase><goals><goal>exec</goal></goals>
<configuration combine.self="override"><executable>python3</executable>
<arguments><argument>${maven.multiModuleProjectDirectory}/scripts/engineering.py</argument></arguments>
</configuration>
</execution></executions>
</plugin></plugins></build>
</profile>
</profiles>
</project>

129
renovate.json Normal file
View File

@@ -0,0 +1,129 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"ignorePaths": [
"generated/**",
"**/node_modules/**",
"**/target/**"
],
"packageRules": [
{
"description": [
"Local reactor parent is built from ../pom.xml; its version is maintained with this repository, not looked up in a registry"
],
"matchManagers": [
"maven"
],
"matchDepTypes": [
"parent",
"parent-root"
],
"matchPackageNames": [
"nl.agilecc.agiledancers:agiledancers"
],
"enabled": false
},
{
"matchPackageNames": [
"@angular/**",
"angular-eslint",
"@angular-eslint/**"
],
"allowedVersions": ">=22.0.0 <23.0.0"
},
{
"description": [
"Angular runtime and compiler must use the same release"
],
"matchPackageNames": [
"@angular/common",
"@angular/compiler",
"@angular/core",
"@angular/forms",
"@angular/platform-browser",
"@angular/router",
"@angular/compiler-cli"
],
"groupName": "Angular framework",
"allowedVersions": ">=22.0.0 <23.0.0"
},
{
"matchPackageNames": [
"@angular/build",
"@angular/cli"
],
"groupName": "Angular CLI",
"allowedVersions": ">=22.0.0 <23.0.0"
},
{
"matchPackageNames": [
"angular-eslint",
"@angular-eslint/**"
],
"allowedVersions": ">=22.0.0 <23.0.0"
},
{
"matchPackageNames": [
"typescript"
],
"allowedVersions": ">=6.0.0 <6.1.0"
},
{
"matchPackageNames": [
"rxjs"
],
"allowedVersions": "^6.5.3 || ^7.4.0"
},
{
"matchPackageNames": [
"node"
],
"allowedVersions": "^22.22.3 || ^24.15.0 || ^26.0.0"
},
{
"matchPackageNames": [
"@types/node"
],
"allowedVersions": "^22.0.0 || ^24.0.0 || ^26.0.0"
},
{
"description": [
"The browser package and its separately published CI image are inseparable"
],
"matchPackageNames": [
"@playwright/test",
"playwright",
"mcr.microsoft.com/playwright"
],
"groupName": "Playwright"
}
],
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": [
"/^renovate\\/Dockerfile$/"
],
"matchStrings": [
"ARG NPM_VERSION=(?<currentValue>[^\\s]+)"
],
"datasourceTemplate": "npm",
"depNameTemplate": "npm"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^engineering\\.properties$/"
],
"matchStrings": [
"PLAYWRIGHT_IMAGE=(?<depName>mcr\\.microsoft\\.com/playwright):v(?<currentValue>[^\\s-]+)-noble"
],
"datasourceTemplate": "docker",
"versioningTemplate": "semver",
"extractVersionTemplate": "^v(?<version>.+)-noble$",
"autoReplaceStringTemplate": "PLAYWRIGHT_IMAGE={{{depName}}}:v{{{newValue}}}-noble"
}
]
}

99
scripts/engineering.py Normal file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Materialize a pinned engineering release without relying on generated files."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
def git(directory, *arguments):
result = subprocess.run(['git', '-C', str(directory), *arguments],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'}, timeout=180)
if result.returncode:
raise ValueError('Git operation failed; check repository access and credentials')
return result.stdout.strip()
def files(directory):
result = {}
if not directory.exists():
return result
for path in directory.rglob('*'):
relative = path.relative_to(directory)
if any(part in {'target', '__pycache__'} for part in relative.parts):
continue
if path.is_symlink():
raise ValueError('Symlinks are not allowed in generated resources: ' + str(relative))
if path.is_file():
result[str(relative)] = (hashlib.sha256(path.read_bytes()).hexdigest(),
bool(path.stat().st_mode & 0o111))
return result
def materialize(root, check=False):
root = Path(root).resolve()
lock = json.loads((root / 'engineering.lock.json').read_text())
if not re.fullmatch(r'v\d+\.\d+\.\d+', lock['tag']):
raise ValueError('Engineering tag must be vMAJOR.MINOR.PATCH')
if not re.fullmatch(r'[0-9a-f]{40}', lock['commit']):
raise ValueError('Engineering commit must be a full SHA')
with tempfile.TemporaryDirectory(prefix='engineering-fetch-') as temporary:
checkout = Path(temporary)
git(checkout, 'init', '-q')
git(checkout, 'fetch', '--quiet', '--depth=1', '--no-tags', '--',
lock['repository'], 'refs/tags/' + lock['tag'])
actual = git(checkout, 'rev-parse', 'FETCH_HEAD^{commit}')
if actual != lock['commit']:
raise ValueError('Engineering tag moved: resolved commit differs from lock')
git(checkout, 'checkout', '--quiet', '--detach', actual)
source = checkout / 'resources'
if not source.is_dir():
raise ValueError('Engineering release has no resources directory')
files(source) # Validate before copying, including symlinks.
(source / 'SOURCE.json').write_text(json.dumps(lock, indent=2, sort_keys=True) + '\n')
destination = root / 'generated'
if destination.is_symlink():
raise ValueError('generated/ must not be a symlink')
expected, existing = files(source), files(destination)
if check:
changed = sorted(k for k in expected.keys() | existing.keys()
if expected.get(k) != existing.get(k))
if changed:
raise ValueError('Generated resources differ; regenerate and commit:\n' + '\n'.join(changed))
print('Engineering resources match ' + lock['tag'] + ' at ' + actual)
return
# Stage on the same filesystem. Preserve the previous tree until replacement succeeds.
with tempfile.TemporaryDirectory(prefix='.engineering-', dir=root) as staging:
staged = Path(staging) / 'next'
previous = Path(staging) / 'previous'
shutil.copytree(source, staged)
if destination.exists():
destination.rename(previous)
try:
staged.rename(destination)
except BaseException:
if previous.exists():
previous.rename(destination)
raise
print('Generated engineering resources from ' + lock['tag'] + ' at ' + actual)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
try:
materialize(args.root, args.check)
except (ValueError, KeyError, OSError, subprocess.TimeoutExpired) as error:
parser.exit(1, str(error) + '\n')
if __name__ == '__main__':
main()

View File

@@ -1,23 +0,0 @@
# Project skills
Reusable agent instructions for this repository. Invoke them explicitly in the
conversation, for example:
> Gebruik grill-me om de JSON-import en -export met mij uit te werken.
## Installed skills
| Skill | Purpose |
| --- | --- |
| [code-review](code-review/SKILL.md) | Review changes against documented coding standards and the originating spec. |
| [grill-me](grill-me/SKILL.md) | Entry point for reviewing a plan or design through questions. |
| [grilling](grilling/SKILL.md) | Interview instructions required by `grill-me`. |
The skills are copied from [Matt Pocock's skills.sh listing](https://skills.sh/mattpocock/skills/grill-me)
and its [upstream repository](https://github.com/mattpocock/skills).
The exact upstream revisions and paths are recorded in [upstream.json](upstream.json).
The upstream [MIT license](LICENSE.mattpocock) is included.
These project-local files are available to the agent from the next conversation
turn through the instructions in the root `AGENTS.md`. They are not installed
globally. Updating them requires explicitly downloading a new upstream revision.

View File

@@ -1,24 +0,0 @@
# License and attribution
ADR brainstorm is maintained by Wanja Krah and is distributed under the MIT
License. See [LICENSE.wanjakrah](LICENSE.wanjakrah) for the license covering
Wanja Krah's original contributions.
The design-tree approach and related interviewing guidance in `SKILL.md` were
adapted from Matt Pocock's `grilling` skill, invoked by his `grill-me` skill.
This includes tracking dependent decisions, settling prerequisites before
asking dependent questions, investigating accessible facts, and leaving
decisions with the user. The wording and workflow have been adapted for ADR
brainstorming; this is not an unmodified copy of the upstream skill.
Upstream source:
- Repository: https://github.com/mattpocock/skills
- Revision: `59db609eb1e18aaf59b63284d0e52255aff0c63c`
- Paths: `skills/productivity/grilling` and `skills/productivity/grill-me`
- License: MIT, copyright (c) 2026 Matt Pocock
Matt Pocock's full copyright and license notice is preserved in
[LICENSE.mattpocock](LICENSE.mattpocock) for the adapted material. Keep both
license files with redistributed copies or substantial portions of this skill.
The two notices preserve attribution under the same MIT terms; they do not
introduce an additional license restriction or imply upstream endorsement.

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,103 +0,0 @@
---
name: adr-brainstorm
description: "Brainstorm with the user about a new ADR. Use when the user wants to stress-test their thinking, or uses any combination of 'adr' and 'brainstorm' in their trigger phrase."
---
An ADR is an Architecture Decision Record. Such a record contains a decision on the architecture or design for the application landscape the user is maintaining.
If an ADR-folder exists, read the README.md of that folder to get a full understanding of any specific rules and properties of how the user works with ADR's that might not be listed in this Skill.
The goal of the ADR is to provide an architectural solution or a design solution for a given context. Therefor, two to four different approaches must be worked out with their pro's and con's, from which the user should select one as their preferred solution.
Below, the brainstorm session is mapped out in multiple steps. Each step must be completed succesfully, before continuing to the next step. Success means reaching a shared understanding and whatever the step entitles as its success criteria.
If the user, or the relevant ADR, indicates that an ADR is to supersede another ADR, read that ADR first.
## Explore decisions with the user
### Follow a design tree
Map the architectural question as a design tree: each decision may
introduce dependent decisions. Track which decisions are agreed,
open, awaiting factual investigation, or explicitly deferred.
Ask dependent questions only after their prerequisites are settled.
Revisit earlier decisions when new evidence changes their assumptions,
and explain which dependent decisions need reconsideration.
Keep the tree within the ADR's agreed scope. Suggest follow-up work
for independent topics rather than continually expanding the ADR.
A deferred topic must have a clear boundary and must not leave the
main decision ambiguous.
Finish when the user has agreed on the architectural decision and its material tradeoffs. Identify remaining implementation questions as follow-up work. Do not leave questions open that could materially change the chosen approach.
### Establish facts before asking for decisions
Finding accessible facts is the agent's responsibility. Inspect relevant
code, configuration, specifications, ADRs, and authoritative documentation
before asking the user questions those sources can answer.
Distinguish observed facts, assumptions, recommendations, and decisions.
Cite sources for material factual claims and explain uncertainty where
evidence is unavailable or conflicting. Ask the user for requirements,
priorities, constraints, and knowledge unavailable from accessible sources.
Evaluate alternatives against the agreed requirements. Present credible
alternatives fairly, including drawbacks of the recommended approach.
Do not infer that an option is unsuitable merely because the current
implementation uses something else.
### Keep the conversation collaborative
Use short rounds, usually one main question or a few closely related
questions. Number questions when asking more than one. Do not ask every
available question at once if that would hinder discussion.
For decision questions, normally provide a recommended answer and its
reason. Where natural, phrase the question so "yes" clearly accepts that
recommendation. Make its scope explicit; avoid combining independent
decisions into one yes/no question.
Use open questions when discovering requirements or when a recommendation
would prematurely narrow the discussion. Allow disagreement, alternatives,
and back-and-forth exploration before seeking a decision.
### Leave architectural decisions with the user
The agent investigates, explains tradeoffs, challenges assumptions, and
recommends. The user selects the architectural approach and accepts its
material tradeoffs.
Do not treat silence, a factual answer, or agreement on one decision as
approval of other decisions. Preserve earlier agreements without repeatedly
requesting confirmation unless their meaning or supporting facts change.
At meaningful milestones, summarize the agreed decisions and remaining
questions. Before finalizing the ADR, confirm shared understanding of the
complete proposal. Keep recommendations and unresolved assumptions out of
the Decisions section until the user accepts them.
### Guide the process through steps
Coöperate with the user via the following steps. Communicate each step in which step you are, and what the step entails. Use the template adr-template.md as a template for the final ADR.
The _first step_ is to get to a shared understanding of the probived context and the problem area the user is in need for a decision. Success for step one means you can describe the context in one to fifty sentences.
The _second step_ is to get to two to four different architectural solutions or design solutions for the described context. Every solution needs a short description of one to fifty sentences and a list of one to ten pro's and one to ten con's. allow discussion of consequences when they affect the choice. Record them formally in _step four_, but don’t postpone discovering migration costs or operational burdens until after selecting an option.
Success for step two means there are more than one credible solutions with a set of relevant pro's and con's by which a user is able to choose, or not choose that solution for the next step.
The _third step_ is to pick the best solution. Ths user must decide what solution he prefers. The choosen solution will be presented as the ADR decision. A decision may branch into a small tree of related decisions, small meaning one to five levels deep. Make the architectural decision, its scope, and its material tradeoffs unambiguous; record remaining implementation questions as follow-up work. Success of step three means there is a shared understanding of the choosen solution and what it entails.
The _fourth step_ is to make clear what the consequences will be for the application landscape. Success of step four means there is a clear understanding of the changes that should be made to software landscape and the way futurue work should be implemented.
The _fifth step_ is fill out the other properties of the ADR that is required by the template. Succes of step five means the ID of the ADR is unique, the title is short and comprehandible, and gives the user an overall idea of what the ADR is about. If the ADR supersedes another ADR, that is listed as well.
Set the Expiration Date of when the ADR should be reviewd. This should be a relative date, to be made absolute by the user as soon as the user moves the ADR to APPROVED.
Finally, set the status to REFINED. It's up to the user to set the Status to either APPROVED - meaning the ADR is accepted and to be applied for all future work - or REJECTED - meaning the ADR is not accepted and should not be taken in consideration.
When updating the ADR with the result of the brainstorm, make it both easy to glanse through, as to read in full, meaning:
- stick to the template
- keep to the formatting as is custom for markdown-files
- high use of headers and sub-headers
- high use of bullit points, lists, and tables where applicable
- keep sentences short and precise where possible
- create images with ASCII-art where possible

View File

@@ -1,20 +0,0 @@
**ID:** <insert ADR followed by a unique number> \
**Title:** <insert short and descriptive title> \
**Supersedes:** - \
**Status:** NEW \
**Expiration Date:** to be determined
## Context
To be determined
## Decisions
To be determined
## Options considered
To be determined
## Consequences
To be determined
## References
<insert a list of references consulted, along with a timestamp of when it was consulted>

View File

@@ -1,3 +0,0 @@
interface:
display_name: "ADR Brainstorm"
short_description: "Brainstorm with the user on an ADR to get it from NEW to REFINED"

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,89 +0,0 @@
---
name: code-review
description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"."
---
Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
- **Standards**: does the code conform to this repo's documented coding standards?
- **Spec**: does the code faithfully implement the originating issue / spec?
Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. Start every sub-agent with a fresh context window.
The issue tracker should have been provided to you. If not, tell the user to run `/setup-matt-pocock-skills`.
## Process
### 1. Pin the fixed point
Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.). If they didn't specify one, ask for it.
Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents.
### 2. Identify the spec source
Look for the originating spec, in this order:
1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in the tracker doc.
2. A path the user passed as an argument.
3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
### 3. Identify the standards sources
Search the repo for every file that documents how code should be written. When `CODING_STANDARDS.md` or `CONTRIBUTING.md` exists, it must be on the list.
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces.
Each smell reads *what it is* → *how to fix*; match it against the diff:
- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies.
- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct.
- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
### 4. Spawn both sub-agents in parallel
Issue both sub-agent calls together, in the foreground, and aggregate the reports they return.
**Standards sub-agent prompt** should include:
- The full diff command and commit list.
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it).
- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
**Spec sub-agent prompt** should include:
- The diff command and commit list.
- The path or fetched contents of the spec.
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
If the spec is missing, skip the Spec sub-agent and note this in the final report.
### 5. Aggregate
Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_).
End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent.
## Why two axes
A change can pass one axis and fail the other:
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
Reporting them separately stops one axis from masking the other.

View File

@@ -1,3 +0,0 @@
interface:
display_name: "Code Review"
short_description: "Review a diff on standards and spec"

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +0,0 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Call the Skill tool with "grilling".

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Grill Me"
short_description: "Sharpen a plan through interview"
policy:
allow_implicit_invocation: false

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,30 +0,0 @@
---
name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
Format a round like so:
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
---
❓ **Q2** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
```
Word each question so "yes" accepts your recommended answer.
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.

View File

@@ -1,3 +0,0 @@
interface:
display_name: "Grilling"
short_description: "Stress-test thinking a round of questions at a time"

View File

@@ -1,14 +0,0 @@
{
"repository": "https://github.com/mattpocock/skills",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c",
"source": "https://skills.sh/mattpocock/skills/grill-me",
"skills": {
"grill-me": "skills/productivity/grill-me",
"grilling": "skills/productivity/grilling",
"code-review": {
"path": "skills/engineering/code-review",
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"source": "https://www.skills.sh/mattpocock/skills/code-review"
}
}
}

View File

@@ -1,10 +0,0 @@
# Specifications
Store feature specifications, requirements and agreed design decisions here.
Use descriptive Markdown filenames, prefixed with a unique S# reference, for example `S1_school-json-import-export.md`.
Each spec-file will get a Status with either the values: NEW, REFINED, IN PROGRESS, IN REVIEW, IMPLEMENTED, REJECTED.
Each spec-file that is refined with the grill-me skill, or manually, will be moved to the refined-folder, and its status-field will be set to REFINED.
When a spec-file gets implemented, at first, the spec-file will be moved to the in_progress-folder, and its status-field will be set to IN_PROGRESS.
When a spec-file is fully implemented, the spec-file will be moved to the in_review-folder, and its status-field will be set to IN_REVIEW.
When a spec-file is reviewed, the spec-file will be moved to the implemented-folder, and its status-field will be set to IMPLEMENTED.
When a spec-file is rejected, the spec-file will be moved to the rejected-folder, and its status-field will be set to REJECTED.

View File

@@ -1,5 +0,0 @@
As DanceSchool Maintainer
I want the ability to exclude certain groups for a specific show
so that I can diversify in the groups and shows that we offer.
State: NEW

View File

View File

@@ -1,3 +0,0 @@
- tested
- changelog
- documented

View File

@@ -1 +0,0 @@
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.

View File

@@ -1,55 +0,0 @@
# School JSON format, version 3
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
`application/json`, validates without writing, and returns `name`, `dancers`,
`groups`, `shows`, `existingId` and `existingName`.
Commit: `POST /api/dance-schools/import` accepts the same file. For replacement,
provide `?replaceId=<existingId>` as explicit confirmation. A mismatch is rejected;
refresh the preview if another import added or removed the school meanwhile.
There is no merge endpoint.
```json
{
"formatVersion": 3,
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
"name": "Studio",
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
"coaches": [{ "id": "c1", "name": "Alex", "dancerId": "d1" }],
"groups": [{
"id": "g1", "category": "Hiphop", "type": "REGULAR",
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
"dancerIds": ["d1"], "coachId": "c1"
}],
"shows": [{
"id": "s1", "name": "Summer show", "preferredDayParts": 1, "finalized": false,
"dayParts": [{ "date": "2027-06-12", "type": "MORNING", "groupIds": ["g1"] }]
}]
}
```
`key` is a persistent UUID independent of the server's numeric school ID. IDs
inside each collection are nonblank strings, unique within that collection, and
local to this file. They can change on export. References must resolve within the
file. A dancer or coach can be referenced by multiple groups. A group can appear
in multiple day parts. A date/type combination can occur once per show, with at most six dayparts.
The optional `coachId` and coach `dancerId` references may be absent or null.
`preferredDayParts` defaults to two (one for a single daypart); `finalized`
defaults to false. Legacy version 2 files remain supported; version 1 is rejected.
Legacy `appearances` counts may be present but do not govern S18 scheduling.
Every imported schedule becomes a draft, even when exported as final.
`preferredDayParts` must be an integer from one to six; a retained value exceeding
the daypart count must be corrected before scheduling or finalization. Collections
may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`;
birth dates cannot be in the future. Ages are nonnegative integers with
`startAge <= endAge`. Enum values use the same uppercase names as the management
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`.
Unknown fields, duplicate JSON properties, duplicate IDs or membership references, invalid
values and unsupported versions are rejected. Validation is repeated on commit.
The entire school is prepared before publishing it under the shared service lock.
Replacing retains the numeric school ID for existing browser links; imported
child IDs are reassigned to avoid collisions with other schools.
Group types: `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`, `REGULAR`, `PRE_SCHOOLER`.
Styles: `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`, `CLASSIC`, `BREAKDANCE`.

View File

@@ -1,144 +0,0 @@
# S12 — Asynchronous feedback for scheduling
Status: IMPLEMENTED
Agreed after the grill-me interview; shared understanding confirmed.
## User story
As a dance-school maintainer, I want feedback after starting Schedule Groups,
so that I can see whether the backend is still calculating, when it finishes
and what the outcome is, including when I leave and return to the show page.
## Background execution and concurrency
- Start scheduling in the backend without keeping the initiating HTTP request
open until the calculation finishes. Navigation, page reloads and disconnected
clients must not stop the calculation.
- The backend owns the current scheduling status for each show. All open
screens see the same status, including screens that did not start the run.
- Allow at most one active calculation per show. Disable Schedule Groups while
it is running and reject duplicate starts in the backend as well.
- Different shows may calculate concurrently. A global capacity limit and a
waiting queue are outside S12.
- Retain the existing 30-minute solver limit and scheduling rules.
## Progress and outcomes
- While running, show elapsed time, the current optimization phase (attendance,
spread or variety), and whether a valid solution has been found.
- Report a valid solution as soon as the solver discovers one, including during
a phase. Extend solver progress reporting rather than waiting for the phase
to finish. Do not apply intermediate solutions.
- Do not present a completion percentage or estimated finish time: the phases
do not represent proportional parts of the calculation time.
- Keep the latest status and outcome per show until a new calculation starts.
Show the finish time and distinguish an optimal solution from a valid solution
whose optimality was not proven within the time limit.
- For a successful result, show a concise summary with concrete results for
attendance, spread and variety, using understandable labels.
- Clearly distinguish invalid input, infeasibility, solver failure, time limit
without a valid solution and rejection because inputs changed. Explain why
no result was applied and that existing assignments were preserved.
Backend-restart interruption recovery belongs to S16.
## Applying results
- Retain the confirmation before starting that explains replacement of existing
assignments. A successful completed calculation automatically replaces them;
no preview/apply step is introduced.
- A valid solution may be applied when the time limit expires even if optimality
has not been proven.
- Preserve the existing scheduling input revision check. A result based on
outdated inputs must not replace the current assignments.
- Failed, infeasible, interrupted, stale and no-solution calculations preserve
existing assignments.
- Report success only after the resulting assignments have been saved. Keep
status and saved results consistent.
## Server-sent events and connection recovery
- Use server-sent events (SSE) for backend-to-frontend status updates.
- On initial connection and reconnection, provide the current backend status.
Correctness must not depend on receiving every previous event.
- Maintain updates across navigation within the application so completion
notifications can appear outside the show page.
- If the connection is lost, indicate that displayed status may be outdated
while the backend calculation continues. Reconnect and reconcile with the
current status when connectivity returns.
- Configure the application's proxy for streaming so updates reach the client
promptly rather than being buffered until completion.
## Completion notifications
- Show an application-wide snackbar when a calculation completes or fails,
including when the user has navigated to another application page.
- Include the show name, a short outcome and a View show action.
- Phase changes update progress without generating snackbars.
- After temporary connection loss, show a missed completion notification once
when the connection recovers. Reconnection must not repeatedly notify for
the same completion.
- Do not replay old snackbars on page reload or reopening the application;
show the retained status on the show page instead.
- Email, browser notifications and notifications outside the application are
outside S12.
## Persistence and backend restarts
The maintainer explicitly transferred durable scheduling status and recovery
following backend restarts to [S16](../in_review/S16_store-state-in-persistent-storage)
when completing S12. The latest status and outcome in S12 survive navigation,
page reloads and SSE reconnection while the same backend process remains active.
Schools, assignments and scheduling status currently remain in memory.
S16 owns PostgreSQL storage of the latest run, atomic persistence of saved
assignments and completed status, and startup recovery of previously running
calculations as Interrupted without automatic resumption. Previously saved
results must survive restart under S16. These are not completion criteria for S12.
Distributed execution and scheduling on another PC remain in S13.
## Out of scope
- Cancelling a calculation or applying an intermediate solution.
- History of earlier calculations beyond the latest status and outcome.
- Global capacity limits, queues and remote workers.
- Changes to scheduling objectives or constraints.
- This specification grants no coding-standards waiver.
## Acceptance criteria
- [x] Starting Schedule Groups returns without waiting for solver completion;
leaving or reloading the page does not interrupt the calculation.
- [x] Opening a show in another screen recovers its current backend status;
duplicate runs for the same show are prevented in frontend and backend,
while different shows can run concurrently.
- [x] Running status shows elapsed time, phase and discovery of a valid solution
during a phase, without a fabricated completion percentage or ETA.
- [x] SSE delivers status changes promptly through the deployed proxy. Initial
connection and reconnection reconcile the current state, and loss of
connectivity visibly marks status as potentially outdated.
- [x] Successful runs automatically apply their result after the existing start
confirmation, retain stale-input protection and distinguish proven optimal
results from valid results at the time limit.
- [x] The final display includes finish time and concrete attendance, spread and
variety results when successful; unsuccessful outcomes are distinguishable
and explain that previous assignments were preserved.
- [x] An application-wide snackbar reports completion or failure with the show
name and View show action, including after navigation to another page.
- [x] Connection recovery notifies once for a missed completion; phase changes,
repeated reconnection and reopening do not replay old snackbars.
- [x] The latest status and outcome survive navigation, page reload and SSE
reconnection within the lifetime of the running backend. Durable storage
and recovery after backend restart are explicitly transferred to S16.
## Review handoff
Background execution, solver progress callbacks, SSE, frontend status recovery,
result summaries, proxy streaming configuration and application-wide completion
notifications are implemented and verified. S12 replaces S9's synchronous request
behavior while retaining scheduling and result-application protections.
See [S12 validation](../S12-validation.md) for verification and retained standards
signals. Durable storage and backend-restart recovery are tracked in S16 and do
not block review of this specification under the agreed scope adjustment.
Implementation is ready for review; final acceptance is pending.

View File

@@ -1,155 +0,0 @@
# S16 — Store state in persistent storage
Status: IMPLEMENTED
Agreed after the grill-me interview; shared understanding confirmed.
## User story
As a dance school maintainer, I want application data to be stored persistently,
so that confirmed changes and saved scheduling results survive server restarts
and application crashes.
## Storage decision
Use PostgreSQL with relational tables for schools, dancers, coaches, groups,
shows and their relationships. Use Flyway to version and apply database schema
changes. Use JPA with Hibernate ORM for application persistence; keep the mappings
inside the repository layer so the existing domain model remains framework independent.
The existing domain has shared relationships and operations on individual school
components. Related changes must be atomic, and multiple maintainers must be able
to work without silently overwriting each other's changes. These requirements
favor relational storage with transactions and referential constraints.
A document database was considered: a whole-school document would align with
JSON import/export. However, the agreed requirements provide no concrete benefit
for that approach over relational storage. The application owns a fixed domain
model; school-defined arbitrary fields and structures are not required.
JSON remains the import/export format, rather than the primary storage model.
## Scope and deployment
- Support multiple schools and concurrent maintainers.
- Target one production environment with one active backend instance.
- A separate database service and its operational management are acceptable.
- Multiple active backend instances, distribution across servers or regions, and
automatic resumption of calculations are outside S16.
- Keep the implementation within existing project architecture and coding
standards; this specification grants no standards waiver.
## Durability and consistency
- Confirm a mutation as successful only after its database transaction commits
durably. All confirmed domain changes and saved scheduling results survive an
application restart or crash.
- Persist schools and all their maintained domain data, relationships and saved
planning state, rather than only a subset of the existing in-memory state.
- Related mutations either succeed completely or leave the previous state intact.
This includes deleting a dancer and cleaning up memberships, deleting a group
and cleaning up show references, deleting a school and its children, and
replacing a school through import.
- Validate imports before publishing the replacement. Preserve the current
import/export behavior and identity semantics.
- Enforce valid relationships through database constraints alongside application
validation.
## Concurrent changes and scheduling
- Use one persistent revision per school for optimistic concurrency control.
- Mutations must identify the school revision on which they are based. Check
that revision and commit the change and new revision atomically.
- Reject stale mutations with a clear conflict message; do not silently overwrite
newer changes. The user can reload current data and decide again.
- Changes to different components of the same school may therefore conflict.
Finer-grained conflict detection is outside S16.
- Retain the scheduling input revision check: a result computed from an outdated
input revision must not replace current planning state.
- After restart, calculations interrupted by the restart must be recognizably
interrupted, rather than remain indefinitely marked as running. They do not
resume automatically. Previously saved results remain available.
## Persistent scheduling status (transferred from S12)
The maintainer explicitly moved durable scheduling status and backend-restart
recovery from S12 to S16 when completing S12. S12 supplies background execution,
status snapshots, SSE and notifications within the lifetime of the backend.
- Persist the latest calculation per show in PostgreSQL, including its run
identifier, school/show identity, start and finish timestamps, state, latest
phase, whether a valid solution was found, outcome explanation and final
attendance/spread/variety summary when available. A run history is not required.
- Persist acceptance of a run before acknowledging its start. After restart or
crash, an accepted calculation must remain identifiable even if it never
produced a result.
- Commit successful assignment replacement and its completed run status
atomically. Publish success through the status API and SSE only after the
transaction commits. A failed commit must not leave a successful status with
missing assignments, or replaced assignments with an outdated running status.
- On startup, mark calculations left Running by the previous backend instance
as Interrupted before serving their recovered status. Explain that no new
result was applied, retain previously saved assignments and allow a new run.
Do not resume calculations automatically or retain their old run-slot guards.
- Recover the latest status and outcome through S12's existing status endpoint
and initial/reconnection SSE snapshots. Preserve stable run identifiers so
reconnecting clients can deduplicate notifications; reopening the application
must not replay old completion snackbars.
- Verify recovery after both graceful restart and abrupt process termination,
including a calculation running at termination and a completed saved result.
## Database failures and schema changes
- Refuse mutations while the database is unavailable and provide a clear failure
message. Do not acknowledge successful storage or fall back to volatile writes.
- Connection and Flyway migration failures at startup must be clearly visible;
the application must not present itself as ready for normal use when required
database initialization has failed.
- Maintain the database schema through versioned Flyway migrations.
## Transition from in-memory storage
Start with an empty database. Existing data is transferred through the current
JSON export/import path; no separate in-memory migration mechanism is required.
Document that existing data must be exported before stopping the old application
for the transition, because its current state exists only in memory.
## Deferred work
Database backup and restore are outside S16 and will be specified separately in
`specs/S17_database-backup-and-restore`. That specification is intentionally empty
for now. S16 does not promise recovery after loss of the database or its volume.
## Acceptance criteria
- [x] PostgreSQL stores the complete maintained domain state and saved scheduling
results, with Flyway managing the relational schema.
- [x] Confirmed changes remain available after both a normal restart and an
application crash, using the same persistent database.
- [x] Failed related mutations and failed school replacement imports leave no
partial changes or dangling references.
- [x] Two edits based on the same school revision cannot silently overwrite one
another: the stale edit receives a clear conflict response, and the user
can reload and retry.
- [x] Scheduling results based on outdated input revisions are rejected.
- [x] The latest accepted scheduling run, its stable identifier, timestamps,
progress, outcome and objective summary survive normal restart and crash
and are returned through the status API and SSE snapshots.
- [x] Successful assignment replacement and completed run status commit
atomically; success is published only after durable commit.
- [x] Calculations left Running become Interrupted before recovered status is
served; previous assignments remain, no automatic resumption occurs and a
new run can start. Reopening does not replay old completion snackbars.
- [x] Database unavailability prevents successful mutation acknowledgements;
startup connection and migration failures are clearly reported.
- [x] An empty database can be initialized through Flyway and populated through
existing JSON import, preserving import/export behavior and identities.
- [x] Setup and transition instructions cover the database service, persistent
storage, connection configuration, migrations and export-before-transition.
## Implementation handoff
PostgreSQL/JPA/Hibernate storage, Flyway migrations, school revision checks, durable scheduling status and
restart recovery are implemented and verified. See [S16 validation](../S16-validation.md)
and [setup and transition instructions](../../docs/persistent-storage.md).
Implementation is ready for review; final acceptance is pending.

View File

@@ -1,145 +0,0 @@
# S18 — Scheduling dance groups across a show
Status: IMPLEMENTED
As a dance school manager,
I want OR-Tools to schedule groups and order performances using participation,
changing-time and variety rules,
so that the complete show is manageable for dancers and enjoyable for audiences.
Refinement confirmed by the maintainer on 2026-11-03. This specification records
the agreed design. Implementation and validation are complete and ready for review.
See [validation evidence](../../docs/s18-validation.md) and
[the applied scheduling rules](../../show-scheduling-rules.md).
## Show scope and configuration
- A show consists of dayparts and can span multiple dates, typically two days.
All counts below apply across the entire show, not separately per date.
- Each show has one configurable integer `preferredDayParts`, between 1 and the number of
dayparts inclusive. Its default is 2, or 1 for a one-daypart show.
- This replaces the existing configurable appearance counts per group type.
- A show without dayparts can be stored but cannot be scheduled.
- If reducing the number of dayparts makes `preferredDayParts` invalid, require the user to
correct it before scheduling again; do not silently change it.
- Groups without dancers are excluded from scheduling and from all performance
counts and showcase-position requirements.
## Required group appearances
- Each nonempty REGULAR, SELECTION and PRE_EDUCATION group performs in exactly
`preferredDayParts` distinct dayparts, once within each selected daypart.
- Each nonempty PRE_SCHOOLER group performs exactly once in the complete show,
regardless of `preferredDayParts`. At most two such groups are expected. They can be assigned
freely to dayparts; no additional placement restriction applies.
- Each nonempty SHOW_CASE group performs exactly once in every daypart.
- With at least two showcase groups, each daypart starts with one showcase and
ends with another. Additional showcases can appear anywhere between them.
Opening and closing groups may differ between dayparts.
- With exactly one showcase group, it appears either first or last in each
daypart; the planner chooses. It does not perform twice in that daypart.
- With no showcase groups, there is no showcase-position requirement.
## Dancer participation and coaches
- A dancer without a showcase membership performs in at most `preferredDayParts` distinct
dayparts across the complete show. This is a hard constraint.
- A showcase participant necessarily performs in all dayparts. Their other
group performances may span more than `preferredDayParts` dayparts, but this is undesirable.
- For each non-coach showcase participant, count the distinct dayparts in which
they perform outside showcases. Their excess is `max(0, count - preferredDayParts)`.
Minimize the sum of these excesses as the first optimization priority.
- A person dancing in several groups must use the same dancer registration in
every group, including when accompanying preschoolers. Up to five group
memberships per dancer are expected.
- Explicitly link a coach registration to the corresponding dancer registration;
never infer identity from matching names. Support maintaining this link.
- Coaches are available in all dayparts and are excluded from both the hard
dancer limit and the excess penalty, including coaches in showcases.
- Being assigned as a group's coach does not automatically mean performing in
that group. Actual performances are determined by dancer membership only.
Coach availability never increases a group's required appearances.
- Dancing coaches remain subject to changing-time rules and contribute to the
group's average age just like other dancing members.
## Changing time and feasibility
- Between any two performances involving the same dancer within a daypart,
there must be at least two intervening performances not involving that dancer.
- This is mandatory for everyone, including coaches and showcase participants.
- The rule restarts at each daypart boundary. Assume enough changing time
between dayparts, including on the same date.
- All hard constraints apply together, even for unusual membership combinations
such as a person dancing in both preschool and ordinary groups. Do not relax
a rule to obtain a result.
## Balance and optimization priorities
- Count all scheduled performances, including preschool and showcase groups.
- The difference between the largest and smallest daypart performance counts
must not exceed two. This is a hard constraint.
- Prefer a difference of zero. Preschool groups are an expected cause of
differences but have no special exemption or exclusive right to cause them.
- Optimize valid schedules in this strict priority order:
1. Minimize the total excess for non-coach showcase participants defined above.
2. Minimize the difference between largest and smallest daypart counts.
3. Improve style and age variety between consecutive performances within
each daypart.
- A lower priority must not worsen a higher priority. Showcases participate in
variety scoring as well.
## Variety scoring
- For adjacent groups, the style penalty is the number of shared styles divided
by the number of distinct styles in their combined style sets. No shared
styles gives zero penalty; two empty style sets also give zero penalty.
- Compute dancer ages on the first date of the show. All dancers in a group,
including dancing coaches, contribute equally to its average age.
- The age penalty decreases linearly from 1 for equal average ages to 0 for a
difference of five years; differences greater than five years also score 0.
Equivalently: `max(0, 1 - abs(meanAgeA - meanAgeB) / 5)`.
- Style and age penalties have equal weight. Minimize their combined penalties
over adjacent performances. Empty groups have already been excluded.
## Solver execution and failure
- Retain a maximum solver runtime of thirty minutes.
- Accept a feasible result without proof of optimality, provided every hard
constraint holds. Distinguish feasibility from proven optimality.
- If no valid result is found, preserve the existing assignments and order.
- Explain whether infeasibility was proven or the time limit expired without
a valid result; do not report a timeout as proof of infeasibility.
- Retain existing protections against concurrent scheduling and applying stale
results after relevant data changes.
## Manual editing and schedule lifecycle
- Add UI and API support for moving performances between dayparts and changing
their order. Partial or temporarily invalid assignments can be saved as drafts.
- Distinguish draft and final schedules explicitly. Display concrete violations
for invalid drafts; do not present them as valid final schedules.
- Add an explicit finalization action that checks all hard rules and permits
finalization only when they all pass. Optimization preferences do not block it.
- Automatically generated valid schedules also start as drafts for manager review.
- Manual changes to a final schedule return it to draft. Changes to relevant
dancer memberships, coach identity links or show settings likewise invalidate
final status when they affect scheduling rules.
- Treat existing schedules as drafts when introducing this lifecycle.
- JSON import preserves assignments and order but makes imported schedules
drafts and validates them against the new rules.
- JSON export remains available for drafts and finals and preserves status.
Import still produces a draft even when the exported status was final.
- Include the new configuration and coach identity information in persistence
and school JSON round trips so scheduling semantics survive saving and import.
## Documentation and validation
- Provide `show-scheduling-rules.md` describing the rules applied by OR-Tools in
plain English, including hard constraints, exceptions, optimization priorities,
scoring, cross-date scope and failure behavior.
- Validate automatic results and finalization using the same agreed rules.
- Verification must cover ordinary, preschool and showcase appearances; coach
identity and exemptions; overlapping memberships; changing gaps; balance;
optimization priority and scoring; multi-date shows; empty groups; invalid
configuration; infeasible and timed-out runs; manual drafts/finalization; and
persistence/import/export of the new information.

View File

@@ -1,160 +0,0 @@
# S1 — School JSON import and export
Status: implemented
## User story
**As** a school maintainer,
**I want** to export and import my dance school as a JSON file,
**so that** I can store my school configuration locally while the application does
not offer persistent storage,
**and so that** I retain ownership of my data.
## Intent
Export downloads a complete snapshot of one dance school to a local JSON file.
Import restores that snapshot as a new school or replaces the corresponding
existing school after explicit confirmation.
## Scope
### In scope
- Exporting an existing school to a readable JSON file.
- Importing a school that does not exist or no longer exists.
- Replacing an existing school with the complete imported snapshot.
- Preserving all configured school data and relationships, including:
- School details and its persistent unique key.
- Dancers and their details.
- Dance groups, types, age ranges and styles.
- Dancer memberships, including dancers belonging to multiple groups.
- Shows, dates, day parts and any existing group assignments to day parts.
- Coaches and any existing links between coaches and groups.
- Full validation, an import preview, confirmation and clear error messages.
Preserving existing data does not add management screens for coaches or the
ability to assign groups to show day parts.
### Out of scope
- Merging an imported snapshot with an existing school.
- Persistent server-side storage.
The existing-school prompt includes a merge option, but selecting it only displays
that merging is not yet supported. It must not change any data. Implementing merge
behavior requires a separate specification.
## Identity and file format
- Every school has a persistent unique key that is included in exports and
preserved through imports and subsequent exports.
- Import identifies an existing school by that key, not by its name or a
runtime-generated numeric ID. The key remains meaningful after a server restart.
- A school with the same name but a different unique key is a separate school.
- A file contains one school and all data needed to restore it without accessing
another school or the original server state.
- The readable, documented JSON format includes a `formatVersion` field.
- Users may edit the JSON manually. Edited files undergo the same validation as
files produced by the application.
- Unknown fields and unsupported format versions are rejected explicitly; data
must not be silently ignored.
- Relationships in the file must be unambiguous and restored correctly. Internal
numeric IDs need not remain identical, provided school identity and all
relationships are preserved without collisions with other schools.
## Export flow
1. The maintainer opens an existing school's management screen.
2. The maintainer selects the JSON export option.
3. The application downloads a JSON file containing a consistent, complete
snapshot of that school.
4. Export does not change the school or any other application data.
## Import flow
1. The maintainer selects the JSON import option and chooses a local file.
2. The application validates the entire file before changing any stored data.
3. For a valid file, the application shows a preview containing the school name
and the numbers of dancers, dance groups and shows.
4. If the unique school key is unknown, the maintainer confirms adding the school
or cancels the import.
5. If the key already exists, the application identifies the school that would be
replaced and offers replace, merge or cancel:
- **Replace:** explicitly warn that the existing school is replaced completely.
After confirmation, replace all its configured data with the snapshot.
- **Merge:** show a message that this functionality is not yet supported and
leave all data unchanged.
- **Cancel:** leave all data unchanged.
6. A confirmed import is committed as one operation. The application must not
expose a partially imported or partially replaced school.
7. If validation or importing fails, show **"Import niet gelukt"** together with
the concrete cause. The user can correct the file and try again.
## Validation and failure behavior
- Validate JSON syntax, the supported file structure and format version, required
data, field values and all relationships before applying the import.
- Reject unknown fields and references to missing objects, including a group's
reference to a dancer absent from the snapshot.
- A failure must not create a partial school, partially replace an existing
school, or modify another school.
- A replacement must not remove the original school until the complete
replacement can succeed. If it fails, the original school remains intact.
- Error messages explain the cause specifically enough to correct the file.
For example: **"Import niet gelukt: groep Hiphop verwijst naar een onbekende
danser."**
## Acceptance criteria
### Export
1. Each existing school offers a JSON-file export that can be downloaded and
stored locally.
2. The export includes its persistent unique key, `formatVersion`, all configured
data listed in scope and all relationships between those objects.
3. Exporting leaves the application data unchanged.
4. A school with no dancers, groups or shows can be exported and imported.
### Adding a school
5. A valid file with an unknown school key shows the preview before any data is
added. Confirming creates the complete school; cancelling changes nothing.
6. An exported school can be restored after the original school is deleted or
the in-memory server state is lost.
7. An existing school with the same name but a different key is neither replaced
nor treated as the same school.
### Replacing a school
8. A valid file with an existing school key prompts the user to replace, merge or
cancel and clearly identifies the existing school.
9. Confirming replacement replaces the complete configuration. Objects and
relationships present only in the previous configuration are not retained as
part of the replaced school.
10. Other schools remain unchanged by replacement.
11. Selecting merge displays that merging is not yet supported and changes
nothing. Cancelling also changes nothing.
### Validation and errors
12. Malformed JSON, unsupported format versions, unknown fields, missing required
data, invalid field values and unresolved relationships cause the entire
import to fail.
13. Each failed import displays **"Import niet gelukt"** and a concrete cause.
14. A failed new-school import adds nothing. A failed replacement preserves the
entire existing school. Other schools are unaffected in either case.
15. After a failure, the user can select a corrected file and retry.
### Round-trip integrity
16. Exporting and importing a school preserves every configured field and
relationship, including a dancer's membership in multiple groups, shows and
their day parts, and any existing coaches or day-part group assignments.
17. Exporting the restored school again preserves its unique school key and
equivalent configuration, even if internal numeric IDs changed.
## Implementation handoff
This specification records agreed behavior. Backend endpoints, the detailed JSON
schema and frontend implementation remain implementation work; this review does
not implement the feature.

View File

@@ -1,225 +0,0 @@
# S2 — ESLint for the frontend
Status: IMPLEMENTED
Agreed after the grill-me review; shared understanding confirmed.
## User story
As a frontend maintainer, I want automated lint checks during development and in
CI, so that likely bugs and Angular/TypeScript problems are caught before changes
are accepted or application images are published.
I also want a fast local commit hook where feasible, without losing control of
which changes are included in my commit.
## Scope and boundary with S3
### In scope
- Install and configure compatible ESLint, TypeScript and Angular lint tooling.
- Apply the recommended JavaScript, TypeScript and Angular rules, including
recommended type-aware checks for TypeScript.
- Lint maintained frontend application code, external and inline Angular HTML
templates, unit tests, Playwright tests and applicable frontend configuration
files.
- Fix all existing violations of the agreed rules and verify that the application
still builds and its tests pass.
- Provide local check and autofix commands, mandatory pipeline enforcement,
editor support and documentation.
- Introduce a staged-file commit hook only if it meets the agreed performance
and change-preservation requirements.
### Out of scope
- Additional opinionated coding conventions or stricter rule sets beyond the
agreed recommended baseline. These belong in S3.
- Backend or Java CI-helper linting.
- Linting dependencies, generated output or generated files such as
`package-lock.json`.
- A new mandatory Prettier check in CI. Prettier remains the formatting tool;
this spec adds its local commit-hook use only when the timing budget permits.
## Existing environment
The reviewed frontend uses Angular 22.2.3, Angular CLI/build 22.2.4, TypeScript
6.0.3 and Prettier 3.9.10. TypeScript strict mode and Angular strict-template
checking are already enabled. Prettier configuration already exists.
No ESLint configuration, lint command or active Git commit hook was present at
review. The frontend package is in `agiledancers-fe/`, below the Git root.
Implementation must account for that layout when installing repository hooks.
Choose versions compatible with the installed Angular CLI and TypeScript. Use
supported configuration for those versions rather than copying incompatible
examples or changing framework versions merely to introduce linting.
## Lint commands and rule behavior
- `npm run lint`, executed from `agiledancers-fe/`, checks the entire maintained
frontend scope without changing files.
- A lint error or warning causes a nonzero exit status. A successful check means
there are no outstanding ESLint errors or warnings.
- `npm run lint:fix` is an explicit local command that applies supported ESLint
autofixes. Developers review its changes and resolve any remaining findings.
- CI uses the checking command only and must not repair, format, stage or commit
source changes itself.
- Keep formatting under the existing Prettier configuration. Do not introduce
conflicting ESLint formatting rules or redefine formatting conventions in S2.
- Narrow exceptions are permitted when a rule is demonstrably unsuitable. Each
suppression has a concrete explanation and the smallest applicable scope.
Do not broadly disable rules merely to avoid investigating existing findings.
- Start with a clean baseline: do not introduce a legacy-violation allowance,
warning backlog or ignored maintained files to make the initial check pass.
- Fixes preserve application behavior; builds and tests must still pass.
## Pipeline integration
Every branch pipeline must run `npm run lint` immediately after frontend
`npm ci`, before frontend tests, frontend builds and container end-to-end tests.
A failed lint check fails the pipeline and prevents image publication. Its
concrete findings must be visible in the job log and retained in the existing
pipeline diagnostics. The local hook never replaces the full pipeline check.
Integrate this into the existing Java-based pipeline orchestration. Repository
hook installation is skipped during CI and Docker package installation.
## Editor support
Recommend the ESLint extension in the existing VS Code project recommendations.
Do not require automatic fixing or formatting on save. Document how developers
can see findings while editing and run the same check as CI.
## Local commit hook
### File selection and tools
- Only process maintained frontend files staged for the current commit.
- Skip frontend checks for commits without applicable staged frontend changes.
- ESLint only checks; it does not autofix in the hook. Errors and warnings block
the commit if ESLint is part of the selected hook configuration.
- Prettier may format maintained frontend TypeScript, JavaScript, HTML, CSS and
JSON configuration files and stage the formatting result automatically.
- Exclude dependencies, build output and generated files such as
`package-lock.json`.
- If both tools are included, format with Prettier before checking the result
with ESLint. Do not run modifying operations concurrently on overlapping files.
### Preserve the intended commit
- Support partially staged files: format the version intended for the commit
while preserving unrelated unstaged changes.
- Automatically staging formatting must not include unrelated local edits or
change which unrelated files are committed.
- On any hook failure, including an ESLint failure after successful formatting,
abort the commit and restore the original staged and unstaged state.
- A conflict while reconciling formatting with unstaged edits also aborts the
commit and restores the original state.
- Report a concrete cause when the hook fails. Do not leave partial formatter,
staging or temporary-state changes behind.
- Use tooling that supports this behavior with its state-preservation protections
enabled; the specific hook manager/staged-file runner is an implementation
choice.
### Three-second performance budget
The budget covers the entire additional hook duration, including file selection,
backup/state handling, tool startup, formatting/checking, restaging and cleanup.
It is a combined budget, not three seconds per tool.
Measure the actual complete hook on the existing development mini-PC for:
1. A single applicable staged frontend file.
2. A mixed commit containing applicable frontend file types.
3. All maintained frontend files staged together.
Use a newly started process and no tool cache for each measured execution,
including cold tool starts. Every measured execution must finish within three
seconds. Record the machine/environment, exact staged scope, commands and timing
results in the implementation evidence. Do not claim the budget based only on
warm cached executions or timings of an individual tool.
Choose the hook configuration from those measurements:
1. Include Prettier and ESLint if the combined complete hook meets the budget.
2. Otherwise omit ESLint from the hook and measure the complete Prettier-only
hook against the same budget and scenarios.
3. If Prettier-only also fails the budget, do not install an active commit hook.
Do not enforce the budget by killing a writing formatter mid-operation. It is an
acceptance criterion for selecting the hook configuration, not a hard runtime
interrupt that risks incomplete changes. The full type-aware ESLint check remains
mandatory in CI regardless of the selected local hook configuration.
### Installation and operation
If a hook configuration meets the budget, set it up automatically during local
`npm ci`. Skip installation in CI and Docker builds. Document the selected tools,
installation behavior and how a developer can disable the local hook.
If no hook configuration meets the budget, document that result and its timing
evidence. Local lint commands and mandatory pipeline enforcement still apply.
## Documentation
Add an explanation to the root `README.md` for both `npm run lint` and
`npm run lint:fix`, including:
- Run the commands from `agiledancers-fe/`.
- What each command checks or changes and the difference between checking and
autofixing.
- That errors and warnings fail the checking command, and autofix may leave
findings requiring manual changes.
- That the pipeline runs the check and blocks publication on failure.
- The selected hook behavior, its measured performance and any omitted checks.
- Automatic local installation, exclusion from CI/Docker installation, and local
opt-out instructions if a hook is installed.
- Prettier formatting/restaging and preservation of partially staged files,
including restoration on hook failure, when applicable.
The frontend README may link to the root explanation to keep instructions
consistent. Record the performance evidence with the implementation documentation.
## Acceptance criteria
- [x] Compatible tooling checks maintained application TypeScript/JavaScript,
Angular templates, unit tests, Playwright tests and applicable frontend
configuration using the agreed recommended and type-aware rules.
- [x] All existing violations are fixed; there is no legacy baseline allowance.
Frontend builds, unit tests and container end-to-end tests still pass.
- [x] `npm run lint` checks the full scope without modifications and fails on
either errors or warnings.
- [x] `npm run lint:fix` offers explicit local autofixes; CI does not modify source.
- [x] Formatting remains under Prettier, without conflicting ESLint rules.
- [x] Any necessary rule suppressions are narrow and concretely justified.
- [x] Every branch pipeline runs lint immediately after frontend `npm ci` and
before frontend tests/builds and container E2E; lint failure prevents
publication and leaves actionable diagnostic output.
- [x] VS Code recommends ESLint without mandatory fix/format-on-save settings.
- [x] Complete-hook measurements cover the agreed single-file, mixed-file and
all-maintained-files cases with fresh processes and no tool cache.
- [x] The measured three-second budget determines combined tools, Prettier-only
or no active hook; no writing formatter is forcibly interrupted to meet it.
- [x] If installed, the hook processes only applicable staged frontend files,
applies Prettier formatting before any ESLint check, and preserves unrelated
files and unstaged edits, including partially staged files.
- [x] A failed installed hook restores the original staged and unstaged state and
blocks the commit with a concrete explanation.
- [x] Hook installation is automatic for local `npm ci` when applicable, skipped
in CI/Docker, and has documented local opt-out instructions.
- [x] Root `README.md` explains `npm run lint`, `npm run lint:fix`, CI enforcement
and the selected hook behavior; measurement evidence is included.
## Implementation handoff
The agreed behavior has been implemented and is ready for review. See the
implementation evidence below.
## Implementation evidence
Implemented with ESLint flat configuration, type-aware TypeScript and Angular
template checks. The measured three-second budget selects a Prettier-only
Husky/lint-staged hook; ESLint remains mandatory in the Java-based pipeline.
See [validation and timings](../../docs/s2-validation.md) for scope, preservation
checks, local installation and successful builds/unit/container E2E tests.

Some files were not shown because too many files have changed in this diff Show More