diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index ce1e63a..8da7663 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -19,21 +19,23 @@ jobs: uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: persist-credentials: false + - name: Verify pinned engineering resources + run: python3 scripts/engineering.py --check - name: Identify workflow attempt # Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for # container names and diagnostic artifacts, including reruns of one SHA. - run: java ci/Ci.java attempt + run: java generated/ci/Ci.java attempt - name: Build and test both images - run: java ci/Ci.java verify + run: java generated/ci/Ci.java verify - name: Publish verified main images if: success() && gitea.ref == 'refs/heads/main' env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: java ci/Ci.java publish + run: java generated/ci/Ci.java publish - name: Collect diagnostics and clean up test containers if: always() - run: java ci/Ci.java cleanup + run: java generated/ci/Ci.java cleanup - name: Retain diagnostics for fourteen days if: always() uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index b0418cd..ad23a78 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -42,8 +42,10 @@ jobs: uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: persist-credentials: false + - name: Verify pinned engineering resources + run: python3 scripts/engineering.py --check - name: Identify workflow attempt - run: java ci/Ci.java attempt + run: java generated/ci/Ci.java attempt - name: Release verified images — persistent PostgreSQL storage env: RELEASE_MODE: ${{ inputs.mode }} @@ -57,7 +59,7 @@ jobs: DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} - run: java ci/Ci.java deploy + run: java generated/ci/Ci.java deploy - name: Retain deployment diagnostics for fourteen days if: always() uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml index feff460..bd91070 100644 --- a/.gitea/workflows/rollback.yaml +++ b/.gitea/workflows/rollback.yaml @@ -19,8 +19,10 @@ jobs: uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: persist-credentials: false + - name: Verify pinned engineering resources + run: python3 scripts/engineering.py --check - name: Identify workflow attempt - run: java ci/Ci.java attempt + run: java generated/ci/Ci.java attempt - name: Restore previous successful release — requires a compatible database schema env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} @@ -31,7 +33,7 @@ jobs: DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} - run: java ci/Ci.java rollback + run: java generated/ci/Ci.java rollback - name: Retain deployment diagnostics for fourteen days if: always() uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 diff --git a/AGENTS.md b/AGENTS.md index fb11189..355bfd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,31 @@ # Project skills and specifications -- Project skills are stored in `skills/`; specifications belong in `specs/`. -- For code reviews, read and follow `skills/code-review/SKILL.md`. If no +- Project skills are stored in `generated/skills/`; specifications belong in [landscape](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/specs). +- For code reviews, read and follow `generated/skills/code-review/SKILL.md`. If no dedicated Skill tool is available, load the file directly. - Use `grill-me` only when the user explicitly requests it, for example `Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`. -- Before using it, read `skills/grill-me/SKILL.md` and its dependency - `skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these +- Before using it, read `generated/skills/grill-me/SKILL.md` and its dependency + `generated/skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these files directly and follow their instructions within the available tools. - Installing or mentioning a skill does not start an interview. Do not apply `grilling` automatically to ordinary development requests. - Keep the upstream skill files unchanged. Source revision and license are - recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`. + recorded in `generated/skills/upstream.json` and `generated/skills/LICENSE.mattpocock`. # Backend changes and reviews - Before changing backend Java, read `CODING_STANDARDS.md`. -- For reviews, use `skills/code-review/SKILL.md` together with +- For reviews, use `generated/skills/code-review/SKILL.md` together with `docs/backend-review.md`. Keep upstream skills unchanged. - Obtain maintainer agreement before adding any standards waiver; record its narrow scope and reason alongside the affected code or configuration. + +# Landscape and engineering + +- Read current [landscape guidance](http://192.168.2.28:3000/wanjakrah/landscape), + including applicable ADRs and specifications. This repository owns its implementation. +- Engineering resources are pinned by `engineering.lock.json`. Do not edit + `generated/`; change engineering and deliberately adopt a released version. +- Regenerate with `mvn -N -Pregenerate-engineering validate`; check with + `python3 scripts/engineering.py --check`. No sibling checkout is needed. diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 0ea25bf..e5073a5 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -1,277 +1,11 @@ -# Backend coding standards +# Agile Dancers coding standards -This document is the authoritative policy for handwritten production and test Java -in `agiledancers-be`. Generated code is out of scope. The Java conventions below also apply to -handwritten Java helpers outside that module. Existing frontend conventions in README.md and agiledancers-fe remain in force. +Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before +changing Java. Their backend-module placeholder means `agiledancers-be` here. +Frontend conventions remain in README.md and agiledancers-fe. -## Scope and authority +The existing maintainer-approved exception for Group's eight-parameter canonical +constructor remains in `tooling/standards/waivers.tsv`. No new waiver is introduced +by S19. For reviews also read [the local companion](docs/backend-review.md). -- Cover handwritten production and test Java in `agiledancers-be`. Exclude - generated code. Dependency injection and production architecture rules apply - where relevant. The Java conventions also cover Java helpers outside the backend; backend - architecture, metrics, coverage and automated checks remain backend-only. - Frontend code is out of scope. -- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/) - supplied source material. Only the rules adopted in this local document apply; - changes to the article do not change project policy. -- The project's pinned Palantir formatter wins formatting conflicts, including - comment layout. S8 remains responsible for formatter integration. -- Do not adopt unrelated static-analysis rule bundles. - -## General language guidance - -- Always prefer newer stable official APIs, language features and officially - recommended coding practices over older alternatives when they suit the task - and are supported by the project's configured language/runtime version. - Preserve required behavior and compatibility; document concrete reasons for - retaining an older approach. This guidance applies to every language used in - the project. - -## Java conventions - -- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants - 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. -- Prefer `java.nio` APIs (including `java.nio.file` and `java.nio.charset`) over - equivalent `java.io` APIs. Use stream APIs where no suitable NIO equivalent - exists, such as process pipes or APIs requiring streams. -- Use varargs (`Type... values`) when an array is the last method or constructor - parameter, including `main(String... args)`. Preserve required override signatures - and framework/API compatibility. -- Prefer Java 25 `IO.print`/`IO.println` for direct standard output and `IO.readln` - for line-oriented standard input. `IO` has no standard-error variant; retain - `System.err` for error output. Use application logging where appropriate. -- Comments explain intent or constraints. Require Javadoc on public classes and - public methods that do more than basic POJO/data-carrier work. Trivial accessors - and plain data carriers do not need boilerplate documentation. Describe behavior, - constraints and failure conditions where relevant. -- Block comments use the following layout, subject to Palantir formatting: - - ```java - /* - 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. -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`. - -Reports never rewrite Java. Formatter application remains S8's separate validate -execution. Generated output under `target` and generated source directories are -outside the handwritten `src/main/java` and `src/test/java` scope. No maintained -file, DTO, configuration class or package is excluded from standards or coverage. -JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions -are configured. Line and branch results are published, with only lines gated. - -Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric, -fully resolved executable signature (or class), exact retained value and the -maintainer agreement/reason. No wildcard waivers are allowed, and a changed value -fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression -annotations/comments cannot silently waive a rule. The only approved waiver is the eight-parameter `Group` canonical constructor, -which preserves its JSON contract while defensively copying both lists. Adding a waiver always requires maintainer agreement. - -## Commands and reports - -Run from the root with JDK 25: - -```sh -mvn -Dformat.mode=check clean verify -# Prepare standalone analysis without running tests: -mvn -pl agiledancers-be test-compile dependency:build-classpath@standards-classpath -mvn -f tooling/standards/pom.xml test exec:java -# Exact local coverage gate after a test/report build: -java tooling/coverage/Coverage.java agiledancers-be/target/site/jacoco/jacoco.xml -``` - -`verify` runs backend tests, builds resolved analysis inputs, generates JaCoCo -reports, runs checker regression tests and standards analysis, then gates exact -module-wide line coverage. `compile` and `test` retain existing formatting behavior -and do not run the verify-only standards checks. Standalone analysis requires its -classpath preparation; unresolved types fail with the path and preparation advice. - -Full metrics and findings: `agiledancers-be/target/standards/report.txt`. -Checker regression results: `tooling/standards/target/surefire-reports`. -Coverage HTML/XML/CSV: `agiledancers-be/target/site/jacoco` (local backend tests) -and `agiledancers-be/target/site/jacoco-combined` (merged browser contribution). -The gate compares integer line counts (`5 * covered >= 4 * total`), rejecting an -empty report and percentages that would round up to 80%. - -CI first passes local verify. It mounts the pinned JaCoCo agent into its disposable -backend test container, exposes the agent only on the isolated Docker network, -and uses the pinned CLI to dump `target/playwright.exec` after Playwright. A missing -or empty dump fails. The following commands merge both datasets and gate the result -before the verified-commit marker can authorize publication: - -```sh -mvn -pl agiledancers-be jacoco:merge@combined jacoco:report@combined-report exec:exec@combined-coverage -``` - -CI retains both coverage reports, execution data, standards findings and regression -results with its usual diagnostics artifacts. The agent is mounted only for tests; -published images contain no agent. PIT and Sonar configuration remain outside S7. -See `specs/S7-validation.md` for migration evidence and retained-signal assessment. +Validation evidence: [S7](docs/validation/S7-validation.md). diff --git a/Dockerfile b/Dockerfile index fe44e43..cf06501 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,9 @@ FROM maven:3.9-eclipse-temurin-25 AS build WORKDIR /workspace COPY pom.xml ./ COPY .mvn .mvn +COPY generated generated COPY tooling tooling -COPY ci ci +COPY engineering.properties engineering.properties COPY agiledancers-be/pom.xml agiledancers-be/pom.xml COPY agiledancers-be/src agiledancers-be/src # Source builds run the same PostgreSQL tests as local/CI verification. diff --git a/README.md b/README.md index ecbebc5..3bb9557 100644 --- a/README.md +++ b/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). Reusable tooling, skills, +standards and the project generator are maintained in +[engineering](http://192.168.2.28:3000/wanjakrah/engineering). + +This project commits the engineering version recorded in `engineering.lock.json` +under `generated/`. Ordinary builds need no other repository checkout. Regenerate +with `mvn -N -Pregenerate-engineering validate`; CI checks the selected tag and +commit with `python3 scripts/engineering.py --check`. To upgrade, update both tag +and commit, follow engineering's UPGRADING.md, regenerate and review the diff. +Project configuration in `engineering.properties`, workflows and `deploy/` remains +application-owned. Read CODING_STANDARDS.md and AGENTS.md for project guidance. + # agiledancers To map dance groups to performance days. The backend runs on Quarkus with Java 25. @@ -299,7 +314,7 @@ during the one-time transition from an in-memory release, and [S6 evidence](docs Maven is the authoritative formatting path. The root build runs the actual Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite -**8.63.4** text recipe in `tooling/format/Format.java`. The pinned +**8.63.4** text recipe in `generated/tooling/format/Format.java`. The pinned `exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import ordering and unused-import removal are enabled; extra Javadoc formatting is off. No OpenRewrite AutoFormat or additional refactoring recipes run. @@ -344,9 +359,9 @@ format-on-save must not override Maven's result. Other editors can invoke the standalone Maven apply command as an external tool. Verification and measurements are recorded in -[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure, +[the S8 evidence](docs/validation/S8-formatting-evidence.md). Reproduce the isolated failure, UTF-8, import, cache and idempotence checks with -`python3 tooling/format/test.py`, and the sequential end-to-end build measurements +the formatter integration test in engineering (`python3 tests/format_integration.py`), and the sequential end-to-end build measurements with `python3 tooling/format/benchmark.py` (requires the ordinary backend test prerequisites). Results are written under `target/`. @@ -357,7 +372,7 @@ It defines the agreed rules, exact metric limits, manual review duties, pinned analysis tools, coverage policy and standalone commands. `mvn verify` runs standards checks and requires at least 80% module-wide backend line coverage. CI also collects and merges coverage from Playwright before publication. Reports are retained with -CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status. +CI diagnostics. See [S7 validation](docs/validation/S7-validation.md) for current migration status. Existing-school mutations require `X-School-Revision` with the revision loaded by diff --git a/adr/README.md b/adr/README.md deleted file mode 100644 index f06618a..0000000 --- a/adr/README.md +++ /dev/null @@ -1,33 +0,0 @@ - -The adr-folder contains Architecture Decision Records. -An Architecture Decision Record is a file that contains a decision that should be applied to all new and altered code and systems. -ADR's can only be added or altered on specific request by the user. Agents should never add or modify ADR's without explicit request to do so. -ADR's are immutable in essence. This means that the only modifications allowed are in its state, or when it applies to typo's, adding clarifications, or removing ambiguity. -An ADR MUST have the following properties: - -- ID -- Title -- Supersedes -- Status (NEW, REFINED, APPROVED, REJECTED, EXPIRED) -- Expiration date -- Context -- Decisions -- Options considered with pro's and cons -- Consequences. -An _ID_ MUST be a unique number, prefixed with ADR (e.g. ADR1, ADR2, ADR3, etc.). This makes it easy to reference an ADR. -A _title_ is a short, unique description of the ADR. It should make it easy to know in a glance what the ADR is about. -The name of the file SHOULD contain the ID and the title, or a short variant of the title, of the ADR. -_Supersedes_ is an optional property that MAY reference another, expired ADR that it supersedes. -When _supersedes_ references another ADR, it MUST do so by referencing its ID. -When _supersedes_ references another ADR, the status of that other ADR MUST equal to EXPIRED. -_Status_ contains the state of the ADR. In the readme, when we mention state, we mean the value of this status-property. ADR's are always in a subfolder of the adr-folder, with the same name as the state, but in lowercase ('adr/new' for ADR's with state NEW, 'adr/refined' for ADR's with state REFINED, etc.). -Each created ADR will start with the state NEW. Apart from the ID and the state, each property can still be modified. Nothing that is in an ADR with state NEW, should be considered when writing or evaluating anything within the software landscape. -An RFD will get the state REFINED, when it is fully worked out. Only RFD's with the state NEW can be changed to REFINED. This means an effort has been made to get to a final version of the RFD. Getting an RFD from NEW to REFINED can be done with or without the help of an agent. However, an agent should always verify mutual understanding of the content with the human owner, before changing the state from NEW to REFINED. -An ADR with the state REFINED can be moved to either APPROVED or REJECTED. This step must be done manually, not by any agent. APPROVED means the decision in the ADR applies to all code and/or software added or altered as far as the content of the ADR applies to the situation. Exceptions are possible with explicit accord. Such an accord should be given by a person. REJECTED means that the ADR should not be considered anywhere when writing or evaluating anything within the software landscape. -An ADR with the state EXPIRED is no longer valid. It should no longer be considered anywhere when writing or evaluating anything within the software landscape, even though it had been the case previously. It MAY have been superseded by a new ADR, but that is not necessary. -An _expiration date_ indicated from when an ADR should be notified for reevaluation. Reaching the _expiration date_ does not mean the ADR is expired. It only means the ADR should be reevaluated by a person, with or without the help of an agent. As a result of the reevaluation, this ADR may get EXPIRED - only after explicit agreement of a person - and it MAY be superseded by a new one. Another option is that only the expiration date itself gets updated to a new expiration date. This also only after explicit agreement of a person. -_Context_ should explain the context of the problem where a decision had to be made for. -The _decision_ describes the decision. This is what all code MUST apply to, unless an explicit exception is approved by a person. -The _options_ describes the various alternatives that were considered, all with its pro's and con's, and why the chosen decision whas chosen. -_Consequences_ describes the consequences to the current state of the application landscape at the time of writing. This could be about a migration timeline, a new process, different tooling, or a change in the stack, or something else. - diff --git a/adr/refined/ADR1_application-size.md b/adr/refined/ADR1_application-size.md deleted file mode 100644 index 54a9805..0000000 --- a/adr/refined/ADR1_application-size.md +++ /dev/null @@ -1,215 +0,0 @@ -**ID:** ADR1 \ -**Title:** Application size \ -**Supersedes:** - \ -**Status:** REFINED \ -**Expiration Date:** Six months after approval; record the calendar date upon manual approval. - -## Context - -Agile Dancers currently keeps its frontend, backend, CI pipeline, skills, -specifications, ADRs, and other documentation in one repository. As the application -grows, humans and agents need more context to understand individual tasks. -Functional dependencies can become entangled, making changes and reuse harder. - -The primary goal is to reduce the context required for a task. Supporting goals -are preventing uncontrolled coupling and enabling reuse and replacement of -capabilities. Repository organization, module boundaries, and deployment -boundaries are separate architectural choices. - -Business capabilities stand on their own, while an application system may support -multiple capabilities. Loosely coupled capability boundaries allow implementations -to change between custom software, libraries, and externally supplied components. -Scheduling and school administration are candidate areas to examine; their exact -system boundaries have not yet been decided. - -Code-level modularity alone is insufficient when shared tables, materialized -views, SQL scripts, or other database access bypass module boundaries. Separate -services would have the same weakness without enforced data ownership. - -Systems can initially run on the same physical server. Independent workload -placement is nevertheless useful: computational work, such as runners and future -scheduling calculations, may run on a mini-PC rather than the Synology. Independent -scaling and release cycles are future possibilities rather than universal current -requirements. Colocation reduces network exposure but process restarts, timeouts, -and resource contention still require defined failure behavior. - -Reusable engineering resources should also support other projects. Specifications -can affect several projects, and ADRs apply across the application landscape. - -## Decisions - -### Capabilities and systems - -Organize the landscape into independently deployable systems. Group capabilities -into systems according to cohesion, data ownership, replacement needs, and -operational requirements, including workload placement. Capability size alone does -not justify a separate system. - -Each system has its own repository and independent deployment lifecycle. Within a -system, use modules to keep its capabilities loosely coupled. A capability does -not automatically require its own service or deployment. Compute workers may be -deployed separately from the business system that delegates work to them. - -Capability contracts should allow implementations to be replaced by custom code, -libraries, or external products, using adapters where necessary. - -### Data ownership - -Each system exclusively owns its data. Other systems access that data through -explicit contracts, never through direct access to its storage. This restriction -also applies to reporting queries, views, migrations, and maintenance scripts. -Cross-system reporting requires explicit contracts or a separately owned -projection. - -PostgreSQL-backed systems initially share one PostgreSQL server/container, with -separate databases or schemas and separate credentials whose permissions enforce -ownership. Sharing physical infrastructure does not permit sharing storage access. -Systems may choose other storage technologies when justified. This permits future -CQRS designs without adopting CQRS through this ADR. - -### Contracts and communication - -Systems communicate through explicit, versioned contracts. Internal domain models -and persistence entities remain private to their owning system and are not shared -through common libraries. Contract definitions and generated clients may be shared. - -Choose synchronous APIs or asynchronous messaging for each interaction according -to its needs. Define failure behavior for each interaction. This ADR mandates -neither a landscape-wide communication mechanism nor a message broker. - -### Landscape and engineering resources - -Maintain a landscape repository containing ADRs, specifications, and the capability -and system map. Specifications identify the projects they affect and may span -multiple systems. ADRs govern the application landscape according to their scope. -Each system retains a README describing its implementation and operation. - -Maintain reusable engineering resources, including pipeline components, skills, -and general engineering documentation, separately from the landscape repository -and individual systems. Group these resources according to maintenance and release -needs; a separate repository per resource type is not required. Version shared -resources so consuming projects can adopt updates deliberately. - -### Adoption and review - -Use this ADR as guidance and the starting point for follow-up specifications. -Detailed system boundaries, contracts, and migration designs are follow-up work. -Migrate incrementally, keeping the application working after each extraction. - -Perform an initial review six months after approval. Review earlier if contract -coordination, operational overhead, or persistent cross-system coupling becomes a -significant burden. The expiration date triggers reevaluation and does not -automatically invalidate the decision. - -## Options considered - -### 1. Improve navigation within the current structure - -Keep the existing projects and deployment structure, improving focused -documentation, task guidance, and file organization. - -Pros: - -- Requires little disruption or migration work. -- Makes relevant context easier to find. -- Retains simple local development and coordinated changes. - -Cons: - -- Offers limited protection against functional entanglement. -- Does not enforce data ownership or support independent workload placement. -- Leaves reuse and replacement dependent on existing application boundaries. - -Not chosen because navigation improvements alone do not establish the ownership -and replacement boundaries needed as the landscape grows. - -### 2. Modular monolith in one repository - -Organize capabilities into modules with explicit interfaces and dependency rules, -while retaining coordinated deployment and one repository. - -Pros: - -- Reduces the code area needed to understand a capability. -- Enables dependency checks and straightforward changes across modules. -- Keeps deployment and in-process communication simple. - -Cons: - -- Shared database access can bypass code-level boundaries through tables, views, - and scripts unless storage ownership is separately enforced. -- Workload placement and deployment remain coupled. -- Independent replacement and reuse may require later extraction. - -Not chosen as the landscape-wide approach because independently enforced ownership -and deployment boundaries are desired. Modular design remains part of the chosen -approach inside each system. - -### 3. Separate projects and repositories with coordinated deployment - -Extract capabilities into separately built libraries or applications with -versioned contracts, while retaining a coordinated deployment lifecycle. - -Pros: - -- Separates source and build context. -- Gives reusable components their own versioned releases. -- Retains a coordinated operational release process. - -Cons: - -- Changes spanning repositories require dependency release coordination. -- Deployment remains coupled despite repository separation. -- Repository separation alone does not enforce storage ownership or runtime - independence. - -Not chosen because it incurs repository coordination costs while retaining a -deployment constraint that would limit workload placement and replacement. - -### 4. Independently deployable systems with internal capability modules — chosen - -Give systems their own repositories, deployment lifecycles, contracts, and enforced -data ownership. Keep capabilities loosely coupled through modules within a system. -Place compute workers independently where useful. - -Pros: - -- Supports focused development context and independent system evolution. -- Enforces data ownership beyond code dependency rules through storage permissions. -- Allows workloads to move between the Synology and mini-PC. -- Supports capability replacement and reuse through explicit contracts. -- Allows storage technology choices per system. - -Cons: - -- Requires contract compatibility and coordination for changes spanning systems. -- Adds deployment, configuration, and operational maintenance for multiple processes. -- Requires explicit failure handling and cross-system consistency decisions. -- Sharing one server or PostgreSQL instance retains common infrastructure failure - points despite logical isolation. - -Chosen because ownership, replaceability, focused context, and independent workload -placement outweigh the additional operational and contract coordination work. - -## Consequences - -- Write follow-up specifications to map capabilities and dependencies, choose - initial system boundaries, define contracts and data ownership, and plan - extraction. This ADR does not itself settle those boundaries or implement them. -- Establish the landscape repository and the repositories and versioning needed - for reusable engineering resources. Preserve links between specifications and - affected projects, and between system READMEs and landscape guidance. -- Extract systems one at a time, establishing contracts and enforcing storage - ownership as part of each extraction. Moving tables into separate schemas is - insufficient while cross-system SQL access remains possible. -- Adapt build and deployment pipelines to release systems independently and - consume versioned shared engineering resources. -- Add appropriate checks for module dependencies, contract compatibility, and - storage isolation. Include views and scripts in boundary enforcement. -- Specify how systems exchange information, handle failed interactions, and keep - derived information consistent. These choices belong to the relevant follow-up - specifications rather than a universal messaging requirement. -- Keep one PostgreSQL container initially. Detailed backup and failover design is - future work; logical data separation does not provide infrastructure failover. -- Record the calendar review date when a maintainer manually approves this ADR. - REFINED records the agreed proposal; approval remains a separate manual step. diff --git a/adr/refined/ADR2_http-api-contracts-and-tooling.md b/adr/refined/ADR2_http-api-contracts-and-tooling.md deleted file mode 100644 index 32f8728..0000000 --- a/adr/refined/ADR2_http-api-contracts-and-tooling.md +++ /dev/null @@ -1,176 +0,0 @@ -**ID:** ADR2 \ -**Title:** HTTP API contracts and tooling \ -**Supersedes:** - \ -**Status:** REFINED \ -**Expiration Date:** Six months after approval; record the calendar date upon manual approval. - -## Context - -Agile Dancers currently uses REST without an explicit architectural decision. -The landscape needs agreed defaults for communication across system boundaries, -contract documentation, client generation, compatibility, and error handling. - -Interactions include frontend access, requests between business systems, -delegation of long-running calculations to workers, change notifications, and -external consumers. Most clients and servers are developed by the same maintainer. -Consumers must nevertheless be able to upgrade independently. The independently -deployable systems proposed in ADR1 are relevant context; ADR1 remains REFINED -and is not approved through this decision. - -The current frontend uses Angular, but future clients may use other frameworks -or languages. Contracts and tooling should support that flexibility. - -For now, interactions use a defined alternative path when another system is -offline. Durable queued delivery is not a current requirement. More specialized -communication mechanisms may become appropriate for particular interactions. - -## Decisions - -### Scope and communication style - -Use HTTP APIs with JSON payloads as the default across system boundaries. -Use resource-oriented endpoints where natural and action-oriented endpoints -where they express business intent more clearly. Do not force business actions -into CRUD operations. For example: - -```text -GET /api/v1/schedules/{id} -POST /api/v1/schedules/{id}/calculate -``` - -Action endpoints still follow HTTP method and status semantics. For long-running -work, use an explicit job or operation resource with a status endpoint when the -caller needs to track progress. A notification can initially use a direct HTTP -request with documented failure behavior; this does not promise durable delivery. - -These are landscape defaults. Document justified exceptions alongside the -relevant interface, including the reason and the alternative contract and failure -behavior. This ADR does not mandate GraphQL, RPC, or messaging infrastructure. - -### Contracts and documentation - -Describe HTTP APIs using OpenAPI. Normally generate the description from server -code, adding explicit descriptions and annotations where needed. Review the -resulting contract as a public interface; generation alone does not ensure -completeness or backward compatibility. - -Use self-hosted Scalar API Reference as the default interactive documentation UI. -Keep the OpenAPI document independently available so consumers and tooling do not -depend on the UI. Publish a contract artifact for each supported major version. - -Choose an OpenAPI specification version supported by the server tooling, Scalar, -and the selected client generators. Do not automatically adopt the latest version -without checking that compatibility. - -### Client generation - -Generate client transport code and contract types from OpenAPI by default, using -OpenAPI Generator. Select the generator appropriate to the consuming technology; -use `typescript-angular` for Angular clients. This choice does not require future -frontends to use Angular. - -Pin generator versions and configuration for reproducible generation. Generate -against a selected contract artifact rather than requiring a running server during -the client build. Keep generated code separate from handwritten application logic -and do not manually edit generated files. Generated static types do not by -themselves provide runtime response validation. - -### Versioning and compatibility - -Put the major contract version in the URL, such as `/api/v1/schedules`. -Keep backward-compatible changes within that major version. Introduce a new -major version for breaking changes. Software release and generated client package -versions may advance independently of the major contract version. - -Allow consumers to upgrade independently. Normally support one contract version; -during transitions, aim to support two. This is a guideline rather than a hard -cap: additional versions may coexist when justified. Document supported versions, -deprecation, and the transition plan, coordinating retirement with affected -consumers. Check compatibility before releasing contract changes. - -### Errors and offline behavior - -Standardize API error bodies on RFC 9457 Problem Details using -`application/problem+json` and appropriate HTTP status codes. Use stable problem -`type` identifiers for programmatic handling and readable explanations for humans. -Clients must not depend on parsing human-readable messages. Document expected -errors in OpenAPI, including validation details where applicable. Do not expose -internal exception details or sensitive information in error responses. - -Define timeouts and the offline alternative path for each interaction. A network -failure may produce no HTTP response, so consumers must handle that separately -from a Problem Details response. Avoid automatic retries of actions unless -repeating them is safe. Do not assume durable delivery or eventual replay. - -### Adoption and review - -Adopt incrementally for new interfaces and interfaces being substantially changed. -Migrate existing interfaces through planned work while preserving compatibility -for existing consumers. No immediate landscape-wide rewrite is required. - -Review six months after approval, or earlier if integration needs expose -limitations. The review date triggers reevaluation rather than automatic expiry. - -## Options considered - -### Communication approach - -| Option | Pros | Cons | Outcome | -| --- | --- | --- | --- | -| Resource-oriented HTTP/JSON with action endpoints | Readable resources and business actions; supports different client technologies; retains HTTP semantics. | Requires consistent endpoint conventions and explicit handling of long-running work and failures. | Chosen as the default. | -| GraphQL | Flexible data selection; typed schema; supports different consumer views of related data. | Adds query authorization and performance concerns; requires additional conventions for work delegation and notifications. | Not chosen: current needs and common client/server ownership do not justify the added complexity. | -| RPC, including JSON-RPC and gRPC | Explicit operation contracts; JSON-RPC is lightweight; gRPC supports strongly typed generated clients. | Operation-oriented contracts require careful evolution; gRPC adds browser integration requirements. | Not chosen as the default: resource-oriented HTTP with optional actions better matches the desired interface flexibility. RPC remains a possible justified exception. | - -### Contract description and authoring - -| Option | Pros | Cons | Outcome | -| --- | --- | --- | --- | -| OpenAPI, normally generated from server code | Language-independent HTTP contracts; supports documentation and client generation; avoids separately maintaining a design language. | Needs annotations, review, and compatibility checks; generated output can be incomplete. | Chosen. | -| TypeSpec generating OpenAPI | Dedicated contract authoring language; can generate descriptions and other artifacts. | Adds a language and compiler to maintain. | Not chosen: no current benefit sufficient to justify the additional tooling. | -| RAML | API description language with reusable modeling constructs. | Its specification repository is archived, creating a maintenance concern for a new standard. | Not chosen. | - -### Interactive documentation - -| Option | Pros | Cons | Outcome | -| --- | --- | --- | --- | -| Scalar API Reference | Readable interactive documentation; integrated request testing; self-hosting and configuration options. | Requires integration and maintenance alongside the server framework. | Chosen: readability and testability are the primary purposes of the UI. | -| Swagger UI | Interactive requests and authentication support; direct integration with Quarkus OpenAPI tooling. | Its expandable operation layout is less aligned with the preferred reading experience. | Not chosen as the landscape default. | - -### Client generation - -| Option | Pros | Cons | Outcome | -| --- | --- | --- | --- | -| OpenAPI Generator | Supports Angular and other client technologies in one tool family; configurable generation. | Generator-specific options and upgrades need management. | Chosen to support future technology changes. | -| ng-openapi-gen | Angular-focused generation; npm-based tooling. | Other client technologies require another generator. | Not chosen as the landscape default. | -| Handwritten clients | Direct control over client code and application integration. | Duplicates contract types and request details; increases drift risk. | Not chosen as the default. | - -## Consequences - -- Apply the defaults to new and substantially changed interfaces. Plan migration - of existing interfaces incrementally, preserving consumer compatibility. -- Generate and publish versioned OpenAPI artifacts. Add Scalar to provide - interactive documentation and request testing for supported contracts. -- Establish pinned OpenAPI Generator configurations for client technologies, - initially Angular. Keep generated code separate from application logic. -- Add contract validation and compatibility checks to release workflows. Verify - generated clients against the actual contract before adopting generator upgrades. -- Introduce URL major versions and coordinate deprecation with consumers. Normally - maintain one version and temporarily two, documenting justified deviations. -- Align API error responses with Problem Details and document expected failures. - Define timeout and offline behavior per interaction, including safe retry rules. -- Record justified exceptions alongside their interfaces rather than imposing an - unsuitable communication mechanism on every interaction. -- Record the calendar review date upon manual approval. REFINED captures the - agreed proposal; approval remains a separate manual step. - -### References - -- [OpenAPI specification](https://spec.openapis.org/oas/) -- [Scalar API Reference configuration](https://scalar.com/products/api-references/configuration) -- [OpenAPI Generator: TypeScript Angular](https://openapi-generator.tech/docs/generators/typescript-angular/) -- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html) -- [Swagger UI configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/) -- [TypeSpec overview](https://learn.microsoft.com/en-us/azure/developer/typespec/overview/) -- [RAML specification repository](https://github.com/raml-org/raml-spec) -- [JSON-RPC specification](https://www.jsonrpc.org/specification) -- [ng-openapi-gen](https://github.com/cyclosproject/ng-openapi-gen) diff --git a/agiledancers-be/pom.xml b/agiledancers-be/pom.xml index 5f45e1f..a58ea87 100644 --- a/agiledancers-be/pom.xml +++ b/agiledancers-be/pom.xml @@ -97,20 +97,20 @@ ${maven.home}/bin/mvn ${maven.multiModuleProjectDirectory} -B-ntp-f - ${maven.multiModuleProjectDirectory}/tooling/standards/pom.xml + ${maven.multiModuleProjectDirectory}/generated/tooling/standards/pom.xml testexec:java coverageverifyexec ${java.home}/bin/java - ${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java + ${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java ${project.build.directory}/site/jacoco/jacoco.xml combined-coveragenoneexec ${java.home}/bin/java - ${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java + ${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java ${project.build.directory}/site/jacoco-combined/jacoco.xml diff --git a/ci/runner/agiledancers-runner.service b/ci/runner/runner.service similarity index 100% rename from ci/runner/agiledancers-runner.service rename to ci/runner/runner.service diff --git a/deploy/health.sh b/deploy/health.sh new file mode 100644 index 0000000..47393f7 --- /dev/null +++ b/deploy/health.sh @@ -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 +} diff --git a/docs/S19-classification.md b/docs/S19-classification.md new file mode 100644 index 0000000..84e047f --- /dev/null +++ b/docs/S19-classification.md @@ -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. diff --git a/docs/S19-validation.md b/docs/S19-validation.md new file mode 100644 index 0000000..61d66d9 --- /dev/null +++ b/docs/S19-validation.md @@ -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. diff --git a/docs/backend-review.md b/docs/backend-review.md index 1cf4e9d..c6fb0b0 100644 --- a/docs/backend-review.md +++ b/docs/backend-review.md @@ -1,19 +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](../generated/skills/code-review/SKILL.md), and +[project standards](../CODING_STANDARDS.md). -Review changed code for intent-revealing comments, missing public-contract Javadoc, -appropriate record use, defensive copying of mutable record components, and -compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers -and framework/lifecycle exceptions to field finality. Confirm architecture and -injection decisions make sense for production and tests. 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 the S7 evidence or change description. -Check narrowly scoped waivers against recorded maintainer agreement and reason. -Never introduce blanket exclusions or weaken thresholds to pass a build. -Do not repeat mechanical checks already enforced by tooling; verify their result. +The backend module is agiledancers-be. The approved Group constructor waiver +remains application-owned in tooling/standards/waivers.tsv. Assess retained +signals against the implementation evidence in docs/validation and the change +description. S19 introduces no standards waiver. diff --git a/docs/gitea-ci.md b/docs/gitea-ci.md index 78649cc..65199df 100644 --- a/docs/gitea-ci.md +++ b/docs/gitea-ci.md @@ -12,7 +12,7 @@ zoals `[skip ci]`; dit is expliciet toegestaan. ## Java-bronbestanden uitvoeren De CI- en setuphulpmiddelen zijn Java 25-bronbestanden. Start ze direct met -`java ci/Ci.java ` vanuit de repositoryroot. De Java-launcher compileert +`java generated/ci/Ci.java ` vanuit de repositoryroot. De Java-launcher compileert de benodigde bronbestanden in het geheugen. Er is geen aparte `javac`-stap, Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt alleen de JDK, inclusief de HTTP-client, process builder en bestandsloten. @@ -76,7 +76,7 @@ Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en backup via: ```sh -java ci/Ci.java configure-docker "$HOME/.docker/daemon.json" +java generated/ci/Ci.java configure-docker "$HOME/.docker/daemon.json" ``` Voor Docker Engine op Linux is dat dezelfde toevoeging aan @@ -112,7 +112,7 @@ Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT Gebruik een repository-runnerregistratietoken, geen instancebrede registratie. Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs. -Voor reproduceerbare configuratie bestaat `java ci/Ci.java configure-gitea`. Maak twee +Voor reproduceerbare configuratie bestaat `java generated/ci/Ci.java configure-gitea`. Maak twee bestanden buiten de repository met mode `600`: één voor een PAT met voldoende repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie), en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden @@ -124,7 +124,7 @@ export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/agiledancers-runner/admin-token" export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token" export REGISTRY_USER=wanjakrah export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld -java ci/Ci.java configure-gitea +java generated/ci/Ci.java configure-gitea ``` Het script stelt de repository-instellingen in via de API en schrijft het @@ -143,9 +143,9 @@ testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijder ```sh export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 -java ci/Ci.java install-runner --prepare-only +java generated/ci/Ci.java install-runner --prepare-only # Na Dockerconfiguratie en het opslaan van het registratietoken: -java ci/Ci.java install-runner +java generated/ci/Ci.java install-runner systemctl --user status agiledancers-runner journalctl --user -u agiledancers-runner -f ``` @@ -200,8 +200,8 @@ De frontend luistert op containerpoort 8080, ondersteunt SPA-routes en proxy't 8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline; de pipeline start geen blijvende applicatie op de NAS. -`java ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests. -`java ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`) +`java generated/ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests. +`java generated/ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`) buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen @@ -238,7 +238,7 @@ blijven de committags bruikbaar en worden de oude `main`-updates overgeslagen. Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen: ```sh -java ci/Ci.java test +java generated/ci/Ci.java test ``` Volledige lokale verificatie met Docker en de vastgestelde platformwaarde: @@ -246,7 +246,7 @@ Volledige lokale verificatie met Docker en de vastgestelde platformwaarde: ```sh export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren -java ci/Ci.java verify +java generated/ci/Ci.java verify ``` ## Operationele oplevering diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 8c68c41..06ffc2f 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -136,8 +136,9 @@ sudo -n /usr/local/bin/docker compose version sudo -n /usr/local/bin/docker info --format '{{.OSType}}/{{.Architecture}}' ``` -Als DSM geen `visudo` bevat, is `deploy/enable-docker.sh` het eenmalige alternatief. -Kopieer het naar de eigen NAS-thuismap en voer het daar met sudo uit. Het maakt +Als DSM geen `visudo` bevat, is `generated/deploy/enable-docker.sh` het eenmalige alternatief. +Kopieer het naar de eigen NAS-thuismap en voer daar +`sudo sh enable-docker.sh agiledancers-deploy /usr/local/bin/docker` uit. Het maakt uitsluitend de bovengenoemde regel, controleert die door als deploymentaccount Docker te starten en verwijdert een nieuwe regel als die controle faalt. Een afwijkende bestaande regel wordt niet overschreven. De implementatiesessie heeft @@ -177,7 +178,7 @@ inrichten. Bewaar `admin-token`, `s6-release-token`, `s6-pull-token` en de priva `~/.ssh/known_hosts` en voer uit: ```sh -java ci/Ci.java configure-deployment +java generated/ci/Ci.java configure-deployment ``` Defaults zijn de bevestigde NAS/account/SSH-poort, repository @@ -269,8 +270,9 @@ nieuwe release door te drukken. controlescript uitvoeren met bestaande lockowner en gekozen release-directory: ```sh - ssh -i -p @ \ - 'timeout -k 5 300 sh -s -- apply ' < deploy/remote.sh + cat deploy/health.sh generated/deploy/remote.sh | \ + ssh -i -p @ \ + '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 ' ``` Gebruik de eerder gepinde hostkey en gecontroleerde waarden uit het journal. @@ -294,9 +296,9 @@ apart: imageherstel maakt geen migratie ongedaan. Rollback naar een oude release van vóór S16 kan de database niet lezen. Backup/restore blijven S17-werk. ```sh -java ci/Ci.java test -java ci/Ci.java release-test -sh -n deploy/remote.sh +java generated/ci/Ci.java test +java generated/ci/Ci.java release-test +sh -n generated/deploy/remote.sh mvn -N -Dformat.mode=check exec:exec@format ``` diff --git a/docs/s18-validation.md b/docs/s18-validation.md index 0e09ab4..4126f37 100644 --- a/docs/s18-validation.md +++ b/docs/s18-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S18 implementation validation Validated on 2026-11-03 against the confirmed S18 specification. diff --git a/docs/s2-validation.md b/docs/s2-validation.md index 7b2a6df..efac27e 100644 --- a/docs/s2-validation.md +++ b/docs/s2-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S2 implementation evidence Validated on the development mini-PC on 2026-10-29: AMD Ryzen 7 7730U (8 cores, @@ -58,7 +60,7 @@ Prettier. The selected configuration uses only Prettier. Staged scopes are: - All: all 52 maintained frontend files, including non-applicable files that the hook must skip, plus a small TS preservation-test fixture; generated lockfile excluded. Exact paths and raw measurements are in - [the timing report](s2-hook-timings.json). + [the timing report](http://192.168.2.28:3000/wanjakrah/agiledancers/src/commit/bd898e92d22f6c412e671908770f4d89c8ae12bc/docs/s2-hook-timings.json). | Candidate | Staged scope | Run 1 | Run 2 | Run 3 | | --- | --- | --- | --- | --- | diff --git a/docs/s4-validation.md b/docs/s4-validation.md index 2d65209..19edb64 100644 --- a/docs/s4-validation.md +++ b/docs/s4-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S4 verification evidence Updated: 2026-10-29. S4 is **complete**. Main publication/pullback passed in run diff --git a/docs/s6-validation.md b/docs/s6-validation.md index 88696f0..8c72f02 100644 --- a/docs/s6-validation.md +++ b/docs/s6-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S6 implementation and operational evidence Status: IN_REVIEW. Production `v1.0.0` is running on the Synology at diff --git a/specs/S12-validation.md b/docs/validation/S12-validation.md similarity index 96% rename from specs/S12-validation.md rename to docs/validation/S12-validation.md index ca746ee..0175bd1 100644 --- a/specs/S12-validation.md +++ b/docs/validation/S12-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S12 implementation validation ## Delivered behavior diff --git a/specs/S16-validation.md b/docs/validation/S16-validation.md similarity index 98% rename from specs/S16-validation.md rename to docs/validation/S16-validation.md index 4979c80..da46f24 100644 --- a/specs/S16-validation.md +++ b/docs/validation/S16-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S16 implementation validation ## Delivered behavior diff --git a/specs/implemented/S7-review-signals.txt b/docs/validation/S7-review-signals.txt similarity index 100% rename from specs/implemented/S7-review-signals.txt rename to docs/validation/S7-review-signals.txt diff --git a/specs/implemented/S7-validation.md b/docs/validation/S7-validation.md similarity index 98% rename from specs/implemented/S7-validation.md rename to docs/validation/S7-validation.md index 2a2ae48..874da50 100644 --- a/specs/implemented/S7-validation.md +++ b/docs/validation/S7-validation.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S7 implementation and validation evidence Status: IN_REVIEW — implementation complete; awaiting project review. diff --git a/specs/implemented/S8-formatting-evidence.md b/docs/validation/S8-formatting-evidence.md similarity index 96% rename from specs/implemented/S8-formatting-evidence.md rename to docs/validation/S8-formatting-evidence.md index 0725125..a3fb208 100644 --- a/specs/implemented/S8-formatting-evidence.md +++ b/docs/validation/S8-formatting-evidence.md @@ -1,3 +1,5 @@ +> Historical validation evidence from application commit `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Commands retain their original paths; current tooling is documented in README.md. + # S8 implementation evidence Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7 diff --git a/engineering.lock.json b/engineering.lock.json new file mode 100644 index 0000000..646ff93 --- /dev/null +++ b/engineering.lock.json @@ -0,0 +1,5 @@ +{ + "repository": "http://192.168.2.28:3000/wanjakrah/engineering.git", + "tag": "v1.0.0", + "commit": "c25ce11a1bb8bf97f24d5e2b12d98680a9c573ca" +} diff --git a/engineering.properties b/engineering.properties new file mode 100644 index 0000000..5e51de6 --- /dev/null +++ b/engineering.properties @@ -0,0 +1,30 @@ +# 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=agiledancers-mini-pc +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 diff --git a/specs/implemented/S7_define-coding-standards-to-be b/generated/CODING_STANDARDS.md similarity index 50% rename from specs/implemented/S7_define-coding-standards-to-be rename to generated/CODING_STANDARDS.md index 6e1ef97..aaad96e 100644 --- a/specs/implemented/S7_define-coding-standards-to-be +++ b/generated/CODING_STANDARDS.md @@ -1,28 +1,32 @@ -# S7 — Backend coding standards +# Backend coding standards -Status: IMPLEMENTED - -Shared understanding confirmed after the grill-me review. - -## User story - -As a developer, I want developers and agents to follow explicit coding standards -for `agiledancers-be`, so that the code stays readable and maintainable. +This document is the authoritative policy for handwritten production and test Java +in ``. Generated code is out of scope. The Java conventions below also apply to +handwritten Java helpers outside that module. Frontend conventions are owned by each consuming project. ## Scope and authority -- Create root `CODING_STANDARDS.md` with the complete agreed backend rules. - Preserve any existing frontend instructions. -- Cover handwritten production and test Java in `agiledancers-be`. Exclude +- Cover handwritten production and test Java in ``. Exclude generated code. Dependency injection and production architecture rules apply - where relevant. Frontend and Java helpers outside the backend are out of scope. -- Use https://www.geeksforgeeks.org/java/coding-guidelines-in-java/ as source - material, not as a changing normative dependency. The local document is complete - and authoritative for the adopted rules. + where relevant. The Java conventions also cover Java helpers outside the backend; backend + architecture, metrics, coverage and automated checks remain backend-only. + Frontend code is out of scope. +- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/) + supplied source material. Only the rules adopted in this local document apply; + changes to the article do not change project policy. - The project's pinned Palantir formatter wins formatting conflicts, including comment layout. S8 remains responsible for formatter integration. - Do not adopt unrelated static-analysis rule bundles. +## General language guidance + +- Always prefer newer stable official APIs, language features and officially + recommended coding practices over older alternatives when they suit the task + and are supported by the project's configured language/runtime version. + Preserve required behavior and compatibility; document concrete reasons for + retaining an older approach. This guidance applies to every language used in + the project. + ## Java conventions - Types use UpperCamelCase; methods and variables use lowerCamelCase; constants @@ -32,9 +36,19 @@ for `agiledancers-be`, so that the code stays readable and maintainable. - Opening braces stay on the declaration/control-statement line. All `if`, `for` and `while` bodies require braces, including enhanced for and do/while bodies. - No wildcard imports. Static imports are allowed only in test classes. -- Comments explain intent or constraints. Document public contracts with Javadoc - where their meaning is not clear from the declaration; do not require boilerplate - documentation for every accessor. +- Prefer `java.nio` APIs (including `java.nio.file` and `java.nio.charset`) over + equivalent `java.io` APIs. Use stream APIs where no suitable NIO equivalent + exists, such as process pipes or APIs requiring streams. +- Use varargs (`Type... values`) when an array is the last method or constructor + parameter, including `main(String... args)`. Preserve required override signatures + and framework/API compatibility. +- Prefer Java 25 `IO.print`/`IO.println` for direct standard output and `IO.readln` + for line-oriented standard input. `IO` has no standard-error variant; retain + `System.err` for error output. Use application logging where appropriate. +- Comments explain intent or constraints. Require Javadoc on public classes and + public methods that do more than basic POJO/data-carrier work. Trivial accessors + and plain data carriers do not need boilerplate documentation. Describe behavior, + constraints and failure conditions where relevant. - Block comments use the following layout, subject to Palantir formatting: ```java @@ -162,50 +176,101 @@ exactly at a blocking limit passes. S7's required implementation. Configuring Sonar in the pipeline remains out of scope for S14. FindBugs/SpotBugs is not an additional required check suite. -## Existing-code migration -- Bring existing backend production and test Java into compliance, including - structural refactoring where necessary. -- Migration is complete when all blocking rules pass and all review signals have - been assessed, with rationale recorded for retained signals. Reaching every - target is not required. -- Preserve public APIs, JSON contracts, persistence formats and application - behavior. Any necessary contract change requires a separate decision. -- Separate enforcement/configuration changes from code refactoring for review. - Strengthen tests where migration affects behavior-sensitive code. +## Implemented checks and manual review -## Acceptance criteria +Checkstyle **12.3.1**, PMD/CPD **7.28.0**, and JaCoCo **0.8.14** are pinned. +`generated/tooling/standards/checkstyle.xml` selects individual naming, structure, import and brace checks; no general analysis bundle is enabled. +`generated/tooling/standards/src/main/java/Standards.java` supplies the targeted measurements. +ArchUnit remains in the backend test suite for architectural boundaries. -- [x] CODING_STANDARDS.md contains all agreed rules and preserves frontend guidance. -- [x] Agent and project code-review instructions enforce discovery and review of - these standards without modifying upstream skills. -- [x] Pinned tools, configurations and targeted checks implement the exact agreed - metrics and identify manual-only rules. -- [x] Verify and standalone checks report actionable blocking failures and review - signals; CI prevents publication after a failure. -- [x] Representative violations demonstrate that their checks fail, including - relevant threshold boundaries and custom measurement semantics. -- [x] Existing handwritten backend production and test code passes all blocking - rules; retained review signals and approved waivers are documented. -- [x] Local backend tests and combined CI coverage meet the agreed line-coverage - gates; line and branch reports are available. -- [x] Clean verification and behavior-preservation tests pass after migration. -- [x] Evidence records tool versions, commands, scope, exclusions, metric and - coverage reports, approved waivers and verification results. +PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the +resolved test dependency classpath. Cyclomatic complexity uses PMD's default +options: boolean paths count, assertions do not. Decisions include if/loops, +catch, ternary and throw; non-default switch alternatives count individually, +including comma-separated labels. There is no aggregate class-complexity gate. +Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch, +recursion and flow rules. The scoped visitors retain those algorithms while +including lambdas in their enclosing executable and excluding nested/local type +bodies, whose methods are measured independently. Compact record constructors +use the same visitors and start with cyclomatic complexity one. -## Implementation handoff +Custom checks measure physical code lines from regular parser tokens, including +brace/declaration lines and mixed code/comment lines. They count multiline literal +lines while excluding physically blank lines. Class length excludes nested-type +tokens, preserving lines shared with enclosing code. Other custom measurements +implement the definitions above: nesting, resolved type fan-out, project-owned +superclass depth, public non-accessor methods, and injected collaborators. +Injection includes inherited fields/setters, explicit `@Inject` constructors, +single constructors on bean classes, records' implicit canonical constructors, +and Lombok required/all-argument constructor injection. Qualifier keys use resolved +annotation names and values, including defaults; repeat sites are deduplicated. +The CPD configuration preserves identifiers/literals, uses 100 tokens and disables +skip blocks. Its line-location union counts overlapping occurrences only once. -This specification records the agreed design. Updating it does not implement -checks, change agent instructions, refactor application code or establish current -coverage. Those are implementation work, with evidence required above. +Checkstyle and targeted checks enforce import/brace/naming/visibility rules, +`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including +loops/catches and lambdas) and initialized fields. PMD's three selected finality +rules additionally cover control-flow assignment and private constructor-initialized +fields. Framework/lifecycle requirements, generated Lombok setters, and external +writes to public/protected fields need review before changing field finality. +Comment intent, Javadoc adequacy for nontrivial public contracts, record suitability +and protection of mutable components require manual review. These are **manual +rules**, as are ambiguous type inference, architecture/DI design decisions, NIO +API selection, trailing-array varargs, console I/O selection and the POJO +documentation boundary. Review all `DESIGN` record-component entries +and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`. -## Implementation evidence +Reports never rewrite Java. Formatter application remains S8's separate validate +execution. Generated output under `target` and generated source directories are +outside the handwritten `src/main/java` and `src/test/java` scope. No maintained +file, DTO, configuration class or package is excluded from standards or coverage. +JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions +are configured. Line and branch results are published, with only lines gated. -Implementation and verification are recorded in `specs/S7-validation.md`; retained -review signals and the sole approved waiver are recorded in -`specs/S7-review-signals.txt` and `tooling/standards/waivers.tsv`. +Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric, +fully resolved executable signature (or class), exact retained value and the +maintainer agreement/reason. No wildcard waivers are allowed, and a changed value +fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression +annotations/comments cannot silently waive a rule. Adding a waiver always requires maintainer agreement. -The maintainer approved GroupDetails-based Java service signatures and the exact -eight-parameter Group constructor waiver in the implementation conversation. -REST/JSON contracts remain unchanged. Clean verify and browser/combined coverage -checks pass. The implementation is ready for project review. +## Commands and reports + +Run from the root with JDK 25: + +```sh +mvn -Dformat.mode=check clean verify +# Prepare standalone analysis without running tests: +mvn -pl 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 /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: `/target/standards/report.txt`. +Checker regression results: `generated/tooling/standards/target/surefire-reports`. +Coverage HTML/XML/CSV: `/target/site/jacoco` (local backend tests) +and `/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 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. diff --git a/generated/SOURCE.json b/generated/SOURCE.json new file mode 100644 index 0000000..d833c0a --- /dev/null +++ b/generated/SOURCE.json @@ -0,0 +1,5 @@ +{ + "commit": "c25ce11a1bb8bf97f24d5e2b12d98680a9c573ca", + "repository": "http://192.168.2.28:3000/wanjakrah/engineering.git", + "tag": "v1.0.0" +} diff --git a/ci/Ci.java b/generated/ci/Ci.java similarity index 94% rename from ci/Ci.java rename to generated/ci/Ci.java index 48e3c97..a7b369e 100644 --- a/ci/Ci.java +++ b/generated/ci/Ci.java @@ -9,8 +9,8 @@ public class Ci { try { if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required"); if (args.length == 0) throw new IllegalArgumentException(usage()); - var env = new HashMap<>(System.getenv()); Path root = Path.of("").toAbsolutePath(); + var env = Recipe.environment(root, System.getenv()); var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75)); switch (args[0]) { case "verify", "publish", "cleanup" -> { @@ -32,7 +32,7 @@ public class Ci { case "configure-gitea" -> Setup.configureGitea(env); case "configure-docker" -> { if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path"); - Setup.configureDocker(Path.of(args[1])); + Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY")); } case "install-runner" -> Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only")); diff --git a/ci/CiTests.java b/generated/ci/CiTests.java similarity index 89% rename from ci/CiTests.java rename to generated/ci/CiTests.java index e1e4456..d33485f 100644 --- a/ci/CiTests.java +++ b/generated/ci/CiTests.java @@ -32,6 +32,26 @@ class CiTests { static void run() throws Exception { count = 0; + test("recipe supports backend-only and frontend-only projects", () -> { + equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server"))); + equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web"))); + failure(() -> Recipe.roles(Map.of())); + failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe"))); + }); + test("project configuration is overridden by environment and rejects escaping modules", () -> { + final Path root = Files.createTempDirectory("recipe-config-"); + try { + Files.writeString( + root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n"); + equal( + "override", + Recipe.environment(root, Map.of("PROJECT_NAME", "override")) + .get("PROJECT_NAME")); + failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside"))); + } finally { + Pipeline.deleteTree(root); + } + }); test("JSON preserves configuration values and escaped text", () -> { String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}"; Object data = Json.parse(json); @@ -49,14 +69,14 @@ class CiTests { Path file = dir.resolve("daemon.json"); String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}"; Files.writeString(file, original); - Setup.configureDocker(file); + Setup.configureDocker(file, "registry.example:3000"); String first = Files.readString(file); - Setup.configureDocker(file); + Setup.configureDocker(file, "registry.example:3000"); equal(first, Files.readString(file)); equal( - List.of("existing:5000", "192.168.2.28:3000"), + List.of("existing:5000", "registry.example:3000"), Json.object(Json.parse(first)).get("insecure-registries")); - equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers"))); + equal(original, Files.readString(dir.resolve("daemon.json.before-engineering"))); } finally { Pipeline.deleteTree(dir); } @@ -67,7 +87,7 @@ class CiTests { Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}"; Files.writeString(file, original); - failure(() -> Setup.configureDocker(file)); + failure(() -> Setup.configureDocker(file, "registry.example:3000")); equal(original, Files.readString(file)); } finally { Pipeline.deleteTree(dir); @@ -253,17 +273,20 @@ class CiTests { "CI_REF", "refs/heads/main", "CI_REPOSITORY", - "wanjakrah/agiledancers", + "owner/recipe-demo", "TARGET_PLATFORM", "linux/amd64", "GITEA_SERVER_URL", - "http://192.168.2.28:3000", + "http://registry.example:3000", "REGISTRY_USER", "test", "REGISTRY_TOKEN", "dummy-token", "CI_PUBLISH_LOCK_DIR", root.resolve("lock").toString())); + env.put("PROJECT_NAME", "recipe-demo"); + env.put("BACKEND_MODULE", "backend"); + env.put("FRONTEND_MODULE", "frontend"); pipeline = new Pipeline(root, env, fake) { @Override String mainHead() { diff --git a/ci/Commands.java b/generated/ci/Commands.java similarity index 100% rename from ci/Commands.java rename to generated/ci/Commands.java diff --git a/ci/Json.java b/generated/ci/Json.java similarity index 100% rename from ci/Json.java rename to generated/ci/Json.java diff --git a/ci/Pipeline.java b/generated/ci/Pipeline.java similarity index 58% rename from ci/Pipeline.java rename to generated/ci/Pipeline.java index ad53b66..d1ac2da 100644 --- a/ci/Pipeline.java +++ b/generated/ci/Pipeline.java @@ -14,15 +14,15 @@ class Pipeline { Pipeline(Path root, Map environment, Commands commands) throws Exception { this.root = root; - this.env = new HashMap<>(environment); + this.env = Recipe.environment(root, environment); this.commands = commands; commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD")); String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1"); check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA"); check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier"); - runName = "agiledancers-" + id + "-" + attempt; - backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt; - frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt; + runName = Recipe.project(env) + "-" + id + "-" + attempt; + backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt; + frontendImage = Recipe.project(env) + "-ci-frontend:" + commit + "-" + id + "-" + attempt; artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts); } @@ -71,7 +71,7 @@ class Pipeline { void isolateDocker() throws Exception { value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}")); env.remove("DOCKER_CONTEXT"); - isolatedConfig = Files.createTempDirectory("agiledancers-docker-"); + isolatedConfig = Files.createTempDirectory(Recipe.project(env) + "-docker-"); env.put("DOCKER_CONFIG", isolatedConfig.toString()); } @@ -98,175 +98,180 @@ class Pipeline { isolateDocker(); check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit"); run("java", "-version"); - run("node", "--version"); - run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"); + if (Recipe.roles(env).contains("frontend")) run("node", "--version"); + if (Recipe.roles(env).contains("backend")) + run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"); CiTests.run(); ReleaseTests.run(); - Path frontend = root.resolve("agiledancers-fe"); - verifyFrontend(); + Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", ".")); + if (Recipe.roles(env).contains("frontend")) verifyFrontend(); String platform = env.get("TARGET_PLATFORM"); - run( - "docker", - "build", - "--platform", - platform, - "--target", - "prebuilt", - "--label", - "org.opencontainers.image.revision=" + commit, - "-t", - backendImage, - "."); - run( - "docker", - "build", - "--platform", - platform, - "--target", - "prebuilt", - "--label", - "org.opencontainers.image.revision=" + commit, - "-t", - frontendImage, - "agiledancers-fe"); - Files.writeString(artifacts.resolve("containers-started"), ""); - run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName); - run( - "docker", - "run", - "-d", - "--name", - runName + "-database", - "--network", - runName, - "--network-alias", - "database", - "--env", - "POSTGRES_USER=agiledancers", - "--env", - "POSTGRES_PASSWORD=ci-only", - "--env", - "POSTGRES_DB=agiledancers", - "postgres:17.6"); - for (int attempt = 0; attempt < 60; attempt++) { - if (result(false, "docker", "exec", runName + "-database", "pg_isready", "-U", "agiledancers") - .code() - == 0) break; - Thread.sleep(1000); + for (String role : Recipe.roles(env)) { + run( + "docker", + "build", + "--platform", + platform, + "--target", + "prebuilt", + "--label", + "org.opencontainers.image.revision=" + commit, + "-t", + role.equals("backend") ? backendImage : frontendImage, + env.getOrDefault( + role.toUpperCase() + "_CONTEXT", + role.equals("backend") ? "." : env.get("FRONTEND_MODULE"))); + } + if (Recipe.enabled(env, "BROWSER_TESTS")) { + Files.writeString(artifacts.resolve("containers-started"), ""); + run("docker", "network", "create", "--label", Recipe.project(env) + ".ci.run=" + runName, runName); + if (Recipe.enabled(env, "CI_DATABASE")) { + run( + "docker", + "run", + "-d", + "--name", + runName + "-database", + "--network", + runName, + "--network-alias", + "database", + "--env", + "POSTGRES_USER=" + env.get("DATABASE_USER"), + "--env", + "POSTGRES_PASSWORD=ci-only", + "--env", + "POSTGRES_DB=" + env.get("DATABASE_NAME"), + "postgres:17.6"); + for (int attempt = 0; attempt < 60; attempt++) { + if (result( + false, + "docker", + "exec", + runName + "-database", + "pg_isready", + "-U", + env.get("DATABASE_USER")) + .code() + == 0) break; + Thread.sleep(1000); + } + } + run( + "docker", + "run", + "-d", + "--name", + runName + "-backend", + "--label", + Recipe.project(env) + ".ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--network-alias", + "backend", + "--memory", + "1g", + "--cpus", + "2", + "--volume", + root.resolve(env.get("BACKEND_MODULE") + "/target/coverage-tools/agent.jar") + + ":/jacoco-agent.jar:ro", + "--env", + "JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300", + "--env", + "DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"), + "--env", + "DATABASE_USER=" + env.get("DATABASE_USER"), + "--env", + "DATABASE_PASSWORD=ci-only", + backendImage); + run( + "docker", + "run", + "-d", + "--name", + runName + "-frontend", + "--label", + Recipe.project(env) + ".ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--network-alias", + "frontend", + "--memory", + "256m", + "--cpus", + "1", + frontendImage); + waitForApplication(); + String user = capture("id", "-u") + ":" + capture("id", "-g"); + run( + "docker", + "run", + "--name", + runName + "-browser", + "--label", + Recipe.project(env) + ".ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--memory", + "2g", + "--cpus", + "2", + "--shm-size=1g", + "--user", + user, + "--env", + "HOME=/tmp", + "--env", + "CI=true", + "--env", + "PLAYWRIGHT_BASE_URL=http://frontend:8080", + "--volume", + frontend + ":/workspace", + "--workdir", + "/workspace", + env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"), + "npm", + "run", + "e2e"); + run( + "docker", + "run", + "--rm", + "--network", + runName, + "--user", + user, + "--volume", + root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage", + "eclipse-temurin:25-jre", + "java", + "-jar", + "/coverage/coverage-tools/cli.jar", + "dump", + "--address", + "backend", + "--destfile", + "/coverage/playwright.exec"); + check( + Files.size(root.resolve(env.get("BACKEND_MODULE") + "/target/playwright.exec")) > 0, + "Playwright backend coverage is missing"); + run( + "mvn", + "-B", + "-ntp", + "-pl", + env.get("BACKEND_MODULE"), + "jacoco:merge@combined", + "jacoco:report@combined-report", + "exec:exec@combined-coverage"); } - run( - "docker", - "run", - "-d", - "--name", - runName + "-backend", - "--label", - "agiledancers.ci.run=" + runName, - "--platform", - platform, - "--network", - runName, - "--network-alias", - "backend", - "--memory", - "1g", - "--cpus", - "2", - "--volume", - root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro", - "--env", - "JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300", - "--env", - "DATABASE_URL=jdbc:postgresql://database:5432/agiledancers", - "--env", - "DATABASE_USER=agiledancers", - "--env", - "DATABASE_PASSWORD=ci-only", - backendImage); - run( - "docker", - "run", - "-d", - "--name", - runName + "-frontend", - "--label", - "agiledancers.ci.run=" + runName, - "--platform", - platform, - "--network", - runName, - "--network-alias", - "frontend", - "--memory", - "256m", - "--cpus", - "1", - frontendImage); - waitForApplication(); - String user = capture("id", "-u") + ":" + capture("id", "-g"); - run( - "docker", - "run", - "--name", - runName + "-browser", - "--label", - "agiledancers.ci.run=" + runName, - "--platform", - platform, - "--network", - runName, - "--memory", - "2g", - "--cpus", - "2", - "--shm-size=1g", - "--user", - user, - "--env", - "HOME=/tmp", - "--env", - "CI=true", - "--env", - "PLAYWRIGHT_BASE_URL=http://frontend:8080", - "--volume", - frontend + ":/workspace", - "--workdir", - "/workspace", - "mcr.microsoft.com/playwright:v1.64.1-noble", - "npm", - "run", - "e2e"); - run( - "docker", - "run", - "--rm", - "--network", - runName, - "--user", - user, - "--volume", - root.resolve("agiledancers-be/target") + ":/coverage", - "eclipse-temurin:25-jre", - "java", - "-jar", - "/coverage/coverage-tools/cli.jar", - "dump", - "--address", - "backend", - "--destfile", - "/coverage/playwright.exec"); - check( - Files.size(root.resolve("agiledancers-be/target/playwright.exec")) > 0, - "Playwright backend coverage is missing"); - run( - "mvn", - "-B", - "-ntp", - "-pl", - "agiledancers-be", - "jacoco:merge@combined", - "jacoco:report@combined-report", - "exec:exec@combined-coverage"); Files.writeString(artifacts.resolve("verified-commit"), commit + "\n"); } catch (Exception e) { failure = e; @@ -289,23 +294,17 @@ class Pipeline { } void verifyFrontend() throws Exception { - Path frontend = root.resolve("agiledancers-fe"); + Path frontend = root.resolve(env.get("FRONTEND_MODULE")); commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked(); commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true) .checked(); - commands.execute( - List.of( - "npm", - "test", - "--", - "--reporters=default", - "--reporters=junit", - "--output-file=unit-results/junit.xml"), - frontend, - env, - null, - true) - .checked(); + final Object configured = Json.parse( + env.getOrDefault( + "FRONTEND_TEST_COMMAND", + "[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]")); + final List test = + ((List) configured).stream().map(Object::toString).toList(); + commands.execute(test, frontend, env, null, true).checked(); commands.execute(List.of("npm", "run", "build"), frontend, env, null, true) .checked(); } @@ -321,7 +320,7 @@ class Pipeline { "-q", "-O", "-", - "http://127.0.0.1:8080/api/dance-schools") + Setup.required(env, "CI_READINESS_URL")) .code() == 0) return; Thread.sleep(2000); @@ -334,15 +333,15 @@ class Pipeline { commands.renew(Duration.ofMinutes(2)); List failures = new ArrayList<>(); for (String path : List.of( - "agiledancers-be/target/surefire-reports", - "agiledancers-be/target/standards", - "tooling/standards/target/surefire-reports", - "agiledancers-be/target/site/jacoco", - "agiledancers-be/target/site/jacoco-combined", - "agiledancers-be/target/failsafe-reports", - "agiledancers-fe/unit-results", - "agiledancers-fe/test-results", - "agiledancers-fe/playwright-report")) { + env.get("BACKEND_MODULE") + "/target/surefire-reports", + env.get("BACKEND_MODULE") + "/target/standards", + "generated/tooling/standards/target/surefire-reports", + env.get("BACKEND_MODULE") + "/target/site/jacoco", + env.get("BACKEND_MODULE") + "/target/site/jacoco-combined", + env.get("BACKEND_MODULE") + "/target/failsafe-reports", + env.get("FRONTEND_MODULE") + "/unit-results", + env.get("FRONTEND_MODULE") + "/test-results", + env.get("FRONTEND_MODULE") + "/playwright-report")) { if (Files.isDirectory(root.resolve(path))) { try { copyTree(root.resolve(path), artifacts.resolve(path)); @@ -352,7 +351,8 @@ class Pipeline { } } for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) { - final Path source = root.resolve("agiledancers-be/target").resolve(data); + final Path source = + root.resolve(env.get("BACKEND_MODULE") + "/target").resolve(data); if (Files.isRegularFile(source)) Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING); } @@ -404,7 +404,7 @@ class Pipeline { "CI_PUBLISH_LOCK_DIR", env.getOrDefault( "XDG_STATE_HOME", - Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci")); + Setup.home().resolve(".local/state").toString()) + "/" + Recipe.project(env) + "-ci")); Files.createDirectories(lockDir); // Shared host lock: Gitea 1.25 ignores workflow concurrency. try (FileChannel channel = FileChannel.open( @@ -419,10 +419,11 @@ class Pipeline { token, true) .checked(); - String prefix = registry + "/" + owner + "/agiledancers-"; - ensureImage(prefix + "backend", backendImage); - ensureImage(prefix + "frontend", frontendImage); - for (String role : List.of("backend", "frontend")) { + String prefix = registry + "/" + owner + "/" + Recipe.project(env) + "-"; + for (String role : Recipe.roles(env)) { + ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage); + } + for (String role : Recipe.roles(env)) { if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; diff --git a/generated/ci/Recipe.java b/generated/ci/Recipe.java new file mode 100644 index 0000000..6e46c70 --- /dev/null +++ b/generated/ci/Recipe.java @@ -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 environment(final Path root, final Map input) throws Exception { + final var result = new HashMap(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 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 roles(final Map env) { + final var roles = new ArrayList(); + 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 env, final String key) { + return Boolean.parseBoolean(env.getOrDefault(key, "false")); + } +} diff --git a/ci/Release.java b/generated/ci/Release.java similarity index 97% rename from ci/Release.java rename to generated/ci/Release.java index 043fef8..395574c 100644 --- a/ci/Release.java +++ b/generated/ci/Release.java @@ -153,6 +153,7 @@ class Release { "A newer release has already succeeded; use explicit rollback for older versions"); String build = api.verified(commit); remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml"))); + remote.call("stage-health", Files.readString(root.resolve("deploy/health.sh"))); String prepared = remote.call("prepare", null, version.toString(), commit, prefix()); return new ReleaseState.Deployment( version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build); @@ -168,7 +169,7 @@ class Release { registry.equals(authority) || registry.equals("127.0.0.1:" + port), "Deployment registry must be the existing Gitea registry or its NAS loopback alias"); String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT); - return registry + "/" + owner + "/agiledancers-"; + return registry + "/" + owner + "/" + Recipe.project(env) + "-"; } void ensure(ReleaseState.Deployment release) throws Exception { @@ -183,6 +184,7 @@ class Release { } String digest(String response, String role) { + if (!Recipe.roles(env).contains(role.toLowerCase(java.util.Locale.ROOT))) return "-"; String value = response.lines() .filter(line -> line.startsWith("S6_" + role + "=")) .map(line -> line.substring(("S6_" + role + "=").length())) diff --git a/ci/ReleaseApi.java b/generated/ci/ReleaseApi.java similarity index 98% rename from ci/ReleaseApi.java rename to generated/ci/ReleaseApi.java index 2971e18..2bcc185 100644 --- a/ci/ReleaseApi.java +++ b/generated/ci/ReleaseApi.java @@ -82,7 +82,7 @@ class ReleaseApi { "target", commit, "message", - "Agiledancers " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run)); + "Release " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run)); Tag tag = tags().stream() .filter(t -> t.version().equals(version)) .findFirst() diff --git a/ci/ReleaseRemote.java b/generated/ci/ReleaseRemote.java similarity index 85% rename from ci/ReleaseRemote.java rename to generated/ci/ReleaseRemote.java index e493d9c..cc77b10 100644 --- a/ci/ReleaseRemote.java +++ b/generated/ci/ReleaseRemote.java @@ -32,8 +32,14 @@ class ReleaseRemote implements AutoCloseable { && Integer.parseInt(port) > 0 && Integer.parseInt(port) <= 65535, "Invalid deployment SSH host, user or port"); - script = Files.readString(root.resolve("deploy/remote.sh")); - credentials = Files.createTempDirectory("agiledancers-deploy-ssh-"); + script = "COMPOSE_PROJECT=" + quote(Setup.required(env, "COMPOSE_PROJECT")) + "\n" + + "DEPLOY_ROLES=" + quote(String.join(" ", Recipe.roles(env))) + "\n" + + "DEPENDENCY_SERVICES=" + quote(env.getOrDefault("DEPENDENCY_SERVICES", "")) + "\n" + + "TARGET_PLATFORM=" + quote(env.getOrDefault("TARGET_PLATFORM", "linux/amd64")) + "\n" + + "REQUIRED_SECRETS=" + quote(env.getOrDefault("REQUIRED_DEPLOY_SECRETS", "")) + "\n" + + Files.readString(root.resolve("deploy/health.sh")) + "\n" + + Files.readString(root.resolve("generated/deploy/remote.sh")); + credentials = Files.createTempDirectory("engineering-deploy-ssh-"); try { writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n"); writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n"); diff --git a/ci/ReleaseSetup.java b/generated/ci/ReleaseSetup.java similarity index 81% rename from ci/ReleaseSetup.java rename to generated/ci/ReleaseSetup.java index 412966d..de8fa26 100644 --- a/ci/ReleaseSetup.java +++ b/generated/ci/ReleaseSetup.java @@ -10,20 +10,12 @@ import java.util.Map; class ReleaseSetup { static void configure(Path root, Map input) throws Exception { var env = new HashMap<>(input); - env.putIfAbsent("GITEA_SERVER_URL", "http://192.168.2.28:3000"); - env.putIfAbsent("CI_REPOSITORY", "wanjakrah/agiledancers"); - env.putIfAbsent("DEPLOY_HOST", "192.168.2.28"); - env.putIfAbsent("DEPLOY_USER", "agiledancers-deploy"); env.putIfAbsent("DEPLOY_SSH_PORT", "22"); - env.putIfAbsent("DEPLOY_DIRECTORY", "/volume1/docker/agiledancers-production"); - env.putIfAbsent("DEPLOY_BIND_IP", "192.168.2.28"); - env.putIfAbsent("DEPLOY_FRONTEND_PORT", "8088"); - env.putIfAbsent("DEPLOY_REGISTRY", "127.0.0.1:3000"); - Path config = Setup.home().resolve(".config/agiledancers-runner"); + Path config = Setup.home().resolve(".config/" + Recipe.project(env) + "-runner"); env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString()); - env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("s6-release-token").toString()); - env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("s6-pull-token").toString()); - env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("s6-deploy-key").toString()); + env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("release-token").toString()); + env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("pull-token").toString()); + env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("deploy-key").toString()); String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE"); String pull = Setup.secret(env, "PULL_TOKEN_FILE"); env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE")); @@ -70,10 +62,16 @@ class ReleaseSetup { Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user"); try (var remote = new ReleaseRemote(root, env, commands, "setup")) { System.out.println(remote.executeScript( - Files.readString(root.resolve("deploy/provision.sh")), + Files.readString(root.resolve("generated/deploy/provision.sh")), pull + "\n", 300, - List.of(remote.directory, registryUser, registry, bind, port))); + List.of( + remote.directory, + registryUser, + registry, + bind, + port, + Setup.required(env, "DEPLOY_DOCKER_BIN")))); remote.acquire(); try { remote.call("preflight", null); diff --git a/ci/ReleaseShellTests.java b/generated/ci/ReleaseShellTests.java similarity index 90% rename from ci/ReleaseShellTests.java rename to generated/ci/ReleaseShellTests.java index bfb5be9..e16d58a 100644 --- a/ci/ReleaseShellTests.java +++ b/generated/ci/ReleaseShellTests.java @@ -17,7 +17,7 @@ class ReleaseShellTests { CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked()); String calls = Files.readString(f.directory.resolve("calls")); CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready")); - CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/dance-schools")); + CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/items")); CiTests.equal(false, calls.contains(" down")); CiTests.equal(false, calls.contains("prune")); } @@ -111,7 +111,19 @@ class ReleaseShellTests { static class Fixture implements AutoCloseable { final Path directory = Files.createTempDirectory("nas-script-test-"); final Map env = new HashMap<>(System.getenv()); - final String script = Files.readString(Path.of("deploy/remote.sh")); + final String health = "application_health() {\n" + + "frontend=$(compose ps -q frontend)\n" + + "docker_cmd exec \"$frontend\" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return" + + " 1\n" + + "curl --fail http://127.0.0.1:8088/api/items -o /dev/null\n" + + "}\n"; + final String script = "COMPOSE_PROJECT=recipe-demo-test\n" + + "DEPLOY_ROLES='backend frontend'\n" + + "DEPENDENCY_SERVICES=database\n" + + "TARGET_PLATFORM=linux/amd64\n" + + "REQUIRED_SECRETS=DATABASE_PASSWORD\n" + + health + "\n" + + Files.readString(Path.of("generated/deploy/remote.sh")); final Commands commands = new Commands(Duration.ofMinutes(1)); Fixture() throws Exception { @@ -150,19 +162,19 @@ class ReleaseShellTests { } String backend() { - return "nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64); + return "nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64); } String frontend() { - return "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64); + return "nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64); } void prepare() throws Exception { call("lock", null, "one").checked(); call("preflight", null, "one").checked(); - call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one") - .checked(); - String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-") + call("stage", "services: {}\n", "one").checked(); + call("stage-health", health, "one").checked(); + String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/recipe-demo-") .checked(); CiTests.equal(true, result.contains("S6_BACKEND=" + backend())); CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend())); diff --git a/ci/ReleaseState.java b/generated/ci/ReleaseState.java similarity index 91% rename from ci/ReleaseState.java rename to generated/ci/ReleaseState.java index 3b526e2..d49eeba 100644 --- a/ci/ReleaseState.java +++ b/generated/ci/ReleaseState.java @@ -9,8 +9,11 @@ record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment pre Deployment { ReleaseApi.sha(sha); Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory"); + Pipeline.check(!backend.equals("-") || !frontend.equals("-"), "A release must contain an image"); for (String image : new String[] {backend, frontend}) - Pipeline.check(image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}"), "Invalid retained image digest"); + Pipeline.check( + (image.equals("-") || image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}")), + "Invalid retained image digest"); } Map json() { diff --git a/ci/ReleaseTests.java b/generated/ci/ReleaseTests.java similarity index 96% rename from ci/ReleaseTests.java rename to generated/ci/ReleaseTests.java index bc0e907..0f733b2 100644 --- a/ci/ReleaseTests.java +++ b/generated/ci/ReleaseTests.java @@ -37,7 +37,7 @@ class ReleaseTests { "GITEA_SERVER_URL", "http://nas:3000", "CI_REPOSITORY", - "owner/agiledancers", + "owner/recipe-demo", "RELEASE_TOKEN", "test")); env.putAll(Map.of( @@ -58,8 +58,8 @@ class ReleaseTests { return new ReleaseState.Deployment( ReleaseVersion.parse(version), sha, - "nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64), - "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64), + "nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64), + "nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64), directory, "10"); } @@ -353,8 +353,14 @@ class ReleaseTests { Fixture(String mode) throws Exception { Files.createDirectories(root.resolve("deploy")); - Files.writeString(root.resolve("deploy/remote.sh"), ""); + Files.createDirectories(root.resolve("generated/deploy")); + env.put("PROJECT_NAME", "recipe-demo"); + env.put("BACKEND_MODULE", "backend"); + env.put("FRONTEND_MODULE", "frontend"); + env.put("COMPOSE_PROJECT", "recipe-demo-test"); + Files.writeString(root.resolve("generated/deploy/remote.sh"), ""); Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n"); + Files.writeString(root.resolve("deploy/health.sh"), "application_health() { true; }\n"); env.put("RELEASE_MODE", mode); if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0"); api = new FakeApi(env, events); @@ -379,7 +385,7 @@ class ReleaseTests { test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> { try (var f = new Fixture()) { f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000"); - CiTests.equal("127.0.0.1:3000/owner/agiledancers-", f.release.prefix()); + CiTests.equal("127.0.0.1:3000/owner/recipe-demo-", f.release.prefix()); f.env.put("DEPLOY_REGISTRY", "other-registry:3000"); CiTests.failure(f.release::prefix); } @@ -476,7 +482,7 @@ class ReleaseTests { test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> { Path directory = Files.createTempDirectory("release-shell-test-"); try { - String script = Files.readString(Path.of("deploy/remote.sh")); + String script = Files.readString(Path.of("generated/deploy/remote.sh")); var commands = new Commands(Duration.ofSeconds(30)); var env = environment(); var first = commands.execute( diff --git a/ci/ReleaseVersion.java b/generated/ci/ReleaseVersion.java similarity index 100% rename from ci/ReleaseVersion.java rename to generated/ci/ReleaseVersion.java diff --git a/ci/Setup.java b/generated/ci/Setup.java similarity index 91% rename from ci/Setup.java rename to generated/ci/Setup.java index ad4f550..0963d5b 100644 --- a/ci/Setup.java +++ b/generated/ci/Setup.java @@ -65,7 +65,7 @@ class Setup { Pipeline.check( Set.of("linux/amd64", "linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture"); - String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers"; + String server = required(env, "GITEA_SERVER_URL"), base = "/api/v1/repos/" + required(env, "CI_REPOSITORY"); api(server, admin, "PATCH", base, Map.of("has_actions", true)); api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user)); api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry)); @@ -81,29 +81,29 @@ class Setup { api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform)); Map response = Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null)); - Path directory = home().resolve(".config/agiledancers-runner"); + Path directory = home().resolve(".config/" + Recipe.project(env) + "-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 void configureDocker(Path file, String registry) throws IOException { Map data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>(); Object entry = data.getOrDefault("insecure-registries", new ArrayList<>()); if (!(entry instanceof List)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged"); List 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"); return; } - registries.add("192.168.2.28:3000"); + registries.add(registry); data.put("insecure-registries", registries); Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent); if (Files.exists(file)) { - Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers"); + Path backup = file.resolveSibling(file.getFileName() + ".before-engineering"); if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES); } Path temp = Files.createTempFile(parent, "daemon-", ".json"); @@ -123,8 +123,8 @@ class Setup { System.getProperty("os.name").equals("Linux") && Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")), "Installer targets the existing Linux x86_64 mini-PC"); - Path runnerDir = home().resolve(".local/share/agiledancers-runner"), - configDir = home().resolve(".config/agiledancers-runner"); + Path runnerDir = home().resolve(".local/share/" + Recipe.project(env) + "-runner"), + configDir = home().resolve(".config/" + Recipe.project(env) + "-runner"); Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user"); for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path); for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool); @@ -166,11 +166,11 @@ class Setup { 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"))); Files.copy( - root.resolve("ci/runner/agiledancers-runner.service"), - units.resolve("agiledancers-runner.service"), + root.resolve("ci/runner/runner.service"), + units.resolve(Recipe.project(env) + "-runner.service"), StandardCopyOption.REPLACE_EXISTING); Files.copy( - root.resolve("ci/runner/WaitForDocker.java"), + root.resolve("generated/ci/runner/WaitForDocker.java"), runnerDir.resolve("WaitForDocker.java"), StandardCopyOption.REPLACE_EXISTING); String dockerHost = env.get("DOCKER_HOST"); @@ -189,8 +189,8 @@ class Setup { "JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n"); boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock"); - Path overrides = units.resolve("agiledancers-runner.service.d"); - Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf"); + Path overrides = units.resolve(Recipe.project(env) + "-runner.service.d"); + Path desktopOverride = overrides.resolve("10-docker-desktop.conf"); if (desktop) { Files.createDirectories(overrides); Files.copy( @@ -219,11 +219,11 @@ class Setup { "register", "--no-interactive", "--instance", - "http://192.168.2.28:3000", + required(env, "GITEA_SERVER_URL"), "--name", - "agiledancers-mini-pc", + required(env, "RUNNER_LABEL"), "--labels", - "agiledancers-mini-pc:host", + required(env, "RUNNER_LABEL") + ":host", "--token", token), runnerDir, @@ -243,7 +243,7 @@ class Setup { .checked(); // Replace any previous default.target link with the desktop-login target. commands.execute( - List.of("systemctl", "--user", "disable", "agiledancers-runner.service"), + List.of("systemctl", "--user", "disable", Recipe.project(env) + "-runner.service"), root, env, null, @@ -251,7 +251,7 @@ class Setup { .checked(); } commands.execute( - List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"), + List.of("systemctl", "--user", "enable", "--now", Recipe.project(env) + "-runner.service"), root, env, null, diff --git a/ci/runner/WaitForDocker.java b/generated/ci/runner/WaitForDocker.java similarity index 100% rename from ci/runner/WaitForDocker.java rename to generated/ci/runner/WaitForDocker.java diff --git a/deploy/enable-docker.sh b/generated/deploy/enable-docker.sh similarity index 80% rename from deploy/enable-docker.sh rename to generated/deploy/enable-docker.sh index 4ef58b9..0fd1a90 100644 --- a/deploy/enable-docker.sh +++ b/generated/deploy/enable-docker.sh @@ -2,9 +2,11 @@ # One-time DSM administrator action, run with sudo; does not restart any service. set -eu test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; } -account=agiledancers-deploy -docker=/usr/local/bin/docker -target=/etc/sudoers.d/agiledancers-deploy +account=${1:?Supply deployment account} +docker=${2:?Supply absolute Docker executable} +case "$account" in ''|*[!a-zA-Z0-9_-]*) exit 1;; esac +case "$docker" in /*) ;; *) exit 1;; esac +target=/etc/sudoers.d/"$account" rule="$account ALL=(root) NOPASSWD: $docker" test -x "$docker" && id "$account" >/dev/null test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; } @@ -12,7 +14,7 @@ if [ -e "$target" ]; then test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; } else umask 077 - temporary=$(mktemp /etc/sudoers.d/agiledancers-deploy.XXXXXX) + temporary=$(mktemp /etc/sudoers.d/"$account".XXXXXX) trap 'rm -f "$temporary"' EXIT printf '%s\n' "$rule" > "$temporary" chmod 440 "$temporary" diff --git a/deploy/provision.sh b/generated/deploy/provision.sh similarity index 97% rename from deploy/provision.sh rename to generated/deploy/provision.sh index 75ed8c1..fbf8b78 100644 --- a/deploy/provision.sh +++ b/generated/deploy/provision.sh @@ -7,7 +7,7 @@ registry_user=$2 registry=$3 bind=$4 port=$5 -docker=/usr/local/bin/docker +docker=${6:?Supply the Docker executable path} test -x "$docker" sudo -n "$docker" compose version sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}' diff --git a/deploy/remote.sh b/generated/deploy/remote.sh similarity index 77% rename from deploy/remote.sh rename to generated/deploy/remote.sh index 8aeabab..145cda3 100644 --- a/deploy/remote.sh +++ b/generated/deploy/remote.sh @@ -7,7 +7,7 @@ action=$1 root=$2 owner=$3 shift 3 -die() { echo "S6: $*" >&2; exit 1; } +die() { echo "Deployment: $*" >&2; exit 1; } case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac test -d "$root" || die 'Provision the deployment directory first' @@ -72,8 +72,13 @@ docker_cmd() { "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@" fi } +: "${COMPOSE_PROJECT:?}" "${DEPLOY_ROLES:?}" "${TARGET_PLATFORM:?}" +case "$COMPOSE_PROJECT" in ''|*[!a-z0-9_-]*) die 'Invalid Compose project';; esac +case "$DEPENDENCY_SERVICES" in *[!a-zA-Z0-9_\ -]*) die 'Invalid dependency services';; esac +case "$DEPLOY_ROLES" in backend|frontend|'backend frontend') ;; *) die 'Invalid deployment roles';; esac +case "$TARGET_PLATFORM" in linux/amd64|linux/arm64) ;; *) die 'Invalid target platform';; esac compose() { - docker_cmd compose --project-name agiledancers-production --project-directory "$root" \ + docker_cmd compose --project-name "$COMPOSE_PROJECT" --project-directory "$root" \ --env-file "$root/production.env" --env-file "$release/release.env" -f "$release/compose.yaml" "$@" } identity() { @@ -81,8 +86,8 @@ identity() { sha=$2 test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \ || die 'Image revision does not match selected SHA' - test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \ - || die 'Image platform must be linux/amd64' + test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "$TARGET_PLATFORM" \ + || die 'Image platform differs from target' } use_release() { case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac @@ -90,11 +95,8 @@ use_release() { test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration' } health() { - backend=$(compose ps -q backend) - frontend=$(compose ps -q frontend) - test -n "$backend" && test -n "$frontend" || return 1 # Compare image IDs, revision labels and the container release labels. - for role in backend frontend; do + for role in $DEPLOY_ROLES; do id=$(compose ps -q "$role") if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1 @@ -102,21 +104,24 @@ health() { test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1 test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1 done - docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1 - curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1 - curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null + if [ -f "$release/health.sh" ]; then . "$release/health.sh"; fi + application_health } case "$action" in preflight) - test -n "${DATABASE_PASSWORD:-}" || die 'Missing DATABASE_PASSWORD in production.env; follow docs/persistent-storage.md before deploying S16' + for secret in ${REQUIRED_SECRETS:-}; do + case "$secret" in ''|*[!A-Z0-9_]*) die 'Invalid required secret name';; esac + eval 'value=${'"$secret"':-}' + test -n "$value" || die "Missing $secret in production.env" + done command -v timeout >/dev/null || die 'timeout is required for bounded remote operations' command -v curl >/dev/null || die 'curl is required' test -x "$DOCKER_BIN" || die 'Docker executable missing' docker_cmd compose version >/dev/null - test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \ - || test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \ - || die 'Synology Docker platform is not linux/amd64' + actual=$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}') + case "$actual" in linux/x86_64) actual=linux/amd64;; linux/aarch64) actual=linux/arm64;; esac + test "$actual" = "$TARGET_PLATFORM" || die 'Docker platform differs from target' test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first' # Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication. case "$BIND_IP" in @@ -131,13 +136,18 @@ case "$action" in test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists' cat > "$release/compose.yaml" ;; + stage-health) + test -f "$release/compose.yaml" || die 'Stage Compose first' + test ! -e "$release/health.sh" || die 'Attempt health configuration already exists' + cat > "$release/health.sh" + ;; prepare) version=$1; sha=$2; prefix=$3 - backend=''; frontend='' - for role in backend frontend; do + backend='-'; frontend='-' + for role in $DEPLOY_ROLES; do repository="$prefix$role" image="$repository:$sha" - docker_cmd pull --platform linux/amd64 "$image" >&2 + docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2 identity "$image" "$sha" digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \ | awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }') @@ -146,6 +156,8 @@ case "$action" in done printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \ "$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env" + printf 'COMPOSE_PROJECT=%s\nDEPLOY_ROLES="%s"\nDEPENDENCY_SERVICES="%s"\nTARGET_PLATFORM=%s\n' \ + "$COMPOSE_PROJECT" "$DEPLOY_ROLES" "${DEPENDENCY_SERVICES:-}" "$TARGET_PLATFORM" >> "$release/release.env" compose config --quiet printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend" ;; @@ -156,7 +168,8 @@ case "$action" in && test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \ || die 'Retained configuration differs from recorded successful release' for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do - docker_cmd pull --platform linux/amd64 "$image" >&2 + [ "$image" != - ] || continue + docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2 identity "$image" "$RELEASE_SHA" done compose config --quiet @@ -172,8 +185,10 @@ case "$action" in end=$(( $(date +%s) + health_seconds )) # Always recreate the frontend too: nginx must resolve the new backend IP. # Recreate application containers only; the database and volume survive releases. - compose up -d --no-build --pull missing --timeout 20 database - compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 backend frontend + if [ -n "${DEPENDENCY_SERVICES:-}" ]; then + compose up -d --no-build --pull missing --timeout 20 $DEPENDENCY_SERVICES + fi + compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 $DEPLOY_ROLES while ! health; do test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out' sleep 1 diff --git a/generated/docs/backend-review.md b/generated/docs/backend-review.md new file mode 100644 index 0000000..ff4a556 --- /dev/null +++ b/generated/docs/backend-review.md @@ -0,0 +1,19 @@ +# Backend review companion + +Use alongside `generated/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. diff --git a/skills/README.md b/generated/skills/README.md similarity index 69% rename from skills/README.md rename to generated/skills/README.md index 3d27842..2e5c712 100644 --- a/skills/README.md +++ b/generated/skills/README.md @@ -1,6 +1,6 @@ -# Project skills +# Engineering skills -Reusable agent instructions for this repository. Invoke them explicitly in the +Reusable agent instructions for consuming repositories. Invoke them explicitly in the conversation, for example: > Gebruik grill-me om de JSON-import en -export met mij uit te werken. @@ -19,5 +19,5 @@ The exact upstream revisions and paths are recorded in [upstream.json](upstream. 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. +turn through the instructions in the consuming project's root `AGENTS.md`. They are not installed +globally. Update upstream sources in engineering, retaining licenses and revision records, then release a new engineering version. Consumers regenerate their pinned copies. diff --git a/skills/adr-brainstorm/ATTRIBUTION.md b/generated/skills/adr-brainstorm/ATTRIBUTION.md similarity index 100% rename from skills/adr-brainstorm/ATTRIBUTION.md rename to generated/skills/adr-brainstorm/ATTRIBUTION.md diff --git a/skills/adr-brainstorm/LICENSE.mattpocock b/generated/skills/adr-brainstorm/LICENSE.mattpocock similarity index 100% rename from skills/adr-brainstorm/LICENSE.mattpocock rename to generated/skills/adr-brainstorm/LICENSE.mattpocock diff --git a/skills/adr-brainstorm/LICENSE.wanjakrah b/generated/skills/adr-brainstorm/LICENSE.wanjakrah similarity index 100% rename from skills/adr-brainstorm/LICENSE.wanjakrah rename to generated/skills/adr-brainstorm/LICENSE.wanjakrah diff --git a/skills/adr-brainstorm/SKILL.md b/generated/skills/adr-brainstorm/SKILL.md similarity index 100% rename from skills/adr-brainstorm/SKILL.md rename to generated/skills/adr-brainstorm/SKILL.md diff --git a/skills/adr-brainstorm/adr-template.md b/generated/skills/adr-brainstorm/adr-template.md similarity index 100% rename from skills/adr-brainstorm/adr-template.md rename to generated/skills/adr-brainstorm/adr-template.md diff --git a/skills/adr-brainstorm/agents/openai.yaml b/generated/skills/adr-brainstorm/agents/openai.yaml similarity index 100% rename from skills/adr-brainstorm/agents/openai.yaml rename to generated/skills/adr-brainstorm/agents/openai.yaml diff --git a/skills/code-review/LICENSE.mattpocock b/generated/skills/code-review/LICENSE.mattpocock similarity index 100% rename from skills/code-review/LICENSE.mattpocock rename to generated/skills/code-review/LICENSE.mattpocock diff --git a/skills/code-review/SKILL.md b/generated/skills/code-review/SKILL.md similarity index 100% rename from skills/code-review/SKILL.md rename to generated/skills/code-review/SKILL.md diff --git a/skills/code-review/agents/openai.yaml b/generated/skills/code-review/agents/openai.yaml similarity index 100% rename from skills/code-review/agents/openai.yaml rename to generated/skills/code-review/agents/openai.yaml diff --git a/skills/grill-me/LICENSE.mattpocock b/generated/skills/grill-me/LICENSE.mattpocock similarity index 100% rename from skills/grill-me/LICENSE.mattpocock rename to generated/skills/grill-me/LICENSE.mattpocock diff --git a/skills/grill-me/SKILL.md b/generated/skills/grill-me/SKILL.md similarity index 100% rename from skills/grill-me/SKILL.md rename to generated/skills/grill-me/SKILL.md diff --git a/skills/grill-me/agents/openai.yaml b/generated/skills/grill-me/agents/openai.yaml similarity index 100% rename from skills/grill-me/agents/openai.yaml rename to generated/skills/grill-me/agents/openai.yaml diff --git a/skills/grilling/LICENSE.mattpocock b/generated/skills/grilling/LICENSE.mattpocock similarity index 100% rename from skills/grilling/LICENSE.mattpocock rename to generated/skills/grilling/LICENSE.mattpocock diff --git a/skills/grilling/SKILL.md b/generated/skills/grilling/SKILL.md similarity index 100% rename from skills/grilling/SKILL.md rename to generated/skills/grilling/SKILL.md diff --git a/skills/grilling/agents/openai.yaml b/generated/skills/grilling/agents/openai.yaml similarity index 100% rename from skills/grilling/agents/openai.yaml rename to generated/skills/grilling/agents/openai.yaml diff --git a/skills/upstream.json b/generated/skills/upstream.json similarity index 100% rename from skills/upstream.json rename to generated/skills/upstream.json diff --git a/generated/tooling/coverage/Coverage.java b/generated/tooling/coverage/Coverage.java new file mode 100644 index 0000000..86d99ed --- /dev/null +++ b/generated/tooling/coverage/Coverage.java @@ -0,0 +1,30 @@ +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]); + } +} diff --git a/tooling/format/Format.java b/generated/tooling/format/Format.java similarity index 93% rename from tooling/format/Format.java rename to generated/tooling/format/Format.java index d8b42da..3413b94 100644 --- a/tooling/format/Format.java +++ b/generated/tooling/format/Format.java @@ -24,14 +24,19 @@ public class Format { } } String configuration = digest(Files.readString(root.resolve("pom.xml")) - + Files.readString(root.resolve("tooling/format/Format.java")) + + Files.readString(root.resolve("generated/tooling/format/Format.java")) + System.getProperty("java.version") + System.getProperty("java.class.path")); if (!configuration.equals(cache.getProperty("configuration"))) cache.clear(); cache.setProperty("configuration", configuration); List sources = new ArrayList<>(); + var settings = new Properties(); + try (var input = Files.newInputStream(root.resolve("engineering.properties"))) { + settings.load(input); + } for (String directory : - List.of("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci", "tooling/format")) { + settings.getProperty("FORMAT_DIRECTORIES", "backend/src").split(",")) { + if (!Files.isDirectory(root.resolve(directory))) continue; try (var paths = Files.walk(root.resolve(directory))) { for (Path path : paths.filter(p -> p.toString().endsWith(".java")) .sorted() diff --git a/tooling/standards/checkstyle.xml b/generated/tooling/standards/checkstyle.xml similarity index 100% rename from tooling/standards/checkstyle.xml rename to generated/tooling/standards/checkstyle.xml diff --git a/tooling/standards/pom.xml b/generated/tooling/standards/pom.xml similarity index 95% rename from tooling/standards/pom.xml rename to generated/tooling/standards/pom.xml index 6fcec2d..d8b7c50 100644 --- a/tooling/standards/pom.xml +++ b/generated/tooling/standards/pom.xml @@ -11,6 +11,6 @@ org.apache.maven.pluginsmaven-compiler-plugin3.16.0 org.apache.maven.pluginsmaven-surefire-plugin3.6.1 - org.codehaus.mojoexec-maven-plugin3.6.3Standards${project.basedir}/../.. + org.codehaus.mojoexec-maven-plugin3.6.3Standards${project.basedir}/../../.. diff --git a/generated/tooling/standards/src/main/java/Standards.java b/generated/tooling/standards/src/main/java/Standards.java new file mode 100644 index 0000000..213b3ad --- /dev/null +++ b/generated/tooling/standards/src/main/java/Standards.java @@ -0,0 +1,590 @@ +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 findings = new ArrayList<>(); + static final Map> codeLines = new HashMap<>(); + static final Set ownedTypes = new HashSet<>(); + static final Map types = new HashMap<>(); + static final List units = new ArrayList<>(); + static Path root; + static String module; + + record Waiver(String metric, String executable, double value, String reason) {} + + static final List 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(); + 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 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 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 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 List all(Node node, Class clazz) { + final List 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 parameters = new ArrayList<>(); + for (var component : compact.getEnclosingType().getRecordComponents()) + parameters.add(component.getTypeMirror().toString()); + return compact.getEnclosingType().getBinaryName() + "#(" + String.join(",", parameters) + ")"; + } + if (node instanceof ASTExecutableDeclaration executable) { + final List parameters = new ArrayList<>(); + for (var parameter : executable.getFormalParameters()) + parameters.add(parameter.getTypeMirror().toString()); + return executable.getEnclosingType().getBinaryName() + "#" + + (executable instanceof ASTConstructorDeclaration ? "" : executable.getName()) + + "(" + String.join(",", parameters) + ")"; + } + return node instanceof ASTTypeDeclaration type ? type.getBinaryName() : location(node); + } + + static Set lines(JavaNode node) { + final Set 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 ownLines = new HashSet<>(); + final Set 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 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 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 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 qualifiers = new HashSet<>(); + for (var annotation : variable.getDeclaredAnnotations()) { + if (annotation.getBinaryName().equals("jakarta.inject.Named") + || annotated(annotation.getAnnotationSymbol(), "jakarta.inject.Qualifier")) { + final Map 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 dependencies, Set 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 dependencies, Set 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 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 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 + ")"); + } +} diff --git a/generated/tooling/standards/src/test/java/StandardsTest.java b/generated/tooling/standards/src/test/java/StandardsTest.java new file mode 100644 index 0000000..1bcbb86 --- /dev/null +++ b/generated/tooling/standards/src/test/java/StandardsTest.java @@ -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 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 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 {}"); + 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 values; + private java.util.List 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, + ""); + 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 { 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 { @jakarta.inject.Inject private T dependency; } class Fixture extends" + + " Parent { @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(); + } + } +} diff --git a/pom.xml b/pom.xml index b00c2a2..932b6c2 100644 --- a/pom.xml +++ b/pom.xml @@ -109,7 +109,7 @@ --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --class-path - ${maven.multiModuleProjectDirectory}/tooling/format/Format.java + ${maven.multiModuleProjectDirectory}/generated/tooling/format/Format.java ${maven.multiModuleProjectDirectory} ${format.mode} @@ -187,4 +187,21 @@ + + + + regenerate-engineering + true + + org.codehaus.mojoexec-maven-plugin3.6.3 + false + regenerate-engineeringvalidateexec + python3 + ${maven.multiModuleProjectDirectory}/scripts/engineering.py + + + + + + diff --git a/scripts/engineering.py b/scripts/engineering.py new file mode 100644 index 0000000..20813d0 --- /dev/null +++ b/scripts/engineering.py @@ -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() diff --git a/specs/README.md b/specs/README.md deleted file mode 100644 index d255b48..0000000 --- a/specs/README.md +++ /dev/null @@ -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. diff --git a/specs/S10_allow-group-exclusion-for-show b/specs/S10_allow-group-exclusion-for-show deleted file mode 100644 index 4b1e248..0000000 --- a/specs/S10_allow-group-exclusion-for-show +++ /dev/null @@ -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 \ No newline at end of file diff --git a/specs/S11_add-season-context-to-groups-and-shows b/specs/S11_add-season-context-to-groups-and-shows deleted file mode 100644 index e69de29..0000000 diff --git a/specs/S13_create-runner-to-allow-scheduling-on-different-pc b/specs/S13_create-runner-to-allow-scheduling-on-different-pc deleted file mode 100644 index e69de29..0000000 diff --git a/specs/S14_add-sonar b/specs/S14_add-sonar deleted file mode 100644 index e69de29..0000000 diff --git a/specs/S15_add-DoD b/specs/S15_add-DoD deleted file mode 100644 index 5c5e528..0000000 --- a/specs/S15_add-DoD +++ /dev/null @@ -1,3 +0,0 @@ -- tested -- changelog -- documented \ No newline at end of file diff --git a/specs/S17_database-backup-and-restore b/specs/S17_database-backup-and-restore deleted file mode 100644 index e69de29..0000000 diff --git a/specs/S3_define-coding-standards-to-fe b/specs/S3_define-coding-standards-to-fe deleted file mode 100644 index 6e788cb..0000000 --- a/specs/S3_define-coding-standards-to-fe +++ /dev/null @@ -1 +0,0 @@ -Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill. \ No newline at end of file diff --git a/specs/School-JSON-format.md b/specs/School-JSON-format.md deleted file mode 100644 index b7d90ec..0000000 --- a/specs/School-JSON-format.md +++ /dev/null @@ -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=` 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`. diff --git a/specs/implemented/S12_support-async-feedback-for-scheduling b/specs/implemented/S12_support-async-feedback-for-scheduling deleted file mode 100644 index 540a5d2..0000000 --- a/specs/implemented/S12_support-async-feedback-for-scheduling +++ /dev/null @@ -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. diff --git a/specs/implemented/S16_store-state-in-persistent-storage b/specs/implemented/S16_store-state-in-persistent-storage deleted file mode 100644 index 3fa8593..0000000 --- a/specs/implemented/S16_store-state-in-persistent-storage +++ /dev/null @@ -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. diff --git a/specs/implemented/S18_new-rules-for-plotting-dance-groups-in-show.md b/specs/implemented/S18_new-rules-for-plotting-dance-groups-in-show.md deleted file mode 100644 index e07d738..0000000 --- a/specs/implemented/S18_new-rules-for-plotting-dance-groups-in-show.md +++ /dev/null @@ -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. diff --git a/specs/implemented/S1_School-JSON-import-export.md b/specs/implemented/S1_School-JSON-import-export.md deleted file mode 100644 index a1c432a..0000000 --- a/specs/implemented/S1_School-JSON-import-export.md +++ /dev/null @@ -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. diff --git a/specs/implemented/S2_implement-eslint-for-fe b/specs/implemented/S2_implement-eslint-for-fe deleted file mode 100644 index 75e3d01..0000000 --- a/specs/implemented/S2_implement-eslint-for-fe +++ /dev/null @@ -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. diff --git a/specs/implemented/S4_create-build-pipeline-at-gitea b/specs/implemented/S4_create-build-pipeline-at-gitea deleted file mode 100644 index 3fa6d1a..0000000 --- a/specs/implemented/S4_create-build-pipeline-at-gitea +++ /dev/null @@ -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. diff --git a/specs/implemented/S6_deployment-gitea-action-to-production.md b/specs/implemented/S6_deployment-gitea-action-to-production.md deleted file mode 100644 index 20a1e39..0000000 --- a/specs/implemented/S6_deployment-gitea-action-to-production.md +++ /dev/null @@ -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. diff --git a/specs/implemented/S8_palentir-formatting-be b/specs/implemented/S8_palentir-formatting-be deleted file mode 100644 index d78de92..0000000 --- a/specs/implemented/S8_palentir-formatting-be +++ /dev/null @@ -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). diff --git a/specs/implemented/S9_spread-dancegroups-via-ortools-setup b/specs/implemented/S9_spread-dancegroups-via-ortools-setup deleted file mode 100644 index 57001f7..0000000 --- a/specs/implemented/S9_spread-dancegroups-via-ortools-setup +++ /dev/null @@ -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. diff --git a/specs/refined/S5_renovate-bot-in-container b/specs/refined/S5_renovate-bot-in-container deleted file mode 100644 index 8f22975..0000000 --- a/specs/refined/S5_renovate-bot-in-container +++ /dev/null @@ -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: . -- 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/` for eligible major updates. -- Use `renovate/minor-patch/` 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. diff --git a/tooling/coverage/Coverage.java b/tooling/coverage/Coverage.java deleted file mode 100644 index 5ef2be3..0000000 --- a/tooling/coverage/Coverage.java +++ /dev/null @@ -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]); - } -} diff --git a/tooling/format/test.py b/tooling/format/test.py deleted file mode 100644 index 2d72f1f..0000000 --- a/tooling/format/test.py +++ /dev/null @@ -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") diff --git a/tooling/standards/src/main/java/Standards.java b/tooling/standards/src/main/java/Standards.java deleted file mode 100644 index 4e0259d..0000000 --- a/tooling/standards/src/main/java/Standards.java +++ /dev/null @@ -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 findings = new ArrayList<>(); - static final Map> codeLines = new HashMap<>(); - static final Set ownedTypes = new HashSet<>(); - static final Map types = new HashMap<>(); - static final List units = new ArrayList<>(); - static Path root; - - record Waiver(String metric, String executable, double value, String reason) {} - - static final List 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 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 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 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 List all(Node node, Class clazz) { - final List 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 parameters = new ArrayList<>(); - for (var component : compact.getEnclosingType().getRecordComponents()) - parameters.add(component.getTypeMirror().toString()); - return compact.getEnclosingType().getBinaryName() + "#(" + String.join(",", parameters) - + ")"; - } - if (node instanceof ASTExecutableDeclaration executable) { - final List parameters = new ArrayList<>(); - for (var parameter : executable.getFormalParameters()) - parameters.add(parameter.getTypeMirror().toString()); - return executable.getEnclosingType().getBinaryName() + "#" - + (executable instanceof ASTConstructorDeclaration ? "" : executable.getName()) - + "(" + String.join(",", parameters) + ")"; - } - return node instanceof ASTTypeDeclaration type ? type.getBinaryName() : location(node); - } - - static Set lines(JavaNode node) { - final Set 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 ownLines = new HashSet<>(); - final Set 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 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 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 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 qualifiers = new HashSet<>(); - for (var annotation : variable.getDeclaredAnnotations()) { - if (annotation.getBinaryName().equals("jakarta.inject.Named") - || annotated(annotation.getAnnotationSymbol(), "jakarta.inject.Qualifier")) { - final Map 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 dependencies, Set 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 dependencies, Set 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 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 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 + ")"); - } -} diff --git a/tooling/standards/src/test/java/StandardsTest.java b/tooling/standards/src/test/java/StandardsTest.java deleted file mode 100644 index 2c6e77e..0000000 --- a/tooling/standards/src/test/java/StandardsTest.java +++ /dev/null @@ -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 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 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 {}"); - 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 values; - private java.util.List 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, - ""); - 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 { 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 { @jakarta.inject.Inject private T dependency; } class Fixture extends" - + " Parent { @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(); - } - } -}