diff --git a/.gitattributes b/.gitattributes
index 21eed96..a58e35f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,6 +4,9 @@
*.cmd text eol=crlf
*.ps1 text eol=crlf
+# The Maven wrapper script must keep LF to run; its Windows twin keeps CRLF.
+mvnw text eol=lf
+
*.png binary
*.jpg binary
*.jpeg binary
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 067d35e..96a8a61 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -7,3 +7,6 @@
# change that lands without its ADR is the drift this repository exists to stop.
/docs/adr/ @JorisJonkers-dev/maintainers
/spec/ @JorisJonkers-dev/maintainers
+
+# The model-driven implementation, deleted at its sunset.
+/emf/ @JorisJonkers-dev/maintainers
diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
new file mode 100644
index 0000000..938f760
--- /dev/null
+++ b/.github/codeql/codeql-config.yml
@@ -0,0 +1,11 @@
+# Paths CodeQL does not analyse. Build output and generated sources are not
+# code anyone wrote here; the model-driven build (emf/) generates Java from its
+# genmodel and Xtext grammar into target/ and src-gen/.
+'name': 'deploy-kit CodeQL'
+
+'paths-ignore':
+ - '**/target/**'
+ - '**/src-gen/**'
+ - '**/xtend-gen/**'
+ - 'node_modules/**'
+ - 'coverage/**'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f41ad6b..0af7e94 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -232,6 +232,42 @@
| tar -xz gitleaks
GITLEAKS="${PWD}/gitleaks" npm run lint:secrets
+ # CodeQL over every language, as a gate: .github/workflows/codeql.yml fails on
+ # any finding, and Pipeline Complete needs this job, so a finding blocks the
+ # merge. The called workflow needs to upload results, hence the permissions.
+ 'codeql':
+ 'name': 'Code scanning'
+ 'permissions':
+ 'actions': 'read'
+ 'contents': 'read'
+ 'security-events': 'write'
+ 'uses': './.github/workflows/codeql.yml'
+
+ # The model-driven implementation under emf/ (docs/adr/emf/0107): its own
+ # Maven build, JDK and gates, deleted with the directory at its sunset.
+ # Every gate it runs is listed in emf/docs/rules.md; the thresholds are in
+ # emf/pom.xml. test/emf-wiring.test.ts proves this step names a real build.
+ 'emf':
+ 'name': 'Model-driven build'
+ 'runs-on': 'ubuntu-latest'
+ 'timeout-minutes': 20
+ 'permissions':
+ 'contents': 'read'
+ 'steps':
+ - 'uses': 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # v7.0.1
+ - 'uses': 'actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6' # v6.0.1
+ 'with':
+ 'distribution': 'temurin'
+ 'java-version-file': 'emf/.java-version'
+ 'cache': 'maven'
+ 'cache-dependency-path': 'emf/**/pom.xml'
+ - 'name': 'Verify'
+ 'working-directory': 'emf'
+ 'run': './mvnw -B -ntp verify'
+ - 'name': 'Summary'
+ 'if': 'always()'
+ 'run': 'emf/scripts/summary.sh >> "$GITHUB_STEP_SUMMARY"'
+
'pipeline-complete':
'name': 'Pipeline Complete'
'runs-on': 'ubuntu-latest'
@@ -248,6 +284,8 @@
- 'package-contents'
- 'actionlint'
- 'secret-scan'
+ - 'codeql'
+ - 'emf'
# Runs even when a dependency failed or was cancelled: the branch ruleset
# names this job, so it has to run and report failure rather than being
# skipped alongside whatever it depends on.
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 98528df..60eee5f 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -1,19 +1,20 @@
-# CodeQL static analysis: JavaScript, TypeScript, and the workflow logic.
-# Runs on pull requests and on a weekly schedule, out of band from `Pipeline
-# Complete`, so a CodeQL finding never blocks a merge while results land in the
-# repository's Security tab.
+# CodeQL static analysis: JavaScript, TypeScript, the workflow logic, and the
+# model-driven implementation's Java under emf/.
+#
+# Every language is analysed without a build (`build-mode: none`), so Tycho and
+# p2 resolution can never break the scan. Generated sources and build output
+# are ignored through .github/codeql/codeql-config.yml.
+#
+# A gate, not a report. ci.yml calls this workflow on every pull request and on
+# every push to main, as the `codeql` job `Pipeline Complete` needs, and the last
+# step fails on any finding of any severity, so a finding blocks the merge. It
+# still uploads to the Security tab, and still runs weekly on its own, so a new
+# query that flags old code is seen without waiting for a change. A finding that
+# is wrong is filtered in .github/codeql/codeql-config.yml, with the reason.
'name': 'CodeQL'
'on':
- 'push':
- 'branches':
- - 'main'
- 'pull_request':
- 'paths':
- - '**.js'
- - '**.mjs'
- - '**.ts'
- - '.github/workflows/*.yml'
+ 'workflow_call':
'schedule':
- 'cron': '22 3 * * 1'
@@ -36,6 +37,7 @@
'include':
- 'language': 'javascript-typescript'
- 'language': 'actions'
+ - 'language': 'java-kotlin'
'steps':
- 'uses': 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # v7.0.1
@@ -44,12 +46,32 @@
'uses': 'github/codeql-action/init@faaca9a8f6edddba5725ffe5adefdab6669a2eca' # v3.38.0
'with':
'languages': '${{ matrix.language }}'
+ 'build-mode': 'none'
'queries': 'security-and-quality'
-
- - 'name': 'Autobuild'
- 'uses': 'github/codeql-action/autobuild@faaca9a8f6edddba5725ffe5adefdab6669a2eca' # v3.38.0
+ 'config-file': './.github/codeql/codeql-config.yml'
- 'name': 'Perform CodeQL Analysis'
'uses': 'github/codeql-action/analyze@faaca9a8f6edddba5725ffe5adefdab6669a2eca' # v3.38.0
'with':
- 'category': '/language:${{matrix.language}}'
\ No newline at end of file
+ 'category': '/language:${{matrix.language}}'
+ 'output': 'sarif'
+
+ # The upload above records findings; this is what makes one fail the
+ # pipeline. Every result counts, notes included, unless CodeQL itself
+ # marked it suppressed.
+ - 'name': 'Fail on any finding'
+ 'shell': 'bash'
+ 'run': |
+ set -euo pipefail
+ findings=$(jq -r '
+ .runs[].results[]
+ | select((.suppressions // []) | length == 0)
+ | .locations[0].physicalLocation as $at
+ | "\(.ruleId) \($at.artifactLocation.uri):\($at.region.startLine) \(.message.text)"
+ ' sarif/*.sarif)
+ if [ -n "${findings}" ]; then
+ printf '%s\n' "${findings}"
+ echo "::error::CodeQL reported findings. Fix them, or filter a wrong one in .github/codeql/codeql-config.yml with its reason."
+ exit 1
+ fi
+ echo "No CodeQL findings."
diff --git a/.gitignore b/.gitignore
index 9abb493..287a053 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,3 +77,7 @@ coverage/
# Scratch
.tmp/
+
+# Maven wrapper: the model-driven build uses the script-only wrapper, so no jar
+# is ever committed.
+.mvn/wrapper/maven-wrapper.jar
diff --git a/.prettierignore b/.prettierignore
index a87dcdc..00e9dbc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,6 +1,7 @@
# Generated
CHANGELOG.md
coverage/
+**/target/
dist/
node_modules/
.github-workflows/
@@ -16,6 +17,7 @@ spec/
# and keep the same style.
.github/workflows/
.github/actions/
+.github/codeql/
# Scratch. `.tmp/` is a working directory, not a deliverable, and its contents
# are whatever a tool last dropped there.
diff --git a/CLAUDE.md b/CLAUDE.md
index 9b81491..993518f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -66,8 +66,10 @@ from the day it lands and deleted at its sunset:
- **The root stays TypeScript.** Every pom, module, check, ledger and decision
of the Java side lives under `emf/`. The root references it only from CI
- (the `emf` job and the `emf` ADR lint step), the `emf` domain in
- `scripts/lint-adrs.ts` and its test, and
+ (the `emf` job, the `emf` ADR lint step and CodeQL's `java-kotlin` entry),
+ `test/emf-wiring.test.ts` with its ledger rows, the `emf` domain in
+ `scripts/lint-adrs.ts` and its test, the release-please and Renovate
+ configuration, and
[`docs/architecture.md#the-parity-contract`](docs/architecture.md#the-parity-contract).
- **Never generate one implementation from the other.** Both are tested,
separately, against committed oracle files under `spec/v1/examples/`.
diff --git a/CONTEXT.md b/CONTEXT.md
index a512233..98997f0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -269,5 +269,11 @@ its objects.
for a DNS name, an ADR decision domain, and the core ring of the compiler's
hexagon.
+**Bootstrap.** The bootstrap set and the bootstrap order, and nothing else.
+The first build of the model-driven implementation under `emf/` is the **EMF
+scaffold** (its Maven build, gates and CI job, with no EMF dependency), and its
+first change that depends on the modelling tools is the **walking skeleton**,
+which proves each tool runs headless.
+
**Config.** Avoid. Env files carry *configuration*; the platform's facts and
policies are *Platform Intent*; an Application's authored document is *Project Intent*.
diff --git a/README.md b/README.md
index da4e07b..818b1a9 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ lands, the **compiler** that turns that model into deployable artifacts.
| [`spec/v1/diagrams/`](spec/v1/diagrams/README.md) | One drawn diagram per chapter, as an SVG with the editable draw.io diagram embedded. One palette; colour carries the layer. |
| [`spec/v1/examples/minimal/`](spec/v1/examples/minimal/README.md) | The smallest complete Application: one project, one Application, one Process, 26 authored lines reaching 10 objects. |
| [`spec/v1/examples/`](spec/v1/examples) | Worked examples: real Applications from this estate, written in the model. |
+| [`emf/`](emf/README.md) | The model-driven implementation: a Java build on Ecore, Xtext, OCL, QVT-Operational and Acceleo, held to parity with the production implementation and deleted after the course. |
| [`scripts/`](scripts/) | The gates: the ADR contract, links, manifests and layer boundaries. TypeScript that Node runs directly ([tooling](docs/architecture.md#tooling)). |
## The shape of the model
diff --git a/docs/architecture-rules.md b/docs/architecture-rules.md
index ae75e81..b82e084 100644
--- a/docs/architecture-rules.md
+++ b/docs/architecture-rules.md
@@ -57,7 +57,7 @@ fails the gate, so the taxonomy cannot grow entries nothing stands behind.
## Rules
-This ledger holds **60** rules, **19** of them pending.
+This ledger holds **63** rules, **19** of them pending.
A row is enforced or pending, never both. An enforced row names its enforcer as
`kind:value`: `depcruise:` a rule in
@@ -136,6 +136,9 @@ moving a live rule to pending fails the gate rather than quietly retiring it.
| RULE-058 | gates | No em-dash enters tracked text outside `docs/mde/` and `CHANGELOG.md` | `file:test/emdash.test.ts` | [test/emdash.test.ts](../test/emdash.test.ts) `contains an em-dash` |
| RULE-059 | gates | A gate's npm script and the CI job that runs it land together, or the script is listed pending with a reason | `file:test/pipeline-wiring.test.ts` | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) `every script either runs in some workflow` |
| RULE-060 | gates | No committed secret matching the default gitleaks ruleset or this repository's own allowlist, checked locally by the same command CI runs | `npm:lint:secrets` | [test/secret-scan-contract.test.ts](../test/secret-scan-contract.test.ts) `secret scan: could not run` |
+| RULE-061 | gates | A workflow step that runs the Maven wrapper runs it in a directory holding a POM and the wrapper, and the model-driven reactor names only modules on disk | `file:test/emf-wiring.test.ts` | [test/emf-wiring.test.ts](../test/emf-wiring.test.ts) `which names a POM that does not exist` |
+| RULE-062 | gates | CodeQL analyses the model-driven implementation's Java without a build, ignoring build output and generated sources | `file:.github/codeql/codeql-config.yml` | [test/emf-wiring.test.ts](../test/emf-wiring.test.ts) `'language': 'java-kotlin'` |
+| RULE-063 | gates | A CodeQL finding of any severity fails `Pipeline Complete`, unless the finding is filtered in the CodeQL configuration | `file:.github/workflows/codeql.yml` | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) `'name': 'Fail on any finding'` |
## Considered and rejected
diff --git a/docs/architecture.md b/docs/architecture.md
index 89ab4b8..25713c0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -261,7 +261,7 @@ lists cases says so rather than skipping it silently.
## Gates
-Fourteen gates hold the structure, and each exists because its absence has already
+Sixteen gates hold the structure, and each exists because its absence has already
cost something in the generation this compiler replaces. Each runs as its own
CI job, aggregated by one required check that fails when any gate job fails,
is cancelled, or is skipped
@@ -286,12 +286,16 @@ proves the two never drift apart.
| package contents | `node scripts/check-package-contents.ts` | `npm pack` shipping a file outside `docs/adr/` and `spec/`, the boundary the package's `files` field states but does not enforce on its own |
| actionlint | a pinned `actionlint` binary | invalid workflow syntax, an undefined `${{ }}` expression, a shellcheck finding inside a `run:` step |
| secret scan | `npm run lint:secrets` | a committed secret matching the default ruleset, or this repository's own allowlist entries |
+| code scanning | CodeQL, called from `ci.yml` as the `codeql` job | any finding, of any severity, in JavaScript, TypeScript, workflow logic or the Java under `emf/`; a wrong one is filtered in `.github/codeql/codeql-config.yml` with its reason |
+| model-driven build | `./mvnw -B -ntp verify` in `emf/` | every gate the [model-driven implementation](../emf/docs/architecture.md#gates) holds itself to: toolchain versions, compiler warnings, tests, coverage and mutation floors, formatting |
Decisions, links, manifests, requirements, rules and docs share one CI job,
`contracts`: all six check a document against a rule rather than code
against a graph.
Boundaries runs alone as `architecture`, because it is the one gate that
speaks for `docs/architecture.md` itself rather than for a document beside it.
+The model-driven build runs alone as `emf`, on its own JDK and Maven, and is
+deleted with `emf/`.
Every rule these gates enforce is written down once, with a greppable id, in
the [rule ledger](architecture-rules.md)
diff --git a/docs/requirements.md b/docs/requirements.md
index 711627b..bd7eaeb 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -19,7 +19,7 @@ test file and holds at least one test; ids are unique; the count this
document states matches the number of rows it holds; and every id cited
anywhere in the tracked tree resolves to a row here.
-This ledger holds **14** rows. The compiler's behaviours join it as they land.
+This ledger holds **16** rows. The compiler's behaviours join it as they land.
| id | a contributor or a consumer can rely on | proved by |
|---|---|---|
@@ -37,3 +37,5 @@ This ledger holds **14** rows. The compiler's behaviours join it as they land.
| REQ-012 | A pull request's title, body and every commit in it carry no agent attribution: no Co-Authored-By trailer naming a coding agent, no "generated with" banner naming one, no link back to an agent session | [test/pr-title-contract.test.ts](../test/pr-title-contract.test.ts) |
| REQ-013 | `npm run verify` runs the same secret scan CI runs, failing on a committed secret rather than only after a push | [test/secret-scan-contract.test.ts](../test/secret-scan-contract.test.ts) |
| REQ-014 | Every rule this repository enforces has a ledger row with a greppable id and a fixture that proves it fires, and a rule not enforced yet is listed as pending with a ticket and a reason rather than dropped | [test/rules-contract.test.ts](../test/rules-contract.test.ts) |
+| REQ-015 | The model-driven build CI runs is the build in the tree: no workflow runs the Maven wrapper where there is no build, the reactor names no missing module, and CodeQL scans its Java | [test/emf-wiring.test.ts](../test/emf-wiring.test.ts) |
+| REQ-016 | A code scanning finding of any severity fails `Pipeline Complete`, so it blocks the merge rather than only landing in the Security tab | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) |
diff --git a/emf/.java-version b/emf/.java-version
new file mode 100644
index 0000000..aabe6ec
--- /dev/null
+++ b/emf/.java-version
@@ -0,0 +1 @@
+21
diff --git a/emf/.mvn/wrapper/maven-wrapper.properties b/emf/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..a3c407f
--- /dev/null
+++ b/emf/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,4 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce
diff --git a/emf/docs/adr/README.md b/emf/docs/adr/README.md
index 3492961..389b60c 100644
--- a/emf/docs/adr/README.md
+++ b/emf/docs/adr/README.md
@@ -41,3 +41,4 @@ taking a number, check both registers.
| [0112](emf/0112-qvto-derives-the-resolved-deployment.md) | QVT-Operational derives the Resolved Deployment | open |
| [0113](emf/0113-acceleo-4-renders-the-deliverable-set.md) | Acceleo 4 renders the Deliverable Set, byte for byte against the committed tree | open |
| [0114](emf/0114-model-behaviours-have-a-java-witness.md) | Every model behaviour in the behaviour ledger has a Java witness, listed inside `emf/` | open |
+| [0115](emf/0115-the-emf-gates-are-estate-shaped.md) | The model-driven build carries the gates the estate's JVM repositories enforce, plus Java-shaped equivalents, and measures its thresholds | open |
diff --git a/emf/docs/adr/emf/0115-the-emf-gates-are-estate-shaped.md b/emf/docs/adr/emf/0115-the-emf-gates-are-estate-shaped.md
new file mode 100644
index 0000000..1c8984d
--- /dev/null
+++ b/emf/docs/adr/emf/0115-the-emf-gates-are-estate-shaped.md
@@ -0,0 +1,66 @@
+---
+tier: decision
+status: proposed
+claim: open
+owner: joris
+date: 2026-09-14
+normative: docs/architecture.md#gates
+rests-on: ["0106"]
+---
+
+# The model-driven build carries the gates the estate's JVM repositories enforce, plus Java-shaped equivalents, and measures its thresholds
+
+## Rests on
+Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the
+claim is that the gates the estate's Gradle repositories run for JVM code have
+Maven equivalents that hold over this build, including once Tycho and generated
+EMF sources arrive. False if: a gate adopted here has to be switched off, or its
+threshold lowered, to let the walking skeleton or a stage land. Settled by: the
+`emf` job green with every gate in `emf/docs/rules.md` enforced after the Task 3
+code generation work merges.
+
+## Why
+The estate's JVM repositories share one set of Gradle convention plugins. An
+audit of fourteen of them found what they actually enforce: JDK 21 toolchains,
+compiler warnings as errors, JaCoCo with a line-coverage floor wired into
+`check`, JUnit with AssertJ and ArchUnit, Renovate, pinned CI actions and one
+aggregating `Pipeline Complete` check. Their formatting and static analysis
+(ktlint, detekt) are Kotlin-only.
+
+This build ports the first group one for one: the enforcer for JDK and Maven
+versions, `-Xlint:all -Werror`, JaCoCo bound to `verify`, JUnit 6 with AssertJ
+and ArchUnit. It gives Java the equivalent the estate gives Kotlin, Spotless
+with palantir-java-format, because Java would otherwise be the one language in
+the estate nobody formats. It adds PIT mutation testing, which the root already
+plans for the TypeScript gates and no estate JVM repository runs yet, because
+the parity code here decides what counts as agreement between two
+implementations and a line that runs is not a line that is checked.
+
+The thresholds are set from the first measurement, not from a convention: the
+first `mvn verify` over the canonical JSON writer and the ledger checks reached
+100% line coverage, 100% branch coverage and 87 of 87 mutants killed, so those
+are the floors, and they only rise. Equivalent mutants were removed by
+restructuring the code, not by lowering the score.
+
+## Alternatives
+| option | cost if taken | why rejected |
+|---|---|---|
+| SpotBugs, Error Prone and NullAway | Stronger static analysis | No estate JVM repository runs any of them; Error Prone only reaches plain jar modules under Tycho, and CodeQL `security-and-quality` already scans the Java |
+| An 80% line floor, the estate's default | Matches the Gradle conventions | The first measurement is 100%, and a floor below what the suite reaches is a floor that lets coverage fall |
+| No mutation testing for a deprecated tree | Faster builds | Parity code that passes by accident makes two implementations look equal when they are not |
+| SBOM and artifact signing | Supply-chain evidence | Nothing is published, and no estate JVM artifact carries either |
+
+## Reversibility
+Undo cost today: deleting plugin blocks and ledger rows, an hour. Becomes
+irreversible once: never; deleted with `emf/`.
+
+## Consequences
+- Every generated source directory Tycho, Xtext or genmodel adds must be excluded
+ from formatting, coverage and mutation in the pull request that adds it. Paid
+ by the walking skeleton first.
+- If PIT cannot run inside an OSGi test runtime, it is scoped to the plain jar
+ modules and the scoping is recorded here. Paid by whichever stage meets it.
+- Every build runs mutation testing, so `mvn verify` grows with the code. Paid in
+ CI minutes, and revisited if the job passes ten minutes.
+- A threshold change is a line in `emf/pom.xml` that has to be argued in review.
+ Paid by whoever wants to lower one.
diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md
index 28f8d64..b550c8b 100644
--- a/emf/docs/architecture.md
+++ b/emf/docs/architecture.md
@@ -31,9 +31,14 @@ The tree is deleted in one pull request when both of these hold:
What that pull request keeps: every oracle file under `spec/v1/examples/`, the
constraint ledger without its OCL column, and the descriptor check. What it
deletes: this directory; the `emf` CI job and the `ADR contract (emf)` step in
-`.github/workflows/ci.yml`; the `emf` domain entry in `scripts/lint-adrs.ts`
-and the two `emf/` cases in `test/adr-contract.test.ts`; and the `emf/`
-mentions in `docs/requirements.md`, `docs/adr/README.md` and `CLAUDE.md`.
+`.github/workflows/ci.yml`; the `java-kotlin` entry in
+`.github/workflows/codeql.yml`; `test/emf-wiring.test.ts`, with `RULE-061`,
+`RULE-062` and `REQ-015`; the `emf` domain entry in `scripts/lint-adrs.ts` and
+the two `emf/` cases in `test/adr-contract.test.ts`; the `emf` exclusion in
+`release-please-config.json`, the `emf maven` rule in `renovate.json`, the
+`/emf/` line in `.github/CODEOWNERS` and the `mvnw` line in `.gitattributes`;
+and the `emf/` mentions in `docs/architecture.md`, `docs/requirements.md`,
+`docs/adr/README.md`, `README.md`, `CONTEXT.md` and `CLAUDE.md`.
## Toolchain
@@ -47,8 +52,11 @@ existing Maven projects, the metamodels open, the Xtext-generated editor reports
OCL constraint violations while a source file is edited, and committed launch
configurations run the transformation and the generator.
-The first change to this tree is a walking skeleton that proves each tool runs
-headless in CI before any model work depends on it: an `.ecore` loads, an OCL
+The first change to this tree is the **EMF scaffold**: the Maven reactor, the
+wrapper, the gates and the `emf` CI job, with no EMF dependency and one module,
+`parity`, holding the canonical JSON writer and the ledger checks. The second is
+the **walking skeleton**, which proves each tool runs headless in CI before any
+model work depends on it: an `.ecore` loads, an OCL
invariant fires, the Xtext parser reads a three-line document, a QVTo identity
transformation runs, and an Acceleo template writes one file.
@@ -161,7 +169,28 @@ that does not exist or an id that no row carries.
## Gates
-One CI job, `emf`, runs `mvn verify` in `emf/` on JDK 21 and is required by
-`Pipeline Complete`. It runs every JUnit suite, the parity suites and the
-ledger checks above. The ADR lint for `emf/docs/adr/` runs in the existing
-`contracts` job, as `node scripts/lint-adrs.ts emf`.
+One CI job, `emf`, runs `./mvnw -B -ntp verify` in `emf/` on the JDK named by
+`emf/.java-version` and is required by `Pipeline Complete`. The wrapper
+downloads the Maven distribution pinned by checksum; no wrapper jar is
+committed. The job writes one line to its summary: tests, line coverage and
+mutation score, from `scripts/summary.sh`. The ADR lint for `emf/docs/adr/` runs
+in the existing `contracts` job, as `node scripts/lint-adrs.ts emf`.
+
+`verify` runs these gates, in this order, and fails on the first that does not
+hold ([0115](adr/emf/0115-the-emf-gates-are-estate-shaped.md)):
+
+| gate | plugin | fails when |
+|---|---|---|
+| toolchain | `maven-enforcer-plugin` | the JDK is not 21, Maven is not 3.9, a plugin version is unpinned, dependency versions do not converge, or anything declares a distribution target |
+| compile | `maven-compiler-plugin` | any `-Xlint:all` warning |
+| tests | `maven-surefire-plugin` | a JUnit test fails, including the ArchUnit module rules and the ledger checks |
+| format | `spotless-maven-plugin` | Java source differs from palantir-java-format; `./mvnw spotless:apply` fixes it |
+| coverage | `jacoco-maven-plugin` | line or branch coverage falls below the floor in `emf/pom.xml` |
+| mutation | `pitest-maven` | the mutation score falls below the threshold in `emf/pom.xml` |
+
+Every rule these gates enforce is listed in [the rule ledger](rules.md), and
+every model behaviour's Java proof in [the witness list](witnesses.md).
+
+CodeQL analyses the Java under `emf/` as `java-kotlin` with no build, ignoring
+build output and generated sources; `test/emf-wiring.test.ts` at the root holds
+that configuration and the `emf` job to the tree they describe.
diff --git a/emf/docs/rules.md b/emf/docs/rules.md
new file mode 100644
index 0000000..c806cfd
--- /dev/null
+++ b/emf/docs/rules.md
@@ -0,0 +1,32 @@
+# Rule ledger
+
+The rules the model-driven build enforces on itself. The root
+[rule ledger](../../docs/architecture-rules.md) holds the rules the root
+enforces, including the two that watch this build from outside; the rules
+here are enforced by Maven inside `emf/` and deleted with it
+([0115](adr/emf/0115-the-emf-gates-are-estate-shaped.md)).
+
+A row names the file that enforces the rule, relative to `emf/`, and the
+literal in that file that does the enforcing. `Ledgers.checkRules` in
+`parity/` fails the build when a file no longer exists or no longer contains
+its literal, so a gate cannot be removed while its row stays. That the rule
+fires is shown once, by breaking it, in the pull request that adds it.
+
+This ledger holds **14** rules.
+
+| id | rule | enforcer | witness |
+|---|---|---|---|
+| EMF-001 | The build runs on JDK 21 and no other major version | `pom.xml` | `[21,22)` |
+| EMF-002 | The build runs on Maven 3.9, the line Tycho 5 requires | `pom.xml` | `[3.9,4)` |
+| EMF-003 | Every plugin version is pinned | `pom.xml` | `` |
+| EMF-004 | Dependency versions converge | `pom.xml` | `` |
+| EMF-005 | Nothing is ever published | `pom.xml` | `` |
+| EMF-006 | Every compiler warning is enabled and fails the build | `pom.xml` | `-Werror` |
+| EMF-007 | Java source is formatted with palantir-java-format, checked in `verify` | `pom.xml` | `` |
+| EMF-008 | Line and branch coverage stay at or above the measured floor | `parity/pom.xml` | `BRANCH` |
+| EMF-009 | The mutation score stays at or above the measured threshold | `parity/pom.xml` | `${emf.mutation.threshold}` |
+| EMF-010 | A module depends only on the modules above it in the architecture's module table | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM` |
+| EMF-011 | No dependency cycle between modules | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `MODULES_HAVE_NO_CYCLES` |
+| EMF-012 | The Maven distribution the wrapper downloads is pinned by checksum | `.mvn/wrapper/maven-wrapper.properties` | `distributionSha256Sum=` |
+| EMF-013 | Every model behaviour has a Java witness | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java` | `Ledgers.checkWitnesses(repository)` |
+| EMF-014 | Every rule in this ledger is still enforced by its named file | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java` | `Ledgers.checkRules(repository)` |
diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md
new file mode 100644
index 0000000..2751646
--- /dev/null
+++ b/emf/docs/witnesses.md
@@ -0,0 +1,17 @@
+# Witnesses
+
+A row of the root [behaviour ledger](../../docs/requirements.md) whose
+behaviour is the model's own is proved in both implementations. The root row
+names the production implementation's test, under `test/model/`; this list
+names the JUnit test that proves the same behaviour here
+([0114](adr/emf/0114-model-behaviours-have-a-java-witness.md)).
+
+`Ledgers.checkWitnesses` in `parity/` fails the `emf` build when a model row
+has no witness here, when a witness names an id that is not a model row, or
+when it names a test method that does not exist.
+
+This list holds **0** witnesses. No model behaviour row exists yet; the first
+lands with #38.
+
+| id | JUnit test |
+|---|---|
diff --git a/emf/mvnw b/emf/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/emf/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/emf/mvnw.cmd b/emf/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/emf/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/emf/parity/pom.xml b/emf/parity/pom.xml
new file mode 100644
index 0000000..f2ff51f
--- /dev/null
+++ b/emf/parity/pom.xml
@@ -0,0 +1,108 @@
+
+
+ 4.0.0
+
+
+ dev.jorisjonkers.deploykit.emf
+ emf-parent
+ 0.1.0-SNAPSHOT
+
+
+ emf-parity
+ deploy-kit model-driven parity
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ test
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ prepare-agent
+ prepare-agent
+
+
+ false
+
+
+
+ report
+ verify
+ report
+
+
+ coverage-floor
+ verify
+ check
+
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ ${emf.coverage.line.minimum}
+
+
+ BRANCH
+ COVEREDRATIO
+ ${emf.coverage.branch.minimum}
+
+
+
+
+
+
+
+
+
+
+ org.pitest
+ pitest-maven
+
+
+ org.pitest
+ pitest-junit5-plugin
+ ${pitest-junit5.version}
+
+
+
+ dev.jorisjonkers.deploykit.emf.*
+ dev.jorisjonkers.deploykit.emf.*
+ ${emf.mutation.threshold}
+ XMLHTML
+ false
+ true
+
+
+
+ mutation-threshold
+ verify
+ mutationCoverage
+
+
+
+
+
+
diff --git a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java b/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java
new file mode 100644
index 0000000..69f782f
--- /dev/null
+++ b/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java
@@ -0,0 +1,160 @@
+package dev.jorisjonkers.deploykit.emf.parity;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Writes a JSON value in the RFC 8785 canonical form the parity contract compares oracle files in:
+ * object keys sorted by UTF-16 code units, numbers as ECMAScript formats a double, no insignificant
+ * whitespace. A value is a {@link Map} with string keys, a {@link List}, a {@link String}, a {@link
+ * Boolean}, a {@link Double}, or an integral {@link Integer}, {@link Long}, {@link Short} or {@link
+ * Byte}. An absent optional field is absent, so {@code null} is refused rather than written.
+ */
+public final class CanonicalJson {
+
+ private static final long MAX_SAFE_INTEGER = 9_007_199_254_740_991L;
+
+ private CanonicalJson() {}
+
+ /** The canonical serialisation of {@code value}. */
+ public static String write(Object value) {
+ StringBuilder out = new StringBuilder();
+ append(out, value, "");
+ return out.toString();
+ }
+
+ private static void append(StringBuilder out, Object value, String pointer) {
+ switch (value) {
+ case null ->
+ throw new IllegalArgumentException(
+ "null at " + pointer + ": an absent optional field is absent, never null");
+ case Map, ?> map -> appendObject(out, map, pointer);
+ case List> list -> appendArray(out, list, pointer);
+ case String text -> appendString(out, text, pointer);
+ case Boolean bool -> out.append(bool);
+ case Double number -> out.append(formatDouble(number, pointer));
+ case Integer number -> out.append(number);
+ case Short number -> out.append(number);
+ case Byte number -> out.append(number);
+ case Long number -> out.append(checkSafe(number, pointer));
+ default ->
+ throw new IllegalArgumentException(
+ value.getClass().getName() + " at " + pointer + " is not a JSON value");
+ }
+ }
+
+ private static void appendObject(StringBuilder out, Map, ?> map, String pointer) {
+ List keys = new ArrayList<>(map.size());
+ for (Object key : map.keySet()) {
+ if (!(key instanceof String text)) {
+ throw new IllegalArgumentException("object key at " + pointer + " is not a string");
+ }
+ keys.add(text);
+ }
+ keys.sort(String::compareTo);
+ out.append('{');
+ String separator = "";
+ for (String key : keys) {
+ out.append(separator);
+ appendString(out, key, pointer);
+ out.append(':');
+ append(out, map.get(key), pointer + "/" + escapePointer(key));
+ separator = ",";
+ }
+ out.append('}');
+ }
+
+ private static void appendArray(StringBuilder out, List> list, String pointer) {
+ out.append('[');
+ for (int i = 0; i < list.size(); i++) {
+ if (i > 0) {
+ out.append(',');
+ }
+ append(out, list.get(i), pointer + "/" + i);
+ }
+ out.append(']');
+ }
+
+ private static void appendString(StringBuilder out, String text, String pointer) {
+ out.append('"');
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (Character.isHighSurrogate(c) && i + 1 < text.length() && Character.isLowSurrogate(text.charAt(i + 1))) {
+ out.append(c).append(text.charAt(++i));
+ } else if (Character.isSurrogate(c)) {
+ throw new IllegalArgumentException("string at " + pointer + " holds a lone surrogate at index " + i);
+ } else {
+ appendChar(out, c);
+ }
+ }
+ out.append('"');
+ }
+
+ private static void appendChar(StringBuilder out, char c) {
+ switch (c) {
+ case '"' -> out.append("\\\"");
+ case '\\' -> out.append("\\\\");
+ case '\b' -> out.append("\\b");
+ case '\f' -> out.append("\\f");
+ case '\n' -> out.append("\\n");
+ case '\r' -> out.append("\\r");
+ case '\t' -> out.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ out.append(String.format("\\u%04x", (int) c));
+ } else {
+ out.append(c);
+ }
+ }
+ }
+ }
+
+ private static long checkSafe(long number, String pointer) {
+ if (Math.abs(number) > MAX_SAFE_INTEGER) {
+ throw new IllegalArgumentException(
+ number + " at " + pointer + " is outside the range a JSON number carries exactly");
+ }
+ return number;
+ }
+
+ /** ECMAScript Number::toString over the shortest decimal that round-trips the double. */
+ private static String formatDouble(double value, String pointer) {
+ if (Double.isNaN(value) || Double.isInfinite(value)) {
+ throw new IllegalArgumentException(value + " at " + pointer + " is not a JSON number");
+ }
+ if (value == 0.0) {
+ return "0";
+ }
+ // Double.toString yields the shortest round-tripping decimal (JDK 19 and later), except that
+ // it never prints fewer than two significant digits; one digit may still round-trip.
+ BigDecimal decimal = new BigDecimal(Double.toString(Math.abs(value))).stripTrailingZeros();
+ BigDecimal oneDigit = decimal.round(new MathContext(1, RoundingMode.HALF_EVEN));
+ if (oneDigit.doubleValue() == Math.abs(value)) {
+ decimal = oneDigit.stripTrailingZeros();
+ }
+ String digits = decimal.unscaledValue().toString();
+ int k = digits.length();
+ int n = decimal.precision() - decimal.scale();
+ String sign = Double.toString(value).startsWith("-") ? "-" : "";
+ if (n > 21 || n <= -6) {
+ String exponent = Integer.toString(n - 1);
+ String mantissa = k == 1 ? digits : digits.charAt(0) + "." + digits.substring(1);
+ return sign + mantissa + "e" + (exponent.startsWith("-") ? exponent : "+" + exponent);
+ }
+ if (n <= 0) {
+ return sign + "0." + "0".repeat(-n) + digits;
+ }
+ if (n >= k) {
+ return sign + digits + "0".repeat(n - k);
+ }
+ return sign + digits.substring(0, n) + "." + digits.substring(n);
+ }
+
+ private static String escapePointer(String segment) {
+ return segment.replace("~", "~0").replace("/", "~1");
+ }
+}
diff --git a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java b/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java
new file mode 100644
index 0000000..41aa8be
--- /dev/null
+++ b/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java
@@ -0,0 +1,143 @@
+package dev.jorisjonkers.deploykit.emf.parity;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * The two ledgers the model-driven build holds itself to, checked against the repository they
+ * describe. Each check returns every violation it finds, never only the first.
+ */
+public final class Ledgers {
+
+ private static final Pattern REQUIREMENT_ROW =
+ Pattern.compile("^\\|\\s*(REQ-\\d{3})\\s*\\|.*\\[[^]]*]\\(\\.\\./([^)]+)\\)\\s*\\|\\s*$");
+ private static final Pattern WITNESS_ROW =
+ Pattern.compile("^\\|\\s*(REQ-\\d{3})\\s*\\|\\s*`([A-Za-z0-9_]+)#([A-Za-z0-9_]+)`\\s*\\|\\s*$");
+ private static final Pattern RULE_ROW =
+ Pattern.compile("^\\|\\s*(EMF-\\d{3})\\s*\\|\\s*[^|]+\\|\\s*`([^`]+)`\\s*\\|\\s*`([^`]+)`\\s*\\|\\s*$");
+ private static final Pattern STATED = Pattern.compile("holds \\*\\*(\\d+)\\*\\*");
+
+ private Ledgers() {}
+
+ /**
+ * The witness list: every behaviour ledger row proved by a test under {@code test/model/} names a
+ * JUnit test here, and every witness names a real model row and a real test method.
+ */
+ public static List checkWitnesses(Path repository) {
+ List errors = new ArrayList<>();
+ Set modelRows = new HashSet<>();
+ for (String line : lines(repository.resolve("docs/requirements.md"))) {
+ Matcher row = REQUIREMENT_ROW.matcher(line);
+ if (row.matches() && row.group(2).startsWith("test/model/")) {
+ modelRows.add(row.group(1));
+ }
+ }
+ Path witnesses = repository.resolve("emf/docs/witnesses.md");
+ Map listed = new LinkedHashMap<>();
+ List text = lines(witnesses);
+ for (String line : text) {
+ Matcher row = WITNESS_ROW.matcher(line);
+ if (!row.matches()) {
+ continue;
+ }
+ String id = row.group(1);
+ if (listed.put(id, row.group(2) + "#" + row.group(3)) != null) {
+ errors.add(id + ": listed twice");
+ }
+ if (!modelRows.contains(id)) {
+ errors.add(id + ": names no model behaviour row in docs/requirements.md");
+ }
+ if (!testMethodExists(repository.resolve("emf"), row.group(2), row.group(3))) {
+ errors.add(id + ": names " + row.group(2) + "#" + row.group(3) + ", which is not a test in emf/");
+ }
+ }
+ for (String id : modelRows) {
+ if (!listed.containsKey(id)) {
+ errors.add(id + ": is a model behaviour with no witness in emf/docs/witnesses.md");
+ }
+ }
+ checkStatedCount(text, listed.size(), "emf/docs/witnesses.md", errors);
+ return errors;
+ }
+
+ /**
+ * The rule ledger: every row names a file under {@code emf/} that exists and still contains the
+ * witness literal that enforces the rule.
+ */
+ public static List checkRules(Path repository) {
+ List errors = new ArrayList<>();
+ Path emf = repository.resolve("emf");
+ List text = lines(emf.resolve("docs/rules.md"));
+ Set ids = new HashSet<>();
+ int rows = 0;
+ for (String line : text) {
+ Matcher row = RULE_ROW.matcher(line);
+ if (!row.matches()) {
+ continue;
+ }
+ rows++;
+ String id = row.group(1);
+ if (!ids.add(id)) {
+ errors.add(id + ": listed twice");
+ }
+ Path enforcer = emf.resolve(row.group(2)).normalize();
+ if (!enforcer.startsWith(emf) || !Files.isRegularFile(enforcer)) {
+ errors.add(id + ": names enforcer " + row.group(2) + ", which is not a file in emf/");
+ } else if (!read(enforcer).contains(row.group(3))) {
+ errors.add(id + ": " + row.group(2) + " no longer contains `" + row.group(3) + "`");
+ }
+ }
+ checkStatedCount(text, rows, "emf/docs/rules.md", errors);
+ return errors;
+ }
+
+ private static void checkStatedCount(List text, int rows, String file, List errors) {
+ Matcher stated = STATED.matcher(String.join("\n", text));
+ if (!stated.find()) {
+ errors.add(file + ": states no row count");
+ } else if (!stated.group(1).equals(Integer.toString(rows))) {
+ errors.add(file + ": states " + stated.group(1) + " rows but holds " + rows);
+ }
+ }
+
+ private static boolean testMethodExists(Path emf, String className, String method) {
+ try (Stream files = io(() -> Files.walk(emf))) {
+ return files.filter(p -> p.toString().contains("src/test/java"))
+ .filter(p -> p.getFileName().toString().equals(className + ".java"))
+ .anyMatch(p -> read(p).matches("(?s).*\\bvoid " + Pattern.quote(method) + "\\s*\\(.*"));
+ }
+ }
+
+ private static List lines(Path file) {
+ return read(file).lines().toList();
+ }
+
+ private static String read(Path file) {
+ return io(() -> Files.readString(file));
+ }
+
+ /** An IO action whose failure is a broken repository, reported rather than declared. */
+ @FunctionalInterface
+ private interface Io {
+ T get() throws IOException;
+ }
+
+ private static T io(Io action) {
+ try {
+ return action.get();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java
new file mode 100644
index 0000000..e7a5f10
--- /dev/null
+++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java
@@ -0,0 +1,53 @@
+package dev.jorisjonkers.deploykit.emf.parity;
+
+import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
+import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+/**
+ * The module direction docs/architecture.md#modules states: a module may depend on the modules above
+ * it in that table and on nothing below. A layer with no classes yet is allowed to be empty; the rule
+ * holds for it the moment its first class lands.
+ */
+@AnalyzeClasses(packages = "dev.jorisjonkers.deploykit.emf", importOptions = ImportOption.DoNotIncludeTests.class)
+class ArchitectureTest {
+
+ private static final String ROOT = "dev.jorisjonkers.deploykit.emf.";
+
+ @ArchTest
+ static final ArchRule MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM = layeredArchitecture()
+ .consideringOnlyDependenciesInLayers()
+ .withOptionalLayers(true)
+ .layer("metamodel")
+ .definedBy(ROOT + "metamodel..")
+ .layer("syntax")
+ .definedBy(ROOT + "syntax..")
+ .layer("resolve")
+ .definedBy(ROOT + "resolve..")
+ .layer("render")
+ .definedBy(ROOT + "render..")
+ .layer("cli")
+ .definedBy(ROOT + "cli..")
+ .layer("parity")
+ .definedBy(ROOT + "parity..")
+ .whereLayer("parity")
+ .mayNotBeAccessedByAnyLayer()
+ .whereLayer("cli")
+ .mayOnlyBeAccessedByLayers("parity")
+ .whereLayer("render")
+ .mayOnlyBeAccessedByLayers("cli", "parity")
+ .whereLayer("resolve")
+ .mayOnlyBeAccessedByLayers("render", "cli", "parity")
+ .whereLayer("syntax")
+ .mayOnlyBeAccessedByLayers("resolve", "render", "cli", "parity")
+ .whereLayer("metamodel")
+ .mayOnlyBeAccessedByLayers("syntax", "resolve", "render", "cli", "parity");
+
+ @ArchTest
+ static final ArchRule MODULES_HAVE_NO_CYCLES =
+ slices().matching(ROOT + "(*)..").should().beFreeOfCycles();
+}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java
new file mode 100644
index 0000000..00d4d7e
--- /dev/null
+++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java
@@ -0,0 +1,164 @@
+package dev.jorisjonkers.deploykit.emf.parity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/**
+ * The RFC 8785 serialisation the parity contract compares oracle files with. Cases marked RFC come
+ * from the specification's own examples.
+ */
+class CanonicalJsonTest {
+
+ @Test
+ void sortsObjectKeysByUtf16CodeUnitsAtEveryDepth() {
+ // RFC 8785 section 3.2.3: the property sorting example.
+ Map inner = new LinkedHashMap<>();
+ inner.put("\u20ac", "Euro Sign");
+ inner.put("\r", "Carriage Return");
+ inner.put("\ufb33", "Hebrew Letter Dalet With Dagesh");
+ inner.put("1", "One");
+ inner.put("\ud83d\ude00", "Emoji: Grinning Face");
+ inner.put("\u0080", "Control");
+ inner.put("\u00f6", "Latin Small Letter O With Diaeresis");
+
+ assertThat(CanonicalJson.write(inner))
+ .isEqualTo("{\"\\r\":\"Carriage Return\",\"1\":\"One\",\"\u0080\":\"Control\","
+ + "\"\u00f6\":\"Latin Small Letter O With Diaeresis\",\"\u20ac\":\"Euro Sign\","
+ + "\"\ud83d\ude00\":\"Emoji: Grinning Face\",\"\ufb33\":\"Hebrew Letter Dalet With Dagesh\"}");
+ }
+
+ @Test
+ void writesNestedStructuresWithoutInsignificantWhitespace() {
+ Map document = new LinkedHashMap<>();
+ document.put("processes", List.of(Map.of("name", "api"), Map.of()));
+ document.put("applications", new ArrayList<>());
+ document.put("enabled", true);
+ document.put("disabled", false);
+
+ assertThat(CanonicalJson.write(document))
+ .isEqualTo("{\"applications\":[],\"disabled\":false,\"enabled\":true,"
+ + "\"processes\":[{\"name\":\"api\"},{}]}");
+ }
+
+ @Test
+ void escapesOnlyWhatTheSpecificationEscapes() {
+ String text = "quote\" backslash\\ controls\b\f\n\r\t\u000f\u001f slash/ del\u007f line\u2028 euro\u20ac";
+
+ assertThat(CanonicalJson.write(text))
+ .isEqualTo("\"quote\\\" backslash\\\\ controls\\b\\f\\n\\r\\t\\u000f\\u001f"
+ + " slash/ del\u007f line\u2028 euro\u20ac\"");
+ }
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @CsvSource({
+ // RFC 8785 appendix B and ECMAScript Number::toString boundaries.
+ "0.0, 0",
+ "-0.0, 0",
+ "1.0, 1",
+ "-1.5, -1.5",
+ "4.50, 4.5",
+ "0.002, 0.002",
+ "0.5, 0.5",
+ "-0.000001, -0.000001",
+ "10.0, 10",
+ "1.0E-6, 0.000001",
+ "0.000001, 0.000001",
+ "0.0000001, 1e-7",
+ "1.0E-27, 1e-27",
+ "123456789012345680000, 123456789012345680000",
+ "1.0E21, 1e+21",
+ "1.0E30, 1e+30",
+ "1.2345E25, 1.2345e+25",
+ "333333333.33333329, 333333333.3333333",
+ "9007199254740991.0, 9007199254740991",
+ "-9007199254740991.0, -9007199254740991",
+ "1.7976931348623157E308, 1.7976931348623157e+308",
+ "4.9E-324, 5e-324",
+ "295147905179352830000, 295147905179352830000",
+ })
+ void formatsDoublesAsEcmaScriptDoes(double value, String expected) {
+ assertThat(CanonicalJson.write(value)).isEqualTo(expected);
+ }
+
+ @Test
+ void writesIntegralNumbersExactlyWithinTheSafeRange() {
+ assertThat(CanonicalJson.write(
+ List.of(0, -7, 42L, (short) 3, (byte) -2, 9007199254740991L, -9007199254740991L)))
+ .isEqualTo("[0,-7,42,3,-2,9007199254740991,-9007199254740991]");
+ }
+
+ @Test
+ void refusesAnAbsentValueWrittenAsNullAndSaysWhere() {
+ Map document = new LinkedHashMap<>();
+ document.put("applications", Arrays.asList(Map.of("id", "auth"), null));
+
+ assertThatThrownBy(() -> CanonicalJson.write(document))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("null at /applications/1: an absent optional field is absent, never null");
+ }
+
+ @Test
+ void escapesPointerSegmentsInMessages() {
+ Map inner = new LinkedHashMap<>();
+ inner.put("a/b~c", null);
+
+ assertThatThrownBy(() -> CanonicalJson.write(Map.of("x", inner))).hasMessageStartingWith("null at /x/a~1b~0c:");
+ }
+
+ @Test
+ void refusesANullRootAndNullKeys() {
+ Map nullKey = new LinkedHashMap<>();
+ nullKey.put(null, 1);
+
+ assertThatThrownBy(() -> CanonicalJson.write(null)).hasMessageStartingWith("null at :");
+ assertThatThrownBy(() -> CanonicalJson.write(nullKey))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("object key at is not a string");
+ }
+
+ @Test
+ void refusesNonStringKeys() {
+ assertThatThrownBy(() -> CanonicalJson.write(Map.of(1, "one")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("object key at is not a string");
+ }
+
+ @Test
+ void refusesNumbersJsonCannotCarryExactly() {
+ assertThatThrownBy(() -> CanonicalJson.write(Double.NaN)).hasMessage("NaN at is not a JSON number");
+ assertThatThrownBy(() -> CanonicalJson.write(Double.POSITIVE_INFINITY))
+ .hasMessage("Infinity at is not a JSON number");
+ assertThatThrownBy(() -> CanonicalJson.write(9007199254740992L))
+ .hasMessage("9007199254740992 at is outside the range a JSON number carries exactly");
+ assertThatThrownBy(() -> CanonicalJson.write(-9007199254740992L))
+ .hasMessage("-9007199254740992 at is outside the range a JSON number carries exactly");
+ }
+
+ @Test
+ void refusesTypesThatAreNotJson() {
+ assertThatThrownBy(() -> CanonicalJson.write(1.5f)).hasMessage("java.lang.Float at is not a JSON value");
+ assertThatThrownBy(() -> CanonicalJson.write(new Object()))
+ .hasMessage("java.lang.Object at is not a JSON value");
+ }
+
+ @Test
+ void refusesStringsThatAreNotWellFormedUnicode() {
+ assertThatThrownBy(() -> CanonicalJson.write("lone \ud800 high"))
+ .hasMessage("string at holds a lone surrogate at index 5");
+ assertThatThrownBy(() -> CanonicalJson.write("lone \udc00 low"))
+ .hasMessage("string at holds a lone surrogate at index 5");
+ assertThatThrownBy(() -> CanonicalJson.write("ends \ud800"))
+ .hasMessage("string at holds a lone surrogate at index 5");
+ assertThatThrownBy(() -> CanonicalJson.write(Map.of("bad \ud800", 1)))
+ .hasMessage("string at holds a lone surrogate at index 4");
+ }
+}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java
new file mode 100644
index 0000000..33719a1
--- /dev/null
+++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java
@@ -0,0 +1,138 @@
+package dev.jorisjonkers.deploykit.emf.parity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class LedgersTest {
+
+ // Fixture ids are assembled so the root requirements lint, which reads every tracked file for
+ // behaviour ledger citations, does not mistake them for citations of rows that do not exist.
+ private static final String MODEL = "REQ-" + "020";
+ private static final String GATE = "REQ-" + "001";
+ private static final String UNKNOWN = "REQ-" + "099";
+
+ private static final String MODEL_ROW =
+ "| " + MODEL + " | parses a project | [test/model/parse.test.ts](../test/model/parse.test.ts) |";
+ private static final String GATE_ROW =
+ "| " + GATE + " | lints ADRs | [test/adr-contract.test.ts](../test/adr-contract.test.ts) |";
+
+ /** The committed ledgers, checked against this repository. */
+ @Test
+ void theCommittedLedgersHold() {
+ Path repository = repository();
+
+ assertThat(Ledgers.checkWitnesses(repository)).isEmpty();
+ assertThat(Ledgers.checkRules(repository)).isEmpty();
+ }
+
+ @Test
+ void aModelBehaviourWithoutAWitnessFails(@TempDir Path root) throws IOException {
+ write(root, "docs/requirements.md", MODEL_ROW + "\n" + GATE_ROW);
+ write(root, "emf/docs/witnesses.md", "This list holds **0** witnesses.\n");
+
+ assertThat(Ledgers.checkWitnesses(root))
+ .containsExactly(MODEL + ": is a model behaviour with no witness in emf/docs/witnesses.md");
+ }
+
+ @Test
+ void aWitnessMustNameAModelRowAndARealTestOnce(@TempDir Path root) throws IOException {
+ write(root, "docs/requirements.md", MODEL_ROW + "\n" + GATE_ROW);
+ write(root, "emf/parity/src/test/java/x/ParseTest.java", "class ParseTest { void parses() {} }");
+ write(root, "emf/parity/src/main/java/x/ParseTest.java", "class ParseTest { void missing() {} }");
+ write(
+ root,
+ "emf/docs/witnesses.md",
+ String.join(
+ "\n",
+ "This list holds **4** witnesses.",
+ "| " + MODEL + " | `ParseTest#parses` |",
+ "| " + MODEL + " | `ParseTest#parses` |",
+ "| " + GATE + " | `ParseTest#parses` |",
+ "| " + UNKNOWN + " | `ParseTest#missing` |"));
+
+ assertThat(Ledgers.checkWitnesses(root))
+ .containsExactly(
+ MODEL + ": listed twice",
+ GATE + ": names no model behaviour row in docs/requirements.md",
+ UNKNOWN + ": names no model behaviour row in docs/requirements.md",
+ UNKNOWN + ": names ParseTest#missing, which is not a test in emf/",
+ "emf/docs/witnesses.md: states 4 rows but holds 3");
+ }
+
+ @Test
+ void aWitnessListStatingNoCountFails(@TempDir Path root) throws IOException {
+ write(root, "docs/requirements.md", GATE_ROW);
+ write(root, "emf/docs/witnesses.md", "No count here.\n");
+
+ assertThat(Ledgers.checkWitnesses(root)).containsExactly("emf/docs/witnesses.md: states no row count");
+ }
+
+ @Test
+ void aRuleWhoseEnforcerNoLongerHoldsItsWitnessFails(@TempDir Path root) throws IOException {
+ write(root, "emf/pom.xml", "-Werror");
+ write(
+ root,
+ "emf/docs/rules.md",
+ String.join(
+ "\n",
+ "This ledger holds **4** rules.",
+ "| EMF-001 | warnings fail | `pom.xml` | `-Werror` |",
+ "| EMF-001 | lint all | `pom.xml` | `-Xlint:all` |",
+ "| EMF-003 | outside | `../docs/rules.md` | `rules` |",
+ "| EMF-004 | missing | `gone.xml` | `x` |"));
+
+ assertThat(Ledgers.checkRules(root))
+ .containsExactly(
+ "EMF-001: listed twice",
+ "EMF-001: pom.xml no longer contains `-Xlint:all`",
+ "EMF-003: names enforcer ../docs/rules.md, which is not a file in emf/",
+ "EMF-004: names enforcer gone.xml, which is not a file in emf/");
+ }
+
+ @Test
+ void aRuleLedgerWhoseCountDriftsFails(@TempDir Path root) throws IOException {
+ write(root, "emf/pom.xml", "-Werror");
+ write(
+ root,
+ "emf/docs/rules.md",
+ "This ledger holds **2** rules.\n| EMF-001 | warnings fail | `pom.xml` | `-Werror` |");
+
+ assertThat(Ledgers.checkRules(root)).containsExactly("emf/docs/rules.md: states 2 rows but holds 1");
+ }
+
+ @Test
+ void aStatedCountTooLargeForAnIntIsReportedNotThrown(@TempDir Path root) throws IOException {
+ write(root, "docs/requirements.md", GATE_ROW);
+ write(root, "emf/docs/witnesses.md", "This list holds **99999999999** witnesses.\n");
+
+ assertThat(Ledgers.checkWitnesses(root))
+ .containsExactly("emf/docs/witnesses.md: states 99999999999 rows but holds 0");
+ }
+
+ @Test
+ void aLedgerThatCannotBeReadFailsLoudly(@TempDir Path root) {
+ assertThatThrownBy(() -> Ledgers.checkRules(root)).isInstanceOf(UncheckedIOException.class);
+ assertThatThrownBy(() -> Ledgers.checkWitnesses(root)).isInstanceOf(UncheckedIOException.class);
+ }
+
+ private static void write(Path root, String relative, String content) throws IOException {
+ Path file = root.resolve(relative);
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, content);
+ }
+
+ private static Path repository() {
+ Path dir = Path.of("").toAbsolutePath();
+ while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) {
+ dir = dir.getParent();
+ }
+ return dir;
+ }
+}
diff --git a/emf/pom.xml b/emf/pom.xml
new file mode 100644
index 0000000..46f9337
--- /dev/null
+++ b/emf/pom.xml
@@ -0,0 +1,135 @@
+
+
+
+ 4.0.0
+
+ dev.jorisjonkers.deploykit.emf
+ emf-parent
+ 0.1.0-SNAPSHOT
+ pom
+ deploy-kit model-driven implementation
+
+
+ parity
+
+
+
+ 21
+ UTF-8
+ UTF-8
+ 2026-09-14T00:00:00Z
+
+
+ 1.00
+ 1.00
+ 100
+
+ 6.1.3
+ 3.27.7
+ 1.5.0
+
+ 2.98.0
+ 0.8.15
+ 1.30.0
+ 1.2.3
+
+
+
+
+
+ org.junit
+ junit-bom
+ ${junit.version}
+ pom
+ import
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+
+
+
+
+
+
+
+ org.apache.maven.pluginsmaven-clean-plugin3.5.0
+ org.apache.maven.pluginsmaven-resources-plugin3.5.0
+ org.apache.maven.pluginsmaven-compiler-plugin3.16.0
+ org.apache.maven.pluginsmaven-surefire-plugin3.6.0
+ org.apache.maven.pluginsmaven-jar-plugin3.5.1
+ org.apache.maven.pluginsmaven-install-plugin3.1.4
+ org.apache.maven.pluginsmaven-deploy-plugin3.1.4
+ org.apache.maven.pluginsmaven-site-plugin3.22.0
+ org.apache.maven.pluginsmaven-enforcer-plugin3.6.3
+ com.diffplug.spotlessspotless-maven-plugin3.10.2
+ org.jacocojacoco-maven-plugin${jacoco.version}
+ org.pitestpitest-maven${pitest.version}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ enforce
+ enforce
+
+
+ [21,22)
+ [3.9,4)
+
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ -Xlint:all
+ -Werror
+
+
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+
+
+ src/*/java/**/*.java
+ ${palantir-java-format.version}
+
+
+
+
+
+ format-check
+ check
+
+
+
+
+
+
diff --git a/emf/scripts/summary.sh b/emf/scripts/summary.sh
new file mode 100755
index 0000000..123fcbe
--- /dev/null
+++ b/emf/scripts/summary.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Prints one Markdown line for the CI job summary: tests, line coverage and
+# mutation score of the model-driven build, read from the reports `mvnw verify`
+# leaves behind. Missing reports print as "n/a" rather than failing, because the
+# summary runs after a failed build too.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+tests=$(cat ./*/target/surefire-reports/TEST-*.xml 2>/dev/null \
+ | grep -o ']*' | grep -o ' tests="[0-9]*"' | grep -o '[0-9]*' \
+ | awk '{s+=$1} END {print (NR ? s : "n/a")}')
+
+coverage=$(cat ./*/target/site/jacoco/jacoco.csv 2>/dev/null | awk -F, '
+ NR > 1 && $1 != "GROUP" {missed += $8; covered += $9}
+ END {if (missed + covered) printf "%.1f%%", 100 * covered / (missed + covered); else print "n/a"}')
+
+mutation=$(cat ./*/target/pit-reports/mutations.xml 2>/dev/null | awk '
+ {total += gsub(/JorisJonkers-dev/renovate-config"]
+ "extends": ["github>JorisJonkers-dev/renovate-config"],
+ "packageRules": [
+ {
+ "description": "The model-driven build under emf/ is deleted at its sunset (emf/docs/adr/emf/0107); its Maven and wrapper updates travel together.",
+ "matchManagers": ["maven", "maven-wrapper"],
+ "matchFileNames": ["emf/**"],
+ "groupName": "emf maven"
+ }
+ ]
}
diff --git a/test/emf-wiring.test.ts b/test/emf-wiring.test.ts
new file mode 100644
index 0000000..1ef1ebf
--- /dev/null
+++ b/test/emf-wiring.test.ts
@@ -0,0 +1,154 @@
+// The model-driven build under emf/ has no npm script, so the pipeline wiring
+// test cannot see it: a workflow step running the Maven wrapper against a
+// directory with no build, or a reactor naming a module that is not on disk,
+// would pass every other check and fail only in CI, or not at all. This test
+// holds the Maven side to the same rule 0102 states for npm: the job CI runs is
+// the build the tree contains. It also holds CodeQL to scanning that build.
+//
+// Deleted with emf/ at its sunset (emf/docs/adr/emf/0107).
+//
+// REQ-015 (docs/requirements.md): the model-driven build CI runs is the build
+// in the tree.
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ writeFileSync,
+} from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { temporary } from "./setup.ts";
+
+const REPOSITORY = join(import.meta.dirname, "..");
+
+interface Workflow {
+ readonly name: string;
+ readonly text: string;
+}
+
+function workflows(): Workflow[] {
+ const dir = join(REPOSITORY, ".github", "workflows");
+ return readdirSync(dir)
+ .filter((name) => name.endsWith(".yml"))
+ .map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }));
+}
+
+/**
+ * Every workflow step that runs the Maven wrapper, as the directory it runs
+ * in. A step is the text between two list markers at the same indent, and the
+ * directory is its `working-directory`, or the repository root without one.
+ */
+function mavenSteps(files: readonly Workflow[]): string[] {
+ return files.flatMap((file) =>
+ file.text
+ .split(/\n(?= {6}- )/)
+ .filter((step) => /\bmvnw\b/.test(step))
+ .map((step) => /'working-directory':\s*'([^']+)'/.exec(step)?.[1] ?? "."),
+ );
+}
+
+/** Every problem with the Maven steps and the reactor under `root`. */
+function mavenWiringErrors(files: readonly Workflow[], root: string): string[] {
+ const errors: string[] = [];
+ for (const dir of mavenSteps(files)) {
+ if (!existsSync(join(root, dir, "pom.xml")))
+ errors.push(
+ `a step runs the Maven wrapper in ${dir}, which names a POM that does not exist`,
+ );
+ if (!existsSync(join(root, dir, "mvnw")))
+ errors.push(
+ `a step runs the Maven wrapper in ${dir}, which holds no mvnw`,
+ );
+ }
+ const reactor = join(root, "emf", "pom.xml");
+ const modules = existsSync(reactor)
+ ? [
+ ...readFileSync(reactor, "utf8").matchAll(/([^<]+)<\/module>/g),
+ ].map((match) => match[1] ?? "")
+ : [];
+ for (const module of modules)
+ if (!existsSync(join(root, "emf", module, "pom.xml")))
+ errors.push(`emf/pom.xml names module ${module}, which is not on disk`);
+ return errors;
+}
+
+function workflow(name: string, text: string): Workflow {
+ return { name, text };
+}
+
+const MAVEN_STEP = [
+ " 'steps':",
+ " - 'uses': 'actions/checkout@abc' # v7",
+ " - 'name': 'Verify'",
+ " 'working-directory': 'emf'",
+ " 'run': './mvnw -B -ntp verify'",
+].join("\n");
+
+describe("the model-driven build CI runs is the build in the tree", () => {
+ it("passes over this repository's workflows and reactor", () => {
+ const files = workflows();
+
+ expect(mavenSteps(files)).toContain("emf");
+ expect(mavenWiringErrors(files, REPOSITORY)).toEqual([]);
+ });
+
+ it("fails a step whose directory names a POM that does not exist", () => {
+ const moved = workflow("ci.yml", MAVEN_STEP.replace("'emf'", "'java'"));
+
+ expect(mavenWiringErrors([moved], REPOSITORY)).toEqual([
+ "a step runs the Maven wrapper in java, which names a POM that does not exist",
+ "a step runs the Maven wrapper in java, which holds no mvnw",
+ ]);
+ });
+
+ it("fails a step with no working directory, since the root holds no build", () => {
+ const rooted = workflow(
+ "ci.yml",
+ MAVEN_STEP.replace(" 'working-directory': 'emf'\n", ""),
+ );
+
+ expect(mavenWiringErrors([rooted], REPOSITORY)).toEqual([
+ "a step runs the Maven wrapper in ., which names a POM that does not exist",
+ "a step runs the Maven wrapper in ., which holds no mvnw",
+ ]);
+ });
+
+ it("fails a reactor that names a module which is not on disk", () => {
+ const root = temporary();
+ mkdirSync(join(root, "emf", "parity"), { recursive: true });
+ writeFileSync(join(root, "emf", "parity", "pom.xml"), "");
+ writeFileSync(
+ join(root, "emf", "pom.xml"),
+ "paritygone",
+ );
+
+ expect(mavenWiringErrors([], root)).toEqual([
+ "emf/pom.xml names module gone, which is not on disk",
+ ]);
+ });
+});
+
+describe("CodeQL scans the model-driven build", () => {
+ const codeql = readFileSync(
+ join(REPOSITORY, ".github", "workflows", "codeql.yml"),
+ "utf8",
+ );
+ const config = readFileSync(
+ join(REPOSITORY, ".github", "codeql", "codeql-config.yml"),
+ "utf8",
+ );
+
+ it("analyses java-kotlin without a build, with the shared configuration", () => {
+ expect(codeql).toContain("'language': 'java-kotlin'");
+ expect(codeql).toContain("'build-mode': 'none'");
+ expect(codeql).toContain(
+ "'config-file': './.github/codeql/codeql-config.yml'",
+ );
+ });
+
+ it("ignores build output and generated sources", () => {
+ expect(config).toContain("- '**/target/**'");
+ expect(config).toContain("- '**/src-gen/**'");
+ });
+});
diff --git a/test/pipeline-wiring.test.ts b/test/pipeline-wiring.test.ts
index 9a363e2..1105f8f 100644
--- a/test/pipeline-wiring.test.ts
+++ b/test/pipeline-wiring.test.ts
@@ -126,3 +126,33 @@ describe("npm script wiring", () => {
).not.toThrow();
});
});
+
+// REQ-016 (docs/requirements.md): a code scanning finding of any severity fails
+// Pipeline Complete.
+describe("code scanning gates the merge", () => {
+ const ci = readFileSync(
+ join(REPOSITORY, ".github", "workflows", "ci.yml"),
+ "utf8",
+ );
+ const codeql = readFileSync(
+ join(REPOSITORY, ".github", "workflows", "codeql.yml"),
+ "utf8",
+ );
+ const needs =
+ /'pipeline-complete':[\s\S]*?'needs':\n((?:\s+- '[\w-]+'\n)+)/.exec(
+ ci,
+ )?.[1] ?? "";
+
+ it("CI calls the CodeQL workflow as a job Pipeline Complete needs", () => {
+ expect(ci).toContain("'uses': './.github/workflows/codeql.yml'");
+ expect(needs).toContain("- 'codeql'");
+ expect(codeql).toContain("'workflow_call':");
+ });
+
+ it("the CodeQL workflow fails on any unsuppressed finding", () => {
+ expect(codeql).toContain("'name': 'Fail on any finding'");
+ expect(codeql).toContain("'output': 'sarif'");
+ expect(codeql).toContain("select((.suppressions // []) | length == 0)");
+ expect(codeql).toContain("exit 1");
+ });
+});