From e7c288e729f2b07594c105abb9b8473fa83209bb Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 16 Sep 2026 10:51:29 +0200 Subject: [PATCH 1/5] docs: decide the parity seam and the two tiers under emf/ Record two decisions for the model-driven implementation. 0120 puts the parity suite on the pipeline's file interface. The suite imports Pipeline, Parsed, Diagnostic and Descriptor today, which is why parity/ is an eclipse-plugin bundle while the architecture says a module needing no p2 bundle stays a plain jar. Moving the suite onto the files the pipeline writes closes that drift and narrows the test surface to the one an examiner exercises from a launch configuration. 0121 splits the tree into bundles/ for what Tycho builds and Eclipse imports and tests/ for what only Maven runs, and makes the second tier Kotlin. Ecore generates Java and a bundle compiles through JDT against a target platform with no Kotlin unit, so the bundle tier stays Java. Both are proposed, and the tree is unchanged: emf/docs/architecture.md describes the flat modules it still has and carries each decision as a "proposed, not landed" note beside the section it will rewrite. --- emf/docs/adr/README.md | 2 + ...0-parity-crosses-the-cli-file-interface.md | 112 ++++++++++++++++ ...21-bundles-and-tests-are-separate-tiers.md | 121 ++++++++++++++++++ emf/docs/architecture.md | 38 +++++- 4 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md create mode 100644 emf/docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md diff --git a/emf/docs/adr/README.md b/emf/docs/adr/README.md index ec5ef0b..75527a5 100644 --- a/emf/docs/adr/README.md +++ b/emf/docs/adr/README.md @@ -42,3 +42,5 @@ taking a number, check both registers. | [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 | +| [0120](emf/0120-parity-crosses-the-cli-file-interface.md) | The parity suite reaches the pipeline through its file interface, and holds no EMF type | open | +| [0121](emf/0121-bundles-and-tests-are-separate-tiers.md) | The tree splits into an Eclipse bundle tier and a test tier, and only the bundle tier is Java | open | diff --git a/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md b/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md new file mode 100644 index 0000000..5aa33fb --- /dev/null +++ b/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md @@ -0,0 +1,112 @@ +--- +tier: decision +status: proposed +claim: open +owner: joris +date: 2026-09-16 +normative: docs/architecture.md#parity +rests-on: ["0106"] +--- + +# The parity suite reaches the pipeline through its file interface, and holds no EMF type + +## Rests on +Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the +claim is that everything the parity contract compares is already a file, so a +suite that calls no EMF type can still prove every case under +`spec/v1/examples/`. False if: a parity case exists that cannot be decided from +the pipeline's own output files and its exit code, and can only be decided by +reading an in-memory model. Settled by: the parity module building as a plain +jar with no dependency on the target platform, green on every case the three +course tasks land. + +## Why +Today `parity/` imports `Pipeline`, `Parsed`, `Diagnostic` from the `cli` +module and `Descriptor`, `ProjectIntentPackage` from `metamodel`. Those are EMF +types, resolved from p2, so the test module has to be an Eclipse bundle to see +them. It is one: `parity/pom.xml` declares `eclipse-plugin` packaging, while +[the architecture](../../architecture.md#toolchain) says modules that need no p2 +bundle, like `parity/`, stay plain jars. The drift is not a typo. One suite +reaching past the pipeline's own interface pulled the whole Eclipse toolchain +into the module whose job is to be independent of it. + +The pipeline's real interface is narrower than its Java types: input files and +arguments in; an exit code, diagnostics and a rendered tree out. That is the +interface [0105](../../../../docs/adr/architecture/0105-two-implementations-meet-at-committed-oracles.md) +already compares the two implementations across, because an oracle file is a +file and nothing else. Two things are compared today through Java instead: the +metamodel descriptor and the parsed intent. Both are values the pipeline can +write, so both can cross the same interface as everything else. + +Moving the suite onto that interface pays three times. The test module loses +every p2 dependency and becomes a plain jar. A parity failure then names an +output a person can open, not a Java field a person has to reconstruct. And the +suite exercises what a run of the pipeline leaves behind, which is what a grader +sees from `mvn verify` and what an examiner sees from a launch configuration, so +a case that passes in CI is a case either of them can reproduce. + +Losing every p2 dependency is also what lets the module be written in a language +the target platform does not carry, which is +[0121](0121-bundles-and-tests-are-separate-tiers.md)'s subject. This decision +stands on its own — the drift it closes is live today — but that is the order the +two are taken in. + +The module keeps its `src/main`: `CanonicalJson` and `Ledgers` are helpers the +suites call, not tests, and neither touches EMF. Only the test sources change. + +It costs the ability to assert on anything the pipeline does not write. That is +the point: a value worth asserting on is a value worth emitting, and one that +is not emitted is not evidence of parity. + +## Alternatives +| option | cost if taken | why rejected | +|---|---|---| +| Leave `parity/` an Eclipse bundle and fix the architecture's sentence instead | Nothing to build; the drift closes by rewriting the doc | Keeps the toolchain inside the module that exists to be free of it, and keeps the widest interface in the tree as the test surface | +| Keep the Java calls, but add a second, file-level suite beside them | Both surfaces proven | Two suites decide the same cases, and the narrow one is never trusted while the wide one exists | +| Make the pipeline a separate process the suite runs | The strictest seam, exit code included | Costs a packaged, runnable artifact and process plumbing for no case the in-process file interface cannot decide; revisit if one appears | +| Export only the descriptor, keep `Parsed` in Java | One less thing to serialise | The parsed intent is the Task 1 evidence, so it is exactly the thing that must be readable as a file | + +## Reversibility +Undo cost today: restoring the Java calls and the `eclipse-plugin` packaging, +half a day, and the serialisers stay useful either way. Becomes irreversible +once: never; deleted with `emf/`. + +## Consequences +- The pipeline writes the parsed intent and the diagnostics as canonical JSON, + and the metamodel writes its descriptor as a build artifact. Paid by the + pull request that moves the suite. +- `parity/pom.xml` becomes `jar` packaging and loses its `META-INF/MANIFEST.MF` + and its `build.properties`, the two PDE artifacts, so it builds without the + target platform. Paid in the same pull request. +- **The module rules stop enforcing unless they are given their classes and a + non-empty guard.** `ArchitectureTest` is `@AnalyzeClasses(packages = + "dev.jorisjonkers.deploykit.emf")`, so it sees the other modules only because + `parity/META-INF/MANIFEST.MF` requires them. Dropping that dependency, which + this decision mandates, leaves the importer with parity's own classes; every + other layer is then empty, `withOptionalLayers(true)` excuses it, and + `EMF-010` and `EMF-011` pass while proving nothing. The suite reads the + class directories the build wrote, by path, **and** asserts the imported set + is non-empty, so an empty import fails loudly instead of passing. Paid in the + same pull request, and shown by a fixture that empties the import. +- A module that declares no dependency is ordered by its position in the + reactor alone, so `-T`, `-pl` or an IDE can run the suite before the bundles + wrote their classes and output files. Each case asserts its expected output + file exists before comparing it, and a missing one fails as a missing file + rather than as a skipped case. That is a different absence from a case with no + committed oracle, which is still listed as not yet a parity case: one is the + pipeline failing to write what it owes, the other is a case nobody has pinned + yet. Paid in the same pull request. +- `EMF-010` and `EMF-011` keep their rows but gain the fixture + [0104](../../../../docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md) + requires, since a rule that has only ever run clean is untested and these two + would be the first to pass vacuously. Paid in the same pull request. +- The module table's dependency rule changes shape: the parity module depends on + no module, only on the files the build writes. That is a row in + [the architecture](../../architecture.md#modules), a row in + [the rule ledger](../../rules.md) for `EMF-010`, **and** the rule's own + enforcement: `MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM` in + `ArchitectureTest` currently names `parity` in five `mayOnlyBeAccessedByLayers` + chains, so it would keep permitting exactly what this decision forbids. The + layer and those five entries go together with the imports. +- A stage that wants a new parity assertion must first make the pipeline emit + the value. Paid by Task 2 and Task 3. diff --git a/emf/docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md b/emf/docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md new file mode 100644 index 0000000..cd8e65a --- /dev/null +++ b/emf/docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md @@ -0,0 +1,121 @@ +--- +tier: decision +status: proposed +claim: open +owner: joris +date: 2026-09-16 +normative: docs/architecture.md#modules +rests-on: ["0106"] +--- + +# The tree splits into an Eclipse bundle tier and a test tier, and only the bundle tier is Java + +## Rests on +Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the +claim is that the modules an examiner opens in Eclipse and the modules that +only ever run from Maven are two disjoint sets, and that the second set needs +nothing from the Eclipse toolchain. False if: a module has to be both, or a +test tier written outside Java cannot hold a gate this build already enforces. +Settled by: `./mvnw -B -ntp verify` green with `bundles/` built by Tycho, the +test tier built as a plain Kotlin jar, and every rule in +[the rule ledger](../../rules.md) still enforced. + +## Why +Every module under `emf/` is an Eclipse bundle today, and the directory listing +says nothing about which of them a course examiner ever opens. Five of them are +the graded artefacts: the metamodels, the grammar, the transformation, the +templates and the pipeline that runs them. One, `parity/`, is this +repository's own evidence, built and run only by Maven, and by +[0120](0120-parity-crosses-the-cli-file-interface.md) it holds no EMF type at +all. Two audiences, two toolchains, one flat list. + +The split names them: `bundles/` for what Tycho builds against `emf.target` +and Eclipse imports, `tests/` for what Maven builds as a plain jar. The boundary +answers a question every reader currently has to work out from a `pom.xml`: is +this file graded, and will a teacher open it. It says nothing about the +documents: `emf/docs/` keeps its architecture, its rule ledger, its witness list +and its register where they are, and a reader who finds them spread out is +finding a different problem than this one. + +The boundary is also a language boundary. Ecore generates Java, Xtext's runtime +hooks are Java, and a bundle compiles through Tycho's JDT compiler against a +target platform that carries no Kotlin: putting Kotlin in a bundle means a +`kotlin-stdlib` unit in `emf.target`, a second compiler ahead of JDT, and an +examiner who needs the Kotlin Eclipse plugin to read the source. So `bundles/` +is Java, without exception. + +The `cli` module is the one worth naming, because it looks like the exception. +It is not run from Eclipse the way the metamodels are read there, so it reads +like a candidate for Kotlin. It is not one: it calls the EMF, OCL, QVTo and +Acceleo standalone APIs, every one of them resolved from p2, so it is a bundle +by its dependencies whatever language it is written in, and it carries the +launch configurations an examiner runs. Kotlin reaches it only as a launcher +wrapping a Java pipeline, which buys argument parsing and pays a module for it. +The part with no teacher, no Eclipse and no p2 dependency is the test tier, and +that is the part this decision moves. `tests/` has none of those constraints and every +other JVM repository in the estate is Kotlin with a mature suite, so it is +Kotlin, and the estate's test idiom applies here rather than being reinvented +in Java. + +The graded modules keep their short directory names. Tycho's convention is a +directory named for the bundle's symbolic name, but Eclipse names an imported +Maven project from its `artifactId` regardless, so the long names buy nothing +an import already gives and cost every path in the tree. + +## Alternatives +| option | cost if taken | why rejected | +|---|---|---| +| Keep the flat module list, document the split in prose | No moves, no path updates | The listing is what a reader sees first; a convention only a document knows is a convention that gets broken | +| Split by course task (`task1/`, `task2/`, `task3/`) instead | Matches the grading schedule exactly | The `cli` and the metamodels span every task, so the split would have to cut them; the deadline is a property of a row in the module table, not of a directory | +| Directories named for bundle symbolic names | Tycho and PDE convention, directory equals Eclipse project | Eclipse takes the project name from the `artifactId` anyway, so it buys a naming match nobody reads and costs 40-character paths | +| Kotlin in the bundles too, one language for the tree | One toolchain to learn | A p2 target platform with no Kotlin, a second compiler before JDT, and generated EMF Java that stays Java regardless | +| Kotlin for `cli` alone, the module no teacher runs as a project | The pipeline entry in the estate's language | `cli` calls four standalone APIs resolved from p2, so it is a bundle by its dependencies; Kotlin would reach only a launcher around the Java pipeline | +| Keep the test tier in Java | No second language in the build | Every other JVM repository in the estate is Kotlin, so a Java suite here is the one suite nobody has a pattern for | + +## Reversibility +Undo cost today: moving six directories back and updating the paths that name +them. `grep -rIl -E 'emf/(metamodel|syntax|cli|resolve|render|parity)'` over the +tracked tree finds them; an afternoon. Becomes +irreversible once: never; deleted with `emf/`. + +## Consequences +- The reactor's `` entries become `bundles/…` and `tests/…`, and the + root's `test/emf-wiring.test.ts` keeps holding them, since it reads the + entries rather than assuming a depth. Paid by the moving pull request. +- The `bundle` profile in `emf/pom.xml`, which activates on a module's + `META-INF/MANIFEST.MF`, now agrees with the directory it sits in. The file + test stays: the directory is documentation, the manifest is the fact. +- Spotless with palantir-java-format covers `bundles/`; the test tier needs + the formatter the estate's Kotlin repositories run. Paid by the pull request + that ports the suite. +- PIT reports many equivalent mutants on Kotlin, so the mutation threshold of + [0115](0115-the-emf-gates-are-estate-shaped.md) may not hold over the test + tier at the number it holds over Java. Lowering it is a decision of its own, + with its own record, and until one exists the threshold stands. +- `emf/README.md` gains the one table that says which artefact lives where and + which task grades it, so the layout answers the navigation question without + a search. Paid by the moving pull request. +- [0114](0114-model-behaviours-have-a-java-witness.md) says every model + behaviour has a **Java** witness, and three of its six witnesses are + `ParityTest` methods in the module this decision ports. The other three sit in + bundles and stay Java. The wording that has to change is "Java", not the rule: + a witness is a JUnit test, in whichever language its module is written. The + pull request that ports the suite amends that decision to say so and carries + the same edit into [the witness list](../../witnesses.md), `EMF-013`'s row in + [the rule ledger](../../rules.md) and the sentence under + [the gates](../../architecture.md#gates) that calls a witness a Java proof. + `emf/docs/architecture.md`'s own + [Witnesses](../../architecture.md#witnesses) section already says "the JUnit + test", so it needs no edit. Nothing here reverses + [0114](0114-model-behaviours-have-a-java-witness.md); if review reads the + change as a reversal rather than an amendment, it supersedes it instead, with + every citation naming the successor. +- Every document that names a module by its current path moves with it, in the + same commit, `emf/docs/architecture.md` and `emf/README.md` included: [the rule ledger](../../rules.md), whose `EMF-010`, `EMF-011`, + `EMF-013`, `EMF-014` and `EMF-016` rows each name a file under `parity/` and + whose `EMF-014` check fails the build the moment a named file is not where its + row says; [the witness list](../../witnesses.md); + [the root architecture](../../../../docs/architecture.md); the refusal + examples' README; and the two root tests that name a parity path in a comment + and in a fixture string. The ledger is the one that breaks the build if it is + forgotten. diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 7c207dd..3275948 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -59,8 +59,9 @@ A module that needs a p2 bundle is an Eclipse bundle: a `META-INF/MANIFEST.MF` naming the bundles it requires, and `eclipse-plugin` packaging. A manifest switches on the parent's `bundle` profile, which runs the module's tests through Maven Surefire on a plain classpath, outside OSGi, so every tool is -exercised through the standalone API a command-line run uses. Modules that -need no p2 bundle, like `parity/`, stay plain jars. +exercised through the standalone API a command-line run uses. A module that +needs no p2 bundle stays a plain jar. Every module is a bundle today, `parity/` +included, for the reason [Parity](#parity) records. 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, @@ -89,6 +90,18 @@ Tycho configuration and the target platform. A module may depend on the modules above it in this table and on nothing below. +Every module is Java. Ecore generates Java, Xtext's runtime hooks are Java, and +a bundle compiles through Tycho's JDT compiler against a target platform holding +no Kotlin unit, so a language other than Java is a bundle's problem before it is +anything else. + +> **Proposed, not landed:** +> [0121](adr/emf/0121-bundles-and-tests-are-separate-tiers.md) splits these six +> into `bundles/` for what Tycho builds and Eclipse imports and `tests/` for what +> only Maven runs, and makes the second tier Kotlin. Until the moving pull +> request lands, the flat list above is the tree, and the Java sentence above +> holds over all of it. + ## Metamodels Two hand-written Ecore metamodels, as the project proposal defines them. Both @@ -232,6 +245,27 @@ Deployment model kept inside `emf/`; it is test input, never an oracle. Output i committed `rendered/` tree, so whitespace, key order and the `GENERATED` header are template decisions made to match the oracle, not presentation. +## Parity + +`parity/` holds this repository's own evidence: the suites that assert each stage +against the committed oracles, the ledger checks, and the module rules. It is +built and run by Maven only, and no examiner opens it. + +Today it reaches the pipeline through Java: `ParityTest` calls `Pipeline`, +`Parsed` and `Diagnostic` from `cli/` and `Descriptor` and `ProjectIntentPackage` +from `metamodel/`. Those types resolve from p2, so the module carries a +`META-INF/MANIFEST.MF` and `eclipse-plugin` packaging, and the sentence in +[Toolchain](#toolchain) about a module that needs no p2 bundle does not reach it. +Its `src/main` holds `CanonicalJson` and `Ledgers`, neither of which touches EMF. + +> **Proposed, not landed:** +> [0120](adr/emf/0120-parity-crosses-the-cli-file-interface.md) moves the suites +> onto the pipeline's file interface — arguments and input files in; an exit +> code, diagnostics, the parsed intent, the descriptor and the rendered tree out +> — so the module holds no EMF type and builds as a plain jar. What follows once +> it lands: a value a parity case asserts on is a value the pipeline writes, and +> a derivation with no output file is not evidence. + ## Witnesses A behaviour ledger row whose behaviour is the model's own is proved in both From a6aea1f57eee3e1f9786a30c1a2e7c84bb1d4b9c Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 16 Sep 2026 11:56:32 +0200 Subject: [PATCH 2/5] feat: write the parsed intent, the diagnostics and the descriptor as files the build leaves behind A run of the pipeline now leaves behind everything the parity contract compares, as files, in the canonical JSON the oracles are committed in. For every case under spec/v1/examples/, Outputs writes the parsed intent or the diagnostics that refused it, beside the exit code the run ended on, under cli/target/parity in a tree that mirrors the example tree, so a written file and its oracle are obviously a pair. DescriptorFile writes the source metamodel's descriptor from the same reflective walk Descriptor already performs, under metamodel/target/parity. Neither file is committed. The canonical JSON writer moves down to metamodel/, the lowest module that writes a canonical file, because the pipeline now writes what the parity suite used to serialise. The parity suites are otherwise untouched: this is the expand half of the move recorded in 0120, so a wrong output shape is caught here rather than after the suites depend on it. Closes #128. --- docs/architecture.md | 2 +- .../deploykit/emf/cli/Outputs.java | 128 +++++++++++++ .../deploykit/emf/cli/OutputsTest.java | 173 ++++++++++++++++++ emf/metamodel/META-INF/MANIFEST.MF | 1 + .../metamodel/descriptor/DescriptorFile.java | 31 ++++ .../emf/metamodel/json}/CanonicalJson.java | 2 +- .../descriptor/DescriptorFileTest.java | 35 ++++ .../metamodel/json}/CanonicalJsonTest.java | 2 +- test/canonical-json.test.ts | 2 +- 9 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java create mode 100644 emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java create mode 100644 emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java rename emf/{parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity => metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json}/CanonicalJson.java (99%) create mode 100644 emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java rename emf/{parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity => metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json}/CanonicalJsonTest.java (99%) diff --git a/docs/architecture.md b/docs/architecture.md index a444a1d..1dc2f7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -265,7 +265,7 @@ The file is an object with one `applications` entry per Application, each an The edge's own fields are fixed by the first case that has one. **The canonical writers** are `src/infrastructure/canonical-json.ts` and -`emf/parity`'s `CanonicalJson`, held to the same cases. An oracle file is exactly +`emf/metamodel`'s `CanonicalJson`, held to the same cases. An oracle file is exactly its canonical text, with no final newline, and a test fails any committed oracle that is not byte-identical to its own canonicalisation. diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java new file mode 100644 index 0000000..ae6e5b0 --- /dev/null +++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java @@ -0,0 +1,128 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import dev.jorisjonkers.deploykit.emf.metamodel.json.CanonicalJson; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; + +/** + * What a run of the pipeline leaves behind: for every case under {@code spec/v1/examples/}, the + * parsed intent or the diagnostics that refused it, in the canonical JSON the oracles are committed + * in, and the exit code the run ended on + * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md). + * + *

