diff --git a/docs/architecture.md b/docs/architecture.md
index 161aa01..f250590 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/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..9602337 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
+([0122](docs/adr/emf/0122-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/bundles/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
new file mode 100644
index 0000000..fd6dbe5
--- /dev/null
+++ b/emf/bundles/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/0121-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/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/bundles/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
new file mode 100644
index 0000000..ac4bc2b
--- /dev/null
+++ b/emf/bundles/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/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 94%
rename from emf/metamodel/META-INF/MANIFEST.MF
rename to emf/bundles/metamodel/META-INF/MANIFEST.MF
index b0b0ef0..d1b99ad 100644
--- a/emf/metamodel/META-INF/MANIFEST.MF
+++ b/emf/bundles/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/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/bundles/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
new file mode 100644
index 0000000..fff6e6b
--- /dev/null
+++ b/emf/bundles/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/0121-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/bundles/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/bundles/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/bundles/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/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/bundles/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
new file mode 100644
index 0000000..8ad6fa1
--- /dev/null
+++ b/emf/bundles/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/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/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/CanonicalJsonTest.java b/emf/bundles/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/bundles/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/bundles/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/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..9407bcb 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
@@ -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
@@ -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
+ ([0122](0122-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/adr/emf/0121-parity-crosses-the-cli-file-interface.md b/emf/docs/adr/emf/0121-parity-crosses-the-cli-file-interface.md
index 0e6b489..0f8718f 100644
--- a/emf/docs/adr/emf/0121-parity-crosses-the-cli-file-interface.md
+++ b/emf/docs/adr/emf/0121-parity-crosses-the-cli-file-interface.md
@@ -51,8 +51,11 @@ the target platform does not carry, which is
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 0bcbb60..534e2af 100644
--- a/emf/docs/architecture.md
+++ b/emf/docs/architecture.md
@@ -60,8 +60,11 @@ 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. `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,
@@ -75,32 +78,39 @@ 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
+([0122](adr/emf/0122-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:**
-> [0122](adr/emf/0122-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 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
@@ -172,7 +182,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.
@@ -247,30 +257,43 @@ 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.
-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:**
-> [0121](adr/emf/0121-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 ([0121](adr/emf/0121-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:
+`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 `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.
+
+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
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, 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.
@@ -289,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 d7cf48c..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 **16** rules.
+This ledger holds **18** rules.
| id | rule | enforcer | witness |
|---|---|---|---|
@@ -25,10 +25,12 @@ This ledger holds **16** 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/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 | `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 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 | `parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java` | `EVERY_UNIT_IS_PINNED` |
+| 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 1a200f8..1146a75 100644
--- a/emf/docs/witnesses.md
+++ b/emf/docs/witnesses.md
@@ -4,9 +4,12 @@ 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 `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.
@@ -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/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
deleted file mode 100644
index fadf308..0000000
--- a/emf/parity/pom.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
- 4.0.0
-
-
- dev.jorisjonkers.deploykit.emf
- emf-parent
- 0.1.0-SNAPSHOT
-
-
- dev.jorisjonkers.deploykit.emf.parity
- eclipse-plugin
- deploy-kit model-driven parity
-
-
-
- com.tngtech.archunit
- archunit-junit5
- test
-
-
-
diff --git a/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java b/emf/parity/src/main/java/dev/jorisjonkers/deploykit/emf/parity/Ledgers.java
deleted file mode 100644
index 41aa8be..0000000
--- a/emf/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/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java
deleted file mode 100644
index e7a5f10..0000000
--- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ArchitectureTest.java
+++ /dev/null
@@ -1,53 +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.importer.ImportOption;
-import com.tngtech.archunit.junit.AnalyzeClasses;
-import com.tngtech.archunit.junit.ArchTest;
-import com.tngtech.archunit.lang.ArchRule;
-
-/**
- * The module direction docs/architecture.md#modules states: a module may depend on the modules above
- * it in that table and on nothing below. A layer with no classes yet is allowed to be empty; the rule
- * holds for it the moment its first class lands.
- */
-@AnalyzeClasses(packages = "dev.jorisjonkers.deploykit.emf", importOptions = ImportOption.DoNotIncludeTests.class)
-class ArchitectureTest {
-
- private static final String ROOT = "dev.jorisjonkers.deploykit.emf.";
-
- @ArchTest
- static final ArchRule MODULES_DEPEND_ONLY_ON_MODULES_ABOVE_THEM = layeredArchitecture()
- .consideringOnlyDependenciesInLayers()
- .withOptionalLayers(true)
- .layer("metamodel")
- .definedBy(ROOT + "metamodel..")
- .layer("syntax")
- .definedBy(ROOT + "syntax..")
- .layer("resolve")
- .definedBy(ROOT + "resolve..")
- .layer("render")
- .definedBy(ROOT + "render..")
- .layer("cli")
- .definedBy(ROOT + "cli..")
- .layer("parity")
- .definedBy(ROOT + "parity..")
- .whereLayer("parity")
- .mayNotBeAccessedByAnyLayer()
- .whereLayer("cli")
- .mayOnlyBeAccessedByLayers("parity")
- .whereLayer("render")
- .mayOnlyBeAccessedByLayers("cli", "parity")
- .whereLayer("resolve")
- .mayOnlyBeAccessedByLayers("render", "cli", "parity")
- .whereLayer("syntax")
- .mayOnlyBeAccessedByLayers("resolve", "render", "cli", "parity")
- .whereLayer("metamodel")
- .mayOnlyBeAccessedByLayers("syntax", "resolve", "render", "cli", "parity");
-
- @ArchTest
- static final ArchRule MODULES_HAVE_NO_CYCLES =
- slices().matching(ROOT + "(*)..").should().beFreeOfCycles();
-}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/LedgersTest.java
deleted file mode 100644
index 33719a1..0000000
--- a/emf/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/parity/src/test/java/x/ParseTest.java", "class ParseTest { void parses() {} }");
- write(root, "emf/parity/src/main/java/x/ParseTest.java", "class ParseTest { void missing() {} }");
- write(
- root,
- "emf/docs/witnesses.md",
- String.join(
- "\n",
- "This list holds **4** witnesses.",
- "| " + MODEL + " | `ParseTest#parses` |",
- "| " + MODEL + " | `ParseTest#parses` |",
- "| " + GATE + " | `ParseTest#parses` |",
- "| " + UNKNOWN + " | `ParseTest#missing` |"));
-
- assertThat(Ledgers.checkWitnesses(root))
- .containsExactly(
- MODEL + ": listed twice",
- GATE + ": names no model behaviour row in docs/requirements.md",
- UNKNOWN + ": names no model behaviour row in docs/requirements.md",
- UNKNOWN + ": names ParseTest#missing, which is not a test in emf/",
- "emf/docs/witnesses.md: states 4 rows but holds 3");
- }
-
- @Test
- void aWitnessListStatingNoCountFails(@TempDir Path root) throws IOException {
- write(root, "docs/requirements.md", GATE_ROW);
- write(root, "emf/docs/witnesses.md", "No count here.\n");
-
- assertThat(Ledgers.checkWitnesses(root)).containsExactly("emf/docs/witnesses.md: states no row count");
- }
-
- @Test
- void aRuleWhoseEnforcerNoLongerHoldsItsWitnessFails(@TempDir Path root) throws IOException {
- write(root, "emf/pom.xml", "-Werror ");
- write(
- root,
- "emf/docs/rules.md",
- String.join(
- "\n",
- "This ledger holds **4** rules.",
- "| EMF-001 | warnings fail | `pom.xml` | `-Werror ` |",
- "| EMF-001 | lint all | `pom.xml` | `-Xlint:all ` |",
- "| EMF-003 | outside | `../docs/rules.md` | `rules` |",
- "| EMF-004 | missing | `gone.xml` | `x` |"));
-
- assertThat(Ledgers.checkRules(root))
- .containsExactly(
- "EMF-001: listed twice",
- "EMF-001: pom.xml no longer contains `-Xlint:all `",
- "EMF-003: names enforcer ../docs/rules.md, which is not a file in emf/",
- "EMF-004: names enforcer gone.xml, which is not a file in emf/");
- }
-
- @Test
- void aRuleLedgerWhoseCountDriftsFails(@TempDir Path root) throws IOException {
- write(root, "emf/pom.xml", "-Werror ");
- write(
- root,
- "emf/docs/rules.md",
- "This ledger holds **2** rules.\n| EMF-001 | warnings fail | `pom.xml` | `-Werror` |");
-
- assertThat(Ledgers.checkRules(root)).containsExactly("emf/docs/rules.md: states 2 rows but holds 1");
- }
-
- @Test
- void aStatedCountTooLargeForAnIntIsReportedNotThrown(@TempDir Path root) throws IOException {
- write(root, "docs/requirements.md", GATE_ROW);
- write(root, "emf/docs/witnesses.md", "This list holds **99999999999** witnesses.\n");
-
- assertThat(Ledgers.checkWitnesses(root))
- .containsExactly("emf/docs/witnesses.md: states 99999999999 rows but holds 0");
- }
-
- @Test
- void aLedgerThatCannotBeReadFailsLoudly(@TempDir Path root) {
- assertThatThrownBy(() -> Ledgers.checkRules(root)).isInstanceOf(UncheckedIOException.class);
- assertThatThrownBy(() -> Ledgers.checkWitnesses(root)).isInstanceOf(UncheckedIOException.class);
- }
-
- private static void write(Path root, String relative, String content) throws IOException {
- Path file = root.resolve(relative);
- Files.createDirectories(file.getParent());
- Files.writeString(file, content);
- }
-
- private static Path repository() {
- Path dir = Path.of("").toAbsolutePath();
- while (!Files.isRegularFile(dir.resolve("emf/pom.xml"))) {
- dir = dir.getParent();
- }
- return dir;
- }
-}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java
deleted file mode 100644
index dabcd48..0000000
--- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package dev.jorisjonkers.deploykit.emf.parity;
-
-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).
- */
-class ParityTest {
-
- private static List casesWithAnIntentOracle() {
- Path examples = repository().resolve("spec/v1/examples");
- try (Stream tree = Files.walk(examples)) {
- return tree.filter(path -> path.endsWith("expected/intent.json"))
- .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 {
- Parsed parsed = Pipeline.intent(intentFile(directory));
-
- assertThat(parsed.diagnostics()).isEmpty();
- assertThat(CanonicalJson.write(parsed.intent())).isEqualTo(read(directory.resolve("expected/intent.json")));
- }
-
- private static List refusalsWithADiagnosticsOracle() {
- Path refusals = repository().resolve("spec/v1/examples/refusals");
- try (Stream files = Files.list(refusals)) {
- return files.filter(path -> path.getFileName().toString().endsWith(".diagnostics.json"))
- .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.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));
- }
-
- @Test
- void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException {
- assertThat(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE)))
- .isEqualTo(read(repository().resolve("spec/v1/examples/expected/descriptor.json")));
- }
-
- @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"));
-
- assertThat(CanonicalJson.write(Pipeline.intent(changed).intent()))
- .isNotEqualTo(read(directory.resolve("expected/intent.json")));
- }
-
- /** 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();
- }
-
- private static List files(Path directory) throws IOException {
- try (Stream files = Files.list(directory)) {
- return files.filter(Files::isRegularFile).sorted().toList();
- }
- }
-
- private static String read(Path path) throws IOException {
- return Files.readString(path, StandardCharsets.UTF_8);
- }
-
- 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/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/TargetPlatformTest.java
deleted file mode 100644
index 56b67eb..0000000
--- a/emf/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/pom.xml b/emf/pom.xml
index 15f5ea9..f196cd1 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
@@ -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.tycho tycho-bnd-plugin ${tycho.version}
org.eclipse.tycho tycho-source-plugin ${tycho.version}
org.codehaus.mojo exec-maven-plugin 3.6.4
+ org.jetbrains.kotlin kotlin-maven-plugin ${kotlin.version}
@@ -166,6 +175,11 @@
${palantir-java-format.version}
+
+
+ src/*/kotlin/**/*.kt
+ ${ktlint.version}
+
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(/
+
+ 4.0.0
+
+
+ dev.jorisjonkers.deploykit.emf
+ emf-parent
+ 0.1.0-SNAPSHOT
+ ../../pom.xml
+
+
+ dev.jorisjonkers.deploykit.emf.parity
+
+ jar
+ deploy-kit model-driven parity
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ 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/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/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/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt
new file mode 100644
index 0000000..863287a
--- /dev/null
+++ b/emf/tests/parity/src/test/kotlin/dev/jorisjonkers/deploykit/emf/metamodel/fixture/LowerReachingUp.kt
@@ -0,0 +1,14 @@
+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
+ * [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.
+ */
+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..47f70a8
--- /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/0121-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..347dea3
--- /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/0121-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
+ }
+ }
+}
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)],
);