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.tychotycho-bnd-plugin${tycho.version} org.eclipse.tychotycho-source-plugin${tycho.version} org.codehaus.mojoexec-maven-plugin3.6.4 + org.jetbrains.kotlinkotlin-maven-plugin${kotlin.version} @@ -166,6 +175,11 @@ ${palantir-java-format.version} + + + src/*/kotlin/**/*.kt + ${ktlint.version} + diff --git a/emf/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)], );