The output tree mirrors the example tree: a case at {@code auth/} writes {@code auth/}, and a + * refusal whose oracle is {@code refusals/unknown-surface.diagnostics.json} writes {@code + * refusals/unknown-surface/}, so a written file and its oracle are obviously a pair. A case writes + * exactly one of {@link #INTENT} and {@link #DIAGNOSTICS}, beside its {@link #EXIT}. + */ +public final class Outputs { + + /** The parsed intent of a case the pipeline accepted. */ + public static final String INTENT = "intent.json"; + + /** The diagnostics of a case the pipeline refused. */ + public static final String DIAGNOSTICS = "diagnostics.json"; + + /** The code the run ended on: {@code 0} when the pipeline accepted the case, {@code 1} when not. */ + public static final String EXIT = "exit"; + + private static final String INTENT_ORACLE = "expected/intent.json"; + private static final String DIAGNOSTICS_ORACLE = ".diagnostics.json"; + private static final String PROJECT = ".project.yml"; + private static final String PLATFORM = "platform.intent.yml"; + + private Outputs() {} + + /** Every case under {@code examples} run through the pipeline, written under {@code out}. */ + public static void write(Path examples, Path out) throws IOException { + for (Path directory : casesWithAnIntentOracle(examples)) { + writeParsed(out.resolve(examples.relativize(directory)), Pipeline.intent(authored(directory))); + } + for (Path oracle : refusalsWithADiagnosticsOracle(examples)) { + String stem = oracle.getFileName().toString().replace(DIAGNOSTICS_ORACLE, ""); + Path set = oracle.resolveSibling(stem); + Path directory = out.resolve(examples.relativize(set)); + // A directory beside the oracle is a set of documents read together; a file is read alone. + if (Files.isDirectory(set)) { + writeDiagnostics(directory, Pipeline.check(documents(set))); + } else { + writeParsed(directory, Pipeline.intent(oracle.resolveSibling(stem + PROJECT))); + } + } + } + + /** The case directories carrying an intent oracle: every case the pipeline is expected to accept. */ + private static List casesWithAnIntentOracle(Path examples) throws IOException { + try (Stream tree = Files.walk(examples)) { + return tree.filter(path -> path.endsWith(INTENT_ORACLE)) + .map(path -> path.getParent().getParent()) + .sorted() + .toList(); + } + } + + /** The diagnostics oracles under {@code refusals/}: every case the pipeline is expected to refuse. */ + private static List refusalsWithADiagnosticsOracle(Path examples) throws IOException { + try (Stream tree = Files.list(examples.resolve("refusals"))) { + return tree.filter(path -> path.getFileName().toString().endsWith(DIAGNOSTICS_ORACLE)) + .sorted() + .toList(); + } + } + + /** The one authored document of a case: its project file, or its Platform document. */ + private static Path authored(Path directory) throws IOException { + return documents(directory).get(0); + } + + /** The authored documents in {@code directory}, sorted; whatever else it holds is not read. */ + private static List documents(Path directory) throws IOException { + try (Stream entries = Files.list(directory)) { + return entries.filter(Outputs::isDocument).sorted().toList(); + } + } + + private static boolean isDocument(Path path) { + String name = path.getFileName().toString(); + return name.endsWith(PROJECT) || name.equals(PLATFORM); + } + + private static void writeParsed(Path directory, Parsed parsed) throws IOException { + if (parsed.ok()) { + write(directory, INTENT, CanonicalJson.write(parsed.intent()), 0); + } else { + writeDiagnostics(directory, parsed.diagnostics()); + } + } + + private static void writeDiagnostics(Path directory, List diagnostics) throws IOException { + write(directory, DIAGNOSTICS, CanonicalJson.write(triples(diagnostics)), diagnostics.isEmpty() ? 0 : 1); + } + + /** + * The {@code (code, document, path)} triples the parity contract fixes, in an order no run can + * change, so a refusal's file is the set the contract compares rather than one reading of it. + */ + private static List triples(List diagnostics) { + return diagnostics.stream() + .map(diagnostic -> (Object) new TreeMap<>(Map.of( + "code", diagnostic.code(), + "document", diagnostic.document(), + "path", diagnostic.path()))) + .sorted(Comparator.comparing(Object::toString)) + .toList(); + } + + private static void write(Path directory, String name, String json, int exit) throws IOException { + Files.createDirectories(directory); + Files.writeString(directory.resolve(name), json, StandardCharsets.UTF_8); + Files.writeString(directory.resolve(EXIT), Integer.toString(exit), StandardCharsets.UTF_8); + } +} diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java new file mode 100644 index 0000000..ac4bc2b --- /dev/null +++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java @@ -0,0 +1,173 @@ +package dev.jorisjonkers.deploykit.emf.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * What a run of the pipeline leaves behind: the files every parity case is decided from, under this + * module's build output, one directory per case. + */ +class OutputsTest { + + /** Where a run of this module leaves what the parity contract compares, under its build output. */ + private static final Path OUTPUT = Path.of("target", "parity"); + + @Test + void aRunLeavesEveryCaseUnderTheModulesBuildOutput() throws IOException { + Path examples = Examples.of(""); + deleteTree(OUTPUT); + + Outputs.write(examples, OUTPUT); + + assertThat(files(OUTPUT)).containsExactlyElementsOf(everyCasesPairedFile(examples)); + } + + @Test + void everyShapeOfCaseLeavesTheFileItsOracleIsPairedWith(@TempDir Path root) throws IOException { + Path examples = root.resolve("examples"); + Path out = root.resolve("out"); + accepted(examples, "minimal"); + refused(examples, "unknown-surface"); + refused(examples, "no-tier-for-audience"); + + Outputs.write(examples, out); + + assertThat(files(out)) + .containsExactly( + "minimal/exit", + "minimal/intent.json", + "refusals/no-tier-for-audience/diagnostics.json", + "refusals/no-tier-for-audience/exit", + "refusals/unknown-surface/diagnostics.json", + "refusals/unknown-surface/exit"); + assertThat(read(out.resolve("minimal/exit"))).isEqualTo("0"); + assertThat(read(out.resolve("minimal/intent.json"))).startsWith("{").endsWith("}"); + assertThat(read(out.resolve("refusals/unknown-surface/exit"))).isEqualTo("1"); + assertThat(read(out.resolve("refusals/unknown-surface/diagnostics.json"))) + .contains("E_UNKNOWN_SURFACE"); + assertThat(read(out.resolve("refusals/no-tier-for-audience/exit"))).isEqualTo("1"); + assertThat(read(out.resolve("refusals/no-tier-for-audience/diagnostics.json"))) + .contains("E_NO_TIER_FOR_AUDIENCE"); + } + + @Test + void aSetTheRunAcceptsLeavesAnEmptyRefusalAndExitZero(@TempDir Path root) throws IOException { + Path examples = root.resolve("examples"); + Path out = root.resolve("out"); + copy(Examples.of("minimal/notes.project.yml"), examples.resolve("refusals/holds/notes.project.yml")); + touch(examples.resolve("refusals/holds.diagnostics.json")); + + Outputs.write(examples, out); + + assertThat(read(out.resolve("refusals/holds/diagnostics.json"))).isEqualTo("[]"); + assertThat(read(out.resolve("refusals/holds/exit"))).isEqualTo("0"); + } + + /** + * The file every oracle under {@code examples} is paired with, and the exit code beside it, read + * from the oracles rather than from the run, so a case the run skipped is a missing file here. + */ + private static List everyCasesPairedFile(Path examples) throws IOException { + try (Stream tree = Files.walk(examples)) { + return tree.flatMap(oracle -> pairedFiles(examples, oracle)) + .sorted() + .toList(); + } + } + + private static Stream pairedFiles(Path examples, Path oracle) { + String name = oracle.getFileName().toString(); + if (oracle.endsWith("expected/intent.json")) { + String directory = relative(examples, oracle.getParent().getParent()); + return Stream.of(directory + "/exit", directory + "/intent.json"); + } + if (name.endsWith(".diagnostics.json")) { + String directory = "refusals/" + name.replace(".diagnostics.json", ""); + return Stream.of(directory + "/diagnostics.json", directory + "/exit"); + } + return Stream.empty(); + } + + /** A case the pipeline accepts, copied out of the real examples with an oracle beside it. */ + private static void accepted(Path examples, String name) throws IOException { + copyDocuments(Examples.of(name), examples.resolve(name)); + touch(examples.resolve(name).resolve("expected").resolve("intent.json")); + } + + /** A case the pipeline refuses, whether its input is one file or a set, with an oracle beside it. */ + private static void refused(Path examples, String stem) throws IOException { + Path source = Examples.of("refusals/" + stem); + Path refusals = examples.resolve("refusals"); + if (Files.isDirectory(source)) { + copyDocuments(source, refusals.resolve(stem)); + } else { + copy(Examples.of("refusals/" + stem + ".project.yml"), refusals.resolve(stem + ".project.yml")); + } + touch(refusals.resolve(stem + ".diagnostics.json")); + } + + /** Every authored document of {@code source}, and nothing else a case's directory happens to hold. */ + private static void copyDocuments(Path source, Path directory) throws IOException { + try (Stream tree = Files.list(source)) { + for (Path document : tree.filter( + path -> path.getFileName().toString().endsWith(".yml")) + .toList()) { + copy(document, directory.resolve(document.getFileName())); + } + } + } + + private static void copy(Path source, Path target) throws IOException { + Files.createDirectories(target.getParent()); + Files.copy(source, target); + } + + private static void touch(Path file) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, ""); + } + + /** Every file under {@code root}, relative to it, sorted, with {@code /} between segments. */ + private static List files(Path root) throws IOException { + try (Stream tree = Files.walk(root)) { + return tree.filter(Files::isRegularFile) + .map(file -> relative(root, file)) + .sorted() + .toList(); + } + } + + private static String relative(Path root, Path file) { + return root.relativize(file).toString().replace(File.separatorChar, '/'); + } + + private static String read(Path file) { + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream tree = Files.walk(root)) { + for (Path path : tree.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } +} diff --git a/emf/metamodel/META-INF/MANIFEST.MF b/emf/metamodel/META-INF/MANIFEST.MF index b0b0ef0..d1b99ad 100644 --- a/emf/metamodel/META-INF/MANIFEST.MF +++ b/emf/metamodel/META-INF/MANIFEST.MF @@ -6,6 +6,7 @@ Bundle-Version: 0.1.0.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.metamodel Export-Package: dev.jorisjonkers.deploykit.emf.metamodel.descriptor, + dev.jorisjonkers.deploykit.emf.metamodel.json, dev.jorisjonkers.deploykit.emf.metamodel.projectintent, dev.jorisjonkers.deploykit.emf.metamodel.projectintent.impl, dev.jorisjonkers.deploykit.emf.metamodel.projectintent.util diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java new file mode 100644 index 0000000..cadf142 --- /dev/null +++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java @@ -0,0 +1,31 @@ +package dev.jorisjonkers.deploykit.emf.metamodel.descriptor; + +import dev.jorisjonkers.deploykit.emf.metamodel.json.CanonicalJson; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The source metamodel's descriptor, written where a run of the build leaves it: the same reflective + * walk {@link Descriptor} performs, in the canonical JSON the oracle is committed in + * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md). The file is named for its oracle, + * {@code spec/v1/examples/expected/descriptor.json}, so the two are obviously a pair. + */ +public final class DescriptorFile { + + /** The name a run writes, the oracle's own. */ + public static final String NAME = "descriptor.json"; + + private DescriptorFile() {} + + /** Writes the source metamodel's descriptor under {@code directory}, and returns the file. */ + public static Path write(Path directory) throws IOException { + Files.createDirectories(directory); + return Files.writeString( + directory.resolve(NAME), + CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE)), + StandardCharsets.UTF_8); + } +} diff --git a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java similarity index 99% rename from emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java rename to emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java index 69f782f..cacd507 100644 --- a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJson.java +++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java @@ -1,4 +1,4 @@ -package dev.jorisjonkers.deploykit.emf.parity; +package dev.jorisjonkers.deploykit.emf.metamodel.json; import java.math.BigDecimal; import java.math.MathContext; diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java new file mode 100644 index 0000000..8ad6fa1 --- /dev/null +++ b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java @@ -0,0 +1,35 @@ +package dev.jorisjonkers.deploykit.emf.metamodel.descriptor; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.jorisjonkers.deploykit.emf.metamodel.json.CanonicalJson; +import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** The descriptor a run of this module's build leaves behind, beside the oracle it is paired with. */ +class DescriptorFileTest { + + /** Where a run of this module leaves what the parity contract compares, under its build output. */ + private static final Path OUTPUT = Path.of("target", "parity"); + + @Test + void aRunLeavesTheDescriptorUnderTheModulesBuildOutput() throws IOException { + Path file = DescriptorFile.write(OUTPUT); + + assertThat(file).isEqualTo(OUTPUT.resolve("descriptor.json")); + assertThat(Files.readString(file, StandardCharsets.UTF_8)) + .isEqualTo(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE))); + } + + @Test + void aDirectoryNoRunHasWrittenYetIsCreated(@TempDir Path directory) throws IOException { + Path file = DescriptorFile.write(directory.resolve("target").resolve("parity")); + + assertThat(file).exists(); + } +} diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java similarity index 99% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java rename to emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java index 00d4d7e..32d63c6 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java +++ b/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java @@ -1,4 +1,4 @@ -package dev.jorisjonkers.deploykit.emf.parity; +package dev.jorisjonkers.deploykit.emf.metamodel.json; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/test/canonical-json.test.ts b/test/canonical-json.test.ts index 03c8077..64bbfcd 100644 --- a/test/canonical-json.test.ts +++ b/test/canonical-json.test.ts @@ -1,4 +1,4 @@ -// REQ-020 (docs/requirements.md). The same cases as emf/parity CanonicalJsonTest. +// REQ-020 (docs/requirements.md). The same cases as emf/metamodel CanonicalJsonTest. import { describe, expect, it } from "vitest"; import { canonicalJson } from "../src/index.ts"; From dd97152b6d138e2ee80ba25b0f4b02f01f6778b4 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 16 Sep 2026 11:56:41 +0200 Subject: [PATCH 3/5] refactor: move the parity suite onto the pipeline's file interface The suite decides every case from the files a run leaves behind and the exit code beside them, so it holds no EMF type, calls nothing, and needs nothing from the target platform. The module becomes a plain jar: its META-INF/MANIFEST.MF and build.properties are gone, and it declares the test libraries the parent's bundle profile used to give it. Each case asserts its file exists before comparing it, so a file the run owes and did not write fails as a missing file rather than as a skipped case (0120). Two gates had to survive that change rather than quietly stop enforcing. ArchitectureTest saw the other modules only because the manifest required them; with no dependency left it reads their class directories by path instead. Because withOptionalLayers excuses an empty layer, EMF-010 and EMF-011 would then pass on an empty import while proving nothing, so EMF-017 fails when a module whose classes the build wrote is missing from the import. All three now fail on a fixture that breaks them, as 0104 requires, and the layered rule no longer names the parity layer as a permitted accessor. Also removes an em-dash the decision record carried, which test/emdash.test.ts refuses. Closes #129. --- ...0-parity-crosses-the-cli-file-interface.md | 18 ++- emf/docs/architecture.md | 45 ++++--- emf/docs/rules.md | 3 +- emf/parity/META-INF/MANIFEST.MF | 10 -- emf/parity/build.properties | 3 - emf/parity/pom.xml | 18 ++- .../emf/cli/fixture/UpperReachingDown.java | 21 +++ .../metamodel/fixture/LowerReachingUp.java | 19 +++ .../emf/parity/ArchitectureTest.java | 76 ++++++----- .../deploykit/emf/parity/ModuleRules.java | 124 ++++++++++++++++++ .../deploykit/emf/parity/ParityTest.java | 107 ++++++++------- 11 files changed, 324 insertions(+), 120 deletions(-) delete mode 100644 emf/parity/META-INF/MANIFEST.MF delete mode 100644 emf/parity/build.properties create mode 100644 emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java create mode 100644 emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java create mode 100644 emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java diff --git a/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md b/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md index 5aa33fb..7a91a8d 100644 --- a/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md +++ b/emf/docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md @@ -48,11 +48,14 @@ a case that passes in CI is a case either of them can reproduce. Losing every p2 dependency is also what lets the module be written in a language the target platform does not carry, which is [0121](0121-bundles-and-tests-are-separate-tiers.md)'s subject. This decision -stands on its own — the drift it closes is live today — but that is the order the +stands on its own (the drift it closes is live today), but that is the order the two are taken in. -The module keeps its `src/main`: `CanonicalJson` and `Ledgers` are helpers the -suites call, not tests, and neither touches EMF. Only the test sources change. +The module keeps `Ledgers` in its `src/main`: it is a helper the suites call, +not a test, and it touches no EMF. `CanonicalJson` does not stay: the pipeline +now writes what the suite used to serialise, so the writer moves down to +`metamodel/`, the lowest module that writes a canonical file, and the suites +compare bytes instead of serialising anything. It costs the ability to assert on anything the pipeline does not write. That is the point: a value worth asserting on is a value worth emitting, and one that @@ -85,9 +88,12 @@ once: never; deleted with `emf/`. this decision mandates, leaves the importer with parity's own classes; every other layer is then empty, `withOptionalLayers(true)` excuses it, and `EMF-010` and `EMF-011` pass while proving nothing. The suite reads the - class directories the build wrote, by path, **and** asserts the imported set - is non-empty, so an empty import fails loudly instead of passing. Paid in the - same pull request, and shown by a fixture that empties the import. + class directories the build wrote, by path, **and** asserts every module whose + classes the build wrote is in the import, so an empty import fails loudly + instead of passing. A module with no class of its own yet, which `resolve/` + and `render/` are until their stage lands, is empty in the tree rather than + missing from the import, and is not reported. Paid in the same pull request, + as `EMF-017`, and shown by a fixture that empties the import. - A module that declares no dependency is ordered by its position in the reactor alone, so `-T`, `-pl` or an IDE can run the suite before the bundles wrote their classes and output files. Each case asserts its expected output diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 3275948..368ea0a 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -60,8 +60,9 @@ naming the bundles it requires, and `eclipse-plugin` packaging. A manifest switches on the parent's `bundle` profile, which runs the module's tests through Maven Surefire on a plain classpath, outside OSGi, so every tool is exercised through the standalone API a command-line run uses. A module that -needs no p2 bundle stays a plain jar. Every module is a bundle today, `parity/` -included, for the reason [Parity](#parity) records. +needs no p2 bundle stays a plain jar, declaring its own test libraries because +the `bundle` profile does not activate for it. `parity/` is the one such +module, for the reason [Parity](#parity) records. 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, @@ -251,20 +252,32 @@ are template decisions made to match the oracle, not presentation. against the committed oracles, the ledger checks, and the module rules. It is built and run by Maven only, and no examiner opens it. -Today it reaches the pipeline through Java: `ParityTest` calls `Pipeline`, -`Parsed` and `Diagnostic` from `cli/` and `Descriptor` and `ProjectIntentPackage` -from `metamodel/`. Those types resolve from p2, so the module carries a -`META-INF/MANIFEST.MF` and `eclipse-plugin` packaging, and the sentence in -[Toolchain](#toolchain) about a module that needs no p2 bundle does not reach it. -Its `src/main` holds `CanonicalJson` and `Ledgers`, neither of which touches EMF. - -> **Proposed, not landed:** -> [0120](adr/emf/0120-parity-crosses-the-cli-file-interface.md) moves the suites -> onto the pipeline's file interface — arguments and input files in; an exit -> code, diagnostics, the parsed intent, the descriptor and the rendered tree out -> — so the module holds no EMF type and builds as a plain jar. What follows once -> it lands: a value a parity case asserts on is a value the pipeline writes, and -> a derivation with no output file is not evidence. +It reaches the pipeline through the pipeline's own interface, and through nothing +else ([0120](adr/emf/0120-parity-crosses-the-cli-file-interface.md)): arguments +and input files in; an exit code, diagnostics, the parsed intent, the descriptor +and the rendered tree out. A run of the build leaves those files under the build +output of the module that wrote them, one directory per case, mirroring +`spec/v1/examples/` so a written file and its oracle are obviously a pair: +`cli/target/parity//` holds `intent.json` or `diagnostics.json` beside the +`exit` the run ended on, and `metamodel/target/parity/` holds `descriptor.json`. +None of them is committed. + +So the module holds no EMF type, needs no p2 bundle, and builds as a plain jar +with no `META-INF/MANIFEST.MF` and no `build.properties`. Its `src/main` holds +`Ledgers`; the canonical JSON writer sits in `metamodel/`, the lowest module that +writes a canonical file, because the pipeline writes what the suite used to +serialise. A value a parity case asserts on is a value the pipeline writes, and a +derivation with no output file is not evidence. + +Two things follow from depending on no module, and both are enforced rather than +remembered. A case asserts its file exists before comparing it, so a file the run +owes and did not write fails as a missing file rather than as a skipped case, +which is a different absence from a case with no committed oracle, and that one +is listed as not yet a parity case. And nothing puts the other modules' classes on this module's +classpath, so `ArchitectureTest` reads their class directories by path and +`EMF-017` fails an import that holds no module: `withOptionalLayers` would +otherwise excuse every empty layer and let `EMF-010` and `EMF-011` pass while +proving nothing. ## Witnesses diff --git a/emf/docs/rules.md b/emf/docs/rules.md index d7cf48c..5aaa2c7 100644 --- a/emf/docs/rules.md +++ b/emf/docs/rules.md @@ -12,7 +12,7 @@ literal in that file that does the enforcing. `Ledgers.checkRules` in 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 **16** rules. +This ledger holds **17** rules. | id | rule | enforcer | witness | |---|---|---|---| @@ -32,3 +32,4 @@ This ledger holds **16** rules. | 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)` | | EMF-015 | Every warning the JDT compiler reports in a Tycho bundle fails the build | `pom.xml` | `true` | | EMF-016 | The target platform resolves from one dated release build and names every unit at an exact version | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java` | `EVERY_UNIT_IS_PINNED` | +| EMF-017 | The module rules run over the classes the build wrote, never over an import that holds no module | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `ModuleRules.modulesMissingFrom(classes)` | diff --git a/emf/parity/META-INF/MANIFEST.MF b/emf/parity/META-INF/MANIFEST.MF deleted file mode 100644 index 87b789a..0000000 --- a/emf/parity/META-INF/MANIFEST.MF +++ /dev/null @@ -1,10 +0,0 @@ -Manifest-Version: 1.0 -Bundle-ManifestVersion: 2 -Bundle-Name: deploy-kit model-driven parity -Bundle-SymbolicName: dev.jorisjonkers.deploykit.emf.parity;singleton:=true -Bundle-Version: 0.1.0.qualifier -Bundle-RequiredExecutionEnvironment: JavaSE-21 -Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.parity -Export-Package: dev.jorisjonkers.deploykit.emf.parity -Require-Bundle: dev.jorisjonkers.deploykit.emf.cli, - dev.jorisjonkers.deploykit.emf.metamodel diff --git a/emf/parity/build.properties b/emf/parity/build.properties deleted file mode 100644 index a1ec8c4..0000000 --- a/emf/parity/build.properties +++ /dev/null @@ -1,3 +0,0 @@ -source.. = src/main/java/ -bin.includes = META-INF/,\ - . diff --git a/emf/parity/pom.xml b/emf/parity/pom.xml index fadf308..3e45cad 100644 --- a/emf/parity/pom.xml +++ b/emf/parity/pom.xml @@ -11,10 +11,26 @@ dev.jorisjonkers.deploykit.emf.parity - eclipse-plugin + + jar deploy-kit model-driven parity + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + com.tngtech.archunit archunit-junit5 diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java new file mode 100644 index 0000000..80bafb6 --- /dev/null +++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java @@ -0,0 +1,21 @@ +package dev.jorisjonkers.deploykit.emf.cli.fixture; + +import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp; + +/** + * The other half of the fixture EMF-010 and EMF-011 are shown firing on: a class in the highest + * module of the table reaching down, which the direction allows, and which closes the cycle the + * upward reach in {@link LowerReachingUp} opens. It is never called; it exists to be imported. + */ +public final class UpperReachingDown { + + private UpperReachingDown() {} + + public static String name() { + return "fixture"; + } + + public static String down() { + return LowerReachingUp.up(); + } +} diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java new file mode 100644 index 0000000..ff8a6ce --- /dev/null +++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java @@ -0,0 +1,19 @@ +package dev.jorisjonkers.deploykit.emf.metamodel.fixture; + +import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown; + +/** + * The fixture EMF-010 and EMF-011 are shown firing on: a class in the lowest module of the table + * reaching up into the highest, which the module direction forbids and which closes a cycle with + * {@link UpperReachingDown} + * (docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md). It is never + * called; it exists to be imported. + */ +public final class LowerReachingUp { + + private LowerReachingUp() {} + + public static String up() { + return UpperReachingDown.name(); + } +} 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 index e7a5f10..dd99d28 100644 --- 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 @@ -1,53 +1,63 @@ package dev.jorisjonkers.deploykit.emf.parity; -import static com.tngtech.archunit.library.Architectures.layeredArchitecture; -import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; 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; +import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown; +import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp; +import org.junit.jupiter.api.Test; /** * 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. + * + *

This suite depends on no module, so it reads the class directories the build wrote by path and + * asserts every module arrived: an empty import would leave every layer optional and let both rules + * pass while proving nothing (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md). Each rule + * is shown firing on a fixture that breaks it, beside the run that holds the tree to it. */ -@AnalyzeClasses(packages = "dev.jorisjonkers.deploykit.emf", importOptions = ImportOption.DoNotIncludeTests.class) +@AnalyzeClasses(locations = ModuleRules.BuiltModules.class, importOptions = ImportOption.DoNotIncludeTests.class) class ArchitectureTest { - private static final String ROOT = "dev.jorisjonkers.deploykit.emf."; + /** A metamodel class reaching up into cli, and the cli class reaching back down into it. */ + private static final JavaClasses REACHING_UP = + new ClassFileImporter().importClasses(LowerReachingUp.class, UpperReachingDown.class); @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"); + static final ArchRule MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM = ModuleRules.layered(); @ArchTest - static final ArchRule MODULES_HAVE_NO_CYCLES = - slices().matching(ROOT + "(*)..").should().beFreeOfCycles(); + static final ArchRule MODULES_HAVE_NO_CYCLES = ModuleRules.acyclic(); + + @ArchTest + void everyModuleTheBuildWroteIsImported(JavaClasses classes) { + assertThat(ModuleRules.modulesMissingFrom(classes)).isEmpty(); + } + + @Test + void theDirectionFailsOnAModuleReachingUp() { + assertThatThrownBy(() -> ModuleRules.layered().check(REACHING_UP)) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("metamodel"); + } + + @Test + void theCycleRuleFailsOnTwoModulesReachingEachOther() { + assertThatThrownBy(() -> ModuleRules.acyclic().check(REACHING_UP)) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Cycle"); + } + + @Test + void theImportGuardFailsOnAnImportHoldingNoModule() { + assertThat(ModuleRules.modulesMissingFrom(new ClassFileImporter().importClasses())) + .contains("metamodel", "syntax", "cli"); + } } diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java new file mode 100644 index 0000000..5a19b53 --- /dev/null +++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java @@ -0,0 +1,124 @@ +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.domain.JavaClasses; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * The module rules of docs/architecture.md#modules, and the classes they are held over, kept apart + * from the suite that runs them so the same rule can be run over a fixture that breaks it + * (docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md). + */ +final class ModuleRules { + + static final String ROOT = "dev.jorisjonkers.deploykit.emf."; + + /** The bundle tier, lowest first: a module may depend on the modules above it and on nothing below. */ + static final List MODULES = List.of("metamodel", "syntax", "resolve", "render", "cli"); + + private ModuleRules() {} + + /** EMF-010: a module depends only on the modules above it in the architecture's module table. */ + static ArchRule layered() { + return 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..") + .whereLayer("cli") + .mayNotBeAccessedByAnyLayer() + .whereLayer("render") + .mayOnlyBeAccessedByLayers("cli") + .whereLayer("resolve") + .mayOnlyBeAccessedByLayers("render", "cli") + .whereLayer("syntax") + .mayOnlyBeAccessedByLayers("resolve", "render", "cli") + .whereLayer("metamodel") + .mayOnlyBeAccessedByLayers("syntax", "resolve", "render", "cli"); + } + + /** EMF-011: no dependency cycle between modules. */ + static ArchRule acyclic() { + return slices().matching(ROOT + "(*)..").should().beFreeOfCycles(); + } + + /** + * EMF-017: the modules the build wrote classes for that are not in {@code classes}. The two rules + * above hold over optional layers, so an import that reached no module would excuse every layer + * and let both pass while proving nothing. This suite depends on no module, which is exactly how + * an empty import happens, so the emptiness is the thing that has to fail. A module with no + * class of its own yet is not missing: its layer is empty in the tree, not in the import. + */ + static List modulesMissingFrom(JavaClasses classes) { + return modulesTheBuildWrote().stream() + .filter(module -> classes.stream() + .noneMatch(imported -> imported.getPackageName().startsWith(ROOT + module))) + .toList(); + } + + /** The modules whose class directory holds a class: what an import of those directories carries. */ + static List modulesTheBuildWrote() { + return MODULES.stream().filter(module -> holdsAClass(classesOf(module))).toList(); + } + + private static boolean holdsAClass(Path directory) { + if (!Files.isDirectory(directory)) { + return false; + } + try (Stream tree = Files.walk(directory)) { + return tree.anyMatch(path -> path.getFileName().toString().endsWith(".class")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * The class directories the build wrote, by path. The suite requires no module, so nothing puts + * their classes on its classpath; the reactor's own output is what it reads instead. + */ + static final class BuiltModules implements LocationProvider { + + @Override + public Set get(Class testClass) { + Set locations = new LinkedHashSet<>(); + for (String module : MODULES) { + locations.add(Location.of(classesOf(module))); + } + return locations; + } + } + + /** Where the build left a module's compiled classes. */ + static Path classesOf(String module) { + return bundles().resolve(module).resolve("target").resolve("classes"); + } + + /** Where the bundle tier's modules sit, found from this suite's own working directory. */ + static Path bundles() { + Path dir = Path.of("").toAbsolutePath(); + while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { + dir = dir.getParent(); + } + return dir.resolve("emf"); + } +} diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java index dabcd48..c487dff 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java +++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java @@ -2,35 +2,44 @@ import static org.assertj.core.api.Assertions.assertThat; -import dev.jorisjonkers.deploykit.emf.cli.Diagnostic; -import dev.jorisjonkers.deploykit.emf.cli.Parsed; -import dev.jorisjonkers.deploykit.emf.cli.Pipeline; -import dev.jorisjonkers.deploykit.emf.metamodel.descriptor.Descriptor; -import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Comparator; import java.util.List; -import java.util.Map; -import java.util.TreeMap; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; /** - * Every case under {@code spec/v1/examples/} that carries an intent oracle, run through the pipeline - * entry and compared with the committed file byte for byte (docs/architecture.md#the-parity-contract). + * Every case under {@code spec/v1/examples/} that carries an oracle, decided from what a run of the + * pipeline left behind and compared with the committed file byte for byte + * (docs/architecture.md#the-parity-contract). + * + *

