Compare commits
29 Commits
v1.0.1
...
dc06353b15
| Author | SHA1 | Date | |
|---|---|---|---|
| dc06353b15 | |||
| 8ccffe7b2b | |||
| 4119733bc3 | |||
| 821bcece4f | |||
| 7beeba33ed | |||
| f809d124e3 | |||
| 369689747d | |||
| 8dffbdbd6b | |||
| f5ac671b65 | |||
| 583935ae5d | |||
| 3fa47582a3 | |||
| ab5fe02328 | |||
| 2a900982e4 | |||
| cf0e829346 | |||
| 5c86b425d5 | |||
| a472bd8d08 | |||
| 23cb4d408f | |||
| 7750819d73 | |||
| 77b7159f0a | |||
| c6a2b70919 | |||
| c6a495a93c | |||
| bd898e92d2 | |||
| d2d4713c51 | |||
| 8546f222fe | |||
| 3c3ca1949c | |||
| 7603b3339b | |||
| 98d53e9cb5 | |||
| 36f4b15516 | |||
| 0f1c5dcd92 |
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: agiledancers-mini-pc
|
runs-on: mini-pc
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
||||||
@@ -19,21 +19,23 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Verify pinned engineering resources
|
||||||
|
run: python3 scripts/engineering.py --check
|
||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
||||||
# container names and diagnostic artifacts, including reruns of one SHA.
|
# 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
|
- name: Build and test both images
|
||||||
run: java ci/Ci.java verify
|
run: java generated/ci/Ci.java verify
|
||||||
- name: Publish verified main images
|
- name: Publish verified main images
|
||||||
if: success() && gitea.ref == 'refs/heads/main'
|
if: success() && gitea.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
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
|
- name: Collect diagnostics and clean up test containers
|
||||||
if: always()
|
if: always()
|
||||||
run: java ci/Ci.java cleanup
|
run: java generated/ci/Ci.java cleanup
|
||||||
- name: Retain diagnostics for fourteen days
|
- name: Retain diagnostics for fourteen days
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
if: gitea.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main'
|
||||||
runs-on: agiledancers-mini-pc
|
runs-on: mini-pc
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
@@ -42,8 +42,10 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Verify pinned engineering resources
|
||||||
|
run: python3 scripts/engineering.py --check
|
||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
run: java ci/Ci.java attempt
|
run: java generated/ci/Ci.java attempt
|
||||||
- name: Release verified images — persistent PostgreSQL storage
|
- name: Release verified images — persistent PostgreSQL storage
|
||||||
env:
|
env:
|
||||||
RELEASE_MODE: ${{ inputs.mode }}
|
RELEASE_MODE: ${{ inputs.mode }}
|
||||||
@@ -57,7 +59,7 @@ jobs:
|
|||||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
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
|
- name: Retain deployment diagnostics for fourteen days
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
if: gitea.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main'
|
||||||
runs-on: agiledancers-mini-pc
|
runs-on: mini-pc
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
@@ -19,8 +19,10 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Verify pinned engineering resources
|
||||||
|
run: python3 scripts/engineering.py --check
|
||||||
- name: Identify workflow attempt
|
- 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
|
- name: Restore previous successful release — requires a compatible database schema
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
@@ -31,7 +33,7 @@ jobs:
|
|||||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
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
|
- name: Retain deployment diagnostics for fourteen days
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||||
|
|||||||
32
AGENTS.md
32
AGENTS.md
@@ -1,22 +1,26 @@
|
|||||||
# Project skills and specifications
|
# Shared skills and specifications
|
||||||
|
|
||||||
- Project skills are stored in `skills/`; specifications belong in `specs/`.
|
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
|
||||||
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
|
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
|
||||||
dedicated Skill tool is available, load the file directly.
|
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
|
||||||
- Use `grill-me` only when the user explicitly requests it, for example
|
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
|
||||||
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
|
Installing or mentioning a skill starts no interview.
|
||||||
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
|
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
|
||||||
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
|
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
|
||||||
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`.
|
|
||||||
|
|
||||||
# Backend changes and reviews
|
# Backend changes and reviews
|
||||||
|
|
||||||
- Before changing backend Java, read `CODING_STANDARDS.md`.
|
- 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.
|
`docs/backend-review.md`. Keep upstream skills unchanged.
|
||||||
- Obtain maintainer agreement before adding any standards waiver; record its
|
- Obtain maintainer agreement before adding any standards waiver; record its
|
||||||
narrow scope and reason alongside the affected code or configuration.
|
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.
|
||||||
|
|||||||
@@ -1,255 +1,11 @@
|
|||||||
# Backend coding standards
|
# Agile Dancers coding standards
|
||||||
|
|
||||||
This document is the authoritative policy for handwritten production and test Java
|
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
|
||||||
in `agiledancers-be`. Generated code and Java helpers outside that module are out
|
changing Java. Their backend-module placeholder means `agiledancers-be` here.
|
||||||
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
|
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
|
Validation evidence: [S7](docs/validation/S7-validation.md).
|
||||||
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.
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ FROM maven:3.9-eclipse-temurin-25 AS build
|
|||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY pom.xml ./
|
COPY pom.xml ./
|
||||||
COPY .mvn .mvn
|
COPY .mvn .mvn
|
||||||
|
COPY generated generated
|
||||||
COPY tooling tooling
|
COPY tooling tooling
|
||||||
COPY ci ci
|
COPY engineering.properties engineering.properties
|
||||||
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
||||||
COPY agiledancers-be/src agiledancers-be/src
|
COPY agiledancers-be/src agiledancers-be/src
|
||||||
# Source builds run the same PostgreSQL tests as local/CI verification.
|
# Source builds run the same PostgreSQL tests as local/CI verification.
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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
|
# agiledancers
|
||||||
|
|
||||||
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
|
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
|
Maven is the authoritative formatting path. The root build runs the actual
|
||||||
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
|
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
|
`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.
|
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
|
||||||
No OpenRewrite AutoFormat or additional refactoring recipes run.
|
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.
|
standalone Maven apply command as an external tool.
|
||||||
|
|
||||||
Verification and measurements are recorded in
|
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
|
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
|
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
|
||||||
prerequisites). Results are written under `target/`.
|
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
|
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
|
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
|
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
|
Existing-school mutations require `X-School-Revision` with the revision loaded by
|
||||||
|
|||||||
@@ -85,38 +85,38 @@
|
|||||||
</execution>
|
</execution>
|
||||||
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
|
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
|
||||||
<configuration><artifactItems>
|
<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.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.14</version><classifier>nodeps</classifier><destFileName>cli.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>
|
||||||
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
|
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
|
||||||
</execution></executions>
|
</execution></executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<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>
|
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<executable>${maven.home}/bin/mvn</executable>
|
<executable>${maven.home}/bin/mvn</executable>
|
||||||
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
|
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
|
||||||
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
|
<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>
|
<argument>test</argument><argument>exec:java</argument>
|
||||||
</arguments>
|
</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
<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>
|
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
|
||||||
</arguments></configuration>
|
</arguments></configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
|
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
|
||||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
<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>
|
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
|
||||||
</arguments></configuration>
|
</arguments></configuration>
|
||||||
</execution></executions>
|
</execution></executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<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>
|
<executions>
|
||||||
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
|
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
|
||||||
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
|
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
|
||||||
|
|||||||
1012
agiledancers-fe/package-lock.json
generated
1012
agiledancers-fe/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,19 +14,19 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "22.2.3",
|
"@angular/common": "22.2.4",
|
||||||
"@angular/compiler": "22.2.3",
|
"@angular/compiler": "22.2.4",
|
||||||
"@angular/core": "22.2.3",
|
"@angular/core": "22.2.4",
|
||||||
"@angular/forms": "22.2.3",
|
"@angular/forms": "22.2.4",
|
||||||
"@angular/platform-browser": "22.2.3",
|
"@angular/platform-browser": "22.2.4",
|
||||||
"@angular/router": "22.2.3",
|
"@angular/router": "22.2.4",
|
||||||
"rxjs": "7.8.2",
|
"rxjs": "7.8.2",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "22.2.4",
|
"@angular/build": "22.2.6",
|
||||||
"@angular/cli": "22.2.4",
|
"@angular/cli": "22.2.6",
|
||||||
"@angular/compiler-cli": "22.2.3",
|
"@angular/compiler-cli": "22.2.4",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.64.1",
|
"@playwright/test": "1.64.1",
|
||||||
"@types/node": "22.20.5",
|
"@types/node": "22.20.5",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -6,6 +6,6 @@ runner:
|
|||||||
timeout: 2h
|
timeout: 2h
|
||||||
shutdown_timeout: 2m
|
shutdown_timeout: 2m
|
||||||
labels:
|
labels:
|
||||||
- agiledancers-mini-pc:host
|
- mini-pc:host
|
||||||
cache:
|
cache:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
19
ci/runner/runner.service
Normal file
19
ci/runner/runner.service
Normal 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
7
deploy/health.sh
Normal 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
|
||||||
|
}
|
||||||
3
docs/S19-classification.md
Normal file
3
docs/S19-classification.md
Normal 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.
|
||||||
12
docs/S19-followup-validation.md
Normal file
12
docs/S19-followup-validation.md
Normal 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
36
docs/S19-validation.md
Normal 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.
|
||||||
@@ -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,
|
The backend module is agiledancers-be. The approved Group constructor waiver
|
||||||
appropriate record use, defensive copying of mutable record components, and
|
remains application-owned in tooling/standards/waivers.tsv. Assess retained
|
||||||
compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers
|
signals against the implementation evidence in docs/validation and the change
|
||||||
and framework/lifecycle exceptions to field finality. Confirm architecture and
|
description. S19 introduces no standards waiver.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ zoals `[skip ci]`; dit is expliciet toegestaan.
|
|||||||
## Java-bronbestanden uitvoeren
|
## Java-bronbestanden uitvoeren
|
||||||
|
|
||||||
De CI- en setuphulpmiddelen zijn Java 25-bronbestanden. Start ze direct met
|
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,
|
de benodigde bronbestanden in het geheugen. Er is geen aparte `javac`-stap,
|
||||||
Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt
|
Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt
|
||||||
alleen de JDK, inclusief de HTTP-client, process builder en bestandsloten.
|
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
|
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.
|
wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
|
||||||
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
|
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
|
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm
|
||||||
en een werkende Docker-daemon
|
en een werkende Docker-daemon
|
||||||
@@ -76,7 +76,7 @@ Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en
|
|||||||
backup via:
|
backup via:
|
||||||
|
|
||||||
```sh
|
```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
|
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
|
Gebruik de Docker Desktop-context of zet `DOCKER_HOST` expliciet. De installer
|
||||||
neemt de geselecteerde socket over in de gebruikersservice. Als de socket later
|
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. Bij Docker Desktop blijft de Desktop-VM eveneens nodig voor builds.
|
||||||
Runner en containers moeten de Synology en internetdownloads kunnen bereiken.
|
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 |
|
| 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.
|
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.
|
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
|
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
|
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.
|
bewaard. Vul die bestanden via een lokale editor of verborgen terminalinvoer.
|
||||||
Voer daarna uit met de werkelijke platformwaarde:
|
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_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
|
||||||
export REGISTRY_USER=wanjakrah
|
export REGISTRY_USER=wanjakrah
|
||||||
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
|
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
|
Het script stelt alleen de applicatie-instellingen, registrysecrets en platformwaarde
|
||||||
runnerregistratietoken naar
|
in. De gedeelde runnerregistratie wordt eenmalig beheerd vanuit engineering.
|
||||||
`~/.config/agiledancers-runner/registration-token` met mode `600`. Het toont geen
|
Applicatiespecifieke deploymentcredentials blijven onder ~/.config/agiledancers-runner/.
|
||||||
tokenwaarden. Voor handmatige inrichting kun je dat registratietoken uit de
|
|
||||||
repository-instellingen halen en in hetzelfde bestand plaatsen.
|
|
||||||
|
|
||||||
De bevestigde Gitea 1.25.4-configuratie heeft geen `[actions]`-sectie: de defaults
|
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
|
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
|
van workflowruns mag die periode niet inkorten. Artifactretentie geldt voor
|
||||||
testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijderd.
|
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
|
```sh
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
systemctl --user status mini-pc-runner
|
||||||
java ci/Ci.java install-runner --prepare-only
|
journalctl --user -u mini-pc-runner -f
|
||||||
# Na Dockerconfiguratie en het opslaan van het registratietoken:
|
systemctl --user restart mini-pc-runner
|
||||||
java ci/Ci.java install-runner
|
|
||||||
systemctl --user status agiledancers-runner
|
|
||||||
journalctl --user -u agiledancers-runner -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
De installer controleert tools, installeert checksum-gecontroleerde runner
|
Runnerstate staat onder ~/.local/share/mini-pc-runner/ en serviceconfiguratie onder
|
||||||
`v0.2.13`, neemt label `agiledancers-mini-pc:host` over en registreert de runner.
|
~/.config/mini-pc-runner/. De runner start samen met Docker Desktop na grafisch
|
||||||
Configuratie en `.runner`-credentials staan in
|
aanmelden, wacht maximaal 120 seconden op Docker en probeert een mislukte start
|
||||||
`~/.local/share/agiledancers-runner`, de service in
|
opnieuw. User lingering staat uit: vóór aanmelden of bij een uitgeschakelde mini-pc
|
||||||
`~/.config/systemd/user/agiledancers-runner.service` en de expliciete Java-, PATH-
|
is de runner niet beschikbaar. De oude agiledancers-runnerstate blijft voor herstel
|
||||||
en Docker-socketinstellingen in `~/.config/agiledancers-runner/environment`.
|
bewaard; applicatiespecifieke deploymenttokens en SSH-sleutels blijven op hun eigen paden.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Images en publicatie
|
## 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;
|
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
|
||||||
de pipeline start geen blijvende applicatie op de NAS.
|
de pipeline start geen blijvende applicatie op de NAS.
|
||||||
|
|
||||||
`java ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests.
|
`java generated/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 publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`)
|
||||||
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
|
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
|
||||||
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
|
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
|
||||||
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
|
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:
|
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
java ci/Ci.java test
|
java generated/ci/Ci.java test
|
||||||
```
|
```
|
||||||
|
|
||||||
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
||||||
@@ -246,7 +232,7 @@ Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
|||||||
```sh
|
```sh
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||||
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
|
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
|
||||||
java ci/Ci.java verify
|
java generated/ci/Ci.java verify
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operationele oplevering
|
## Operationele oplevering
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ sudo -n /usr/local/bin/docker compose version
|
|||||||
sudo -n /usr/local/bin/docker info --format '{{.OSType}}/{{.Architecture}}'
|
sudo -n /usr/local/bin/docker info --format '{{.OSType}}/{{.Architecture}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Als DSM geen `visudo` bevat, is `deploy/enable-docker.sh` het eenmalige alternatief.
|
Als DSM geen `visudo` bevat, is `generated/deploy/enable-docker.sh` het eenmalige alternatief.
|
||||||
Kopieer het naar de eigen NAS-thuismap en voer het daar met sudo uit. Het maakt
|
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
|
uitsluitend de bovengenoemde regel, controleert die door als deploymentaccount
|
||||||
Docker te starten en verwijdert een nieuwe regel als die controle faalt. Een
|
Docker te starten en verwijdert een nieuwe regel als die controle faalt. Een
|
||||||
afwijkende bestaande regel wordt niet overschreven. De implementatiesessie heeft
|
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:
|
`~/.ssh/known_hosts` en voer uit:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
java ci/Ci.java configure-deployment
|
java generated/ci/Ci.java configure-deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
Defaults zijn de bevestigde NAS/account/SSH-poort, repository
|
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:
|
controlescript uitvoeren met bestaande lockowner en gekozen release-directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cat deploy/health.sh generated/deploy/remote.sh | \
|
||||||
ssh -i <private-key-path> -p <ssh-port> <deploy-user>@<nas> \
|
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.
|
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.
|
van vóór S16 kan de database niet lezen. Backup/restore blijven S17-werk.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
java ci/Ci.java test
|
java generated/ci/Ci.java test
|
||||||
java ci/Ci.java release-test
|
java generated/ci/Ci.java release-test
|
||||||
sh -n deploy/remote.sh
|
sh -n generated/deploy/remote.sh
|
||||||
mvn -N -Dformat.mode=check exec:exec@format
|
mvn -N -Dformat.mode=check exec:exec@format
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# S18 implementation validation
|
||||||
|
|
||||||
Validated on 2026-11-03 against the confirmed S18 specification.
|
Validated on 2026-11-03 against the confirmed S18 specification.
|
||||||
|
|||||||
@@ -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
|
# S2 implementation evidence
|
||||||
|
|
||||||
Validated on the development mini-PC on 2026-10-29: AMD Ryzen 7 7730U (8 cores,
|
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
|
- All: all 52 maintained frontend files, including non-applicable files that the
|
||||||
hook must skip, plus a small TS preservation-test fixture; generated lockfile
|
hook must skip, plus a small TS preservation-test fixture; generated lockfile
|
||||||
excluded. Exact paths and raw measurements are in
|
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 |
|
| Candidate | Staged scope | Run 1 | Run 2 | Run 3 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
|||||||
@@ -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
|
# S4 verification evidence
|
||||||
|
|
||||||
Updated: 2026-10-29. S4 is **complete**. Main publication/pullback passed in run
|
Updated: 2026-10-29. S4 is **complete**. Main publication/pullback passed in run
|
||||||
|
|||||||
@@ -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
|
# S6 implementation and operational evidence
|
||||||
|
|
||||||
Status: IN_REVIEW. Production `v1.0.0` is running on the Synology at
|
Status: IN_REVIEW. Production `v1.0.0` is running on the Synology at
|
||||||
|
|||||||
@@ -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
|
# S12 implementation validation
|
||||||
|
|
||||||
## Delivered behavior
|
## Delivered behavior
|
||||||
@@ -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
|
# S16 implementation validation
|
||||||
|
|
||||||
## Delivered behavior
|
## Delivered behavior
|
||||||
@@ -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
|
# S7 implementation and validation evidence
|
||||||
|
|
||||||
Status: IN_REVIEW — implementation complete; awaiting project review.
|
Status: IN_REVIEW — implementation complete; awaiting project review.
|
||||||
@@ -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
|
# S8 implementation evidence
|
||||||
|
|
||||||
Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7
|
Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7
|
||||||
5
engineering.lock.json
Normal file
5
engineering.lock.json
Normal 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
35
engineering.properties
Normal 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
|
||||||
@@ -1,28 +1,32 @@
|
|||||||
# S7 — Backend coding standards
|
# Backend coding standards
|
||||||
|
|
||||||
Status: IMPLEMENTED
|
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
|
||||||
Shared understanding confirmed after the grill-me review.
|
handwritten Java helpers outside that module. Frontend conventions are owned by each consuming project.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Scope and authority
|
## Scope and authority
|
||||||
|
|
||||||
- Create root `CODING_STANDARDS.md` with the complete agreed backend rules.
|
- Cover handwritten production and test Java in `<backend-module>`. Exclude
|
||||||
Preserve any existing frontend instructions.
|
|
||||||
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
|
|
||||||
generated code. Dependency injection and production architecture rules apply
|
generated code. Dependency injection and production architecture rules apply
|
||||||
where relevant. Frontend and Java helpers outside the backend are out of scope.
|
where relevant. The Java conventions also cover Java helpers outside the backend; backend
|
||||||
- Use https://www.geeksforgeeks.org/java/coding-guidelines-in-java/ as source
|
architecture, metrics, coverage and automated checks remain backend-only.
|
||||||
material, not as a changing normative dependency. The local document is complete
|
Frontend code is out of scope.
|
||||||
and authoritative for the adopted rules.
|
- [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
|
- The project's pinned Palantir formatter wins formatting conflicts, including
|
||||||
comment layout. S8 remains responsible for formatter integration.
|
comment layout. S8 remains responsible for formatter integration.
|
||||||
- Do not adopt unrelated static-analysis rule bundles.
|
- 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
|
## Java conventions
|
||||||
|
|
||||||
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
|
- 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`
|
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
|
||||||
and `while` bodies require braces, including enhanced for and do/while bodies.
|
and `while` bodies require braces, including enhanced for and do/while bodies.
|
||||||
- No wildcard imports. Static imports are allowed only in test classes.
|
- No wildcard imports. Static imports are allowed only in test classes.
|
||||||
- Comments explain intent or constraints. Document public contracts with Javadoc
|
- Prefer `java.nio` APIs (including `java.nio.file` and `java.nio.charset`) over
|
||||||
where their meaning is not clear from the declaration; do not require boilerplate
|
equivalent `java.io` APIs. Use stream APIs where no suitable NIO equivalent
|
||||||
documentation for every accessor.
|
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:
|
- Block comments use the following layout, subject to Palantir formatting:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@@ -139,7 +153,7 @@ exactly at a blocking limit passes.
|
|||||||
repeating checks already enforced by tooling.
|
repeating checks already enforced by tooling.
|
||||||
- Keep upstream skill files unchanged. Put project-specific code-review guidance
|
- Keep upstream skill files unchanged. Put project-specific code-review guidance
|
||||||
in a companion file referenced by project instructions and used alongside
|
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
|
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
|
||||||
code or configuration. Agents need maintainer agreement before introducing one.
|
code or configuration. Agents need maintainer agreement before introducing one.
|
||||||
No blanket suppressions, exclusions of difficult files or weakened thresholds
|
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
|
S7's required implementation. Configuring Sonar in the pipeline remains out of
|
||||||
scope for S14. FindBugs/SpotBugs is not an additional required check suite.
|
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
|
## Implemented checks and manual review
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the
|
||||||
- [x] Agent and project code-review instructions enforce discovery and review of
|
resolved test dependency classpath. Cyclomatic complexity uses PMD's default
|
||||||
these standards without modifying upstream skills.
|
options: boolean paths count, assertions do not. Decisions include if/loops,
|
||||||
- [x] Pinned tools, configurations and targeted checks implement the exact agreed
|
catch, ternary and throw; non-default switch alternatives count individually,
|
||||||
metrics and identify manual-only rules.
|
including comma-separated labels. There is no aggregate class-complexity gate.
|
||||||
- [x] Verify and standalone checks report actionable blocking failures and review
|
Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch,
|
||||||
signals; CI prevents publication after a failure.
|
recursion and flow rules. The scoped visitors retain those algorithms while
|
||||||
- [x] Representative violations demonstrate that their checks fail, including
|
including lambdas in their enclosing executable and excluding nested/local type
|
||||||
relevant threshold boundaries and custom measurement semantics.
|
bodies, whose methods are measured independently. Compact record constructors
|
||||||
- [x] Existing handwritten backend production and test code passes all blocking
|
use the same visitors and start with cyclomatic complexity one.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Checkstyle and targeted checks enforce import/brace/naming/visibility rules,
|
||||||
checks, change agent instructions, refactor application code or establish current
|
`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including
|
||||||
coverage. Those are implementation work, with evidence required above.
|
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
|
Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric,
|
||||||
review signals and the sole approved waiver are recorded in
|
fully resolved executable signature (or class), exact retained value and the
|
||||||
`specs/S7-review-signals.txt` and `tooling/standards/waivers.tsv`.
|
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
|
## Commands and reports
|
||||||
eight-parameter Group constructor waiver in the implementation conversation.
|
|
||||||
REST/JSON contracts remain unchanged. Clean verify and browser/combined coverage
|
Run from the root with JDK 25:
|
||||||
checks pass. The implementation is ready for project review.
|
|
||||||
|
```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
5
generated/SOURCE.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"commit": "46df72f008113d0ed683a6c7344b1d7309564d84",
|
||||||
|
"repository": "http://192.168.2.28:3000/wanjakrah/engineering.git",
|
||||||
|
"tag": "v2.0.0"
|
||||||
|
}
|
||||||
@@ -3,14 +3,20 @@ import java.time.*;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
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 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 {
|
try {
|
||||||
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
|
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
|
||||||
if (args.length == 0) throw new IllegalArgumentException(usage());
|
if (args.length == 0) throw new IllegalArgumentException(usage());
|
||||||
var env = new HashMap<>(System.getenv());
|
|
||||||
Path root = Path.of("").toAbsolutePath();
|
Path root = Path.of("").toAbsolutePath();
|
||||||
|
var env = Recipe.environment(root, System.getenv());
|
||||||
var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75));
|
var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75));
|
||||||
switch (args[0]) {
|
switch (args[0]) {
|
||||||
case "verify", "publish", "cleanup" -> {
|
case "verify", "publish", "cleanup" -> {
|
||||||
@@ -30,9 +36,11 @@ public class Ci {
|
|||||||
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
}
|
}
|
||||||
case "configure-gitea" -> Setup.configureGitea(env);
|
case "configure-gitea" -> Setup.configureGitea(env);
|
||||||
|
case "configure-runner" -> Setup.configureRunner(env);
|
||||||
|
case "check-runner" -> Setup.checkRunner(env);
|
||||||
case "configure-docker" -> {
|
case "configure-docker" -> {
|
||||||
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
|
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" ->
|
case "install-runner" ->
|
||||||
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
|
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
|
||||||
@@ -56,7 +64,8 @@ public class Ci {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String usage() {
|
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";
|
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ class CiTests {
|
|||||||
static void test(String name, Test test) throws Exception {
|
static void test(String name, Test test) throws Exception {
|
||||||
test.run();
|
test.run();
|
||||||
count++;
|
count++;
|
||||||
System.out.println("PASS " + name);
|
IO.println("PASS " + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void equal(Object expected, Object actual) {
|
static void equal(Object expected, Object actual) {
|
||||||
@@ -32,6 +32,75 @@ class CiTests {
|
|||||||
|
|
||||||
static void run() throws Exception {
|
static void run() throws Exception {
|
||||||
count = 0;
|
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", () -> {
|
test("JSON preserves configuration values and escaped text", () -> {
|
||||||
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
|
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
|
||||||
Object data = Json.parse(json);
|
Object data = Json.parse(json);
|
||||||
@@ -49,14 +118,14 @@ class CiTests {
|
|||||||
Path file = dir.resolve("daemon.json");
|
Path file = dir.resolve("daemon.json");
|
||||||
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
|
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
|
||||||
Files.writeString(file, original);
|
Files.writeString(file, original);
|
||||||
Setup.configureDocker(file);
|
Setup.configureDocker(file, "registry.example:3000");
|
||||||
String first = Files.readString(file);
|
String first = Files.readString(file);
|
||||||
Setup.configureDocker(file);
|
Setup.configureDocker(file, "registry.example:3000");
|
||||||
equal(first, Files.readString(file));
|
equal(first, Files.readString(file));
|
||||||
equal(
|
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"));
|
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 {
|
} finally {
|
||||||
Pipeline.deleteTree(dir);
|
Pipeline.deleteTree(dir);
|
||||||
}
|
}
|
||||||
@@ -67,7 +136,7 @@ class CiTests {
|
|||||||
Path file = dir.resolve("daemon.json");
|
Path file = dir.resolve("daemon.json");
|
||||||
String original = "{\"insecure-registries\":\"invalid\"}";
|
String original = "{\"insecure-registries\":\"invalid\"}";
|
||||||
Files.writeString(file, original);
|
Files.writeString(file, original);
|
||||||
failure(() -> Setup.configureDocker(file));
|
failure(() -> Setup.configureDocker(file, "registry.example:3000"));
|
||||||
equal(original, Files.readString(file));
|
equal(original, Files.readString(file));
|
||||||
} finally {
|
} finally {
|
||||||
Pipeline.deleteTree(dir);
|
Pipeline.deleteTree(dir);
|
||||||
@@ -233,7 +302,7 @@ class CiTests {
|
|||||||
Pipeline.deleteTree(dir);
|
Pipeline.deleteTree(dir);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
System.out.println(count + " CI tests passed");
|
IO.println(count + " CI tests passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
static class Fixture implements AutoCloseable {
|
static class Fixture implements AutoCloseable {
|
||||||
@@ -253,17 +322,20 @@ class CiTests {
|
|||||||
"CI_REF",
|
"CI_REF",
|
||||||
"refs/heads/main",
|
"refs/heads/main",
|
||||||
"CI_REPOSITORY",
|
"CI_REPOSITORY",
|
||||||
"wanjakrah/agiledancers",
|
"owner/recipe-demo",
|
||||||
"TARGET_PLATFORM",
|
"TARGET_PLATFORM",
|
||||||
"linux/amd64",
|
"linux/amd64",
|
||||||
"GITEA_SERVER_URL",
|
"GITEA_SERVER_URL",
|
||||||
"http://192.168.2.28:3000",
|
"http://registry.example:3000",
|
||||||
"REGISTRY_USER",
|
"REGISTRY_USER",
|
||||||
"test",
|
"test",
|
||||||
"REGISTRY_TOKEN",
|
"REGISTRY_TOKEN",
|
||||||
"dummy-token",
|
"dummy-token",
|
||||||
"CI_PUBLISH_LOCK_DIR",
|
"CI_PUBLISH_LOCK_DIR",
|
||||||
root.resolve("lock").toString()));
|
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) {
|
pipeline = new Pipeline(root, env, fake) {
|
||||||
@Override
|
@Override
|
||||||
String mainHead() {
|
String mainHead() {
|
||||||
@@ -14,15 +14,15 @@ class Pipeline {
|
|||||||
|
|
||||||
Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
|
Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
|
||||||
this.root = root;
|
this.root = root;
|
||||||
this.env = new HashMap<>(environment);
|
this.env = Recipe.environment(root, environment);
|
||||||
this.commands = commands;
|
this.commands = commands;
|
||||||
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
|
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
|
||||||
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
|
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
|
||||||
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
|
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
|
||||||
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
|
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
|
||||||
runName = "agiledancers-" + id + "-" + attempt;
|
runName = Recipe.project(env) + "-" + id + "-" + attempt;
|
||||||
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
|
backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt;
|
||||||
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
frontendImage = Recipe.project(env) + "-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
||||||
artifacts = root.resolve("ci/artifacts");
|
artifacts = root.resolve("ci/artifacts");
|
||||||
Files.createDirectories(artifacts);
|
Files.createDirectories(artifacts);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ class Pipeline {
|
|||||||
void isolateDocker() throws Exception {
|
void isolateDocker() throws Exception {
|
||||||
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
||||||
env.remove("DOCKER_CONTEXT");
|
env.remove("DOCKER_CONTEXT");
|
||||||
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
|
isolatedConfig = Files.createTempDirectory(Recipe.project(env) + "-docker-");
|
||||||
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +98,15 @@ class Pipeline {
|
|||||||
isolateDocker();
|
isolateDocker();
|
||||||
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
||||||
run("java", "-version");
|
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");
|
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
|
||||||
CiTests.run();
|
CiTests.run();
|
||||||
ReleaseTests.run();
|
ReleaseTests.run();
|
||||||
Path frontend = root.resolve("agiledancers-fe");
|
Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", "."));
|
||||||
verifyFrontend();
|
if (Recipe.roles(env).contains("frontend")) verifyFrontend();
|
||||||
String platform = env.get("TARGET_PLATFORM");
|
String platform = env.get("TARGET_PLATFORM");
|
||||||
|
for (String role : Recipe.roles(env)) {
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
"build",
|
"build",
|
||||||
@@ -115,22 +117,15 @@ class Pipeline {
|
|||||||
"--label",
|
"--label",
|
||||||
"org.opencontainers.image.revision=" + commit,
|
"org.opencontainers.image.revision=" + commit,
|
||||||
"-t",
|
"-t",
|
||||||
backendImage,
|
role.equals("backend") ? backendImage : frontendImage,
|
||||||
".");
|
env.getOrDefault(
|
||||||
run(
|
role.toUpperCase() + "_CONTEXT",
|
||||||
"docker",
|
role.equals("backend") ? "." : env.get("FRONTEND_MODULE")));
|
||||||
"build",
|
}
|
||||||
"--platform",
|
if (Recipe.enabled(env, "BROWSER_TESTS")) {
|
||||||
platform,
|
|
||||||
"--target",
|
|
||||||
"prebuilt",
|
|
||||||
"--label",
|
|
||||||
"org.opencontainers.image.revision=" + commit,
|
|
||||||
"-t",
|
|
||||||
frontendImage,
|
|
||||||
"agiledancers-fe");
|
|
||||||
Files.writeString(artifacts.resolve("containers-started"), "");
|
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(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
@@ -142,18 +137,26 @@ class Pipeline {
|
|||||||
"--network-alias",
|
"--network-alias",
|
||||||
"database",
|
"database",
|
||||||
"--env",
|
"--env",
|
||||||
"POSTGRES_USER=agiledancers",
|
"POSTGRES_USER=" + env.get("DATABASE_USER"),
|
||||||
"--env",
|
"--env",
|
||||||
"POSTGRES_PASSWORD=ci-only",
|
"POSTGRES_PASSWORD=ci-only",
|
||||||
"--env",
|
"--env",
|
||||||
"POSTGRES_DB=agiledancers",
|
"POSTGRES_DB=" + env.get("DATABASE_NAME"),
|
||||||
"postgres:17.6");
|
"postgres:17.6");
|
||||||
for (int attempt = 0; attempt < 60; attempt++) {
|
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()
|
.code()
|
||||||
== 0) break;
|
== 0) break;
|
||||||
Thread.sleep(1000);
|
Thread.sleep(1000);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
@@ -161,7 +164,7 @@ class Pipeline {
|
|||||||
"--name",
|
"--name",
|
||||||
runName + "-backend",
|
runName + "-backend",
|
||||||
"--label",
|
"--label",
|
||||||
"agiledancers.ci.run=" + runName,
|
Recipe.project(env) + ".ci.run=" + runName,
|
||||||
"--platform",
|
"--platform",
|
||||||
platform,
|
platform,
|
||||||
"--network",
|
"--network",
|
||||||
@@ -173,13 +176,14 @@ class Pipeline {
|
|||||||
"--cpus",
|
"--cpus",
|
||||||
"2",
|
"2",
|
||||||
"--volume",
|
"--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",
|
"--env",
|
||||||
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
|
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
|
||||||
"--env",
|
"--env",
|
||||||
"DATABASE_URL=jdbc:postgresql://database:5432/agiledancers",
|
"DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"),
|
||||||
"--env",
|
"--env",
|
||||||
"DATABASE_USER=agiledancers",
|
"DATABASE_USER=" + env.get("DATABASE_USER"),
|
||||||
"--env",
|
"--env",
|
||||||
"DATABASE_PASSWORD=ci-only",
|
"DATABASE_PASSWORD=ci-only",
|
||||||
backendImage);
|
backendImage);
|
||||||
@@ -190,7 +194,7 @@ class Pipeline {
|
|||||||
"--name",
|
"--name",
|
||||||
runName + "-frontend",
|
runName + "-frontend",
|
||||||
"--label",
|
"--label",
|
||||||
"agiledancers.ci.run=" + runName,
|
Recipe.project(env) + ".ci.run=" + runName,
|
||||||
"--platform",
|
"--platform",
|
||||||
platform,
|
platform,
|
||||||
"--network",
|
"--network",
|
||||||
@@ -210,7 +214,7 @@ class Pipeline {
|
|||||||
"--name",
|
"--name",
|
||||||
runName + "-browser",
|
runName + "-browser",
|
||||||
"--label",
|
"--label",
|
||||||
"agiledancers.ci.run=" + runName,
|
Recipe.project(env) + ".ci.run=" + runName,
|
||||||
"--platform",
|
"--platform",
|
||||||
platform,
|
platform,
|
||||||
"--network",
|
"--network",
|
||||||
@@ -232,7 +236,7 @@ class Pipeline {
|
|||||||
frontend + ":/workspace",
|
frontend + ":/workspace",
|
||||||
"--workdir",
|
"--workdir",
|
||||||
"/workspace",
|
"/workspace",
|
||||||
"mcr.microsoft.com/playwright:v1.64.1-noble",
|
env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"),
|
||||||
"npm",
|
"npm",
|
||||||
"run",
|
"run",
|
||||||
"e2e");
|
"e2e");
|
||||||
@@ -245,7 +249,7 @@ class Pipeline {
|
|||||||
"--user",
|
"--user",
|
||||||
user,
|
user,
|
||||||
"--volume",
|
"--volume",
|
||||||
root.resolve("agiledancers-be/target") + ":/coverage",
|
root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage",
|
||||||
"eclipse-temurin:25-jre",
|
"eclipse-temurin:25-jre",
|
||||||
"java",
|
"java",
|
||||||
"-jar",
|
"-jar",
|
||||||
@@ -256,17 +260,18 @@ class Pipeline {
|
|||||||
"--destfile",
|
"--destfile",
|
||||||
"/coverage/playwright.exec");
|
"/coverage/playwright.exec");
|
||||||
check(
|
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");
|
"Playwright backend coverage is missing");
|
||||||
run(
|
run(
|
||||||
"mvn",
|
"mvn",
|
||||||
"-B",
|
"-B",
|
||||||
"-ntp",
|
"-ntp",
|
||||||
"-pl",
|
"-pl",
|
||||||
"agiledancers-be",
|
env.get("BACKEND_MODULE"),
|
||||||
"jacoco:merge@combined",
|
"jacoco:merge@combined",
|
||||||
"jacoco:report@combined-report",
|
"jacoco:report@combined-report",
|
||||||
"exec:exec@combined-coverage");
|
"exec:exec@combined-coverage");
|
||||||
|
}
|
||||||
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
failure = e;
|
failure = e;
|
||||||
@@ -289,23 +294,17 @@ class Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void verifyFrontend() throws Exception {
|
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", "ci"), frontend, env, null, true).checked();
|
||||||
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
|
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
|
||||||
.checked();
|
.checked();
|
||||||
commands.execute(
|
final Object configured = Json.parse(
|
||||||
List.of(
|
env.getOrDefault(
|
||||||
"npm",
|
"FRONTEND_TEST_COMMAND",
|
||||||
"test",
|
"[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]"));
|
||||||
"--",
|
final List<String> test =
|
||||||
"--reporters=default",
|
((List<?>) configured).stream().map(Object::toString).toList();
|
||||||
"--reporters=junit",
|
commands.execute(test, frontend, env, null, true).checked();
|
||||||
"--output-file=unit-results/junit.xml"),
|
|
||||||
frontend,
|
|
||||||
env,
|
|
||||||
null,
|
|
||||||
true)
|
|
||||||
.checked();
|
|
||||||
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
|
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
|
||||||
.checked();
|
.checked();
|
||||||
}
|
}
|
||||||
@@ -321,7 +320,7 @@ class Pipeline {
|
|||||||
"-q",
|
"-q",
|
||||||
"-O",
|
"-O",
|
||||||
"-",
|
"-",
|
||||||
"http://127.0.0.1:8080/api/dance-schools")
|
Setup.required(env, "CI_READINESS_URL"))
|
||||||
.code()
|
.code()
|
||||||
== 0) return;
|
== 0) return;
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
@@ -334,15 +333,15 @@ class Pipeline {
|
|||||||
commands.renew(Duration.ofMinutes(2));
|
commands.renew(Duration.ofMinutes(2));
|
||||||
List<String> failures = new ArrayList<>();
|
List<String> failures = new ArrayList<>();
|
||||||
for (String path : List.of(
|
for (String path : List.of(
|
||||||
"agiledancers-be/target/surefire-reports",
|
env.get("BACKEND_MODULE") + "/target/surefire-reports",
|
||||||
"agiledancers-be/target/standards",
|
env.get("BACKEND_MODULE") + "/target/standards",
|
||||||
"tooling/standards/target/surefire-reports",
|
"generated/tooling/standards/target/surefire-reports",
|
||||||
"agiledancers-be/target/site/jacoco",
|
env.get("BACKEND_MODULE") + "/target/site/jacoco",
|
||||||
"agiledancers-be/target/site/jacoco-combined",
|
env.get("BACKEND_MODULE") + "/target/site/jacoco-combined",
|
||||||
"agiledancers-be/target/failsafe-reports",
|
env.get("BACKEND_MODULE") + "/target/failsafe-reports",
|
||||||
"agiledancers-fe/unit-results",
|
env.get("FRONTEND_MODULE") + "/unit-results",
|
||||||
"agiledancers-fe/test-results",
|
env.get("FRONTEND_MODULE") + "/test-results",
|
||||||
"agiledancers-fe/playwright-report")) {
|
env.get("FRONTEND_MODULE") + "/playwright-report")) {
|
||||||
if (Files.isDirectory(root.resolve(path))) {
|
if (Files.isDirectory(root.resolve(path))) {
|
||||||
try {
|
try {
|
||||||
copyTree(root.resolve(path), artifacts.resolve(path));
|
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")) {
|
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))
|
if (Files.isRegularFile(source))
|
||||||
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ class Pipeline {
|
|||||||
|
|
||||||
void publish() throws Exception {
|
void publish() throws Exception {
|
||||||
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
@@ -404,7 +404,7 @@ class Pipeline {
|
|||||||
"CI_PUBLISH_LOCK_DIR",
|
"CI_PUBLISH_LOCK_DIR",
|
||||||
env.getOrDefault(
|
env.getOrDefault(
|
||||||
"XDG_STATE_HOME",
|
"XDG_STATE_HOME",
|
||||||
Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
|
Setup.home().resolve(".local/state").toString()) + "/" + Recipe.project(env) + "-ci"));
|
||||||
Files.createDirectories(lockDir);
|
Files.createDirectories(lockDir);
|
||||||
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
||||||
try (FileChannel channel = FileChannel.open(
|
try (FileChannel channel = FileChannel.open(
|
||||||
@@ -419,12 +419,13 @@ class Pipeline {
|
|||||||
token,
|
token,
|
||||||
true)
|
true)
|
||||||
.checked();
|
.checked();
|
||||||
String prefix = registry + "/" + owner + "/agiledancers-";
|
String prefix = registry + "/" + owner + "/" + Recipe.project(env) + "-";
|
||||||
ensureImage(prefix + "backend", backendImage);
|
for (String role : Recipe.roles(env)) {
|
||||||
ensureImage(prefix + "frontend", frontendImage);
|
ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage);
|
||||||
for (String role : List.of("backend", "frontend")) {
|
}
|
||||||
|
for (String role : Recipe.roles(env)) {
|
||||||
if (!commit.equals(mainHead())) {
|
if (!commit.equals(mainHead())) {
|
||||||
System.out.println("Newer main exists; moving tags unchanged");
|
IO.println("Newer main exists; moving tags unchanged");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String image = prefix + role;
|
String image = prefix + role;
|
||||||
@@ -461,7 +462,7 @@ class Pipeline {
|
|||||||
void ensureImage(String repository, String source) throws Exception {
|
void ensureImage(String repository, String source) throws Exception {
|
||||||
String ref = repository + ":" + commit;
|
String ref = repository + ":" + commit;
|
||||||
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
|
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).*")) {
|
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
|
||||||
run("docker", "tag", source, ref);
|
run("docker", "tag", source, ref);
|
||||||
run("docker", "push", ref);
|
run("docker", "push", ref);
|
||||||
61
generated/ci/Recipe.java
Normal file
61
generated/ci/Recipe.java
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ class Release {
|
|||||||
remote.call("preflight", null);
|
remote.call("preflight", null);
|
||||||
ReleaseState before = ReleaseState.parse(remote.call("state", null));
|
ReleaseState before = ReleaseState.parse(remote.call("state", null));
|
||||||
selected = select(before);
|
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.
|
// Validate the recovery pair before any replacement too.
|
||||||
if (before.current() != null) ensure(before.current());
|
if (before.current() != null) ensure(before.current());
|
||||||
if (mode.equals("new")) {
|
if (mode.equals("new")) {
|
||||||
@@ -108,7 +108,7 @@ class Release {
|
|||||||
record(outcome);
|
record(outcome);
|
||||||
remote.call("finish", null);
|
remote.call("finish", null);
|
||||||
pending = false;
|
pending = false;
|
||||||
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
IO.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
record(outcome);
|
record(outcome);
|
||||||
@@ -153,6 +153,7 @@ class Release {
|
|||||||
"A newer release has already succeeded; use explicit rollback for older versions");
|
"A newer release has already succeeded; use explicit rollback for older versions");
|
||||||
String build = api.verified(commit);
|
String build = api.verified(commit);
|
||||||
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
|
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());
|
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
|
||||||
return new ReleaseState.Deployment(
|
return new ReleaseState.Deployment(
|
||||||
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
|
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),
|
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
|
||||||
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
|
"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);
|
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 {
|
void ensure(ReleaseState.Deployment release) throws Exception {
|
||||||
@@ -183,6 +184,7 @@ class Release {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String digest(String response, String role) {
|
String digest(String response, String role) {
|
||||||
|
if (!Recipe.roles(env).contains(role.toLowerCase(java.util.Locale.ROOT))) return "-";
|
||||||
String value = response.lines()
|
String value = response.lines()
|
||||||
.filter(line -> line.startsWith("S6_" + role + "="))
|
.filter(line -> line.startsWith("S6_" + role + "="))
|
||||||
.map(line -> line.substring(("S6_" + role + "=").length()))
|
.map(line -> line.substring(("S6_" + role + "=").length()))
|
||||||
@@ -82,7 +82,7 @@ class ReleaseApi {
|
|||||||
"target",
|
"target",
|
||||||
commit,
|
commit,
|
||||||
"message",
|
"message",
|
||||||
"Agiledancers " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
|
"Release " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
|
||||||
Tag tag = tags().stream()
|
Tag tag = tags().stream()
|
||||||
.filter(t -> t.version().equals(version))
|
.filter(t -> t.version().equals(version))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
@@ -32,8 +32,14 @@ class ReleaseRemote implements AutoCloseable {
|
|||||||
&& Integer.parseInt(port) > 0
|
&& Integer.parseInt(port) > 0
|
||||||
&& Integer.parseInt(port) <= 65535,
|
&& Integer.parseInt(port) <= 65535,
|
||||||
"Invalid deployment SSH host, user or port");
|
"Invalid deployment SSH host, user or port");
|
||||||
script = Files.readString(root.resolve("deploy/remote.sh"));
|
script = "COMPOSE_PROJECT=" + quote(Setup.required(env, "COMPOSE_PROJECT")) + "\n"
|
||||||
credentials = Files.createTempDirectory("agiledancers-deploy-ssh-");
|
+ "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 {
|
try {
|
||||||
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
|
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
|
||||||
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
|
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
|
||||||
@@ -10,20 +10,12 @@ import java.util.Map;
|
|||||||
class ReleaseSetup {
|
class ReleaseSetup {
|
||||||
static void configure(Path root, Map<String, String> input) throws Exception {
|
static void configure(Path root, Map<String, String> input) throws Exception {
|
||||||
var env = new HashMap<>(input);
|
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_SSH_PORT", "22");
|
||||||
env.putIfAbsent("DEPLOY_DIRECTORY", "/volume1/docker/agiledancers-production");
|
Path config = Setup.home().resolve(".config/" + Recipe.project(env) + "-runner");
|
||||||
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");
|
|
||||||
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
|
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
|
||||||
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("s6-release-token").toString());
|
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("release-token").toString());
|
||||||
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("s6-pull-token").toString());
|
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("pull-token").toString());
|
||||||
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("s6-deploy-key").toString());
|
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("deploy-key").toString());
|
||||||
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
||||||
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
|
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
|
||||||
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_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]);
|
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
|
||||||
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
|
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
|
||||||
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
|
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
|
||||||
System.out.println(remote.executeScript(
|
IO.println(remote.executeScript(
|
||||||
Files.readString(root.resolve("deploy/provision.sh")),
|
Files.readString(root.resolve("generated/deploy/provision.sh")),
|
||||||
pull + "\n",
|
pull + "\n",
|
||||||
300,
|
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();
|
remote.acquire();
|
||||||
try {
|
try {
|
||||||
remote.call("preflight", null);
|
remote.call("preflight", null);
|
||||||
@@ -88,7 +86,7 @@ class ReleaseSetup {
|
|||||||
for (String variable :
|
for (String variable :
|
||||||
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
|
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
|
||||||
variable(api, admin, variable, env.get(variable));
|
variable(api, admin, variable, env.get(variable));
|
||||||
System.out.println(
|
IO.println(
|
||||||
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
|
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class ReleaseShellTests {
|
|||||||
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
|
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
|
||||||
String calls = Files.readString(f.directory.resolve("calls"));
|
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://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(" down"));
|
||||||
CiTests.equal(false, calls.contains("prune"));
|
CiTests.equal(false, calls.contains("prune"));
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,19 @@ class ReleaseShellTests {
|
|||||||
static class Fixture implements AutoCloseable {
|
static class Fixture implements AutoCloseable {
|
||||||
final Path directory = Files.createTempDirectory("nas-script-test-");
|
final Path directory = Files.createTempDirectory("nas-script-test-");
|
||||||
final Map<String, String> env = new HashMap<>(System.getenv());
|
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));
|
final Commands commands = new Commands(Duration.ofMinutes(1));
|
||||||
|
|
||||||
Fixture() throws Exception {
|
Fixture() throws Exception {
|
||||||
@@ -150,19 +162,19 @@ class ReleaseShellTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String backend() {
|
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() {
|
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 {
|
void prepare() throws Exception {
|
||||||
call("lock", null, "one").checked();
|
call("lock", null, "one").checked();
|
||||||
call("preflight", null, "one").checked();
|
call("preflight", null, "one").checked();
|
||||||
call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one")
|
call("stage", "services: {}\n", "one").checked();
|
||||||
.checked();
|
call("stage-health", health, "one").checked();
|
||||||
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-")
|
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/recipe-demo-")
|
||||||
.checked();
|
.checked();
|
||||||
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
|
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
|
||||||
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
|
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
|
||||||
@@ -9,8 +9,11 @@ record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment pre
|
|||||||
Deployment {
|
Deployment {
|
||||||
ReleaseApi.sha(sha);
|
ReleaseApi.sha(sha);
|
||||||
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
|
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})
|
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() {
|
Map<String, Object> json() {
|
||||||
@@ -14,7 +14,7 @@ class ReleaseTests {
|
|||||||
static void test(String name, CiTests.Test test) throws Exception {
|
static void test(String name, CiTests.Test test) throws Exception {
|
||||||
test.run();
|
test.run();
|
||||||
count++;
|
count++;
|
||||||
System.out.println("PASS release: " + name);
|
IO.println("PASS release: " + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, String> environment() {
|
static Map<String, String> environment() {
|
||||||
@@ -37,7 +37,7 @@ class ReleaseTests {
|
|||||||
"GITEA_SERVER_URL",
|
"GITEA_SERVER_URL",
|
||||||
"http://nas:3000",
|
"http://nas:3000",
|
||||||
"CI_REPOSITORY",
|
"CI_REPOSITORY",
|
||||||
"owner/agiledancers",
|
"owner/recipe-demo",
|
||||||
"RELEASE_TOKEN",
|
"RELEASE_TOKEN",
|
||||||
"test"));
|
"test"));
|
||||||
env.putAll(Map.of(
|
env.putAll(Map.of(
|
||||||
@@ -58,8 +58,8 @@ class ReleaseTests {
|
|||||||
return new ReleaseState.Deployment(
|
return new ReleaseState.Deployment(
|
||||||
ReleaseVersion.parse(version),
|
ReleaseVersion.parse(version),
|
||||||
sha,
|
sha,
|
||||||
"nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64),
|
"nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64),
|
||||||
"nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64),
|
"nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64),
|
||||||
directory,
|
directory,
|
||||||
"10");
|
"10");
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@ class ReleaseTests {
|
|||||||
apiTests();
|
apiTests();
|
||||||
shellTests();
|
shellTests();
|
||||||
ReleaseShellTests.run();
|
ReleaseShellTests.run();
|
||||||
System.out.println(count + " release behavior tests passed");
|
IO.println(count + " release behavior tests passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
static class FakeApi extends ReleaseApi {
|
static class FakeApi extends ReleaseApi {
|
||||||
@@ -353,8 +353,14 @@ class ReleaseTests {
|
|||||||
|
|
||||||
Fixture(String mode) throws Exception {
|
Fixture(String mode) throws Exception {
|
||||||
Files.createDirectories(root.resolve("deploy"));
|
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/compose.yaml"), "services: {}\n");
|
||||||
|
Files.writeString(root.resolve("deploy/health.sh"), "application_health() { true; }\n");
|
||||||
env.put("RELEASE_MODE", mode);
|
env.put("RELEASE_MODE", mode);
|
||||||
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
|
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
|
||||||
api = new FakeApi(env, events);
|
api = new FakeApi(env, events);
|
||||||
@@ -379,7 +385,7 @@ class ReleaseTests {
|
|||||||
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
|
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
|
||||||
try (var f = new Fixture()) {
|
try (var f = new Fixture()) {
|
||||||
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
|
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");
|
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
|
||||||
CiTests.failure(f.release::prefix);
|
CiTests.failure(f.release::prefix);
|
||||||
}
|
}
|
||||||
@@ -476,7 +482,7 @@ class ReleaseTests {
|
|||||||
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
|
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
|
||||||
Path directory = Files.createTempDirectory("release-shell-test-");
|
Path directory = Files.createTempDirectory("release-shell-test-");
|
||||||
try {
|
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 commands = new Commands(Duration.ofSeconds(30));
|
||||||
var env = environment();
|
var env = environment();
|
||||||
var first = commands.execute(
|
var first = commands.execute(
|
||||||
@@ -65,7 +65,7 @@ class Setup {
|
|||||||
Pipeline.check(
|
Pipeline.check(
|
||||||
Set.of("linux/amd64", "linux/arm64").contains(platform),
|
Set.of("linux/amd64", "linux/arm64").contains(platform),
|
||||||
"Set TARGET_PLATFORM from the verified Synology architecture");
|
"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, "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_USER", Map.of("data", user));
|
||||||
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
|
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
|
||||||
@@ -79,31 +79,98 @@ class Setup {
|
|||||||
exists = false;
|
exists = false;
|
||||||
}
|
}
|
||||||
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
|
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
|
||||||
Map<String, Object> response =
|
IO.println("Actions, project registry secrets and platform configured; using the shared runner.");
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
Map<String, Object> data =
|
||||||
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
|
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
|
||||||
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
|
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
|
||||||
if (!(entry instanceof List<?>))
|
if (!(entry instanceof List<?>))
|
||||||
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
|
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
|
||||||
List<Object> registries = new ArrayList<>((List<?>) entry);
|
List<Object> registries = new ArrayList<>((List<?>) entry);
|
||||||
if (registries.contains("192.168.2.28:3000")) {
|
if (registries.contains(registry)) {
|
||||||
System.out.println("Internal registry already configured; no change");
|
IO.println("Internal registry already configured; no change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
registries.add("192.168.2.28:3000");
|
registries.add(registry);
|
||||||
data.put("insecure-registries", registries);
|
data.put("insecure-registries", registries);
|
||||||
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
|
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
|
||||||
Files.createDirectories(parent);
|
Files.createDirectories(parent);
|
||||||
if (Files.exists(file)) {
|
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);
|
if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
}
|
}
|
||||||
Path temp = Files.createTempFile(parent, "daemon-", ".json");
|
Path temp = Files.createTempFile(parent, "daemon-", ".json");
|
||||||
@@ -114,7 +181,7 @@ class Setup {
|
|||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(temp);
|
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)
|
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")
|
System.getProperty("os.name").equals("Linux")
|
||||||
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
|
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
|
||||||
"Installer targets the existing Linux x86_64 mini-PC");
|
"Installer targets the existing Linux x86_64 mini-PC");
|
||||||
Path runnerDir = home().resolve(".local/share/agiledancers-runner"),
|
Path runnerDir = home().resolve(".local/share/" + runnerName(env) + "-runner"),
|
||||||
configDir = home().resolve(".config/agiledancers-runner");
|
configDir = home().resolve(".config/" + runnerName(env) + "-runner");
|
||||||
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
|
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
|
||||||
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
|
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);
|
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)
|
String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
|
||||||
.checked();
|
.checked();
|
||||||
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
|
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(
|
Files.copy(
|
||||||
root.resolve("ci/runner/agiledancers-runner.service"),
|
root.resolve("generated/ci/runner/WaitForDocker.java"),
|
||||||
units.resolve("agiledancers-runner.service"),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
Files.copy(
|
|
||||||
root.resolve("ci/runner/WaitForDocker.java"),
|
|
||||||
runnerDir.resolve("WaitForDocker.java"),
|
runnerDir.resolve("WaitForDocker.java"),
|
||||||
StandardCopyOption.REPLACE_EXISTING);
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
String dockerHost = env.get("DOCKER_HOST");
|
String dockerHost = env.get("DOCKER_HOST");
|
||||||
@@ -189,8 +259,8 @@ class Setup {
|
|||||||
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
|
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
|
||||||
+ "\n");
|
+ "\n");
|
||||||
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
|
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
|
||||||
Path overrides = units.resolve("agiledancers-runner.service.d");
|
Path overrides = units.resolve(runnerName(env) + "-runner.service.d");
|
||||||
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
|
Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
|
||||||
if (desktop) {
|
if (desktop) {
|
||||||
Files.createDirectories(overrides);
|
Files.createDirectories(overrides);
|
||||||
Files.copy(
|
Files.copy(
|
||||||
@@ -203,7 +273,7 @@ class Setup {
|
|||||||
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
|
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
|
||||||
.checked();
|
.checked();
|
||||||
if (prepareOnly) {
|
if (prepareOnly) {
|
||||||
System.out.println("Runner installed; registration and startup still required.");
|
IO.println("Runner installed; registration and startup still required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
||||||
@@ -219,11 +289,11 @@ class Setup {
|
|||||||
"register",
|
"register",
|
||||||
"--no-interactive",
|
"--no-interactive",
|
||||||
"--instance",
|
"--instance",
|
||||||
"http://192.168.2.28:3000",
|
required(env, "GITEA_SERVER_URL"),
|
||||||
"--name",
|
"--name",
|
||||||
"agiledancers-mini-pc",
|
runnerName(env),
|
||||||
"--labels",
|
"--labels",
|
||||||
"agiledancers-mini-pc:host",
|
env.getOrDefault("RUNNER_LABEL", "mini-pc") + ":host",
|
||||||
"--token",
|
"--token",
|
||||||
token),
|
token),
|
||||||
runnerDir,
|
runnerDir,
|
||||||
@@ -243,7 +313,7 @@ class Setup {
|
|||||||
.checked();
|
.checked();
|
||||||
// Replace any previous default.target link with the desktop-login target.
|
// Replace any previous default.target link with the desktop-login target.
|
||||||
commands.execute(
|
commands.execute(
|
||||||
List.of("systemctl", "--user", "disable", "agiledancers-runner.service"),
|
List.of("systemctl", "--user", "disable", runnerName(env) + "-runner.service"),
|
||||||
root,
|
root,
|
||||||
env,
|
env,
|
||||||
null,
|
null,
|
||||||
@@ -251,13 +321,13 @@ class Setup {
|
|||||||
.checked();
|
.checked();
|
||||||
}
|
}
|
||||||
commands.execute(
|
commands.execute(
|
||||||
List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"),
|
List.of("systemctl", "--user", "enable", "--now", runnerName(env) + "-runner.service"),
|
||||||
root,
|
root,
|
||||||
env,
|
env,
|
||||||
null,
|
null,
|
||||||
true)
|
true)
|
||||||
.checked();
|
.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) {
|
static Path executable(Map<String, String> env, String name) {
|
||||||
@@ -3,11 +3,18 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
|
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
|
||||||
class WaitForDocker {
|
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]);
|
long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]);
|
||||||
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
|
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
|
||||||
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
|
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) {
|
while (System.nanoTime() < deadline) {
|
||||||
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
|
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
|
||||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||||
@@ -17,7 +24,7 @@ class WaitForDocker {
|
|||||||
long remaining = Math.max(1, deadline - System.nanoTime());
|
long remaining = Math.max(1, deadline - System.nanoTime());
|
||||||
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
|
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
|
||||||
&& process.exitValue() == 0) {
|
&& process.exitValue() == 0) {
|
||||||
System.out.println("Docker is available; starting the Gitea runner.");
|
IO.println("Docker is available; starting the Gitea runner.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
# One-time DSM administrator action, run with sudo; does not restart any service.
|
# One-time DSM administrator action, run with sudo; does not restart any service.
|
||||||
set -eu
|
set -eu
|
||||||
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
|
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
|
||||||
account=agiledancers-deploy
|
account=${1:?Supply deployment account}
|
||||||
docker=/usr/local/bin/docker
|
docker=${2:?Supply absolute Docker executable}
|
||||||
target=/etc/sudoers.d/agiledancers-deploy
|
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"
|
rule="$account ALL=(root) NOPASSWD: $docker"
|
||||||
test -x "$docker" && id "$account" >/dev/null
|
test -x "$docker" && id "$account" >/dev/null
|
||||||
test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; }
|
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; }
|
test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; }
|
||||||
else
|
else
|
||||||
umask 077
|
umask 077
|
||||||
temporary=$(mktemp /etc/sudoers.d/agiledancers-deploy.XXXXXX)
|
temporary=$(mktemp /etc/sudoers.d/"$account".XXXXXX)
|
||||||
trap 'rm -f "$temporary"' EXIT
|
trap 'rm -f "$temporary"' EXIT
|
||||||
printf '%s\n' "$rule" > "$temporary"
|
printf '%s\n' "$rule" > "$temporary"
|
||||||
chmod 440 "$temporary"
|
chmod 440 "$temporary"
|
||||||
@@ -7,7 +7,7 @@ registry_user=$2
|
|||||||
registry=$3
|
registry=$3
|
||||||
bind=$4
|
bind=$4
|
||||||
port=$5
|
port=$5
|
||||||
docker=/usr/local/bin/docker
|
docker=${6:?Supply the Docker executable path}
|
||||||
test -x "$docker"
|
test -x "$docker"
|
||||||
sudo -n "$docker" compose version
|
sudo -n "$docker" compose version
|
||||||
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'
|
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'
|
||||||
@@ -7,7 +7,7 @@ action=$1
|
|||||||
root=$2
|
root=$2
|
||||||
owner=$3
|
owner=$3
|
||||||
shift 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 "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac
|
||||||
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
|
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
|
||||||
test -d "$root" || die 'Provision the deployment directory first'
|
test -d "$root" || die 'Provision the deployment directory first'
|
||||||
@@ -72,8 +72,13 @@ docker_cmd() {
|
|||||||
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||||
fi
|
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() {
|
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" "$@"
|
--env-file "$root/production.env" --env-file "$release/release.env" -f "$release/compose.yaml" "$@"
|
||||||
}
|
}
|
||||||
identity() {
|
identity() {
|
||||||
@@ -81,8 +86,8 @@ identity() {
|
|||||||
sha=$2
|
sha=$2
|
||||||
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|
||||||
|| die 'Image revision does not match selected SHA'
|
|| die 'Image revision does not match selected SHA'
|
||||||
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \
|
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "$TARGET_PLATFORM" \
|
||||||
|| die 'Image platform must be linux/amd64'
|
|| die 'Image platform differs from target'
|
||||||
}
|
}
|
||||||
use_release() {
|
use_release() {
|
||||||
case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac
|
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'
|
test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration'
|
||||||
}
|
}
|
||||||
health() {
|
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.
|
# 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")
|
id=$(compose ps -q "$role")
|
||||||
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
|
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
|
||||||
test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1
|
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.revision"}}' "$id")" = "$RELEASE_SHA" || return 1
|
||||||
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
|
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
|
||||||
done
|
done
|
||||||
docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1
|
if [ -f "$release/health.sh" ]; then . "$release/health.sh"; fi
|
||||||
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1
|
application_health
|
||||||
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$action" in
|
case "$action" in
|
||||||
preflight)
|
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 timeout >/dev/null || die 'timeout is required for bounded remote operations'
|
||||||
command -v curl >/dev/null || die 'curl is required'
|
command -v curl >/dev/null || die 'curl is required'
|
||||||
test -x "$DOCKER_BIN" || die 'Docker executable missing'
|
test -x "$DOCKER_BIN" || die 'Docker executable missing'
|
||||||
docker_cmd compose version >/dev/null
|
docker_cmd compose version >/dev/null
|
||||||
test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \
|
actual=$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')
|
||||||
|| test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \
|
case "$actual" in linux/x86_64) actual=linux/amd64;; linux/aarch64) actual=linux/arm64;; esac
|
||||||
|| die 'Synology Docker platform is not linux/amd64'
|
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'
|
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.
|
# Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication.
|
||||||
case "$BIND_IP" in
|
case "$BIND_IP" in
|
||||||
@@ -131,13 +136,18 @@ case "$action" in
|
|||||||
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
|
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
|
||||||
cat > "$release/compose.yaml"
|
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)
|
prepare)
|
||||||
version=$1; sha=$2; prefix=$3
|
version=$1; sha=$2; prefix=$3
|
||||||
backend=''; frontend=''
|
backend='-'; frontend='-'
|
||||||
for role in backend frontend; do
|
for role in $DEPLOY_ROLES; do
|
||||||
repository="$prefix$role"
|
repository="$prefix$role"
|
||||||
image="$repository:$sha"
|
image="$repository:$sha"
|
||||||
docker_cmd pull --platform linux/amd64 "$image" >&2
|
docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2
|
||||||
identity "$image" "$sha"
|
identity "$image" "$sha"
|
||||||
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
|
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
|
||||||
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
|
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
|
||||||
@@ -146,6 +156,8 @@ case "$action" in
|
|||||||
done
|
done
|
||||||
printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \
|
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"
|
"$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
|
compose config --quiet
|
||||||
printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend"
|
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" \
|
&& test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \
|
||||||
|| die 'Retained configuration differs from recorded successful release'
|
|| die 'Retained configuration differs from recorded successful release'
|
||||||
for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do
|
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"
|
identity "$image" "$RELEASE_SHA"
|
||||||
done
|
done
|
||||||
compose config --quiet
|
compose config --quiet
|
||||||
@@ -172,8 +185,10 @@ case "$action" in
|
|||||||
end=$(( $(date +%s) + health_seconds ))
|
end=$(( $(date +%s) + health_seconds ))
|
||||||
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
||||||
# Recreate application containers only; the database and volume survive releases.
|
# Recreate application containers only; the database and volume survive releases.
|
||||||
compose up -d --no-build --pull missing --timeout 20 database
|
if [ -n "${DEPENDENCY_SERVICES:-}" ]; then
|
||||||
compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend
|
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
|
while ! health; do
|
||||||
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
||||||
sleep 1
|
sleep 1
|
||||||
19
generated/docs/backend-review.md
Normal file
19
generated/docs/backend-review.md
Normal 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.
|
||||||
35
generated/tooling/coverage/Coverage.java
Normal file
35
generated/tooling/coverage/Coverage.java
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,13 @@ import org.openrewrite.text.*;
|
|||||||
|
|
||||||
/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
|
/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
|
||||||
public class Format {
|
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();
|
Path root = Path.of(args[0]).toAbsolutePath().normalize();
|
||||||
boolean check = switch (args[1]) {
|
boolean check = switch (args[1]) {
|
||||||
case "check" -> true;
|
case "check" -> true;
|
||||||
@@ -24,14 +30,19 @@ public class Format {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
String configuration = digest(Files.readString(root.resolve("pom.xml"))
|
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.version")
|
||||||
+ System.getProperty("java.class.path"));
|
+ System.getProperty("java.class.path"));
|
||||||
if (!configuration.equals(cache.getProperty("configuration"))) cache.clear();
|
if (!configuration.equals(cache.getProperty("configuration"))) cache.clear();
|
||||||
cache.setProperty("configuration", configuration);
|
cache.setProperty("configuration", configuration);
|
||||||
List<SourceFile> sources = new ArrayList<>();
|
List<SourceFile> sources = new ArrayList<>();
|
||||||
|
var settings = new Properties();
|
||||||
|
try (var input = Files.newInputStream(root.resolve("engineering.properties"))) {
|
||||||
|
settings.load(input);
|
||||||
|
}
|
||||||
for (String directory :
|
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))) {
|
try (var paths = Files.walk(root.resolve(directory))) {
|
||||||
for (Path path : paths.filter(p -> p.toString().endsWith(".java"))
|
for (Path path : paths.filter(p -> p.toString().endsWith(".java"))
|
||||||
.sorted()
|
.sorted()
|
||||||
@@ -59,7 +70,7 @@ public class Format {
|
|||||||
.getChangeset()
|
.getChangeset()
|
||||||
.getAllResults();
|
.getAllResults();
|
||||||
for (Result result : results) {
|
for (Result result : results) {
|
||||||
System.out.println((check ? "Needs formatting: " : "Formatted: ")
|
IO.println((check ? "Needs formatting: " : "Formatted: ")
|
||||||
+ result.getAfter().getSourcePath());
|
+ result.getAfter().getSourcePath());
|
||||||
}
|
}
|
||||||
if (check && !results.isEmpty()) {
|
if (check && !results.isEmpty()) {
|
||||||
@@ -91,8 +102,7 @@ public class Format {
|
|||||||
try (var output = Files.newOutputStream(cacheFile)) {
|
try (var output = Files.newOutputStream(cacheFile)) {
|
||||||
cache.store(output, "Successfully formatted content only");
|
cache.store(output, "Successfully formatted content only");
|
||||||
}
|
}
|
||||||
System.out.println(
|
IO.println("Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
|
||||||
"Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String digest(String value) throws Exception {
|
static String digest(String value) throws Exception {
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
<build><plugins>
|
<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-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.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>
|
</plugins></build>
|
||||||
</project>
|
</project>
|
||||||
596
generated/tooling/standards/src/main/java/Standards.java
Normal file
596
generated/tooling/standards/src/main/java/Standards.java
Normal 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 + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
372
generated/tooling/standards/src/test/java/StandardsTest.java
Normal file
372
generated/tooling/standards/src/test/java/StandardsTest.java
Normal 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
8
git-tools/ATTRIBUTION.md
Normal 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Matt Pocock
|
Copyright (c) 2026 Wanja Krah
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
49
git-tools/README.md
Normal file
49
git-tools/README.md
Normal 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
167
git-tools/pruneLocalBranches
Executable 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
git-tools/test-prune-local-branches.py
Normal file
52
git-tools/test-prune-local-branches.py
Normal 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)
|
||||||
21
pom.xml
21
pom.xml
@@ -87,7 +87,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
<version>3.6.3</version>
|
<version>3.6.4</version>
|
||||||
<inherited>false</inherited>
|
<inherited>false</inherited>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<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.util=ALL-UNNAMED</argument>
|
||||||
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</argument>
|
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</argument>
|
||||||
<argument>--class-path</argument><classpath/>
|
<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>${maven.multiModuleProjectDirectory}</argument>
|
||||||
<argument>${format.mode}</argument>
|
<argument>${format.mode}</argument>
|
||||||
</arguments>
|
</arguments>
|
||||||
@@ -187,4 +187,21 @@
|
|||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</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>
|
</project>
|
||||||
|
|||||||
129
renovate.json
Normal file
129
renovate.json
Normal 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
99
scripts/engineering.py
Normal 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()
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Code Review"
|
|
||||||
short_description: "Review a diff on standards and spec"
|
|
||||||
@@ -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".
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Grill Me"
|
|
||||||
short_description: "Sharpen a plan through interview"
|
|
||||||
policy:
|
|
||||||
allow_implicit_invocation: false
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Grilling"
|
|
||||||
short_description: "Stress-test thinking a round of questions at a time"
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- tested
|
|
||||||
- changelog
|
|
||||||
- documented
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# S4 — Build and publish pipelines in Gitea
|
|
||||||
|
|
||||||
Status: IMPLEMENTED
|
|
||||||
|
|
||||||
Agreed after the grill-me review; shared understanding confirmed.
|
|
||||||
|
|
||||||
## User story
|
|
||||||
|
|
||||||
As a maintainer, I want every branch push to build and test the application,
|
|
||||||
so that broken changes are detected and successful main-branch builds provide
|
|
||||||
identifiable Docker images in our own registry.
|
|
||||||
|
|
||||||
## Agreed environment and feasibility
|
|
||||||
|
|
||||||
- Gitea and its container registry run on the existing Synology.
|
|
||||||
- The existing development mini-PC runs the Gitea Actions runner, separately from
|
|
||||||
Gitea. No additional hardware is available or required.
|
|
||||||
- Gitea supports push-triggered Actions workflows and a runner on a separate
|
|
||||||
machine. Codex can implement the repository configuration and assist with the
|
|
||||||
installation and configuration required on the existing machines.
|
|
||||||
- Gitea was inspected as version 1.25.4. Match the runner and workflow setup to
|
|
||||||
the installed version rather than assuming newer documentation applies.
|
|
||||||
- The mini-PC was inspected as Linux x86_64 with a Docker client installed, but
|
|
||||||
without a reachable Docker daemon. Configuring a working daemon is part of S4.
|
|
||||||
- The current internal Gitea/registry endpoint is `http://192.168.2.28:3000`.
|
|
||||||
Use that endpoint over the trusted internal network and explicitly configure
|
|
||||||
Docker clients that need registry access to trust this particular HTTP registry.
|
|
||||||
Introducing HTTPS is outside S4.
|
|
||||||
- The runner and job containers need access to Gitea, the registry and dependency
|
|
||||||
download sources. The runner connects to Gitea; it needs no public incoming port.
|
|
||||||
- The mini-PC must be on, awake and running the runner. Jobs wait while it is
|
|
||||||
unavailable; S4 does not promise unattended availability when the PC is off.
|
|
||||||
|
|
||||||
References:
|
|
||||||
|
|
||||||
- [Gitea 1.25 Actions quickstart](https://docs.gitea.com/1.25/usage/actions/quickstart/)
|
|
||||||
- [Gitea 1.25 Actions design](https://docs.gitea.com/1.25/usage/actions/design/)
|
|
||||||
- [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Version-controlled Gitea Actions workflows and supporting build configuration.
|
|
||||||
- Enable Actions as needed at instance and repository level, configure Docker on
|
|
||||||
the mini-PC, and install, register and configure its runner.
|
|
||||||
- Build and test both backend and frontend for every push to every branch,
|
|
||||||
including newly created branches, except when the author intentionally uses a
|
|
||||||
supported skip marker such as `[skip ci]`. Separate tag and pull-request event workflows
|
|
||||||
are not required; branch pushes also check changes used in pull requests.
|
|
||||||
- Build two runnable Docker images: backend and frontend.
|
|
||||||
- Run end-to-end tests against those built containers before publication.
|
|
||||||
- Publish both images only from successful builds of the `main` branch to the
|
|
||||||
container registry on the Synology.
|
|
||||||
- Configure registry authentication and the necessary secrets outside Git.
|
|
||||||
- Preserve failure diagnostics and document operation and recovery.
|
|
||||||
- Demonstrate a real successful pipeline and images that can be pulled back.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Automatic deployment, starting application containers on the Synology, and OTAP.
|
|
||||||
Starting temporary containers for pipeline tests is in scope.
|
|
||||||
- New linting or static-analysis gates, such as ESLint or Sonar. Existing tests,
|
|
||||||
including architecture tests, remain mandatory.
|
|
||||||
- Merge or release policies, separate tag/release workflows, and automatic image
|
|
||||||
deletion or retention cleanup. Published images remain available for now.
|
|
||||||
- Multi-architecture images, HTTPS setup, new hardware or provisioning additional
|
|
||||||
infrastructure.
|
|
||||||
|
|
||||||
## Build and verification behavior
|
|
||||||
|
|
||||||
1. Every ordinary branch push runs backend and frontend builds and their existing
|
|
||||||
tests. Explicit Gitea-supported skip markers are an accepted maintainer-approved
|
|
||||||
opt-out.
|
|
||||||
2. Backend verification includes the existing unit, integration/system and
|
|
||||||
architecture tests. Current entry point: `mvn clean verify` using Java 25.
|
|
||||||
3. Frontend verification uses the lockfile (`npm ci`), unit tests (`npm test`) and
|
|
||||||
production build (`npm run build`) with a supported Node version.
|
|
||||||
4. Build both Docker images on every branch, including branches that cannot
|
|
||||||
publish. The current Dockerfile only covers the backend; frontend packaging
|
|
||||||
must be added as part of implementation.
|
|
||||||
5. The frontend image serves the production Angular build, supports direct SPA
|
|
||||||
route navigation, and forwards `/api` requests to the backend container.
|
|
||||||
Container addressing/configuration is an implementation choice.
|
|
||||||
6. Run the existing Playwright end-to-end suite with Chromium against the built
|
|
||||||
frontend/backend containers, through the frontend's API forwarding. Adapt the
|
|
||||||
existing development-server test configuration as necessary: the acceptance
|
|
||||||
run must exercise the actual images, not development servers.
|
|
||||||
7. Determine the intended server's CPU architecture during implementation. Build
|
|
||||||
for that single target architecture and run the end-to-end tests on that same
|
|
||||||
architecture. Do not infer the Synology's architecture from the mini-PC.
|
|
||||||
Any architecture mismatch must be resolved and tested on the existing hardware
|
|
||||||
before claiming acceptance.
|
|
||||||
8. A failed build or test fails the pipeline and prevents publication. Test
|
|
||||||
containers are temporary and are cleaned up after the run, including failure.
|
|
||||||
|
|
||||||
## Publication and image identity
|
|
||||||
|
|
||||||
- Publish separate backend and frontend images under the repository owner's
|
|
||||||
namespace in the existing Gitea container registry. Document the exact names.
|
|
||||||
- Feature branches must not publish application images.
|
|
||||||
- Both images from a main-branch commit use the full Git commit SHA as their tag.
|
|
||||||
This identifies the matching pair independently of moving tags.
|
|
||||||
- Once both commit images have been successfully published, reruns verify/reuse
|
|
||||||
them without overwriting either commit tag.
|
|
||||||
- Publication succeeds only when both images are published and can be pulled
|
|
||||||
back from the registry. A partial publication fails the pipeline; one image may
|
|
||||||
already exist. A rerun preserves that image and may publish the missing image.
|
|
||||||
- After both commit images are available, update the moving `main` tags only if
|
|
||||||
the commit is still the current main-branch head. Serialize/coordinate that
|
|
||||||
operation so an older, slower pipeline cannot move `main` backwards.
|
|
||||||
- The two moving tags are not an atomic pair. Use the same commit SHA on both
|
|
||||||
images when an exact matching pair is needed. A failure updating a moving tag
|
|
||||||
must be visible as a pipeline failure and recoverable through a rerun.
|
|
||||||
- No automatic deletion of published images is introduced in S4.
|
|
||||||
|
|
||||||
## Failure diagnostics and operation
|
|
||||||
|
|
||||||
- Failed pipelines retain available test reports, container logs, and Playwright
|
|
||||||
traces and screenshots for fourteen days. Collect available diagnostics even
|
|
||||||
when a preceding step fails; do not hide the original failure.
|
|
||||||
- Provide a short management guide covering:
|
|
||||||
- Docker/runner installation, registration, startup and required access.
|
|
||||||
- Gitea instance/repository Actions settings and registry configuration.
|
|
||||||
- Required credentials, permissions and secret names, without secret values.
|
|
||||||
- Which settings exist outside Git and how to reproduce them.
|
|
||||||
- Trigger behavior, mini-PC availability and queued jobs.
|
|
||||||
- Image names, tags and commands to pull a matching image pair.
|
|
||||||
- Finding diagnostics, rerunning failed jobs and completing partial publication.
|
|
||||||
- The target architecture and how it was established.
|
|
||||||
- Keep registry credentials and registration tokens out of repository files and
|
|
||||||
logs; use the relevant secret settings.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] A push to any branch, including a new branch, starts a pipeline, except for
|
|
||||||
an explicit supported skip marker chosen by the author.
|
|
||||||
- [x] Backend and frontend build and all existing tests, including architecture
|
|
||||||
and Playwright end-to-end tests, pass before publication is permitted.
|
|
||||||
- [x] Every branch builds both Docker images; end-to-end tests exercise those
|
|
||||||
images through the frontend and its `/api` forwarding.
|
|
||||||
- [x] A failing build or test fails the pipeline without publishing images.
|
|
||||||
- [x] A successful feature-branch pipeline publishes no application images.
|
|
||||||
- [x] A successful `main` pipeline publishes both images to the Synology registry
|
|
||||||
with the full commit SHA; both images can be pulled back.
|
|
||||||
- [x] Published commit images are preserved on rerun; partial publication can be
|
|
||||||
completed without overwriting an existing commit image.
|
|
||||||
- [x] Moving `main` tags are updated only after both commit images are available,
|
|
||||||
and an older pipeline cannot move them backwards.
|
|
||||||
- [x] Publication or pull-verification failure makes the pipeline fail, even if
|
|
||||||
some images have already been uploaded.
|
|
||||||
- [x] Available failure diagnostics are retained for fourteen days, and temporary
|
|
||||||
test containers are cleaned up on success and failure.
|
|
||||||
- [x] The runner operates on the existing mini-PC, with Gitea and the registry on
|
|
||||||
the existing Synology; no additional hardware is needed.
|
|
||||||
- [x] The single target architecture is established and the images are tested on
|
|
||||||
that architecture using the existing hardware.
|
|
||||||
- [x] A management guide records setup, external settings, secrets, image usage
|
|
||||||
and recovery, including the need to keep the mini-PC/runner available.
|
|
||||||
- [x] Actual pipeline runs demonstrate successful main-branch publication,
|
|
||||||
feature-branch checks without publication, and failure blocking publication.
|
|
||||||
|
|
||||||
## Implementation handoff
|
|
||||||
|
|
||||||
This specification captures the agreed design. It does not install the runner,
|
|
||||||
change server settings or implement the pipeline. S4 is complete only when the
|
|
||||||
configuration, documentation and operational acceptance evidence are delivered;
|
|
||||||
workflow files alone are insufficient.
|
|
||||||
|
|
||||||
|
|
||||||
## Implementation progress
|
|
||||||
|
|
||||||
Repository implementation and local Docker verification are available. See
|
|
||||||
[management instructions](../../docs/gitea-ci.md) and
|
|
||||||
[validation evidence](../../docs/s4-validation.md).
|
|
||||||
|
|
||||||
Backend verification (20 tests), frontend verification (14 tests), both runtime
|
|
||||||
image builds and both Playwright tests against the containers passed. Fifteen
|
|
||||||
Java CI publication/configuration tests pass. The runner binary and user-service
|
|
||||||
configuration are installed on the mini-PC.
|
|
||||||
|
|
||||||
The maintainer confirmed the Synology as `x86_64` / `linux/amd64`. Gitea secrets
|
|
||||||
and the target-platform variable are configured; the runner is registered and
|
|
||||||
online. Registry login succeeded. Real feature runs #1 and #3 passed, including
|
|
||||||
container E2E; deliberate failure run #2 blocked the build/publication path and
|
|
||||||
provided downloadable diagnostics.
|
|
||||||
|
|
||||||
PR #1 was subsequently merged by the maintainer. Main run #7 succeeded for
|
|
||||||
commit `2356a31aa612c8b0ce4b386a0a65da6589e23285`, publishing and pulling both
|
|
||||||
full-SHA images and both main aliases. Registry digests are recorded in the
|
|
||||||
validation evidence.
|
|
||||||
|
|
||||||
The final real-registry probes confirmed partial-publication recovery, immutable
|
|
||||||
commit-tag preservation on rerun and stale-main protection in isolated validation
|
|
||||||
repositories. Application manifests remained unchanged. The maintainer confirmed
|
|
||||||
that the Synology app.ini has no actions section, so Gitea 1.25.4's documented
|
|
||||||
90-day artifact and 365-day log defaults apply; the workflow requests fourteen
|
|
||||||
days for diagnostic artifacts. Explicit skip markers are accepted by the
|
|
||||||
maintainer. All acceptance criteria are met; see the validation evidence for
|
|
||||||
probe details, digests and the distinction from full Gitea workflow reruns.
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# S6 — Manual versioned production deployment through Gitea
|
|
||||||
|
|
||||||
Status: IMPLEMENTED
|
|
||||||
|
|
||||||
Agreed after the grill-me interview; shared understanding confirmed.
|
|
||||||
|
|
||||||
## User story
|
|
||||||
|
|
||||||
As a developer, I want to manually release a verified main-branch build from
|
|
||||||
Gitea to the Synology, choosing a semantic version increment, so that I control
|
|
||||||
when the launching partner receives a new production version and can recover
|
|
||||||
the previous working version when necessary.
|
|
||||||
|
|
||||||
This replaces the original automatic-deployment story. Build, test and image
|
|
||||||
publication remain automatic; production deployment requires a deliberate action.
|
|
||||||
|
|
||||||
## Existing foundation and scope
|
|
||||||
|
|
||||||
- Extend the implemented S4 pipeline, which verifies builds and publishes matching
|
|
||||||
frontend/backend images with immutable full commit SHA tags for successful main
|
|
||||||
builds. Deploy those existing images without rebuilding them.
|
|
||||||
- Target one production Docker Compose stack on the Synology (`linux/amd64`).
|
|
||||||
The existing Gitea runner triggers deployment over SSH.
|
|
||||||
- Keep one active backend. A brief restart interruption is acceptable, including
|
|
||||||
interruption of a scheduling calculation; the user may restart the calculation.
|
|
||||||
- Production is private to the LAN or existing VPN. A configurable Synology
|
|
||||||
hostname/IP and port provide access without an application login.
|
|
||||||
- Publish only the frontend port. Keep the backend reachable within the Compose
|
|
||||||
network; route browser API requests through the frontend proxy.
|
|
||||||
- Actual server addresses, ports, paths and account configuration must be
|
|
||||||
established and documented during implementation, without committing secrets.
|
|
||||||
|
|
||||||
## Manual release interface
|
|
||||||
|
|
||||||
Provide a separate **Deploy production** workflow using Gitea's native manual
|
|
||||||
workflow trigger (`workflow_dispatch`). Its Run workflow popup offers:
|
|
||||||
|
|
||||||
- **New release** or **Retry release** mode.
|
|
||||||
- For a new release: a release-type choice of **patch**, **minor** or **major**.
|
|
||||||
- For a retry: the existing release version tag.
|
|
||||||
|
|
||||||
Provide a manual rollback action for the previous successful production release.
|
|
||||||
The native Gitea button need not be renamed or embedded into an existing build
|
|
||||||
run. Gitea 1.25.4 supports the required modal and choice inputs.
|
|
||||||
|
|
||||||
### New release eligibility and ordering
|
|
||||||
|
|
||||||
- Run the release workflow from main. Branch builds cannot deploy production.
|
|
||||||
- Pin the current main SHA at workflow start. Require successful build checks and
|
|
||||||
image publication for that exact SHA; missing or failed verification leaves
|
|
||||||
production unchanged. Both images must be retrievable before replacement.
|
|
||||||
- Recheck main immediately before changing production. If it has advanced, abort
|
|
||||||
without changing production. Once replacement starts, finish deployment or
|
|
||||||
recovery using the pinned SHA; later commits require another manual release.
|
|
||||||
- Serialize deployment, recovery, rollback, version allocation and tag creation.
|
|
||||||
Prevent stale runs from replacing newer successful production releases.
|
|
||||||
- Reject a new release for an already release-tagged commit and direct the
|
|
||||||
operator to retry or rollback instead. Repeated clicks must not create extra
|
|
||||||
versions or deploy out of order.
|
|
||||||
|
|
||||||
## Semantic versioning and Git tags
|
|
||||||
|
|
||||||
- Use one shared application version for frontend and backend, represented by
|
|
||||||
an immutable annotated Git tag `vMAJOR.MINOR.PATCH` on the verified SHA.
|
|
||||||
- The first release is `v1.0.0`. Inspect remote tags before initialization and
|
|
||||||
fail visibly on conflicting existing history instead of overwriting it.
|
|
||||||
- Subsequent releases increment the highest existing release version tag,
|
|
||||||
including tags belonging to failed deployments. A rollback does not lower the
|
|
||||||
version baseline. Patch increments PATCH; minor increments MINOR and resets
|
|
||||||
PATCH; major increments MAJOR and resets MINOR and PATCH.
|
|
||||||
- For example, from `v1.2.3`, the choices produce `v1.2.4`, `v1.3.0` or `v2.0.0`.
|
|
||||||
- After checking eligibility and image availability, create the tag before
|
|
||||||
changing production. This establishes tag-writing access before replacement.
|
|
||||||
Never move a release tag or reuse its version for different code.
|
|
||||||
- Retain a tag if deployment subsequently fails or aborts. Record the outcome so
|
|
||||||
a tag is not mistaken for proof of successful production deployment.
|
|
||||||
- A retry reuses the existing tag and exact SHA without allocating a version.
|
|
||||||
Require successful build verification and matching available images. Retrying
|
|
||||||
an older release must not supersede a newer successful release. Retrying an
|
|
||||||
existing release is an explicit exception to the current-main requirement.
|
|
||||||
- Rollback restores an existing release and creates no new version. It is an
|
|
||||||
explicit exception to the prohibition on replacing newer code with older code.
|
|
||||||
- Continue using SHA-tagged container images; the Git release tag and deployment
|
|
||||||
record map the semantic version to that SHA and image pair.
|
|
||||||
- Maven/npm version fields and automated changelog generation are outside S6.
|
|
||||||
Do not introduce a version-bump commit and rebuild into the release path.
|
|
||||||
|
|
||||||
## Deployment checks and recovery
|
|
||||||
|
|
||||||
- Retrieve the candidate images and validate prerequisites before replacing the
|
|
||||||
running release. Frontend and backend must use the same selected commit SHA.
|
|
||||||
- Within five minutes of starting the replacement, require all of:
|
|
||||||
- Backend readiness succeeds.
|
|
||||||
- The frontend page is served.
|
|
||||||
- A read-only API request succeeds through the frontend proxy.
|
|
||||||
- Both running containers match the selected SHA/image pair.
|
|
||||||
- Checks must not create or modify user data. Log the selected SHA/version and
|
|
||||||
final outcome.
|
|
||||||
- A failed candidate automatically restores the previous successful image pair
|
|
||||||
and checks its health. The workflow remains failed even if recovery succeeds.
|
|
||||||
- Retain references to the current and previous successful releases and ensure
|
|
||||||
their images remain available. A manual rollback targets that previous release;
|
|
||||||
fail before modifying production if its required images cannot be retrieved.
|
|
||||||
- If the first deployment fails with no previous release, or recovery itself
|
|
||||||
fails, stop with a clear failure report and manual recovery instructions.
|
|
||||||
Preserve diagnostics; do not enter an endless deployment/recovery loop.
|
|
||||||
- Record successful production release state separately from allocated Git tags,
|
|
||||||
so failed attempts do not become the rollback target.
|
|
||||||
|
|
||||||
## Data policy and S16/S17 boundaries
|
|
||||||
|
|
||||||
S16 is **not a prerequisite** for S6. Until S16 is implemented, application state
|
|
||||||
is held in memory and may be lost during deployment, rollback or recovery. The
|
|
||||||
launching-partner phase explicitly accepts this limitation. Restoring old images
|
|
||||||
does not restore lost data.
|
|
||||||
|
|
||||||
- State the temporary data-loss limitation in the workflow description and
|
|
||||||
deployment runbook. The operator is responsible for informing the launching
|
|
||||||
partner; do not add a further approval step to each deployment.
|
|
||||||
- After S16 is implemented, deployments and rollbacks must preserve saved data.
|
|
||||||
Keep persistent storage outside replaceable containers and do not delete it
|
|
||||||
during deployment or recovery.
|
|
||||||
- After S16, ordinary release and rollback paths require database compatibility
|
|
||||||
with the previous application version. Incompatible migrations require a
|
|
||||||
separately planned maintenance procedure; image rollback cannot undo them.
|
|
||||||
- Database backup and restore remain separate S17 work. S6 does not promise
|
|
||||||
recovery of data lost before persistence or through storage loss.
|
|
||||||
|
|
||||||
## Credentials and operational documentation
|
|
||||||
|
|
||||||
- Use a dedicated Synology deployment account with SSH key authentication and a
|
|
||||||
pinned server host key. Document the narrowly required deployment permissions.
|
|
||||||
- Use a separate registry credential with pull access for production and a
|
|
||||||
credential with the required repository permission for release-tag creation.
|
|
||||||
- Store deployment secrets in Gitea Actions secrets, provision server-side
|
|
||||||
credentials securely where needed, and keep secret values out of Git and logs.
|
|
||||||
- Document initial provisioning, configuration, release/retry/rollback operation,
|
|
||||||
credential setup, health checks, release records and manual recovery.
|
|
||||||
- Retain deployment diagnostics for fourteen days, including failed deployment
|
|
||||||
and recovery outcomes, without exposing credentials.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Successful main builds still automatically verify and publish matching SHA
|
|
||||||
images; publishing does not automatically change production.
|
|
||||||
- [x] Gitea's manual workflow form provides new/retry mode and the appropriate
|
|
||||||
release-type or existing-tag inputs; invalid combinations fail clearly.
|
|
||||||
- [x] The first release and subsequent patch/minor/major releases allocate the
|
|
||||||
agreed versions and immutable annotated remote Git tags on verified SHAs.
|
|
||||||
- [x] Duplicate new releases, tag conflicts, concurrent attempts, stale main
|
|
||||||
snapshots and older pending deployments cannot overwrite release identity
|
|
||||||
or replace a newer successful release.
|
|
||||||
- [x] An unverified release or missing image is rejected before production changes;
|
|
||||||
branch builds cannot deploy production.
|
|
||||||
- [x] A real Synology deployment serves the frontend and proxied API over the
|
|
||||||
private endpoint, with both containers running the selected image pair.
|
|
||||||
- [x] Readiness, frontend, proxied read-only API and image-identity checks establish
|
|
||||||
success within five minutes or trigger recovery.
|
|
||||||
- [x] A deliberately failed release restores the previous successful version,
|
|
||||||
while the workflow reports failure. Failed first deployment and failed
|
|
||||||
recovery produce actionable diagnostics and manual recovery instructions.
|
|
||||||
- [x] A failed release can be retried with its existing tag/SHA without allocating
|
|
||||||
another version or superseding a newer successful release.
|
|
||||||
- [x] Manual rollback demonstrably restores the previous successful release with
|
|
||||||
no new version, and required retained images are available.
|
|
||||||
- [x] The temporary pre-S16 data-loss policy is visible in workflow documentation
|
|
||||||
and the runbook. Once S16 applies, demonstrate saved data surviving both
|
|
||||||
deployment and rollback, including the migration compatibility requirement.
|
|
||||||
The latter is conditional S16 acceptance and is not applicable yet.
|
|
||||||
- [x] Server setup and operational recovery documentation are usable; diagnostics
|
|
||||||
and release outcomes are retained for fourteen days without secrets.
|
|
||||||
|
|
||||||
## Implementation and evidence
|
|
||||||
|
|
||||||
This specification records the confirmed design. The evidence combines automated
|
|
||||||
behavior tests, real Gitea production release/guard runs and isolated real NAS
|
|
||||||
fault tests. Their distinct scopes and the virtual-tag boundary of the isolated
|
|
||||||
tests are documented in `docs/s6-validation.md`. Configuration alone does not
|
|
||||||
complete S6.
|
|
||||||
|
|
||||||
Implementation PR #12 is merged. Production `v1.0.0` runs at
|
|
||||||
`http://192.168.2.28:8088` following a successful main build and real manual Gitea
|
|
||||||
deployment. Native duplicate/branch guards and downloadable artifacts were
|
|
||||||
verified. Twelve isolated real NAS checks cover recovery, retry, distinct-version
|
|
||||||
rollback, failure guards and manual recovery; they use separate state/containers
|
|
||||||
and virtual test tags, without changing production. The test resources have been
|
|
||||||
removed. See `docs/s6-validation.md` for run links, digests, evidence boundaries
|
|
||||||
and the conditional S16 data-persistence acceptance. S6 is ready for review.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# S8 — Palantir formatting for Java
|
|
||||||
|
|
||||||
Status: IMPLEMENTED
|
|
||||||
|
|
||||||
Agreed after the grill-me review; shared understanding confirmed.
|
|
||||||
|
|
||||||
## User story
|
|
||||||
|
|
||||||
As a developer, I want maintained Java code formatted using Palantir Java Format
|
|
||||||
through the Maven workflow, so that local development and CI use consistent,
|
|
||||||
reproducible formatting without unnecessary build overhead.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Backend Java production code and Java tests.
|
|
||||||
- Maintained Java CI/source-launcher helpers under `ci/`, despite those files
|
|
||||||
being outside ordinary Maven source roots.
|
|
||||||
- Integration with local Maven builds, CI checking and standalone format/check
|
|
||||||
commands.
|
|
||||||
- A separate initial formatting commit, performance measurements and README
|
|
||||||
instructions.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Generated code and build output.
|
|
||||||
- Frontend formatting, which remains under Prettier.
|
|
||||||
- Additional coding conventions, naming rules, structural checks or refactorings.
|
|
||||||
Those belong in S7.
|
|
||||||
|
|
||||||
## Formatter and integration
|
|
||||||
|
|
||||||
- Use the **actual Palantir Java Format engine** and its Palantir style.
|
|
||||||
- OpenRewrite is the preferred integration route. S8 includes the custom
|
|
||||||
Palantir adapter/recipe and bootstrap required to make that route work.
|
|
||||||
- OpenRewrite's own `AutoFormat` is not an equivalent replacement for Palantir
|
|
||||||
and must not be substituted silently.
|
|
||||||
- Spotless is not the agreed default: the maintainer has experienced unacceptable
|
|
||||||
slowness with it. Do not choose it merely because a native integration exists.
|
|
||||||
- Pin the formatter, plugin and adapter dependencies to explicit versions in
|
|
||||||
Maven configuration. Local development and CI use the same pinned engine.
|
|
||||||
- Formatter/integration updates are reviewed through PRs and include any resulting
|
|
||||||
formatting changes. Do not implicitly resolve the newest formatter at build time.
|
|
||||||
- Include Palantir's import ordering and unused-import removal. Do not add
|
|
||||||
separate import policies or unrelated code transformations.
|
|
||||||
- Leave optional extra Javadoc reformatting disabled for the initial adoption.
|
|
||||||
|
|
||||||
## Maven behavior
|
|
||||||
|
|
||||||
### Local builds
|
|
||||||
|
|
||||||
Local builds apply formatting before compilation, so the compiled and tested
|
|
||||||
sources are the formatted sources. Early lifecycle integration means ordinary
|
|
||||||
`mvn compile`, `mvn test`, `mvn verify` and `mvn install` may format maintained
|
|
||||||
Java files.
|
|
||||||
|
|
||||||
Binding only to `install` does not satisfy this specification: the existing CI
|
|
||||||
verification uses `mvn clean verify` and would not reach that phase.
|
|
||||||
|
|
||||||
Provide standalone commands to:
|
|
||||||
|
|
||||||
- Apply formatting without performing the complete application build.
|
|
||||||
- Check formatting without modifying maintained sources or performing the complete
|
|
||||||
application build.
|
|
||||||
|
|
||||||
The concrete commands/profile names are implementation work and must be documented.
|
|
||||||
|
|
||||||
### CI
|
|
||||||
|
|
||||||
- CI runs the check-only mode before compilation and fails if formatting differs
|
|
||||||
from the committed source.
|
|
||||||
- CI must not repair or rewrite maintained Java source files automatically.
|
|
||||||
- Use the same pinned Palantir engine, configuration and file scope locally and
|
|
||||||
in CI; only apply versus check behavior differs.
|
|
||||||
- Integrate the checking invocation into the existing Java-based CI orchestration.
|
|
||||||
- Show actionable diagnostics identifying the files that need formatting and how
|
|
||||||
to apply the formatter locally.
|
|
||||||
- A formatting failure prevents successful verification and image publication.
|
|
||||||
Demonstrate that the check leaves maintained source files unchanged on failure.
|
|
||||||
|
|
||||||
## Clean-checkout bootstrap
|
|
||||||
|
|
||||||
Normal Maven commands from the repository root must work from a clean checkout
|
|
||||||
without first requiring a developer to compile or install a custom recipe artifact
|
|
||||||
by hand.
|
|
||||||
|
|
||||||
The custom recipe/adapter must be available before it is needed to format the
|
|
||||||
application sources. Placing it in ordinary backend source code and assuming that
|
|
||||||
it has already been compiled is not a valid bootstrap solution.
|
|
||||||
|
|
||||||
Prove bootstrap behavior with no previously installed project-specific recipe
|
|
||||||
artifact. Normal dependency resolution is allowed; any extra tooling module or
|
|
||||||
bootstrap must be included in the implementation and documentation.
|
|
||||||
|
|
||||||
## Correctness and Java 25
|
|
||||||
|
|
||||||
- Demonstrate support for every Java construct actually used in the maintained
|
|
||||||
backend, tests and CI helpers on the project's JDK 25.
|
|
||||||
- Running the formatter on JDK 25 is not, by itself, proof that its parser supports
|
|
||||||
every possible Java 25 construct. Record relevant limitations of the chosen
|
|
||||||
pinned version.
|
|
||||||
- An unprocessable file or formatter error fails the operation/build with its
|
|
||||||
path and a concrete cause. Do not silently skip it or exclude it merely to
|
|
||||||
obtain a successful build or faster measurement.
|
|
||||||
- The adapter must preserve source paths and encoding and avoid corrupting files.
|
|
||||||
- Formatting is deterministic and idempotent: applying it again produces no
|
|
||||||
further source differences. Check-only and apply mode agree on the result.
|
|
||||||
- Application behavior remains unchanged; builds and tests must pass after the
|
|
||||||
initial formatting and subsequent formatter changes.
|
|
||||||
|
|
||||||
## Initial adoption and commit separation
|
|
||||||
|
|
||||||
Apply the chosen formatting to all existing maintained Java files in scope.
|
|
||||||
Record this initial mass formatting in a separate **formatting-only commit**,
|
|
||||||
without functional changes. Keep integration/configuration changes separate so
|
|
||||||
reviewers can distinguish tooling changes from the mechanical source rewrite.
|
|
||||||
|
|
||||||
Native import ordering/removal is part of that formatting commit. Do not combine
|
|
||||||
it with unrelated cleanup or refactoring.
|
|
||||||
|
|
||||||
## Performance requirements
|
|
||||||
|
|
||||||
The integration must be measured rather than selected on an unverified claim that
|
|
||||||
one wrapper is faster than another.
|
|
||||||
|
|
||||||
- A repeated ordinary build with no Java-source changes may incur at most
|
|
||||||
**three seconds of additional formatting-related overhead**.
|
|
||||||
- Include the required integration/recipe/bootstrap overhead in that comparison;
|
|
||||||
do not report only time spent inside the Palantir formatting function.
|
|
||||||
- Compare otherwise equivalent builds with and without the formatting integration
|
|
||||||
under the same environment/cache conditions.
|
|
||||||
- Also measure a build after changing a maintained Java file and a `clean verify`
|
|
||||||
run. Record these timings; the agreed three-second threshold specifically
|
|
||||||
applies to the repeated unchanged-build case.
|
|
||||||
- Report one-time dependency downloads separately, rather than disguising ongoing
|
|
||||||
overhead as first-time setup or counting downloads as formatter execution.
|
|
||||||
- Record the machine, JDK/Maven/tool versions, commands, file scope, cache state
|
|
||||||
and results with the implementation evidence.
|
|
||||||
|
|
||||||
The time threshold is an acceptance criterion, not a hard interrupt that kills a
|
|
||||||
formatter while it is writing source files. If the selected integration cannot
|
|
||||||
meet the threshold, report the measurements and obtain agreement before choosing
|
|
||||||
an alternative integration or relaxing the bound. Do not silently skip formatting,
|
|
||||||
switch engines, choose Spotless or weaken the criterion.
|
|
||||||
|
|
||||||
## README and editor integration
|
|
||||||
|
|
||||||
Explain in the root `README.md`:
|
|
||||||
|
|
||||||
- The formatter, pinned versions/configuration and maintained file scope.
|
|
||||||
- The standalone apply and check commands.
|
|
||||||
- Which local Maven lifecycle commands can rewrite files and when that occurs.
|
|
||||||
- The CI check-only invocation and how to reproduce and resolve a failure locally.
|
|
||||||
- Clean-checkout bootstrap behavior and any included tooling artifact/module.
|
|
||||||
- Available supported IDE integration and how to keep its result consistent with
|
|
||||||
the project's pinned formatter.
|
|
||||||
- The performance evidence and any relevant Java 25 syntax limitations.
|
|
||||||
|
|
||||||
Maven is the authoritative formatting path regardless of editor. Do not require
|
|
||||||
format-on-save or an IDE plugin. An IDE integration must not redefine the agreed
|
|
||||||
style or silently take precedence over the Maven result.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The actual Palantir engine formats all maintained backend Java sources,
|
|
||||||
Java tests and `ci/*.java`; generated code is excluded.
|
|
||||||
- [x] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented
|
|
||||||
and reproducible from a clean checkout without manual recipe installation.
|
|
||||||
- [x] Plugin, formatter and adapter dependencies are pinned, with matching local
|
|
||||||
and CI configuration.
|
|
||||||
- [x] Local Maven builds apply formatting before compilation; `compile`, `test`,
|
|
||||||
`verify` and `install` reach the intended integration.
|
|
||||||
- [x] Standalone apply and check commands work without the full application build.
|
|
||||||
- [x] CI checks only, leaves maintained sources unchanged, reports formatting
|
|
||||||
differences and fails before image publication.
|
|
||||||
- [x] Import ordering and unused-import removal follow Palantir; optional extra
|
|
||||||
Javadoc reformatting remains disabled and no unrelated refactoring is added.
|
|
||||||
- [x] All currently used Java 25 syntax is handled, or causes an explicit failure
|
|
||||||
requiring resolution rather than silent exclusion.
|
|
||||||
- [x] Apply/check results agree and a second apply produces no differences.
|
|
||||||
- [x] Initial formatting is isolated in a formatting-only commit; application
|
|
||||||
builds and tests remain green.
|
|
||||||
- [x] Equivalent repeated unchanged-build measurements demonstrate at most three
|
|
||||||
seconds of additional integration overhead. Changed-file and clean-build
|
|
||||||
measurements and one-time downloads are reported separately.
|
|
||||||
- [x] Failure to meet the performance bound is reported before changing the route
|
|
||||||
or bound; a writing formatter is not killed to force compliance.
|
|
||||||
- [x] README instructions cover local and CI commands, bootstrap, optional IDE
|
|
||||||
use, authoritative Maven formatting and verification evidence.
|
|
||||||
|
|
||||||
## Implementation handoff
|
|
||||||
|
|
||||||
This specification records agreed behavior. It does not install a formatter,
|
|
||||||
create an adapter, change Maven/CI behavior or reformat source files. Recipe loading,
|
|
||||||
Java 25 compatibility and performance must be demonstrated during implementation;
|
|
||||||
there is no pre-existing project benchmark establishing the preferred route's
|
|
||||||
performance.
|
|
||||||
|
|
||||||
Implementation and measured acceptance evidence: [S8-formatting-evidence.md](../S8-formatting-evidence.md).
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# S9 — Schedule dance groups across show dayparts with OR-Tools
|
|
||||||
|
|
||||||
Status: IMPLEMENTED
|
|
||||||
|
|
||||||
Agreed after the grill-me review; shared understanding confirmed.
|
|
||||||
|
|
||||||
## User story
|
|
||||||
|
|
||||||
As a dance-school maintainer, I want to assign my school's dance groups to a
|
|
||||||
show's dated dayparts, using configurable appearance counts per group type,
|
|
||||||
so that appearances are spread evenly across the show.
|
|
||||||
|
|
||||||
## Show configuration
|
|
||||||
|
|
||||||
- A show can have one to six dayparts across multiple dates. Each daypart has a
|
|
||||||
date and a type: MORNING, AFTERNOON or EVENING. A date/type combination must be
|
|
||||||
unique within a show. For example, Friday afternoon, Saturday afternoon and
|
|
||||||
Saturday evening are three distinct dayparts.
|
|
||||||
- An unscheduled show may have no dayparts while being configured.
|
|
||||||
- Daypart dates are the source of truth. Remove the separate editable show date.
|
|
||||||
Display the range from the earliest to the latest daypart date, or
|
|
||||||
"No dates configured" when there are none.
|
|
||||||
- Each show stores an editable appearance count for every dance-group type,
|
|
||||||
including types with no current groups. Remove the backend
|
|
||||||
`DanceGroupType.dayPartsRequired` property.
|
|
||||||
- Initialize counts when creating the show using the following defaults, where
|
|
||||||
N is the configured number of dayparts:
|
|
||||||
|
|
||||||
| Type | Default appearances |
|
|
||||||
| --- | --- |
|
|
||||||
| PRE_SCHOOLER | 1 |
|
|
||||||
| SHOW_CASE | N |
|
|
||||||
| PRE_EDUCATION | N |
|
|
||||||
| All remaining types | min(2, N) |
|
|
||||||
|
|
||||||
- PRE_SCHOOLER's count is editable; one is a default, not a fixed exception.
|
|
||||||
- Both frontend and backend enforce integer counts greater than zero and no
|
|
||||||
greater than N for a configured show. With one daypart, all fields display one
|
|
||||||
and are disabled in the frontend.
|
|
||||||
- A show without dayparts remains unscheduled; usable counts must be initialized
|
|
||||||
when its initial daypart configuration becomes available.
|
|
||||||
- Subsequent daypart edits preserve the saved counts rather than reapplying
|
|
||||||
defaults. Block saving if a count exceeds the new number of dayparts until
|
|
||||||
the user corrects it. Adding a daypart does not automatically increase counts.
|
|
||||||
|
|
||||||
## Scheduling rules and objective
|
|
||||||
|
|
||||||
- Use Google OR-Tools in the backend.
|
|
||||||
- Include every current dance group belonging to the show's school.
|
|
||||||
- Each group must appear in exactly the number of distinct dayparts configured
|
|
||||||
for its type. A group cannot appear twice within one daypart.
|
|
||||||
- Among assignments satisfying those requirements, minimize the difference
|
|
||||||
between the largest and smallest number of group appearances per daypart.
|
|
||||||
Count every configured daypart, including an empty one.
|
|
||||||
- Assign groups to dayparts only. Do not calculate performance order or timing.
|
|
||||||
- Require at least one school group and one configured daypart. Disable the
|
|
||||||
scheduling action with an explanation when either is missing, and enforce
|
|
||||||
the same requirements in the backend.
|
|
||||||
|
|
||||||
## Scheduling action and result handling
|
|
||||||
|
|
||||||
- Provide an explicit "Schedule groups" action clearly indicating that it
|
|
||||||
replaces the current assignments. A separate preview/accept workflow is
|
|
||||||
outside this specification.
|
|
||||||
- Run scheduling synchronously, with a solver time limit of 30 minutes.
|
|
||||||
For S9, the frontend may remain blocked until the request returns; configure
|
|
||||||
the request path to accommodate the agreed duration.
|
|
||||||
- If a valid result is found, save it as a complete replacement of the existing
|
|
||||||
assignments. This includes a valid result whose optimality has not been proven
|
|
||||||
when the time limit is reached.
|
|
||||||
- Distinguish "optimal balance" from "valid result; optimal balance not proven"
|
|
||||||
in the result presented to the user.
|
|
||||||
- If validation fails, the problem is infeasible, the solver fails, or the limit
|
|
||||||
is reached without a valid result, explain the outcome and preserve existing
|
|
||||||
assignments. Never silently reduce required appearances.
|
|
||||||
- Disable the scheduling action while a run is pending. The backend must reject
|
|
||||||
another concurrent run for the same show.
|
|
||||||
- Before saving, verify that scheduling inputs have not changed during the run.
|
|
||||||
If they changed, reject the stale result, preserve existing assignments and
|
|
||||||
explain that the user must run scheduling again.
|
|
||||||
|
|
||||||
## Changes affecting existing schedules
|
|
||||||
|
|
||||||
- Before accepting a potentially invalidating edit, warn that an existing show
|
|
||||||
schedule may become invalid, name the affected shows and allow cancellation.
|
|
||||||
- Cover adding/deleting school groups, changing a group's type, changing show
|
|
||||||
dayparts and changing appearance counts when affected shows have assignments.
|
|
||||||
Name-only edits do not require this warning.
|
|
||||||
- After accepting the edit, do not automatically reschedule. Identify schedules
|
|
||||||
whose assignments no longer meet the appearance requirements and offer the
|
|
||||||
explicit scheduling action again.
|
|
||||||
- Newly added groups use their type's saved count on the next scheduling run.
|
|
||||||
|
|
||||||
## JSON import/export
|
|
||||||
|
|
||||||
- Preserve dated dayparts, group assignments and every per-type appearance
|
|
||||||
setting through JSON export/import.
|
|
||||||
- Update the format version and validation for the new structure, including
|
|
||||||
allowing the same daypart type on different dates.
|
|
||||||
- No legacy export compatibility or migration is required: the application is
|
|
||||||
still greenfield and no exports have been made.
|
|
||||||
|
|
||||||
## Optional repeatability
|
|
||||||
|
|
||||||
Identical inputs producing identical assignments is a nice-to-have for
|
|
||||||
testability, not a mandatory scheduling requirement. Spend at most three
|
|
||||||
minutes investigating how to achieve it. Implement it only if it requires at
|
|
||||||
most ten additional code statements; otherwise omit it. Changed inputs may
|
|
||||||
produce different assignments regardless.
|
|
||||||
|
|
||||||
## Out of scope and follow-up specifications
|
|
||||||
|
|
||||||
- Group exclusions: S10_allow-group-exclusion-for-show.
|
|
||||||
- Season context: S11_add-season-context-go-groups-and-shows.
|
|
||||||
- Asynchronous scheduling feedback: S12_support-async-feedback-for-scheduling.
|
|
||||||
- Scheduling on another PC: S13_create-runner-to-allow-scheduling-on-different-pc.
|
|
||||||
- Availability restrictions, pinned assignments, capacity limits, shared-dancer
|
|
||||||
constraints, duration/audience balancing and performance ordering.
|
|
||||||
|
|
||||||
S10–S13 are intentionally empty placeholders and are to be refined separately.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Configure up to six dated dayparts, reject duplicate date/type combinations
|
|
||||||
and derive the displayed show date range from them.
|
|
||||||
- [x] Persist editable per-type counts with the agreed defaults, remove the
|
|
||||||
backend type-level property and validate counts in frontend and backend.
|
|
||||||
- [x] With one daypart, every count is one and frontend fields are disabled.
|
|
||||||
- [x] Daypart edits preserve configured counts and require correction of counts
|
|
||||||
that exceed the new number of dayparts.
|
|
||||||
- [x] OR-Tools assigns every school group to exactly its type's required number
|
|
||||||
of distinct dayparts and minimizes the busiest/quietest appearance gap.
|
|
||||||
- [x] Scheduling requires groups and dayparts and replaces assignments only after
|
|
||||||
a successful, current result.
|
|
||||||
- [x] The synchronous request supports a 30-minute solver limit, accepts valid
|
|
||||||
unproven results and communicates whether optimality was proven.
|
|
||||||
- [x] Failed, infeasible, stale and no-result runs preserve existing assignments
|
|
||||||
and explain why no replacement was saved.
|
|
||||||
- [x] Duplicate concurrent runs for a show are prevented in frontend and backend.
|
|
||||||
- [x] Potentially invalidating edits warn beforehand, identify affected shows,
|
|
||||||
permit cancellation and expose invalid schedules afterward without
|
|
||||||
automatically rescheduling.
|
|
||||||
- [x] New-format JSON round-trips dates, assignments and all type settings;
|
|
||||||
legacy import support is not required.
|
|
||||||
- [x] Any repeatability work respects both the three-minute investigation limit
|
|
||||||
and the ten-additional-statement implementation limit.
|
|
||||||
|
|
||||||
## Implementation handoff
|
|
||||||
|
|
||||||
This specification records the agreed behavior implemented on the current branch.
|
|
||||||
The current application uses in-memory storage with JSON import/export for
|
|
||||||
transfer. S9 adds the scheduling model, frontend controls, backend operation and
|
|
||||||
JSON changes within that existing persistence approach.
|
|
||||||
|
|
||||||
## Implementation validation
|
|
||||||
|
|
||||||
- Backend: 29 tests, including real OR-Tools balancing, input validation, JSON
|
|
||||||
round-trips, concurrent scheduling, stale input rejection and failure preservation.
|
|
||||||
- Frontend: 20 tests covering count validation, disabled single-daypart fields, edit cancellation,
|
|
||||||
API timeout contract, production build and lint checks.
|
|
||||||
- Playwright: real REST workflow for dated dayparts, scheduling and cancellation.
|
|
||||||
- Repeatability uses one solver-parameter statement (single worker, fixed seed);
|
|
||||||
no extended investigation was needed.
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# S5 — Renovate bot in a container
|
|
||||||
|
|
||||||
Status: REFINED
|
|
||||||
|
|
||||||
Agreed after the grill-me review; shared understanding confirmed.
|
|
||||||
|
|
||||||
## User story
|
|
||||||
|
|
||||||
As a developer, I want a containerized Renovate bot to check the repositories on
|
|
||||||
our Gitea server and propose dependency updates through pull requests, so that
|
|
||||||
frontend, backend and build infrastructure dependencies remain maintainable and
|
|
||||||
we reduce risks from outdated versions.
|
|
||||||
|
|
||||||
I want external release age and project compatibility to constrain those
|
|
||||||
proposals. I retain control over merging and can upgrade dependencies manually
|
|
||||||
when I deliberately choose to act outside the bot's waiting period.
|
|
||||||
|
|
||||||
## Environment and scope
|
|
||||||
|
|
||||||
- Gitea and its container registry run on the existing Synology. Renovate runs in
|
|
||||||
a container on that same Synology; no additional hardware is required.
|
|
||||||
- The established Synology target platform is `linux/amd64`.
|
|
||||||
- Discover all repositories on this Gitea instance for which the dedicated bot
|
|
||||||
account has the necessary access. Support explicit repository exclusions.
|
|
||||||
- Before dependency PRs are proposed for a newly discovered repository, create an
|
|
||||||
onboarding/configuration PR. A maintainer must approve and merge that
|
|
||||||
configuration first.
|
|
||||||
- This project contains a `renovate.json` with its agreed compatibility and update
|
|
||||||
boundaries. Other onboarded repositories have their own reviewed configuration.
|
|
||||||
- Use a dedicated bot account/token with the access required for repository
|
|
||||||
discovery, branches, PRs, dashboards and dependency metadata lookup. Keep
|
|
||||||
credential values outside Git and logs.
|
|
||||||
|
|
||||||
### Dependency surfaces
|
|
||||||
|
|
||||||
Cover supported maintained dependency declarations, including:
|
|
||||||
|
|
||||||
- Frontend dependencies and development dependencies, compilers and test tools.
|
|
||||||
- Backend dependencies, Maven parents/BOMs, plugins and version properties.
|
|
||||||
- Docker base images and maintained container-image references.
|
|
||||||
- Pinned workflow actions, including version/digest references.
|
|
||||||
- The Renovate container's own version declaration.
|
|
||||||
- Related lockfile changes and separate lockfile maintenance.
|
|
||||||
|
|
||||||
Detection must include relevant maintained references outside ordinary manifests,
|
|
||||||
for example the Playwright image reference in the Java CI helper. Where related
|
|
||||||
references must match, update and validate them together. The Playwright package
|
|
||||||
and browser image must stay aligned.
|
|
||||||
|
|
||||||
One explicit manual boundary remains: the runner binary download version and its
|
|
||||||
binary SHA-256 checksum in the CI setup helper are maintained manually. Document
|
|
||||||
this boundary. Do not generate a partial runner update, leave an obsolete
|
|
||||||
checksum, or confuse a Git commit SHA with the binary's SHA-256.
|
|
||||||
|
|
||||||
Unsupported dependency references or missing required metadata must be visible as
|
|
||||||
limitations/blockers, rather than silently counted as successfully managed.
|
|
||||||
|
|
||||||
## Schedule and execution
|
|
||||||
|
|
||||||
- Run daily at **06:00 in `Europe/Amsterdam`**. The schedule/timezone are
|
|
||||||
configurable and the default is recorded in the deployment configuration.
|
|
||||||
- Provide the actual scheduler/container setup on the Synology; adding an image
|
|
||||||
declaration alone does not make the periodic execution operational.
|
|
||||||
- The Synology can perform dependency checks while the development mini-PC is
|
|
||||||
off. Project CI jobs still depend on their existing runner availability.
|
|
||||||
- The daily run can discover candidates, maintain eligible PRs and propose new
|
|
||||||
eligible updates within the agreed limits. It does not merge updates.
|
|
||||||
|
|
||||||
## Seven-day release-age policy
|
|
||||||
|
|
||||||
### Universal bot policy
|
|
||||||
|
|
||||||
- A release must be at least **seven days old** before Renovate proposes that
|
|
||||||
version in a dependency PR.
|
|
||||||
- Apply this to all bot-proposed dependency updates, including internal libraries,
|
|
||||||
security updates, infrastructure dependencies and the bot's own image update.
|
|
||||||
There is no internal-library or security-update exception.
|
|
||||||
- Apply the same check when an existing PR is updated to a newer version. An
|
|
||||||
existing eligible PR must not gain an ineligible new version through refresh.
|
|
||||||
- Missing or unreliable release timestamps block the affected proposal until its
|
|
||||||
age is demonstrable. Do not treat missing metadata as proof of sufficient age.
|
|
||||||
- Show the concrete blocker in the repository dashboard, with useful diagnostics.
|
|
||||||
A dependent/coherent update group cannot be partially proposed in an invalid
|
|
||||||
combination to work around an age blocker.
|
|
||||||
- Enforce the policy centrally across onboarded repositories. Repository-local
|
|
||||||
configuration, security-update defaults or bot retry/approval mechanisms must
|
|
||||||
not silently relax the waiting period or timestamp requirement.
|
|
||||||
- A maintainer may choose to upgrade earlier **manually**, outside Renovate. That
|
|
||||||
does not authorize Renovate to bypass its policy.
|
|
||||||
|
|
||||||
### Lockfiles and transitives
|
|
||||||
|
|
||||||
The waiting period also applies to newly introduced or changed transitive
|
|
||||||
versions in bot-generated lockfile changes. Determine those changes relative to
|
|
||||||
the repository base revision used by the proposal.
|
|
||||||
|
|
||||||
- Validate the age of those versions before the PR is created or updated.
|
|
||||||
- Unknown ages block the affected proposal, just as they do for direct updates.
|
|
||||||
- Existing unchanged versions in the base revision are not newly introduced by
|
|
||||||
the bot; the policy is not a requirement to rewrite all historical lock entries.
|
|
||||||
- Additional validation is part of S5 where Renovate's native behavior cannot
|
|
||||||
establish this guarantee. Do not assume a package-manager cutoff covers every
|
|
||||||
fallback path.
|
|
||||||
- A failing validator must actually prevent PR creation/update. A command that
|
|
||||||
fails but only adds an artifact error or failed status to an already-created
|
|
||||||
ineligible PR does not satisfy this requirement.
|
|
||||||
|
|
||||||
### Metadata limitations
|
|
||||||
|
|
||||||
Not every datasource/registry supplies usable release timestamps. This includes
|
|
||||||
some container-image sources. Such updates remain blocked and visible unless
|
|
||||||
reliable metadata support is implemented and validated; manual maintenance is
|
|
||||||
possible outside the bot.
|
|
||||||
|
|
||||||
For example, the npm release timestamp of Playwright is not proof of the release
|
|
||||||
age of its separately published container image. Both references must satisfy
|
|
||||||
applicable policy before their coordinated bot proposal is offered.
|
|
||||||
|
|
||||||
## Compatibility and upgrade boundaries
|
|
||||||
|
|
||||||
### Project-owned boundaries
|
|
||||||
|
|
||||||
- The official Angular compatibility table is the reference for Angular's Node.js,
|
|
||||||
TypeScript and RxJS compatibility: <https://angular.dev/reference/versions>.
|
|
||||||
- Project maintainers encode and maintain the applicable ranges in
|
|
||||||
`renovate.json`, including supported Node majors and TypeScript minor ranges.
|
|
||||||
- Renovate must stay within those explicit ranges and must not independently
|
|
||||||
widen the policy to make a proposed version eligible.
|
|
||||||
- Boundaries must reflect the actual installed framework/tooling metadata where
|
|
||||||
relevant. Remaining in the same dependency major is not sufficient if Angular
|
|
||||||
requires a narrower range.
|
|
||||||
- Node.js, TypeScript and RxJS updates, including major updates, may be proposed
|
|
||||||
when the explicit project boundaries allow them and they remain compatible with
|
|
||||||
the project's Angular version.
|
|
||||||
|
|
||||||
### Angular upgrades
|
|
||||||
|
|
||||||
- Angular major upgrades are performed manually through a separate specification.
|
|
||||||
- Libraries/tooling whose major numbering follows Angular are upgraded to a new
|
|
||||||
major with Angular, also through that manual specification.
|
|
||||||
- Renovate proposes only minor and patch updates for Angular and that aligned
|
|
||||||
family, maintaining a coherent compatible combination.
|
|
||||||
- Runtime/compiler dependencies that require matching versions stay aligned.
|
|
||||||
Do not assume unrelated Angular tooling repositories must all have identical
|
|
||||||
patch numbers merely because their majors align.
|
|
||||||
- Group related changes where necessary to preserve compatibility. TypeScript and
|
|
||||||
RxJS proposals still obey their own explicit allowed ranges.
|
|
||||||
|
|
||||||
### Declared versions versus installed software
|
|
||||||
|
|
||||||
Maintain coherent repository declarations where a dependency appears in multiple
|
|
||||||
places, such as Node requirements in manifests, version files, Docker definitions
|
|
||||||
and setup checks. A bot PR does not install a new Node version on the mini-PC or
|
|
||||||
replace a running container. Those are documented manual management actions.
|
|
||||||
|
|
||||||
## Branches, PRs and limits
|
|
||||||
|
|
||||||
- Use the branch prefix `renovate/`.
|
|
||||||
- Use `renovate/major/<dependency-or-group>` for eligible major updates.
|
|
||||||
- Use `renovate/minor-patch/<dependency-or-group>` for eligible minor/patch updates.
|
|
||||||
- Each independent dependency has its own update PR. Related packages from one
|
|
||||||
upstream project may be grouped to maintain a coherent update.
|
|
||||||
- Keep major-update proposals separate from minor/patch proposals. Necessary
|
|
||||||
companion changes belong with the coherent upgrade they support; unrelated
|
|
||||||
smaller updates are not mixed into a major PR.
|
|
||||||
- Maintain at most **five open dependency PRs per repository**, including the
|
|
||||||
separate lockfile-maintenance PR when one exists.
|
|
||||||
- Queue additional eligible updates for a later daily round after slots become
|
|
||||||
available. There is no dynamic sixth/seventh overflow PR or custom overflow
|
|
||||||
bundling requirement.
|
|
||||||
- Onboarding/configuration PRs are outside this dependency-PR limit and precede
|
|
||||||
ordinary dependency proposals.
|
|
||||||
- Renovate creates and updates proposals only. Maintainers review the change and
|
|
||||||
CI result and merge manually; automerge is disabled.
|
|
||||||
|
|
||||||
## Lockfile maintenance
|
|
||||||
|
|
||||||
- Ordinary manifest updates include the matching lockfile changes in the same
|
|
||||||
PR, so reproducible installation remains valid.
|
|
||||||
- Separately maintain lockfiles within the existing manifest boundaries on a
|
|
||||||
branch such as `renovate/minor-patch/lockfile-maintenance`.
|
|
||||||
- Keep that maintenance PR separate from ordinary dependency-update PRs and count
|
|
||||||
it within the five-PR limit.
|
|
||||||
- Apply the same seven-day checks to newly resolved/changed versions in that PR.
|
|
||||||
- Do not split a normal manifest update from the lockfile required by that update
|
|
||||||
into separately mergeable, inconsistent proposals.
|
|
||||||
|
|
||||||
## Maintaining existing PRs
|
|
||||||
|
|
||||||
During daily execution, Renovate may refresh existing bot proposals and rebase
|
|
||||||
them when the base branch changes. Refreshed candidates must again pass age and
|
|
||||||
compatibility checks.
|
|
||||||
|
|
||||||
Do not overwrite manual changes on bot branches. If manual edits or conflicts
|
|
||||||
prevent safe maintenance, preserve those edits and show that maintainer action is
|
|
||||||
required in the dashboard.
|
|
||||||
|
|
||||||
## Visibility and management
|
|
||||||
|
|
||||||
- Provide a Dependency Dashboard issue per onboarded repository for pending,
|
|
||||||
open and blocked updates and relevant failures.
|
|
||||||
- Explain age, missing metadata and compatibility blockers concretely.
|
|
||||||
- Preserve execution logs and the result of each scheduled run for **thirty days**,
|
|
||||||
with rotation/retention configured and secret values omitted/redacted.
|
|
||||||
- Retain operational failure information on the Synology even if startup or Gitea
|
|
||||||
connectivity fails before repository processing can update a dashboard.
|
|
||||||
- Provide a management guide covering bot access and secret locations, repository
|
|
||||||
discovery/exclusions and onboarding, schedule/timezone, manual execution,
|
|
||||||
dashboards/logs, configuration validation and recovery.
|
|
||||||
- Document project compatibility maintenance, the manual runner version/checksum
|
|
||||||
boundary, blocked unsupported metadata, manual host/runtime updates and applying
|
|
||||||
an approved Renovate-image version to the running installation.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] A working Renovate container and daily 06:00 Europe/Amsterdam schedule run
|
|
||||||
on the existing linux/amd64 Synology.
|
|
||||||
- [ ] Discovery covers authorized Gitea repositories, respects exclusions and
|
|
||||||
requires onboarding/configuration approval before dependency proposals.
|
|
||||||
- [ ] This project has a reviewed `renovate.json` with explicit framework and
|
|
||||||
dependency boundaries; compatible managed dependency surfaces are detected.
|
|
||||||
- [ ] Direct, security and new/changed transitive lockfile versions younger than
|
|
||||||
seven days cannot enter new or refreshed bot PRs.
|
|
||||||
- [ ] Unknown release ages block proposals visibly; internal dependencies do not
|
|
||||||
bypass the waiting period.
|
|
||||||
- [ ] Validation proves repository overrides, security defaults and error/fallback
|
|
||||||
paths cannot create or update an ineligible PR.
|
|
||||||
- [ ] Node.js, TypeScript and RxJS updates stay inside the maintainer-controlled
|
|
||||||
Angular compatibility bounds, including any permitted major upgrades.
|
|
||||||
- [ ] Angular and Angular-major-aligned library major upgrades are not proposed;
|
|
||||||
compatible minor/patch changes and coupled package/image references remain
|
|
||||||
coherent.
|
|
||||||
- [ ] Independent proposals and permitted coherent groups use the agreed
|
|
||||||
renovate/major/... and renovate/minor-patch/... branch conventions.
|
|
||||||
- [ ] Major proposals remain separate from unrelated minor/patch updates.
|
|
||||||
- [ ] No more than five dependency PRs are open per repository, including lockfile
|
|
||||||
maintenance; remaining candidates wait rather than form overflow PRs.
|
|
||||||
- [ ] Normal manifest PRs include matching lockfiles; separate lockfile maintenance
|
|
||||||
stays within manifest ranges and satisfies the same age policy.
|
|
||||||
- [ ] Daily maintenance preserves manual branch edits and reports conflicts;
|
|
||||||
merging and actual host/container upgrades remain manual.
|
|
||||||
- [ ] The runner binary version/checksum is explicitly documented as manual, with
|
|
||||||
no partial or incorrect automated checksum updates.
|
|
||||||
- [ ] Dashboards expose repository-level state; thirty-day logs also expose
|
|
||||||
failures before repository processing without disclosing secrets.
|
|
||||||
- [ ] Management documentation and real execution/PR evidence demonstrate the
|
|
||||||
operational setup and enforcement. Configuration files alone are insufficient.
|
|
||||||
|
|
||||||
## Implementation handoff
|
|
||||||
|
|
||||||
This specification records agreed behavior. It does not create the bot account,
|
|
||||||
configure credentials, deploy a container, install a schedule or create PRs/issues.
|
|
||||||
Those actions, any necessary additional age validation and the operational evidence
|
|
||||||
are implementation work. S5 is complete only when its behavior has been proven on
|
|
||||||
the working installation.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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 {
|
|
||||||
public static void main(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;
|
|
||||||
System.out.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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Integration checks against an isolated checkout; run python3 tooling/format/test.py."""
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[2]
|
|
||||||
with tempfile.TemporaryDirectory(prefix="palantir-test-") as temporary:
|
|
||||||
fixture = Path(temporary)
|
|
||||||
for name in ("pom.xml", ".mvn", "tooling/format"):
|
|
||||||
source = root / name
|
|
||||||
destination = fixture / name
|
|
||||||
if source.is_dir():
|
|
||||||
shutil.copytree(source, destination)
|
|
||||||
else:
|
|
||||||
shutil.copy2(source, destination)
|
|
||||||
for name in ("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci/runner"):
|
|
||||||
(fixture / name).mkdir(parents=True)
|
|
||||||
source = fixture / "ci/runner/Example.java"
|
|
||||||
original = "import java.util.List;\nclass Example{String text=\"héllo\";int value(){return 1;}}\n"
|
|
||||||
source.write_text(original)
|
|
||||||
|
|
||||||
def run(mode, success=True):
|
|
||||||
result = subprocess.run(
|
|
||||||
["mvn", "-N", "-B", "-ntp", "exec:exec@format", "-Dformat.mode=" + mode],
|
|
||||||
cwd=fixture, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
||||||
assert (result.returncode == 0) == success, result.stdout
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
output = run("check", False)
|
|
||||||
assert "ci/runner/Example.java" in output and "mvn -N exec:exec@format" in output
|
|
||||||
assert source.read_text() == original
|
|
||||||
run("apply")
|
|
||||||
formatted = source.read_text()
|
|
||||||
assert "import java.util.List" not in formatted and "héllo" in formatted
|
|
||||||
run("check")
|
|
||||||
run("apply")
|
|
||||||
assert source.read_text() == formatted
|
|
||||||
# Force a full (uncached) second pass to prove engine idempotence.
|
|
||||||
(fixture / "target/palantir-cache.properties").unlink()
|
|
||||||
run("check")
|
|
||||||
source.write_text(formatted + "class Broken { ??? }\n")
|
|
||||||
invalid = source.read_bytes()
|
|
||||||
output = run("apply", False)
|
|
||||||
assert "ci/runner/Example.java" in output
|
|
||||||
assert source.read_bytes() == invalid
|
|
||||||
# A new file is discovered even after a successful cached check.
|
|
||||||
source.write_text(formatted)
|
|
||||||
extra = fixture / "ci/New.java"
|
|
||||||
extra.write_text("class New{}")
|
|
||||||
run("check", False)
|
|
||||||
assert extra.read_text() == "class New{}"
|
|
||||||
print("PASS: check is read-only, apply/check agree, imports, UTF-8, idempotence, parser errors, new files")
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
record Waiver(String metric, String executable, double value, String reason) {}
|
|
||||||
|
|
||||||
static final List<Waiver> waivers = new ArrayList<>();
|
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
findings.clear();
|
|
||||||
codeLines.clear();
|
|
||||||
types.clear();
|
|
||||||
units.clear();
|
|
||||||
ownedTypes.clear();
|
|
||||||
root = Path.of(args[0]).toAbsolutePath().normalize();
|
|
||||||
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("agiledancers-be/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("agiledancers-be/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("agiledancers-be/target/standards-classpath.txt");
|
|
||||||
if (Files.exists(cp))
|
|
||||||
config.prependAuxClasspath(Files.readString(cp)
|
|
||||||
+ File.pathSeparator
|
|
||||||
+ root.resolve("agiledancers-be/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("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("agiledancers-be/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("agiledancers-be/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("agiledancers-be/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 + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
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.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-");
|
|
||||||
Path prod = directory.resolve("agiledancers-be/src/main/java");
|
|
||||||
Path test = directory.resolve("agiledancers-be/src/test/java");
|
|
||||||
Files.createDirectories(prod);
|
|
||||||
Files.createDirectories(test);
|
|
||||||
Path config = directory.resolve("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("agiledancers-be/src/main/java/Fixture.java")));
|
|
||||||
String report =
|
|
||||||
Files.readString(directory.resolve("agiledancers-be/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("agiledancers-be/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("agiledancers-be/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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user