The suite calls nothing and holds no EMF type + * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md): the pipeline's interface here is + * arguments and input files in, an exit code and output files out. A file a case needs and the run + * did not write fails as a missing file, never as a skipped case. */ class ParityTest { + /** Where a run of the pipeline leaves the parsed intent and the diagnostics, under {@code emf/}. */ + private static final String PIPELINE_OUTPUT = "emf/cli/target/parity"; + + /** Where a run of the build leaves the source metamodel's descriptor, under {@code emf/}. */ + private static final String METAMODEL_OUTPUT = "emf/metamodel/target/parity"; + + private static final String INTENT = "intent.json"; + private static final String DIAGNOSTICS = "diagnostics.json"; + private static final String DESCRIPTOR = "descriptor.json"; + private static final String EXIT = "exit"; + private static List casesWithAnIntentOracle() { - Path examples = repository().resolve("spec/v1/examples"); + Path examples = examples(); try (Stream tree = Files.walk(examples)) { - return tree.filter(path -> path.endsWith("expected/intent.json")) + return tree.filter(path -> path.endsWith("expected/" + INTENT)) .map(path -> path.getParent().getParent()) .sorted() .toList(); @@ -42,16 +51,17 @@ private static List casesWithAnIntentOracle() { @ParameterizedTest(name = "{0}") @MethodSource("casesWithAnIntentOracle") void theParsedIntentEqualsTheCommittedOracle(Path directory) throws IOException { - Parsed parsed = Pipeline.intent(intentFile(directory)); + Path written = output(PIPELINE_OUTPUT, directory); - assertThat(parsed.diagnostics()).isEmpty(); - assertThat(CanonicalJson.write(parsed.intent())).isEqualTo(read(directory.resolve("expected/intent.json"))); + assertThat(left(written, EXIT)).isEqualTo("0"); + assertThat(left(written, INTENT)) + .isEqualTo(read(directory.resolve("expected").resolve(INTENT))); } private static List refusalsWithADiagnosticsOracle() { - Path refusals = repository().resolve("spec/v1/examples/refusals"); + Path refusals = examples().resolve("refusals"); try (Stream files = Files.list(refusals)) { - return files.filter(path -> path.getFileName().toString().endsWith(".diagnostics.json")) + return files.filter(path -> path.getFileName().toString().endsWith("." + DIAGNOSTICS)) .sorted() .toList(); } catch (IOException e) { @@ -62,59 +72,56 @@ private static List refusalsWithADiagnosticsOracle() { @ParameterizedTest(name = "{0}") @MethodSource("refusalsWithADiagnosticsOracle") void aRefusedDocumentEqualsItsCommittedDiagnostics(Path oracle) throws IOException { - String stem = oracle.getFileName().toString().replace(".diagnostics.json", ""); - Path set = oracle.resolveSibling(stem); - // A directory is a set of documents read together; a file beside the oracle is read alone. - List diagnostics = Files.isDirectory(set) - ? Pipeline.check(files(set)) - : Pipeline.intent(oracle.resolveSibling(stem + ".project.yml")).diagnostics(); - - assertThat(CanonicalJson.write(diagnostics.stream() - .map(diagnostic -> (Object) new TreeMap<>(Map.of( - "code", diagnostic.code(), - "document", diagnostic.document(), - "path", diagnostic.path()))) - .sorted(Comparator.comparing(Object::toString)) - .toList())) - .isEqualTo(read(oracle)); + String stem = oracle.getFileName().toString().replace("." + DIAGNOSTICS, ""); + Path written = output(PIPELINE_OUTPUT, oracle.resolveSibling(stem)); + + assertThat(left(written, EXIT)).isEqualTo("1"); + assertThat(left(written, DIAGNOSTICS)).isEqualTo(read(oracle)); } @Test void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException { - assertThat(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE))) - .isEqualTo(read(repository().resolve("spec/v1/examples/expected/descriptor.json"))); + Path written = repository().resolve(METAMODEL_OUTPUT); + + assertThat(left(written, DESCRIPTOR)) + .isEqualTo(read(examples().resolve("expected").resolve(DESCRIPTOR))); } @Test void oneChangedFieldNoLongerMatchesTheOracle() throws IOException { Path directory = casesWithAnIntentOracle().get(0); - Path project = intentFile(directory); - Path changed = Files.createTempDirectory("parity").resolve(project.getFileName()); - Files.writeString(changed, read(project).replace("owner: joris", "owner: someone-else")); + String written = left(output(PIPELINE_OUTPUT, directory), INTENT); + String changed = written.replace("\"owner\":\"joris\"", "\"owner\":\"someone-else\""); - assertThat(CanonicalJson.write(Pipeline.intent(changed).intent())) - .isNotEqualTo(read(directory.resolve("expected/intent.json"))); + assertThat(changed).isNotEqualTo(written); + assertThat(changed).isNotEqualTo(read(directory.resolve("expected").resolve(INTENT))); } - /** The one authored document of a case: its project file, or its Platform document. */ - private static Path intentFile(Path directory) throws IOException { - return files(directory).stream() - .filter(path -> path.getFileName().toString().endsWith(".project.yml") - || path.getFileName().toString().equals("platform.intent.yml")) - .findFirst() - .orElseThrow(); + /** Where the run left a case's files: the output tree mirrors the example tree, case for case. */ + private static Path output(String root, Path directory) { + return repository().resolve(root).resolve(examples().relativize(directory)); } - private static List files(Path directory) throws IOException { - try (Stream files = Files.list(directory)) { - return files.filter(Files::isRegularFile).sorted().toList(); - } + /** + * A file the run owes, read. Its absence is the pipeline failing to write what it owes, which is + * a different thing from a case with no committed oracle, and is reported as the missing file. + */ + private static String left(Path directory, String name) throws IOException { + Path file = directory.resolve(name); + assertThat(file) + .as("%s: a run of the pipeline leaves this file behind, and did not", file) + .exists(); + return read(file); } private static String read(Path path) throws IOException { return Files.readString(path, StandardCharsets.UTF_8); } + private static Path examples() { + return repository().resolve("spec/v1/examples"); + } + private static Path repository() { Path dir = Path.of("").toAbsolutePath(); while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { From b386cb10af8ffe5a6fe6b4e8bd1b33133cddb459 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 16 Sep 2026 12:02:22 +0200 Subject: [PATCH 4/5] refactor: split emf/ into a bundle tier and a test tier The listing now says which modules a course examiner opens. `bundles/` holds the five Tycho builds Eclipse imports; `tests/` holds the parity tier Maven alone runs, which by 0120 holds no EMF type and needs nothing from the target platform. The directory names stay short, since Eclipse names an imported project from its `artifactId` regardless (0121). Every document that names a module by its old path moves with it: the rule ledger, whose EMF-014 check fails the build the moment a row's named file is not where the row says, the witness list, this tree's architecture, two decision records, the refusal examples' README, the root architecture, and the two root tests that name a parity path in a comment and in a fixture string. `emf/README.md` gains the table that says which artefact lives where and which task grades it. Two things the move would have broken quietly. The module POMs now sit two directories below the parent, so each declares its `relativePath`, and `scripts/lint-codes.ts` matched `emf//src/test/` one segment deep, which would have stopped counting a Java test as exercising a code for the whole tree rather than only for the tier that moved. Closes #130. --- docs/architecture.md | 2 +- emf/README.md | 20 +++++- emf/{ => bundles}/cli/META-INF/MANIFEST.MF | 0 emf/{ => bundles}/cli/build.properties | 0 emf/{ => bundles}/cli/pom.xml | 1 + .../deploykit/emf/cli/Constraints.java | 0 .../deploykit/emf/cli/Diagnostic.java | 0 .../deploykit/emf/cli/IntentJson.java | 0 .../deploykit/emf/cli/Outputs.java | 0 .../deploykit/emf/cli/Parsed.java | 0 .../deploykit/emf/cli/Pipeline.java | 0 .../deploykit/emf/cli/Pointer.java | 0 .../deploykit/emf/cli/ConstraintsTest.java | 0 .../deploykit/emf/cli/DiagnosticTest.java | 0 .../deploykit/emf/cli/Examples.java | 0 .../deploykit/emf/cli/IntentJsonTest.java | 0 .../deploykit/emf/cli/IntentSetTest.java | 0 .../deploykit/emf/cli/OutputsTest.java | 0 .../deploykit/emf/cli/PipelineTest.java | 0 .../deploykit/emf/cli/PlatformIntentTest.java | 0 .../deploykit/emf/cli/PointerTest.java | 0 .../deploykit/emf/cli/WorkedExamplesTest.java | 0 .../metamodel/META-INF/MANIFEST.MF | 0 emf/{ => bundles}/metamodel/build.properties | 0 emf/{ => bundles}/metamodel/model/empty.xmi | 0 emf/{ => bundles}/metamodel/model/notes.xmi | 0 .../metamodel/model/project-intent.ecore | 0 .../metamodel/model/project-intent.genmodel | 0 .../metamodel/model/project-intent.ocl | 0 .../metamodel/model/skeleton.ecore | 0 .../metamodel/model/skeleton.ocl | 0 emf/{ => bundles}/metamodel/plugin.properties | 0 emf/{ => bundles}/metamodel/plugin.xml | 0 emf/{ => bundles}/metamodel/pom.xml | 1 + .../emf/metamodel/GenerateProjectIntent.mwe2 | 0 .../emf/metamodel/descriptor/Descriptor.java | 0 .../metamodel/descriptor/DescriptorFile.java | 0 .../emf/metamodel/json/CanonicalJson.java | 0 .../deploykit/emf/metamodel/SkeletonTest.java | 0 .../descriptor/DescriptorFileTest.java | 0 .../metamodel/descriptor/DescriptorTest.java | 0 .../emf/metamodel/json/CanonicalJsonTest.java | 0 emf/{ => bundles}/render/META-INF/MANIFEST.MF | 0 emf/{ => bundles}/render/build.properties | 0 emf/{ => bundles}/render/file.launch | 0 emf/{ => bundles}/render/model/file.mtl | 0 emf/{ => bundles}/render/model/notes.ecore | 0 emf/{ => bundles}/render/pom.xml | 1 + .../deploykit/emf/render/SkeletonTest.java | 0 .../resolve/META-INF/MANIFEST.MF | 0 emf/{ => bundles}/resolve/build.properties | 0 emf/{ => bundles}/resolve/identity.launch | 0 emf/{ => bundles}/resolve/model/identity.qvto | 0 emf/{ => bundles}/resolve/model/notes.ecore | 0 emf/{ => bundles}/resolve/pom.xml | 1 + .../deploykit/emf/resolve/SkeletonTest.java | 0 emf/{ => bundles}/syntax/META-INF/MANIFEST.MF | 0 emf/{ => bundles}/syntax/build.properties | 0 emf/{ => bundles}/syntax/pom.xml | 1 + .../emf/syntax/GenerateProjectIntent.mwe2 | 0 .../deploykit/emf/syntax/PlatformIntent.xtext | 0 .../syntax/PlatformIntentRuntimeModule.java | 0 .../syntax/PlatformIntentStandaloneSetup.java | 0 .../deploykit/emf/syntax/ProjectIntent.xtext | 0 .../syntax/ProjectIntentRuntimeModule.java | 0 .../syntax/ProjectIntentStandaloneSetup.java | 0 .../emf/syntax/blocks/BlockTokenSource.java | 0 .../emf/syntax/blocks/BlockTokens.java | 0 .../syntax/linking/ProjectIntentScopes.java | 0 .../emf/syntax/linking/UnlinkedNames.java | 0 .../antlr/PlatformIntentTokenSource.java | 0 .../antlr/ProjectIntentTokenSource.java | 0 .../values/ProjectIntentValueConverters.java | 0 .../syntax/blocks/BlockTokenSourceTest.java | 0 .../emf/syntax/blocks/BlockTokensTest.java | 0 .../emf/syntax/linking/LinkingTest.java | 0 .../ProjectIntentValueConvertersTest.java | 0 ...straints-are-complete-ocl-named-by-code.md | 2 +- ...14-model-behaviours-have-a-java-witness.md | 2 +- emf/docs/architecture.md | 63 ++++++++++--------- emf/docs/rules.md | 12 ++-- emf/docs/witnesses.md | 2 +- emf/pom.xml | 12 ++-- emf/scripts/summary.sh | 6 +- emf/{ => tests}/parity/pom.xml | 1 + .../deploykit/emf/parity/Ledgers.java | 0 .../emf/cli/fixture/UpperReachingDown.java | 0 .../metamodel/fixture/LowerReachingUp.java | 0 .../emf/parity/ArchitectureTest.java | 0 .../deploykit/emf/parity/LedgersTest.java | 4 +- .../deploykit/emf/parity/ModuleRules.java | 2 +- .../deploykit/emf/parity/ParityTest.java | 4 +- .../emf/parity/TargetPlatformTest.java | 0 scripts/lint-codes.ts | 2 +- spec/v1/examples/refusals/README.md | 2 +- test/canonical-json.test.ts | 2 +- test/codes-lint.test.ts | 4 +- 97 files changed, 89 insertions(+), 58 deletions(-) rename emf/{ => bundles}/cli/META-INF/MANIFEST.MF (100%) rename emf/{ => bundles}/cli/build.properties (100%) rename emf/{ => bundles}/cli/pom.xml (96%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Diagnostic.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Parsed.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java (100%) rename emf/{ => bundles}/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/DiagnosticTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/Examples.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentSetTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PlatformIntentTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java (100%) rename emf/{ => bundles}/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java (100%) rename emf/{ => bundles}/metamodel/META-INF/MANIFEST.MF (100%) rename emf/{ => bundles}/metamodel/build.properties (100%) rename emf/{ => bundles}/metamodel/model/empty.xmi (100%) rename emf/{ => bundles}/metamodel/model/notes.xmi (100%) rename emf/{ => bundles}/metamodel/model/project-intent.ecore (100%) rename emf/{ => bundles}/metamodel/model/project-intent.genmodel (100%) rename emf/{ => bundles}/metamodel/model/project-intent.ocl (100%) rename emf/{ => bundles}/metamodel/model/skeleton.ecore (100%) rename emf/{ => bundles}/metamodel/model/skeleton.ocl (100%) rename emf/{ => bundles}/metamodel/plugin.properties (100%) rename emf/{ => bundles}/metamodel/plugin.xml (100%) rename emf/{ => bundles}/metamodel/pom.xml (98%) rename emf/{ => bundles}/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/GenerateProjectIntent.mwe2 (100%) rename emf/{ => bundles}/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java (100%) rename emf/{ => bundles}/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java (100%) rename emf/{ => bundles}/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java (100%) rename emf/{ => bundles}/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/SkeletonTest.java (100%) rename emf/{ => bundles}/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java (100%) rename emf/{ => bundles}/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java (100%) rename emf/{ => bundles}/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java (100%) rename emf/{ => bundles}/render/META-INF/MANIFEST.MF (100%) rename emf/{ => bundles}/render/build.properties (100%) rename emf/{ => bundles}/render/file.launch (100%) rename emf/{ => bundles}/render/model/file.mtl (100%) rename emf/{ => bundles}/render/model/notes.ecore (100%) rename emf/{ => bundles}/render/pom.xml (92%) rename emf/{ => bundles}/render/src/test/java/dev/jorisjonkers/deploykit/emf/render/SkeletonTest.java (100%) rename emf/{ => bundles}/resolve/META-INF/MANIFEST.MF (100%) rename emf/{ => bundles}/resolve/build.properties (100%) rename emf/{ => bundles}/resolve/identity.launch (100%) rename emf/{ => bundles}/resolve/model/identity.qvto (100%) rename emf/{ => bundles}/resolve/model/notes.ecore (100%) rename emf/{ => bundles}/resolve/pom.xml (92%) rename emf/{ => bundles}/resolve/src/test/java/dev/jorisjonkers/deploykit/emf/resolve/SkeletonTest.java (100%) rename emf/{ => bundles}/syntax/META-INF/MANIFEST.MF (100%) rename emf/{ => bundles}/syntax/build.properties (100%) rename emf/{ => bundles}/syntax/pom.xml (99%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/GenerateProjectIntent.mwe2 (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntent.xtext (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentRuntimeModule.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentStandaloneSetup.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentStandaloneSetup.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/PlatformIntentTokenSource.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java (100%) rename emf/{ => bundles}/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java (100%) rename emf/{ => bundles}/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java (100%) rename emf/{ => bundles}/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java (100%) rename emf/{ => bundles}/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java (100%) rename emf/{ => bundles}/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java (100%) rename emf/{ => tests}/parity/pom.xml (96%) rename emf/{ => tests}/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java (100%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java (100%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java (100%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java (100%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java (96%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java (98%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java (96%) rename emf/{ => tests}/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java (100%) diff --git a/docs/architecture.md b/docs/architecture.md index 1dc2f7b..5e19693 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -265,7 +265,7 @@ The file is an object with one `applications` entry per Application, each an The edge's own fields are fixed by the first case that has one. **The canonical writers** are `src/infrastructure/canonical-json.ts` and -`emf/metamodel`'s `CanonicalJson`, held to the same cases. An oracle file is exactly +`emf/bundles/metamodel`'s `CanonicalJson`, held to the same cases. An oracle file is exactly its canonical text, with no final newline, and a test fails any committed oracle that is not byte-identical to its own canonicalisation. diff --git a/emf/README.md b/emf/README.md index b382bd3..ee2165f 100644 --- a/emf/README.md +++ b/emf/README.md @@ -24,6 +24,22 @@ oracle files under `spec/v1/examples/` that both are tested against, separately. | [docs/architecture.md](docs/architecture.md) | the structure: toolchain, modules, how each stage meets the contract | | [docs/adr/README.md](docs/adr/README.md) | the decisions that shaped it, and the register | +## Where each artefact lives + +This table says which artefact lives where and which course task grades +it, so the split tree +([0121](docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md)) +answers the navigation question without a search. + +| artefact | lives in | graded in | how an examiner opens it | +|---|---|---|---| +| the two Ecore metamodels and their OCL | `emf/bundles/metamodel` | Task 1 | imported in step 2; `model/skeleton.ecore` and `model/skeleton.ocl` open and validate as step 3 describes | +| the Xtext grammar and generated editor | `emf/bundles/syntax` | Task 1 | imported in step 2; the generated editor reports OCL constraint violations while a source file is edited in it | +| the QVTo transformation | `emf/bundles/resolve` | Task 2 | imported in step 2; `identity.launch` runs it, as step 4 describes | +| the Acceleo templates | `emf/bundles/render` | Task 3 | imported in step 2; `file.launch` runs them, as step 4 describes | +| the pipeline entry point | `emf/bundles/cli` | Task 1 onward | imported in step 2, alongside the rest | +| the parity suite | `emf/tests/parity` | no task grades it | it is Maven-only; no examiner opens it | + ## Building ```sh @@ -41,7 +57,9 @@ and Acceleo 4 SDKs installed from the same release: 1. Open `emf/emf.target` and choose **Set as Active Target Platform**. 2. **File > Import > Maven > Existing Maven Projects**, with `emf/` as the - root directory, and import every module. + root directory. The importer walks the whole tree, so that one root + still finds the five bundles nested under `emf/bundles/` and the parity + suite nested under `emf/tests/parity`; import every module. 3. In `dev.jorisjonkers.deploykit.emf.metamodel`, open `model/skeleton.ecore`. Open `model/empty.xmi` with the Sample Reflective Ecore Model Editor, load `model/skeleton.ocl` through **OCL > Load Document**, and validate: the diff --git a/emf/cli/META-INF/MANIFEST.MF b/emf/bundles/cli/META-INF/MANIFEST.MF similarity index 100% rename from emf/cli/META-INF/MANIFEST.MF rename to emf/bundles/cli/META-INF/MANIFEST.MF diff --git a/emf/cli/build.properties b/emf/bundles/cli/build.properties similarity index 100% rename from emf/cli/build.properties rename to emf/bundles/cli/build.properties diff --git a/emf/cli/pom.xml b/emf/bundles/cli/pom.xml similarity index 96% rename from emf/cli/pom.xml rename to emf/bundles/cli/pom.xml index 34b3cdc..548611a 100644 --- a/emf/cli/pom.xml +++ b/emf/bundles/cli/pom.xml @@ -8,6 +8,7 @@ dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.cli diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Diagnostic.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Diagnostic.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Diagnostic.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Diagnostic.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/IntentJson.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Outputs.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Parsed.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Parsed.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Parsed.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Parsed.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java b/emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java similarity index 100% rename from emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java rename to emf/bundles/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/DiagnosticTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/DiagnosticTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/DiagnosticTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/DiagnosticTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/Examples.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/Examples.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/Examples.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/Examples.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentJsonTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentSetTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentSetTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentSetTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/IntentSetTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/OutputsTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PipelineTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PlatformIntentTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PlatformIntentTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PlatformIntentTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PlatformIntentTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java b/emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java similarity index 100% rename from emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java rename to emf/bundles/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/WorkedExamplesTest.java diff --git a/emf/metamodel/META-INF/MANIFEST.MF b/emf/bundles/metamodel/META-INF/MANIFEST.MF similarity index 100% rename from emf/metamodel/META-INF/MANIFEST.MF rename to emf/bundles/metamodel/META-INF/MANIFEST.MF diff --git a/emf/metamodel/build.properties b/emf/bundles/metamodel/build.properties similarity index 100% rename from emf/metamodel/build.properties rename to emf/bundles/metamodel/build.properties diff --git a/emf/metamodel/model/empty.xmi b/emf/bundles/metamodel/model/empty.xmi similarity index 100% rename from emf/metamodel/model/empty.xmi rename to emf/bundles/metamodel/model/empty.xmi diff --git a/emf/metamodel/model/notes.xmi b/emf/bundles/metamodel/model/notes.xmi similarity index 100% rename from emf/metamodel/model/notes.xmi rename to emf/bundles/metamodel/model/notes.xmi diff --git a/emf/metamodel/model/project-intent.ecore b/emf/bundles/metamodel/model/project-intent.ecore similarity index 100% rename from emf/metamodel/model/project-intent.ecore rename to emf/bundles/metamodel/model/project-intent.ecore diff --git a/emf/metamodel/model/project-intent.genmodel b/emf/bundles/metamodel/model/project-intent.genmodel similarity index 100% rename from emf/metamodel/model/project-intent.genmodel rename to emf/bundles/metamodel/model/project-intent.genmodel diff --git a/emf/metamodel/model/project-intent.ocl b/emf/bundles/metamodel/model/project-intent.ocl similarity index 100% rename from emf/metamodel/model/project-intent.ocl rename to emf/bundles/metamodel/model/project-intent.ocl diff --git a/emf/metamodel/model/skeleton.ecore b/emf/bundles/metamodel/model/skeleton.ecore similarity index 100% rename from emf/metamodel/model/skeleton.ecore rename to emf/bundles/metamodel/model/skeleton.ecore diff --git a/emf/metamodel/model/skeleton.ocl b/emf/bundles/metamodel/model/skeleton.ocl similarity index 100% rename from emf/metamodel/model/skeleton.ocl rename to emf/bundles/metamodel/model/skeleton.ocl diff --git a/emf/metamodel/plugin.properties b/emf/bundles/metamodel/plugin.properties similarity index 100% rename from emf/metamodel/plugin.properties rename to emf/bundles/metamodel/plugin.properties diff --git a/emf/metamodel/plugin.xml b/emf/bundles/metamodel/plugin.xml similarity index 100% rename from emf/metamodel/plugin.xml rename to emf/bundles/metamodel/plugin.xml diff --git a/emf/metamodel/pom.xml b/emf/bundles/metamodel/pom.xml similarity index 98% rename from emf/metamodel/pom.xml rename to emf/bundles/metamodel/pom.xml index a69de53..c94736c 100644 --- a/emf/metamodel/pom.xml +++ b/emf/bundles/metamodel/pom.xml @@ -8,6 +8,7 @@ dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.metamodel diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/GenerateProjectIntent.mwe2 b/emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/GenerateProjectIntent.mwe2 similarity index 100% rename from emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/GenerateProjectIntent.mwe2 rename to emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/GenerateProjectIntent.mwe2 diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java b/emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java similarity index 100% rename from emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java rename to emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java b/emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java similarity index 100% rename from emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java rename to emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFile.java diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java b/emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java similarity index 100% rename from emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java rename to emf/bundles/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJson.java diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/SkeletonTest.java b/emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/SkeletonTest.java similarity index 100% rename from emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/SkeletonTest.java rename to emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/SkeletonTest.java diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java b/emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java similarity index 100% rename from emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java rename to emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorFileTest.java diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java b/emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java similarity index 100% rename from emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java rename to emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/DescriptorTest.java diff --git a/emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java b/emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java similarity index 100% rename from emf/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java rename to emf/bundles/metamodel/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/json/CanonicalJsonTest.java diff --git a/emf/render/META-INF/MANIFEST.MF b/emf/bundles/render/META-INF/MANIFEST.MF similarity index 100% rename from emf/render/META-INF/MANIFEST.MF rename to emf/bundles/render/META-INF/MANIFEST.MF diff --git a/emf/render/build.properties b/emf/bundles/render/build.properties similarity index 100% rename from emf/render/build.properties rename to emf/bundles/render/build.properties diff --git a/emf/render/file.launch b/emf/bundles/render/file.launch similarity index 100% rename from emf/render/file.launch rename to emf/bundles/render/file.launch diff --git a/emf/render/model/file.mtl b/emf/bundles/render/model/file.mtl similarity index 100% rename from emf/render/model/file.mtl rename to emf/bundles/render/model/file.mtl diff --git a/emf/render/model/notes.ecore b/emf/bundles/render/model/notes.ecore similarity index 100% rename from emf/render/model/notes.ecore rename to emf/bundles/render/model/notes.ecore diff --git a/emf/render/pom.xml b/emf/bundles/render/pom.xml similarity index 92% rename from emf/render/pom.xml rename to emf/bundles/render/pom.xml index 985ab93..882c737 100644 --- a/emf/render/pom.xml +++ b/emf/bundles/render/pom.xml @@ -8,6 +8,7 @@ dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.render diff --git a/emf/render/src/test/java/dev/jorisjonkers/deploykit/emf/render/SkeletonTest.java b/emf/bundles/render/src/test/java/dev/jorisjonkers/deploykit/emf/render/SkeletonTest.java similarity index 100% rename from emf/render/src/test/java/dev/jorisjonkers/deploykit/emf/render/SkeletonTest.java rename to emf/bundles/render/src/test/java/dev/jorisjonkers/deploykit/emf/render/SkeletonTest.java diff --git a/emf/resolve/META-INF/MANIFEST.MF b/emf/bundles/resolve/META-INF/MANIFEST.MF similarity index 100% rename from emf/resolve/META-INF/MANIFEST.MF rename to emf/bundles/resolve/META-INF/MANIFEST.MF diff --git a/emf/resolve/build.properties b/emf/bundles/resolve/build.properties similarity index 100% rename from emf/resolve/build.properties rename to emf/bundles/resolve/build.properties diff --git a/emf/resolve/identity.launch b/emf/bundles/resolve/identity.launch similarity index 100% rename from emf/resolve/identity.launch rename to emf/bundles/resolve/identity.launch diff --git a/emf/resolve/model/identity.qvto b/emf/bundles/resolve/model/identity.qvto similarity index 100% rename from emf/resolve/model/identity.qvto rename to emf/bundles/resolve/model/identity.qvto diff --git a/emf/resolve/model/notes.ecore b/emf/bundles/resolve/model/notes.ecore similarity index 100% rename from emf/resolve/model/notes.ecore rename to emf/bundles/resolve/model/notes.ecore diff --git a/emf/resolve/pom.xml b/emf/bundles/resolve/pom.xml similarity index 92% rename from emf/resolve/pom.xml rename to emf/bundles/resolve/pom.xml index a58544f..878c0d3 100644 --- a/emf/resolve/pom.xml +++ b/emf/bundles/resolve/pom.xml @@ -8,6 +8,7 @@ dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.resolve diff --git a/emf/resolve/src/test/java/dev/jorisjonkers/deploykit/emf/resolve/SkeletonTest.java b/emf/bundles/resolve/src/test/java/dev/jorisjonkers/deploykit/emf/resolve/SkeletonTest.java similarity index 100% rename from emf/resolve/src/test/java/dev/jorisjonkers/deploykit/emf/resolve/SkeletonTest.java rename to emf/bundles/resolve/src/test/java/dev/jorisjonkers/deploykit/emf/resolve/SkeletonTest.java diff --git a/emf/syntax/META-INF/MANIFEST.MF b/emf/bundles/syntax/META-INF/MANIFEST.MF similarity index 100% rename from emf/syntax/META-INF/MANIFEST.MF rename to emf/bundles/syntax/META-INF/MANIFEST.MF diff --git a/emf/syntax/build.properties b/emf/bundles/syntax/build.properties similarity index 100% rename from emf/syntax/build.properties rename to emf/bundles/syntax/build.properties diff --git a/emf/syntax/pom.xml b/emf/bundles/syntax/pom.xml similarity index 99% rename from emf/syntax/pom.xml rename to emf/bundles/syntax/pom.xml index b107cb1..9eecb85 100644 --- a/emf/syntax/pom.xml +++ b/emf/bundles/syntax/pom.xml @@ -8,6 +8,7 @@ dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.syntax diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/GenerateProjectIntent.mwe2 b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/GenerateProjectIntent.mwe2 similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/GenerateProjectIntent.mwe2 rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/GenerateProjectIntent.mwe2 diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntent.xtext b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntent.xtext similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntent.xtext rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntent.xtext diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentRuntimeModule.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentRuntimeModule.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentRuntimeModule.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentRuntimeModule.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentStandaloneSetup.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentStandaloneSetup.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentStandaloneSetup.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/PlatformIntentStandaloneSetup.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentRuntimeModule.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentStandaloneSetup.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentStandaloneSetup.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentStandaloneSetup.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntentStandaloneSetup.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSource.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokens.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/ProjectIntentScopes.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/linking/UnlinkedNames.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/PlatformIntentTokenSource.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/PlatformIntentTokenSource.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/PlatformIntentTokenSource.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/PlatformIntentTokenSource.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/parser/antlr/ProjectIntentTokenSource.java diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java b/emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java similarity index 100% rename from emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java rename to emf/bundles/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConverters.java diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java b/emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java similarity index 100% rename from emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java rename to emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokenSourceTest.java diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java b/emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java similarity index 100% rename from emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java rename to emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/blocks/BlockTokensTest.java diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java b/emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java similarity index 100% rename from emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java rename to emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/linking/LinkingTest.java diff --git a/emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java b/emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java similarity index 100% rename from emf/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java rename to emf/bundles/syntax/src/test/java/dev/jorisjonkers/deploykit/emf/syntax/values/ProjectIntentValueConvertersTest.java diff --git a/emf/docs/adr/emf/0110-constraints-are-complete-ocl-named-by-code.md b/emf/docs/adr/emf/0110-constraints-are-complete-ocl-named-by-code.md index 3d9b924..77e6705 100644 --- a/emf/docs/adr/emf/0110-constraints-are-complete-ocl-named-by-code.md +++ b/emf/docs/adr/emf/0110-constraints-are-complete-ocl-named-by-code.md @@ -23,7 +23,7 @@ OCL invariant evaluated standalone, and that each refused case yields the same set of `(code, path)` pairs from OCL as from Zod. False if: a ledger constraint needs Java code outside OCL, or the containment chain cannot produce the JSON Pointer the TypeScript side reports. Settled by: every refused case's -diagnostics parity green in `emf/parity`, and the ledger check finding an +diagnostics parity green in `emf/tests/parity`, and the ledger check finding an invariant for every `CONS-NNN` row. ## Why diff --git a/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md b/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md index f6accd9..09df1f4 100644 --- a/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md +++ b/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md @@ -17,7 +17,7 @@ validation, resolution and rendering can each be proved by a JUnit test here, and that a list inside `emf/` can be checked against the root ledger without the root knowing about it. False if: a model behaviour row can only be proved in one implementation, or keeping the list requires a column in the root -ledger. Settled by: the witness check in `emf/parity` green, and failing on a +ledger. Settled by: the witness check in `emf/tests/parity` green, and failing on a fixture that removes one witness. ## Why diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index 368ea0a..cf49d9d 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -61,8 +61,10 @@ switches on the parent's `bundle` profile, which runs the module's tests through Maven Surefire on a plain classpath, outside OSGi, so every tool is exercised through the standalone API a command-line run uses. A module that needs no p2 bundle stays a plain jar, declaring its own test libraries because -the `bundle` profile does not activate for it. `parity/` is the one such -module, for the reason [Parity](#parity) records. +the `bundle` profile does not activate for it. `tests/parity` is the one such +module, for the reason [Parity](#parity) records. The profile activates on a +module's `META-INF/MANIFEST.MF` rather than on the tier it sits in, and stays +that way: the directory is documentation, the manifest is the fact. 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, @@ -76,32 +78,37 @@ whose suite covers that tool. ## Modules -One Maven module per pipeline stage, under one parent `pom.xml` that owns the -Tycho configuration and the target platform. +Two tiers, under one parent `pom.xml` that owns the Tycho configuration and the +target platform. `bundles/` holds one Maven module per pipeline stage: what +Tycho builds against `emf.target`, and what an examiner imports into Eclipse. +`tests/` holds what Maven alone builds and runs, and what no examiner opens +([0121](adr/emf/0121-bundles-and-tests-are-separate-tiers.md)). The boundary +answers the question a flat list left every reader to work out from a +`pom.xml`: is this file graded, and will a teacher open it. + +The directories keep their short names. Tycho's convention is a directory named +for the bundle's symbolic name, but Eclipse names an imported Maven project from +its `artifactId` regardless, so the long names would buy a match nobody reads +and cost every path in the tree. | module | holds | graded in | |---|---|---| -| `metamodel/` | the source and target `.ecore` and `.genmodel`, Complete OCL `.ocl` for the source metamodel, the descriptor exporter | Task 1 | -| `syntax/` | the Xtext grammar for the authored YAML subset, and the generated editor bundles that run the OCL validators | Task 1 | -| `resolve/` | the QVTo transformation from Project Intent and Platform Intent to the Resolved Deployment | Task 2 | -| `render/` | the Acceleo 4 templates from a Resolved Deployment model to the Deliverable Set's files | Task 3 | -| `cli/` | the pipeline entry point: files in, the parsed intent, diagnostics and rendered files out | Task 1 onward | -| `parity/` | JUnit suites asserting each stage against the committed oracles, and the witness ledger check | Task 1 onward | +| `bundles/metamodel` | the source and target `.ecore` and `.genmodel`, Complete OCL `.ocl` for the source metamodel, the descriptor exporter, and the canonical JSON writer both it and `cli` write through | Task 1 | +| `bundles/syntax` | the Xtext grammar for the authored YAML subset, and the generated editor bundles that run the OCL validators | Task 1 | +| `bundles/resolve` | the QVTo transformation from Project Intent and Platform Intent to the Resolved Deployment | Task 2 | +| `bundles/render` | the Acceleo 4 templates from a Resolved Deployment model to the Deliverable Set's files | Task 3 | +| `bundles/cli` | the pipeline entry point: files in, the parsed intent, diagnostics and rendered files out | Task 1 onward | +| `tests/parity` | JUnit suites asserting each stage against the committed oracles, and the witness ledger check | no task grades it | A module may depend on the modules above it in this table and on nothing -below. - -Every module is Java. Ecore generates Java, Xtext's runtime hooks are Java, and -a bundle compiles through Tycho's JDT compiler against a target platform holding -no Kotlin unit, so a language other than Java is a bundle's problem before it is -anything else. +below. `tests/parity` depends on no module at all: it reads the files a run +leaves behind, which is what [Parity](#parity) records. -> **Proposed, not landed:** -> [0121](adr/emf/0121-bundles-and-tests-are-separate-tiers.md) splits these six -> into `bundles/` for what Tycho builds and Eclipse imports and `tests/` for what -> only Maven runs, and makes the second tier Kotlin. Until the moving pull -> request lands, the flat list above is the tree, and the Java sentence above -> holds over all of it. +Every module of `bundles/` is Java, without exception. Ecore generates Java, +Xtext's runtime hooks are Java, and a bundle compiles through Tycho's JDT +compiler against a target platform holding no Kotlin unit, so a language other +than Java is a bundle's problem before it is anything else. `tests/` carries +none of those constraints. ## Metamodels @@ -173,7 +180,7 @@ package, because a tier's proxy is an Application a project file declares, and one package is what lets that be an Ecore reference. The constraint ledger's OCL column lives in `emf/`: a table mapping each -`CONS-NNN` id to the OCL invariant that enforces it. `parity/` fails when a +`CONS-NNN` id to the OCL invariant that enforces it. `tests/parity` fails when a ledger constraint has no invariant, or an invariant names a code no ledger row carries. @@ -248,7 +255,7 @@ are template decisions made to match the oracle, not presentation. ## Parity -`parity/` holds this repository's own evidence: the suites that assert each stage +`tests/parity` holds this repository's own evidence: the suites that assert each stage against the committed oracles, the ledger checks, and the module rules. It is built and run by Maven only, and no examiner opens it. @@ -258,13 +265,13 @@ and input files in; an exit code, diagnostics, the parsed intent, the descriptor and the rendered tree out. A run of the build leaves those files under the build output of the module that wrote them, one directory per case, mirroring `spec/v1/examples/` so a written file and its oracle are obviously a pair: -`cli/target/parity//` holds `intent.json` or `diagnostics.json` beside the -`exit` the run ended on, and `metamodel/target/parity/` holds `descriptor.json`. +`bundles/cli/target/parity//` holds `intent.json` or `diagnostics.json` beside the +`exit` the run ended on, and `bundles/metamodel/target/parity/` holds `descriptor.json`. None of them is committed. So the module holds no EMF type, needs no p2 bundle, and builds as a plain jar with no `META-INF/MANIFEST.MF` and no `build.properties`. Its `src/main` holds -`Ledgers`; the canonical JSON writer sits in `metamodel/`, the lowest module that +`Ledgers`; the canonical JSON writer sits in `bundles/metamodel`, the lowest module that writes a canonical file, because the pipeline writes what the suite used to serialise. A value a parity case asserts on is a value the pipeline writes, and a derivation with no output file is not evidence. @@ -283,7 +290,7 @@ proving nothing. A behaviour ledger row whose behaviour is the model's own is proved in both implementations. `emf/docs/witnesses.md` lists, for each such `REQ-NNN` id, the -JUnit test that proves it here. `parity/` fails when a model row in +JUnit test that proves it here. `tests/parity` fails when a model row in `docs/requirements.md` has no witness in that file, or a witness names a test that does not exist or an id that no row carries. diff --git a/emf/docs/rules.md b/emf/docs/rules.md index 5aaa2c7..735c9c3 100644 --- a/emf/docs/rules.md +++ b/emf/docs/rules.md @@ -25,11 +25,11 @@ This ledger holds **17** rules. | 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, in every module | `pom.xml` | `BRANCH` | | EMF-009 | The mutation score stays at or above the measured threshold, in every module | `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-010 | A module depends only on the modules above it in the architecture's module table | `tests/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 | `tests/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)` | +| EMF-013 | Every model behaviour has a Java witness | `tests/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 | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java` | `Ledgers.checkRules(repository)` | | EMF-015 | Every warning the JDT compiler reports in a Tycho bundle fails the build | `pom.xml` | `true` | -| EMF-016 | The target platform resolves from one dated release build and names every unit at an exact version | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java` | `EVERY_UNIT_IS_PINNED` | -| EMF-017 | The module rules run over the classes the build wrote, never over an import that holds no module | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `ModuleRules.modulesMissingFrom(classes)` | +| EMF-016 | The target platform resolves from one dated release build and names every unit at an exact version | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java` | `EVERY_UNIT_IS_PINNED` | +| EMF-017 | The module rules run over the classes the build wrote, never over an import that holds no module | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `ModuleRules.modulesMissingFrom(classes)` | diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md index 1a200f8..125e7dd 100644 --- a/emf/docs/witnesses.md +++ b/emf/docs/witnesses.md @@ -6,7 +6,7 @@ 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 +`Ledgers.checkWitnesses` in `tests/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. diff --git a/emf/pom.xml b/emf/pom.xml index 15f5ea9..2448e55 100644 --- a/emf/pom.xml +++ b/emf/pom.xml @@ -16,12 +16,12 @@ deploy-kit model-driven implementation - metamodel - syntax - cli - resolve - render - parity + bundles/metamodel + bundles/syntax + bundles/cli + bundles/resolve + bundles/render + tests/parity diff --git a/emf/scripts/summary.sh b/emf/scripts/summary.sh index ff38fa5..2506e89 100755 --- a/emf/scripts/summary.sh +++ b/emf/scripts/summary.sh @@ -6,15 +6,15 @@ set -euo pipefail cd "$(dirname "$0")/.." -tests=$(cat ./*/target/surefire-reports/TEST-*.xml 2>/dev/null \ +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, ' +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 ' +mutation=$(cat ./*/*/target/pit-reports/mutations.xml 2>/dev/null | awk ' {total += gsub(/dev.jorisjonkers.deploykit.emf emf-parent 0.1.0-SNAPSHOT + ../../pom.xml dev.jorisjonkers.deploykit.emf.parity diff --git a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java b/emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java similarity index 100% rename from emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java rename to emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java similarity index 100% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java similarity index 100% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java similarity index 100% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java similarity index 96% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java index 33719a1..42c16ef 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java +++ b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java @@ -44,8 +44,8 @@ void aModelBehaviourWithoutAWitnessFails(@TempDir Path root) throws IOException @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/tests/parity/src/test/java/x/ParseTest.java", "class ParseTest { void parses() {} }"); + write(root, "emf/tests/parity/src/main/java/x/ParseTest.java", "class ParseTest { void missing() {} }"); write( root, "emf/docs/witnesses.md", diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java similarity index 98% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java index 5a19b53..2c5e867 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java +++ b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java @@ -119,6 +119,6 @@ static Path bundles() { while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { dir = dir.getParent(); } - return dir.resolve("emf"); + return dir.resolve("emf").resolve("bundles"); } } diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java similarity index 96% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java index c487dff..bc72e36 100644 --- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java +++ b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java @@ -26,10 +26,10 @@ class ParityTest { /** Where a run of the pipeline leaves the parsed intent and the diagnostics, under {@code emf/}. */ - private static final String PIPELINE_OUTPUT = "emf/cli/target/parity"; + private static final String PIPELINE_OUTPUT = "emf/bundles/cli/target/parity"; /** Where a run of the build leaves the source metamodel's descriptor, under {@code emf/}. */ - private static final String METAMODEL_OUTPUT = "emf/metamodel/target/parity"; + private static final String METAMODEL_OUTPUT = "emf/bundles/metamodel/target/parity"; private static final String INTENT = "intent.json"; private static final String DIAGNOSTICS = "diagnostics.json"; diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java similarity index 100% rename from emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java rename to emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java diff --git a/scripts/lint-codes.ts b/scripts/lint-codes.ts index df19de9..53fe1d6 100644 --- a/scripts/lint-codes.ts +++ b/scripts/lint-codes.ts @@ -26,7 +26,7 @@ const CODE = /(? { const result = codeErrors( { "spec/v1/10-intent.md": chapter(ALPHA, BETA, GAMMA), - "emf/parity/src/test/java/X.java": ALPHA, + "emf/tests/parity/src/test/java/X.java": ALPHA, "spec/v1/examples/refusals/x.diagnostics.json": `[{"code":"${BETA}"}]`, - "emf/parity/src/main/java/Y.java": GAMMA, + "emf/tests/parity/src/main/java/Y.java": GAMMA, }, [pending("#1", GAMMA)], ); From 5ac927615370e6820125396cbc59d0336b36c0f9 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 16 Sep 2026 12:13:11 +0200 Subject: [PATCH 5/5] refactor: port the test tier to Kotlin, with the gates the estate runs on it `tests/parity` is written in the estate's JVM language, with the idiom its other repositories already use: JUnit 5, AssertJ, ArchUnit, and a test function named as a sentence in backticks. The bundle tier stays Java, because Ecore generates Java, Xtext's runtime hooks are Java, and a bundle compiles through JDT against a target platform holding no Kotlin unit (0121). The gates move with it. Spotless gains a ktlint step over `src/*/kotlin`, and EMF-018 is its ledger row. The Kotlin compiler runs with `-Werror`, which is EMF-006's rule in the compiler this tier uses. The coverage floor holds unchanged. The mutation threshold also stands at 100, which needed one thing rather than a decision to lower it: `kotlinc` emits a null check for every platform type crossing into Kotlin and a `closeFinally` for every `use`, and removing either call is an equivalent mutation, not an uncovered one, so pitest is told not to mutate calls to them. That is the same treatment `bundles/cli` already gives the one-off OCL language registration. The word that changes in 0114 is "Java", not the rule: a witness is a JUnit test in whichever language its module is written, so `Ledgers.checkWitnesses` now reads a `.kt` function and a `.java` method alike, three of the six witnesses name Kotlin functions and three name Java methods, and a new fixture proves a Java witness in a bundle is still found. The decision record, the witness list, EMF-013's row and the gates sentence say so. Closes #131. --- ...14-model-behaviours-have-a-java-witness.md | 13 +- emf/docs/architecture.md | 14 +- emf/docs/rules.md | 17 +- emf/docs/witnesses.md | 11 +- emf/pom.xml | 14 ++ emf/tests/parity/pom.xml | 54 +++++- .../deploykit/emf/parity/Ledgers.java | 143 --------------- .../deploykit/emf/parity/Ledgers.kt | 133 ++++++++++++++ .../emf/cli/fixture/UpperReachingDown.java | 21 --- .../emf/parity/ArchitectureTest.java | 63 ------- .../deploykit/emf/parity/LedgersTest.java | 138 -------------- .../deploykit/emf/parity/ModuleRules.java | 124 ------------- .../deploykit/emf/parity/ParityTest.java | 132 -------------- .../emf/parity/TargetPlatformTest.java | 82 --------- .../emf/cli/fixture/UpperReachingDown.kt | 14 ++ .../emf/metamodel/fixture/LowerReachingUp.kt} | 15 +- .../deploykit/emf/parity/ArchitectureTest.kt | 69 +++++++ .../deploykit/emf/parity/LedgersTest.kt | 168 ++++++++++++++++++ .../deploykit/emf/parity/ModuleRules.kt | 87 +++++++++ .../deploykit/emf/parity/ParityTest.kt | 115 ++++++++++++ .../deploykit/emf/parity/Repository.kt | 13 ++ .../emf/parity/TargetPlatformTest.kt | 76 ++++++++ 22 files changed, 783 insertions(+), 733 deletions(-) delete mode 100644 emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java create mode 100644 emf/tests/parity/src/main/kotlin/dev/jorisjonkers/deploykit/emf/parity/Ledgers.kt delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java delete mode 100644 emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.kt rename emf/tests/parity/src/test/{java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java => kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt} (62%) create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.kt create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ParityTest.kt create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/Repository.kt create mode 100644 emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.kt diff --git a/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md b/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md index 09df1f4..339f58a 100644 --- a/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md +++ b/emf/docs/adr/emf/0114-model-behaviours-have-a-java-witness.md @@ -8,7 +8,7 @@ normative: docs/architecture.md#witnesses rests-on: ["0106"] --- -# Every model behaviour in the behaviour ledger has a Java witness, listed inside `emf/` +# Every model behaviour in the behaviour ledger has a JUnit witness, listed inside `emf/` ## Rests on Resting on [0106](0106-the-model-is-expressible-in-the-emf-toolchain.md), the @@ -41,8 +41,17 @@ Undo cost today: nothing exists. Becomes irreversible once: never; deleted with `emf/`. ## Consequences -- A model behaviour row lands with a Java witness or the `emf` job fails. Paid +- A model behaviour row lands with a JUnit witness or the `emf` job fails. Paid by its author, in the same pull request. +- **Amended when the test tier was ported to Kotlin + ([0121](0121-bundles-and-tests-are-separate-tiers.md)).** This decision said + "Java" because every module was Java when it was taken. The rule is unchanged: + a witness is a JUnit test, in whichever language its module is written. Three + of the six witnesses are now Kotlin functions of `tests/parity` and three are + Java methods of a bundle, so `Ledgers.checkWitnesses` reads a `.kt` function + and a `.java` method alike, and a witness row names the function as it is + written, backticks and spaces included. Nothing here is reversed; the word + that changed is "Java", not the rule. - The check needs to know which rows are model behaviours; the TypeScript tests that prove them live under `test/model/`, and that path is the marker. Paid once, when the first model test lands. diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md index cf49d9d..74b65dc 100644 --- a/emf/docs/architecture.md +++ b/emf/docs/architecture.md @@ -108,7 +108,9 @@ Every module of `bundles/` is Java, without exception. Ecore generates Java, Xtext's runtime hooks are Java, and a bundle compiles through Tycho's JDT compiler against a target platform holding no Kotlin unit, so a language other than Java is a bundle's problem before it is anything else. `tests/` carries -none of those constraints. +none of those constraints and is Kotlin, the estate's JVM language, with the +test idiom its other repositories run: JUnit 5, AssertJ, ArchUnit, and a test +function named as a sentence in backticks. ## Metamodels @@ -290,7 +292,8 @@ proving nothing. A behaviour ledger row whose behaviour is the model's own is proved in both implementations. `emf/docs/witnesses.md` lists, for each such `REQ-NNN` id, the -JUnit test that proves it here. `tests/parity` fails when a model row in +JUnit test that proves it here, in whichever language its module is written. +`tests/parity` fails when a model row in `docs/requirements.md` has no witness in that file, or a witness names a test that does not exist or an id that no row carries. @@ -309,14 +312,15 @@ 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 outside a bundle, or anything declares a distribution target | -| compile | `maven-compiler-plugin`, or `tycho-compiler-plugin` in a bundle | any `-Xlint:all` warning, or in a bundle any warning the JDT compiler reports | +| compile | `maven-compiler-plugin`, `tycho-compiler-plugin` in a bundle, or `kotlin-maven-plugin` in the test tier | any `-Xlint:all` warning, in a bundle any warning the JDT compiler reports, and in the test tier any warning `kotlinc` reports | | 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 | +| format | `spotless-maven-plugin` | Java source differs from palantir-java-format, or Kotlin source from ktlint; `./mvnw spotless:apply` fixes either | | coverage | `jacoco-maven-plugin` | line or branch coverage of a module's hand-written classes falls below the floor in `emf/pom.xml` | | mutation | `pitest-maven` | the mutation score of a module's hand-written classes 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). +every model behaviour's JUnit proof in [the witness list](witnesses.md), in +whichever language its module is written. 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 diff --git a/emf/docs/rules.md b/emf/docs/rules.md index 735c9c3..3454482 100644 --- a/emf/docs/rules.md +++ b/emf/docs/rules.md @@ -8,11 +8,11 @@ here are enforced by Maven inside `emf/` and deleted with it 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 +`tests/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 **17** rules. +This ledger holds **18** rules. | id | rule | enforcer | witness | |---|---|---|---| @@ -25,11 +25,12 @@ This ledger holds **17** rules. | 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, in every module | `pom.xml` | `BRANCH` | | EMF-009 | The mutation score stays at or above the measured threshold, in every module | `pom.xml` | `${emf.mutation.threshold}` | -| EMF-010 | A module depends only on the modules above it in the architecture's module table | `tests/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 | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `MODULES_HAVE_NO_CYCLES` | +| EMF-010 | A module depends only on the modules above it in the architecture's module table | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt` | `MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM` | +| EMF-011 | No dependency cycle between modules | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt` | `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 | `tests/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 | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java` | `Ledgers.checkRules(repository)` | +| EMF-013 | Every model behaviour has a JUnit witness, in whichever language its module is written | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt` | `Ledgers.checkWitnesses(repository)` | +| EMF-014 | Every rule in this ledger is still enforced by its named file | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt` | `Ledgers.checkRules(repository)` | | EMF-015 | Every warning the JDT compiler reports in a Tycho bundle fails the build | `pom.xml` | `true` | -| EMF-016 | The target platform resolves from one dated release build and names every unit at an exact version | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java` | `EVERY_UNIT_IS_PINNED` | -| EMF-017 | The module rules run over the classes the build wrote, never over an import that holds no module | `tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java` | `ModuleRules.modulesMissingFrom(classes)` | +| EMF-016 | The target platform resolves from one dated release build and names every unit at an exact version | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.kt` | `EVERY_UNIT_IS_PINNED` | +| EMF-017 | The module rules run over the classes the build wrote, never over an import that holds no module | `tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt` | `ModuleRules.modulesMissingFrom(classes)` | +| EMF-018 | Kotlin source is formatted with ktlint, checked in `verify` | `pom.xml` | `${ktlint.version}` | diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md index 125e7dd..1146a75 100644 --- a/emf/docs/witnesses.md +++ b/emf/docs/witnesses.md @@ -4,7 +4,10 @@ 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)). +([0114](adr/emf/0114-model-behaviours-have-a-java-witness.md)). A witness is a +JUnit test in whichever language its module is written, so a row here names a +Kotlin function of the test tier or a Java method of a bundle, and the check +reads both. `Ledgers.checkWitnesses` in `tests/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 @@ -14,9 +17,9 @@ This list holds **6** witnesses. | id | JUnit test | |---|---| -| REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` | -| REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` | -| REQ-024 | `ParityTest#aRefusedDocumentEqualsItsCommittedDiagnostics` | +| REQ-021 | `ParityTest#the parsed intent equals the committed oracle` | +| REQ-023 | `ParityTest#the metamodels structure equals the committed descriptor` | +| REQ-024 | `ParityTest#a refused document equals its committed diagnostics` | | REQ-029 | `LinkingTest#aRouteAndAScrapeLinkToTheVeryProcessAndSurfaceTheirApplicationHolds` | | REQ-030 | `PlatformIntentTest#aPlatformDocumentParsesAndATierWithoutItsEndpointIsRefused` | | REQ-031 | `IntentSetTest#theWorkedEstateIsRefusedExactlyWhereThePlatformDocumentSaysItWillBe` | diff --git a/emf/pom.xml b/emf/pom.xml index 2448e55..4689d0e 100644 --- a/emf/pom.xml +++ b/emf/pom.xml @@ -39,7 +39,10 @@ 3.27.7 1.5.0 + 2.3.21 + 2.98.0 + 1.7.1 0.8.15 1.30.0 1.2.3 @@ -65,6 +68,11 @@ archunit-junit5 ${archunit.version} + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + @@ -91,6 +99,7 @@ org.eclipse.tychotycho-bnd-plugin${tycho.version} org.eclipse.tychotycho-source-plugin${tycho.version} org.codehaus.mojoexec-maven-plugin3.6.4 + org.jetbrains.kotlinkotlin-maven-plugin${kotlin.version} @@ -166,6 +175,11 @@ ${palantir-java-format.version} + + + src/*/kotlin/**/*.kt + ${ktlint.version} + diff --git a/emf/tests/parity/pom.xml b/emf/tests/parity/pom.xml index ea62ae8..94b8e30 100644 --- a/emf/tests/parity/pom.xml +++ b/emf/tests/parity/pom.xml @@ -16,12 +16,16 @@ A plain jar: the suites decide every case from the files a run leaves behind, so the module holds no EMF type and resolves nothing from the target platform (docs/adr/emf/0120). It carries no META-INF/MANIFEST.MF, so the parent's `bundle` profile does not activate and its test libraries - are declared here instead. + are declared here instead. It is the test tier, so it is Kotlin (docs/adr/emf/0121). --> jar deploy-kit model-driven parity + + org.jetbrains.kotlin + kotlin-stdlib + org.junit.jupiter junit-jupiter @@ -38,4 +42,52 @@ test + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + + ${maven.compiler.release} + + -Werror + + + + compile + compile + compile + + + test-compile + test-compile + test-compile + + + + + + org.pitest + pitest-maven + + + + kotlin.jvm.internal.Intrinsics + kotlin.jdk7.AutoCloseableKt + + + + + diff --git a/emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java b/emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java deleted file mode 100644 index 41aa8be..0000000 --- a/emf/tests/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java +++ /dev/null @@ -1,143 +0,0 @@ -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/tests/parity/src/main/kotlin/dev/jorisjonkers/deploykit/emf/parity/Ledgers.kt b/emf/tests/parity/src/main/kotlin/dev/jorisjonkers/deploykit/emf/parity/Ledgers.kt new file mode 100644 index 0000000..c8e0e69 --- /dev/null +++ b/emf/tests/parity/src/main/kotlin/dev/jorisjonkers/deploykit/emf/parity/Ledgers.kt @@ -0,0 +1,133 @@ +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 kotlin.streams.asSequence + +/** + * 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. + */ +object Ledgers { + private val REQUIREMENT_ROW = Regex("""^\|\s*(REQ-\d{3})\s*\|.*\[[^]]*]\(\.\./([^)]+)\)\s*\|\s*$""") + private val WITNESS_ROW = Regex("""^\|\s*(REQ-\d{3})\s*\|\s*`([A-Za-z0-9_]+)#([^`]+)`\s*\|\s*$""") + private val RULE_ROW = Regex("""^\|\s*(EMF-\d{3})\s*\|\s*[^|]+\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*$""") + private val STATED = Regex("""holds \*\*(\d+)\*\*""") + + /** + * The witness list: every behaviour ledger row proved by a test under `test/model/` names a JUnit + * test here, and every witness names a real model row and a real test method. + */ + fun checkWitnesses(repository: Path): List { + val errors = mutableListOf() + val modelRows = + lines(repository.resolve("docs/requirements.md")) + .mapNotNull { REQUIREMENT_ROW.matchEntire(it) } + .filter { it.groupValues[2].startsWith("test/model/") } + .map { it.groupValues[1] } + .toSet() + val text = lines(repository.resolve("emf/docs/witnesses.md")) + val listed = linkedMapOf() + for (line in text) { + val row = WITNESS_ROW.matchEntire(line) ?: continue + val (id, type, method) = row.destructured + if (listed.put(id, "$type#$method") != null) { + errors.add("$id: listed twice") + } + if (id !in modelRows) { + errors.add("$id: names no model behaviour row in docs/requirements.md") + } + if (!testMethodExists(repository.resolve("emf"), type, method)) { + errors.add("$id: names $type#$method, which is not a test in emf/") + } + } + for (id in modelRows) { + if (id !in listed) { + 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 `emf/` that exists and still contains the witness + * literal that enforces the rule. + */ + fun checkRules(repository: Path): List { + val errors = mutableListOf() + val emf = repository.resolve("emf") + val text = lines(emf.resolve("docs/rules.md")) + val ids = mutableSetOf() + var rows = 0 + for (line in text) { + val row = RULE_ROW.matchEntire(line) ?: continue + rows++ + val (id, named, literal) = row.destructured + if (!ids.add(id)) { + errors.add("$id: listed twice") + } + val enforcer = emf.resolve(named).normalize() + if (!enforcer.startsWith(emf) || !Files.isRegularFile(enforcer)) { + errors.add("$id: names enforcer $named, which is not a file in emf/") + } else if (!read(enforcer).contains(literal)) { + errors.add("$id: $named no longer contains `$literal`") + } + } + checkStatedCount(text, rows, "emf/docs/rules.md", errors) + return errors + } + + private fun checkStatedCount( + text: List, + rows: Int, + file: String, + errors: MutableList, + ) { + val stated = STATED.find(text.joinToString("\n")) + if (stated == null) { + errors.add("$file: states no row count") + } else if (stated.groupValues[1] != rows.toString()) { + errors.add("$file: states ${stated.groupValues[1]} rows but holds $rows") + } + } + + /** + * Whether `className` declares `method` as a test somewhere under `emf/`. A witness is a JUnit + * test in whichever language its module is written, so both tiers are searched and both + * declarations are read (docs/adr/emf/0114). + */ + private fun testMethodExists( + emf: Path, + className: String, + method: String, + ): Boolean = + Files.walk(emf).use { files -> + files + .asSequence() + .filter { it.toString().contains("src/test/") } + .filter { it.fileName.toString() in setOf("$className.java", "$className.kt") } + .any { declaresTest(read(it), method) } + } + + private fun declaresTest( + source: String, + method: String, + ): Boolean { + val name = Regex.escape(method) + return Regex("""\bvoid $name\s*\(""").containsMatchIn(source) || + Regex("""\bfun `$name`\s*\(""").containsMatchIn(source) + } + + private fun lines(file: Path): List = read(file).lines() + + /** A read whose failure is a broken repository, reported rather than declared. */ + private fun read(file: Path): String = + try { + Files.readString(file) + } catch (e: IOException) { + throw UncheckedIOException(e) + } +} diff --git a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java deleted file mode 100644 index 80bafb6..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.jorisjonkers.deploykit.emf.cli.fixture; - -import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp; - -/** - * The other half of the fixture EMF-010 and EMF-011 are shown firing on: a class in the highest - * module of the table reaching down, which the direction allows, and which closes the cycle the - * upward reach in {@link LowerReachingUp} opens. It is never called; it exists to be imported. - */ -public final class UpperReachingDown { - - private UpperReachingDown() {} - - public static String name() { - return "fixture"; - } - - public static String down() { - return LowerReachingUp.up(); - } -} diff --git a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java deleted file mode 100644 index dd99d28..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.jorisjonkers.deploykit.emf.parity; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -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; -import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown; -import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp; -import org.junit.jupiter.api.Test; - -/** - * 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. - * - *

This suite depends on no module, so it reads the class directories the build wrote by path and - * asserts every module arrived: an empty import would leave every layer optional and let both rules - * pass while proving nothing (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md). Each rule - * is shown firing on a fixture that breaks it, beside the run that holds the tree to it. - */ -@AnalyzeClasses(locations = ModuleRules.BuiltModules.class, importOptions = ImportOption.DoNotIncludeTests.class) -class ArchitectureTest { - - /** A metamodel class reaching up into cli, and the cli class reaching back down into it. */ - private static final JavaClasses REACHING_UP = - new ClassFileImporter().importClasses(LowerReachingUp.class, UpperReachingDown.class); - - @ArchTest - static final ArchRule MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM = ModuleRules.layered(); - - @ArchTest - static final ArchRule MODULES_HAVE_NO_CYCLES = ModuleRules.acyclic(); - - @ArchTest - void everyModuleTheBuildWroteIsImported(JavaClasses classes) { - assertThat(ModuleRules.modulesMissingFrom(classes)).isEmpty(); - } - - @Test - void theDirectionFailsOnAModuleReachingUp() { - assertThatThrownBy(() -> ModuleRules.layered().check(REACHING_UP)) - .isInstanceOf(AssertionError.class) - .hasMessageContaining("metamodel"); - } - - @Test - void theCycleRuleFailsOnTwoModulesReachingEachOther() { - assertThatThrownBy(() -> ModuleRules.acyclic().check(REACHING_UP)) - .isInstanceOf(AssertionError.class) - .hasMessageContaining("Cycle"); - } - - @Test - void theImportGuardFailsOnAnImportHoldingNoModule() { - assertThat(ModuleRules.modulesMissingFrom(new ClassFileImporter().importClasses())) - .contains("metamodel", "syntax", "cli"); - } -} diff --git a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java deleted file mode 100644 index 42c16ef..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java +++ /dev/null @@ -1,138 +0,0 @@ -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/tests/parity/src/test/java/x/ParseTest.java", "class ParseTest { void parses() {} }"); - write(root, "emf/tests/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/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java deleted file mode 100644 index 2c5e867..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.java +++ /dev/null @@ -1,124 +0,0 @@ -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.domain.JavaClasses; -import com.tngtech.archunit.core.importer.Location; -import com.tngtech.archunit.junit.LocationProvider; -import com.tngtech.archunit.lang.ArchRule; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Stream; - -/** - * The module rules of docs/architecture.md#modules, and the classes they are held over, kept apart - * from the suite that runs them so the same rule can be run over a fixture that breaks it - * (docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md). - */ -final class ModuleRules { - - static final String ROOT = "dev.jorisjonkers.deploykit.emf."; - - /** The bundle tier, lowest first: a module may depend on the modules above it and on nothing below. */ - static final List MODULES = List.of("metamodel", "syntax", "resolve", "render", "cli"); - - private ModuleRules() {} - - /** EMF-010: a module depends only on the modules above it in the architecture's module table. */ - static ArchRule layered() { - return 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..") - .whereLayer("cli") - .mayNotBeAccessedByAnyLayer() - .whereLayer("render") - .mayOnlyBeAccessedByLayers("cli") - .whereLayer("resolve") - .mayOnlyBeAccessedByLayers("render", "cli") - .whereLayer("syntax") - .mayOnlyBeAccessedByLayers("resolve", "render", "cli") - .whereLayer("metamodel") - .mayOnlyBeAccessedByLayers("syntax", "resolve", "render", "cli"); - } - - /** EMF-011: no dependency cycle between modules. */ - static ArchRule acyclic() { - return slices().matching(ROOT + "(*)..").should().beFreeOfCycles(); - } - - /** - * EMF-017: the modules the build wrote classes for that are not in {@code classes}. The two rules - * above hold over optional layers, so an import that reached no module would excuse every layer - * and let both pass while proving nothing. This suite depends on no module, which is exactly how - * an empty import happens, so the emptiness is the thing that has to fail. A module with no - * class of its own yet is not missing: its layer is empty in the tree, not in the import. - */ - static List modulesMissingFrom(JavaClasses classes) { - return modulesTheBuildWrote().stream() - .filter(module -> classes.stream() - .noneMatch(imported -> imported.getPackageName().startsWith(ROOT + module))) - .toList(); - } - - /** The modules whose class directory holds a class: what an import of those directories carries. */ - static List modulesTheBuildWrote() { - return MODULES.stream().filter(module -> holdsAClass(classesOf(module))).toList(); - } - - private static boolean holdsAClass(Path directory) { - if (!Files.isDirectory(directory)) { - return false; - } - try (Stream tree = Files.walk(directory)) { - return tree.anyMatch(path -> path.getFileName().toString().endsWith(".class")); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - /** - * The class directories the build wrote, by path. The suite requires no module, so nothing puts - * their classes on its classpath; the reactor's own output is what it reads instead. - */ - static final class BuiltModules implements LocationProvider { - - @Override - public Set get(Class testClass) { - Set locations = new LinkedHashSet<>(); - for (String module : MODULES) { - locations.add(Location.of(classesOf(module))); - } - return locations; - } - } - - /** Where the build left a module's compiled classes. */ - static Path classesOf(String module) { - return bundles().resolve(module).resolve("target").resolve("classes"); - } - - /** Where the bundle tier's modules sit, found from this suite's own working directory. */ - static Path bundles() { - Path dir = Path.of("").toAbsolutePath(); - while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { - dir = dir.getParent(); - } - return dir.resolve("emf").resolve("bundles"); - } -} diff --git a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java deleted file mode 100644 index bc72e36..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package dev.jorisjonkers.deploykit.emf.parity; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -/** - * Every case under {@code spec/v1/examples/} that carries an oracle, decided from what a run of the - * pipeline left behind and compared with the committed file byte for byte - * (docs/architecture.md#the-parity-contract). - * - *

The suite calls nothing and holds no EMF type - * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md): the pipeline's interface here is - * arguments and input files in, an exit code and output files out. A file a case needs and the run - * did not write fails as a missing file, never as a skipped case. - */ -class ParityTest { - - /** Where a run of the pipeline leaves the parsed intent and the diagnostics, under {@code emf/}. */ - private static final String PIPELINE_OUTPUT = "emf/bundles/cli/target/parity"; - - /** Where a run of the build leaves the source metamodel's descriptor, under {@code emf/}. */ - private static final String METAMODEL_OUTPUT = "emf/bundles/metamodel/target/parity"; - - private static final String INTENT = "intent.json"; - private static final String DIAGNOSTICS = "diagnostics.json"; - private static final String DESCRIPTOR = "descriptor.json"; - private static final String EXIT = "exit"; - - private static List casesWithAnIntentOracle() { - Path examples = examples(); - try (Stream tree = Files.walk(examples)) { - return tree.filter(path -> path.endsWith("expected/" + INTENT)) - .map(path -> path.getParent().getParent()) - .sorted() - .toList(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @ParameterizedTest(name = "{0}") - @MethodSource("casesWithAnIntentOracle") - void theParsedIntentEqualsTheCommittedOracle(Path directory) throws IOException { - Path written = output(PIPELINE_OUTPUT, directory); - - assertThat(left(written, EXIT)).isEqualTo("0"); - assertThat(left(written, INTENT)) - .isEqualTo(read(directory.resolve("expected").resolve(INTENT))); - } - - private static List refusalsWithADiagnosticsOracle() { - Path refusals = examples().resolve("refusals"); - try (Stream files = Files.list(refusals)) { - return files.filter(path -> path.getFileName().toString().endsWith("." + DIAGNOSTICS)) - .sorted() - .toList(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @ParameterizedTest(name = "{0}") - @MethodSource("refusalsWithADiagnosticsOracle") - void aRefusedDocumentEqualsItsCommittedDiagnostics(Path oracle) throws IOException { - String stem = oracle.getFileName().toString().replace("." + DIAGNOSTICS, ""); - Path written = output(PIPELINE_OUTPUT, oracle.resolveSibling(stem)); - - assertThat(left(written, EXIT)).isEqualTo("1"); - assertThat(left(written, DIAGNOSTICS)).isEqualTo(read(oracle)); - } - - @Test - void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException { - Path written = repository().resolve(METAMODEL_OUTPUT); - - assertThat(left(written, DESCRIPTOR)) - .isEqualTo(read(examples().resolve("expected").resolve(DESCRIPTOR))); - } - - @Test - void oneChangedFieldNoLongerMatchesTheOracle() throws IOException { - Path directory = casesWithAnIntentOracle().get(0); - String written = left(output(PIPELINE_OUTPUT, directory), INTENT); - String changed = written.replace("\"owner\":\"joris\"", "\"owner\":\"someone-else\""); - - assertThat(changed).isNotEqualTo(written); - assertThat(changed).isNotEqualTo(read(directory.resolve("expected").resolve(INTENT))); - } - - /** Where the run left a case's files: the output tree mirrors the example tree, case for case. */ - private static Path output(String root, Path directory) { - return repository().resolve(root).resolve(examples().relativize(directory)); - } - - /** - * A file the run owes, read. Its absence is the pipeline failing to write what it owes, which is - * a different thing from a case with no committed oracle, and is reported as the missing file. - */ - private static String left(Path directory, String name) throws IOException { - Path file = directory.resolve(name); - assertThat(file) - .as("%s: a run of the pipeline leaves this file behind, and did not", file) - .exists(); - return read(file); - } - - private static String read(Path path) throws IOException { - return Files.readString(path, StandardCharsets.UTF_8); - } - - private static Path examples() { - return repository().resolve("spec/v1/examples"); - } - - 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/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java b/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java deleted file mode 100644 index 56b67eb..0000000 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package dev.jorisjonkers.deploykit.emf.parity; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import javax.xml.parsers.DocumentBuilderFactory; -import org.junit.jupiter.api.Test; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; - -/** - * The target platform resolves from one dated build of a simultaneous release, and names every unit at an exact - * version, so a clean runner cache resolves the same bundles as the last green build (docs/adr/emf/0108). - */ -class TargetPlatformTest { - - static final String EVERY_UNIT_IS_PINNED = "every unit names an exact version from one dated repository"; - - private static final String DATED_RELEASE = "https://download\\.eclipse\\.org/releases/\\d{4}-\\d{2}/\\d{12}/"; - private static final String EXACT_VERSION = "\\d+\\.\\d+\\.\\d+(\\.[\\w-]+)?"; - - @Test - void everyUnitIsPinned() throws Exception { - assertThat(problems(repository().resolve("emf/emf.target"))) - .as(EVERY_UNIT_IS_PINNED) - .isEmpty(); - } - - @Test - void anUnpinnedUnitOrAnUndatedRepositoryIsAProblem() throws Exception { - Path target = Files.createTempFile("emf", ".target"); - Files.writeString(target, """ - - - - - - - """); - - assertThat(problems(target)) - .containsExactly( - "repository https://download.eclipse.org/releases/latest/ is not a dated release build", - "unit b is not pinned: 0.0.0", - "unit c is not pinned: [1.0.0,2.0.0)"); - } - - private static List problems(Path target) throws Exception { - Element root = DocumentBuilderFactory.newInstance() - .newDocumentBuilder() - .parse(target.toFile()) - .getDocumentElement(); - List problems = new ArrayList<>(); - NodeList repositories = root.getElementsByTagName("repository"); - for (int i = 0; i < repositories.getLength(); i++) { - String location = ((Element) repositories.item(i)).getAttribute("location"); - if (!location.matches(DATED_RELEASE)) { - problems.add("repository " + location + " is not a dated release build"); - } - } - NodeList units = root.getElementsByTagName("unit"); - for (int i = 0; i < units.getLength(); i++) { - Element unit = (Element) units.item(i); - String version = unit.getAttribute("version"); - if (!version.matches(EXACT_VERSION) || version.equals("0.0.0")) { - problems.add("unit " + unit.getAttribute("id") + " is not pinned: " + version); - } - } - return problems; - } - - 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/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.kt new file mode 100644 index 0000000..320882d --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/cli/fixture/UpperReachingDown.kt @@ -0,0 +1,14 @@ +package dev.jorisjonkers.deploykit.emf.cli.fixture + +import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp + +/** + * The other half of the fixture EMF-010 and EMF-011 are shown firing on: a class in the highest + * module of the table reaching down, which the direction allows, and which closes the cycle the + * upward reach in [LowerReachingUp] opens. It is never called; it exists to be imported. + */ +object UpperReachingDown { + fun name(): String = "fixture" + + fun down(): String = LowerReachingUp.up() +} diff --git a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt similarity index 62% rename from emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java rename to emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt index ff8a6ce..863287a 100644 --- a/emf/tests/parity/src/test/java/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.java +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt @@ -1,19 +1,14 @@ -package dev.jorisjonkers.deploykit.emf.metamodel.fixture; +package dev.jorisjonkers.deploykit.emf.metamodel.fixture -import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown; +import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown /** * The fixture EMF-010 and EMF-011 are shown firing on: a class in the lowest module of the table * reaching up into the highest, which the module direction forbids and which closes a cycle with - * {@link UpperReachingDown} + * [UpperReachingDown] * (docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md). It is never * called; it exists to be imported. */ -public final class LowerReachingUp { - - private LowerReachingUp() {} - - public static String up() { - return UpperReachingDown.name(); - } +object LowerReachingUp { + fun up(): String = UpperReachingDown.name() } diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt new file mode 100644 index 0000000..fa1652f --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.kt @@ -0,0 +1,69 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import com.tngtech.archunit.core.domain.JavaClasses +import com.tngtech.archunit.core.importer.ClassFileImporter +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 +import dev.jorisjonkers.deploykit.emf.cli.fixture.UpperReachingDown +import dev.jorisjonkers.deploykit.emf.metamodel.fixture.LowerReachingUp +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +/** + * 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. + * + * This suite depends on no module, so it reads the class directories the build wrote by path and + * asserts every module the build wrote arrived: an empty import would leave every layer optional and + * let both rules pass while proving nothing + * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md). Each rule is shown firing on a + * fixture that breaks it, beside the run that holds the tree to it. + */ +@AnalyzeClasses( + locations = [ModuleRules.BuiltModules::class], + importOptions = [ImportOption.DoNotIncludeTests::class], +) +class ArchitectureTest { + @ArchTest + fun `every module the build wrote is imported`(classes: JavaClasses) { + assertThat(ModuleRules.modulesMissingFrom(classes)).isEmpty() + } + + @Test + fun `the direction fails on a module reaching up`() { + assertThatThrownBy { ModuleRules.layered().check(REACHING_UP) } + .isInstanceOf(AssertionError::class.java) + .hasMessageContaining("metamodel") + } + + @Test + fun `the cycle rule fails on two modules reaching each other`() { + assertThatThrownBy { ModuleRules.acyclic().check(REACHING_UP) } + .isInstanceOf(AssertionError::class.java) + .hasMessageContaining("Cycle") + } + + @Test + fun `the import guard fails on an import holding no module`() { + assertThat(ModuleRules.modulesMissingFrom(ClassFileImporter().importClasses())) + .contains("metamodel", "syntax", "cli") + } + + companion object { + @ArchTest + @JvmField + val MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM: ArchRule = ModuleRules.layered() + + @ArchTest + @JvmField + val MODULES_HAVE_NO_CYCLES: ArchRule = ModuleRules.acyclic() + + /** A metamodel class reaching up into cli, and the cli class reaching back down into it. */ + private val REACHING_UP: JavaClasses = + ClassFileImporter().importClasses(LowerReachingUp::class.java, UpperReachingDown::class.java) + } +} diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt new file mode 100644 index 0000000..48772ea --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.kt @@ -0,0 +1,168 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.UncheckedIOException +import java.nio.file.Files +import java.nio.file.Path + +class LedgersTest { + /** The committed ledgers, checked against this repository. */ + @Test + fun `the committed ledgers hold`() { + val repository = repository() + + assertThat(Ledgers.checkWitnesses(repository)).isEmpty() + assertThat(Ledgers.checkRules(repository)).isEmpty() + } + + @Test + fun `a model behaviour without a witness fails`( + @TempDir root: Path, + ) { + 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 + fun `a witness must name a model row and a real test once`( + @TempDir root: Path, + ) { + write(root, "docs/requirements.md", "$MODEL_ROW\n$GATE_ROW") + write(root, "emf/tests/parity/src/test/kotlin/x/ParseTest.kt", "class ParseTest { fun `parses`() {} }") + write(root, "emf/tests/parity/src/main/kotlin/x/ParseTest.kt", "class ParseTest { fun `missing`() {} }") + write( + root, + "emf/docs/witnesses.md", + listOf( + "This list holds **4** witnesses.", + "| $MODEL | `ParseTest#parses` |", + "| $MODEL | `ParseTest#parses` |", + "| $GATE | `ParseTest#parses` |", + "| $UNKNOWN | `ParseTest#missing` |", + ).joinToString("\n"), + ) + + 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", + ) + } + + /** A Java witness is read the same way, so the rule is the module's language and not the suite's. */ + @Test + fun `a witness naming a java test in a bundle is found`( + @TempDir root: Path, + ) { + write(root, "docs/requirements.md", MODEL_ROW) + write(root, "emf/bundles/cli/src/test/java/x/ParseTest.java", "class ParseTest { void parses() {} }") + write( + root, + "emf/docs/witnesses.md", + "This list holds **1** witnesses.\n| $MODEL | `ParseTest#parses` |", + ) + + assertThat(Ledgers.checkWitnesses(root)).isEmpty() + } + + @Test + fun `a witness list stating no count fails`( + @TempDir root: Path, + ) { + 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 + fun `a rule whose enforcer no longer holds its witness fails`( + @TempDir root: Path, + ) { + write(root, "emf/pom.xml", "-Werror") + write( + root, + "emf/docs/rules.md", + listOf( + "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` |", + ).joinToString("\n"), + ) + + 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 + fun `a rule ledger whose count drifts fails`( + @TempDir root: Path, + ) { + 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 + fun `a stated count too large for an int is reported not thrown`( + @TempDir root: Path, + ) { + 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 + fun `a ledger that cannot be read fails loudly`( + @TempDir root: Path, + ) { + assertThatThrownBy { Ledgers.checkRules(root) }.isInstanceOf(UncheckedIOException::class.java) + assertThatThrownBy { Ledgers.checkWitnesses(root) }.isInstanceOf(UncheckedIOException::class.java) + } + + companion object { + // 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 const val MODEL = "REQ-" + "020" + private const val GATE = "REQ-" + "001" + private const val UNKNOWN = "REQ-" + "099" + + private const val MODEL_ROW = + "| $MODEL | parses a project | [test/model/parse.test.ts](../test/model/parse.test.ts) |" + private const val GATE_ROW = + "| $GATE | lints ADRs | [test/adr-contract.test.ts](../test/adr-contract.test.ts) |" + + private fun write( + root: Path, + relative: String, + content: String, + ) { + val file = root.resolve(relative) + Files.createDirectories(file.parent) + Files.writeString(file, content) + } + } +} diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.kt new file mode 100644 index 0000000..93e8c6f --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ModuleRules.kt @@ -0,0 +1,87 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import com.tngtech.archunit.core.domain.JavaClasses +import com.tngtech.archunit.core.importer.Location +import com.tngtech.archunit.junit.LocationProvider +import com.tngtech.archunit.lang.ArchRule +import com.tngtech.archunit.library.Architectures.layeredArchitecture +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices +import java.nio.file.Files +import java.nio.file.Path +import kotlin.streams.asSequence + +/** + * The module rules of docs/architecture.md#modules, and the classes they are held over, kept apart + * from the suite that runs them so the same rule can be run over a fixture that breaks it + * (docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md). + */ +object ModuleRules { + const val ROOT = "dev.jorisjonkers.deploykit.emf." + + /** The bundle tier, lowest first: a module may depend on the modules above it and on nothing below. */ + val MODULES = listOf("metamodel", "syntax", "resolve", "render", "cli") + + /** EMF-010: a module depends only on the modules above it in the architecture's module table. */ + fun layered(): ArchRule = + 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..") + .whereLayer("cli") + .mayNotBeAccessedByAnyLayer() + .whereLayer("render") + .mayOnlyBeAccessedByLayers("cli") + .whereLayer("resolve") + .mayOnlyBeAccessedByLayers("render", "cli") + .whereLayer("syntax") + .mayOnlyBeAccessedByLayers("resolve", "render", "cli") + .whereLayer("metamodel") + .mayOnlyBeAccessedByLayers("syntax", "resolve", "render", "cli") + + /** EMF-011: no dependency cycle between modules. */ + fun acyclic(): ArchRule = slices().matching(ROOT + "(*)..").should().beFreeOfCycles() + + /** + * EMF-017: the modules the build wrote classes for that are not in `classes`. The two rules above + * hold over optional layers, so an import that reached no module would excuse every layer and let + * both pass while proving nothing. This suite depends on no module, which is exactly how an empty + * import happens, so the emptiness is the thing that has to fail. A module with no class of its + * own yet is not missing: its layer is empty in the tree, not in the import. + */ + fun modulesMissingFrom(classes: JavaClasses): List = + modulesTheBuildWrote().filter { module -> + classes.none { it.packageName.startsWith(ROOT + module) } + } + + /** The modules whose class directory holds a class: what an import of those directories carries. */ + fun modulesTheBuildWrote(): List = MODULES.filter { holdsAClass(classesOf(it)) } + + private fun holdsAClass(directory: Path): Boolean = + Files.isDirectory(directory) && + Files.walk(directory).use { tree -> + tree.asSequence().any { it.fileName.toString().endsWith(".class") } + } + + /** Where the build left a module's compiled classes. */ + fun classesOf(module: String): Path = bundles().resolve(module).resolve("target").resolve("classes") + + /** Where the bundle tier's modules sit. */ + fun bundles(): Path = repository().resolve("emf").resolve("bundles") + + /** + * The class directories the build wrote, by path. The suite requires no module, so nothing puts + * their classes on its classpath; the reactor's own output is what it reads instead. + */ + class BuiltModules : LocationProvider { + override fun get(testClass: Class<*>): Set = MODULES.map { Location.of(classesOf(it)) }.toSet() + } +} diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ParityTest.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ParityTest.kt new file mode 100644 index 0000000..f9ddcab --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/ParityTest.kt @@ -0,0 +1,115 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.nio.file.Files +import java.nio.file.Path +import kotlin.streams.asSequence + +/** + * Every case under `spec/v1/examples/` that carries an oracle, decided from what a run of the + * pipeline left behind and compared with the committed file byte for byte + * (docs/architecture.md#the-parity-contract). + * + * The suite calls nothing and holds no EMF type + * (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md): the pipeline's interface here is + * arguments and input files in, an exit code and output files out. A file a case needs and the run + * did not write fails as a missing file, never as a skipped case. + */ +class ParityTest { + @ParameterizedTest(name = "{0}") + @MethodSource("casesWithAnIntentOracle") + fun `the parsed intent equals the committed oracle`(directory: Path) { + val written = output(PIPELINE_OUTPUT, directory) + + assertThat(left(written, EXIT)).isEqualTo("0") + assertThat(left(written, INTENT)).isEqualTo(read(directory.resolve("expected").resolve(INTENT))) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("refusalsWithADiagnosticsOracle") + fun `a refused document equals its committed diagnostics`(oracle: Path) { + val stem = oracle.fileName.toString().removeSuffix(".$DIAGNOSTICS") + val written = output(PIPELINE_OUTPUT, oracle.resolveSibling(stem)) + + assertThat(left(written, EXIT)).isEqualTo("1") + assertThat(left(written, DIAGNOSTICS)).isEqualTo(read(oracle)) + } + + @Test + fun `the metamodels structure equals the committed descriptor`() { + val written = repository().resolve(METAMODEL_OUTPUT) + + assertThat(left(written, DESCRIPTOR)).isEqualTo(read(examples().resolve("expected").resolve(DESCRIPTOR))) + } + + @Test + fun `one changed field no longer matches the oracle`() { + val directory = casesWithAnIntentOracle().first() + val written = left(output(PIPELINE_OUTPUT, directory), INTENT) + val changed = written.replace("\"owner\":\"joris\"", "\"owner\":\"someone-else\"") + + assertThat(changed).isNotEqualTo(written) + assertThat(changed).isNotEqualTo(read(directory.resolve("expected").resolve(INTENT))) + } + + companion object { + /** Where a run of the pipeline leaves the parsed intent and the diagnostics, under `emf/`. */ + private const val PIPELINE_OUTPUT = "emf/bundles/cli/target/parity" + + /** Where a run of the build leaves the source metamodel's descriptor, under `emf/`. */ + private const val METAMODEL_OUTPUT = "emf/bundles/metamodel/target/parity" + + private const val INTENT = "intent.json" + private const val DIAGNOSTICS = "diagnostics.json" + private const val DESCRIPTOR = "descriptor.json" + private const val EXIT = "exit" + + @JvmStatic + fun casesWithAnIntentOracle(): List = + Files.walk(examples()).use { tree -> + tree + .asSequence() + .filter { it.endsWith("expected/$INTENT") } + .map { it.parent.parent } + .sorted() + .toList() + } + + @JvmStatic + fun refusalsWithADiagnosticsOracle(): List = + Files.list(examples().resolve("refusals")).use { files -> + files + .asSequence() + .filter { it.fileName.toString().endsWith(".$DIAGNOSTICS") } + .sorted() + .toList() + } + + /** Where the run left a case's files: the output tree mirrors the example tree, case for case. */ + private fun output( + root: String, + directory: Path, + ): Path = repository().resolve(root).resolve(examples().relativize(directory)) + + /** + * A file the run owes, read. Its absence is the pipeline failing to write what it owes, which + * is a different thing from a case with no committed oracle, and is reported as the missing + * file. + */ + private fun left( + directory: Path, + name: String, + ): String { + val file = directory.resolve(name) + assertThat(file).`as`("%s: a run of the pipeline leaves this file behind, and did not", file).exists() + return read(file) + } + + private fun read(path: Path): String = Files.readString(path, Charsets.UTF_8) + + private fun examples(): Path = repository().resolve("spec/v1/examples") + } +} diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/Repository.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/Repository.kt new file mode 100644 index 0000000..4a7e06f --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/Repository.kt @@ -0,0 +1,13 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import java.nio.file.Files +import java.nio.file.Path + +/** The repository this suite runs inside, found from its own working directory. */ +fun repository(): Path { + var dir = Path.of("").toAbsolutePath() + while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) { + dir = dir.parent + } + return dir +} diff --git a/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.kt new file mode 100644 index 0000000..68ee133 --- /dev/null +++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.kt @@ -0,0 +1,76 @@ +package dev.jorisjonkers.deploykit.emf.parity + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.w3c.dom.Element +import java.nio.file.Files +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The target platform resolves from one dated build of a simultaneous release, and names every unit + * at an exact version, so a clean runner cache resolves the same bundles as the last green build + * (docs/adr/emf/0108). + */ +class TargetPlatformTest { + @Test + fun `every unit is pinned`() { + assertThat(problems(repository().resolve("emf/emf.target"))).`as`(EVERY_UNIT_IS_PINNED).isEmpty() + } + + @Test + fun `an unpinned unit or an undated repository is a problem`() { + val target = Files.createTempFile("emf", ".target") + Files.writeString( + target, + """ + + + + + + + """.trimIndent(), + ) + + assertThat(problems(target)) + .containsExactly( + "repository https://download.eclipse.org/releases/latest/ is not a dated release build", + "unit b is not pinned: 0.0.0", + "unit c is not pinned: [1.0.0,2.0.0)", + ) + } + + companion object { + const val EVERY_UNIT_IS_PINNED = "every unit names an exact version from one dated repository" + + private val DATED_RELEASE = Regex("""https://download\.eclipse\.org/releases/\d{4}-\d{2}/\d{12}/""") + private val EXACT_VERSION = Regex("""\d+\.\d+\.\d+(\.[\w-]+)?""") + + private fun problems(target: Path): List { + val root = + DocumentBuilderFactory + .newInstance() + .newDocumentBuilder() + .parse(target.toFile()) + .documentElement + val problems = mutableListOf() + val repositories = root.getElementsByTagName("repository") + for (i in 0 until repositories.length) { + val location = (repositories.item(i) as Element).getAttribute("location") + if (!DATED_RELEASE.matches(location)) { + problems.add("repository $location is not a dated release build") + } + } + val units = root.getElementsByTagName("unit") + for (i in 0 until units.length) { + val unit = units.item(i) as Element + val version = unit.getAttribute("version") + if (!EXACT_VERSION.matches(version) || version == "0.0.0") { + problems.add("unit ${unit.getAttribute("id")} is not pinned: $version") + } + } + return problems + } + } +}