Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 19 additions & 1 deletion emf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ oracle files under `spec/v1/examples/` that both are tested against, separately.
| [docs/architecture.md](docs/architecture.md) | the structure: toolchain, modules, how each stage meets the contract |
| [docs/adr/README.md](docs/adr/README.md) | the decisions that shaped it, and the register |

## Where each artefact lives

This table says which artefact lives where and which course task grades
it, so the split tree
([0121](docs/adr/emf/0121-bundles-and-tests-are-separate-tiers.md))
answers the navigation question without a search.

| artefact | lives in | graded in | how an examiner opens it |
|---|---|---|---|
| the two Ecore metamodels and their OCL | `emf/bundles/metamodel` | Task 1 | imported in step 2; `model/skeleton.ecore` and `model/skeleton.ocl` open and validate as step 3 describes |
| the Xtext grammar and generated editor | `emf/bundles/syntax` | Task 1 | imported in step 2; the generated editor reports OCL constraint violations while a source file is edited in it |
| the QVTo transformation | `emf/bundles/resolve` | Task 2 | imported in step 2; `identity.launch` runs it, as step 4 describes |
| the Acceleo templates | `emf/bundles/render` | Task 3 | imported in step 2; `file.launch` runs them, as step 4 describes |
| the pipeline entry point | `emf/bundles/cli` | Task 1 onward | imported in step 2, alongside the rest |
| the parity suite | `emf/tests/parity` | no task grades it | it is Maven-only; no examiner opens it |

## Building

```sh
Expand All @@ -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
Expand Down
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions emf/cli/pom.xml → emf/bundles/cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<groupId>dev.jorisjonkers.deploykit.emf</groupId>
<artifactId>emf-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<artifactId>dev.jorisjonkers.deploykit.emf.cli</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package dev.jorisjonkers.deploykit.emf.cli;

import dev.jorisjonkers.deploykit.emf.metamodel.json.CanonicalJson;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;

/**
* What a run of the pipeline leaves behind: for every case under {@code spec/v1/examples/}, the
* parsed intent or the diagnostics that refused it, in the canonical JSON the oracles are committed
* in, and the exit code the run ended on
* (docs/adr/emf/0120-parity-crosses-the-cli-file-interface.md).
*
* <p>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<Path> casesWithAnIntentOracle(Path examples) throws IOException {
try (Stream<Path> 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<Path> refusalsWithADiagnosticsOracle(Path examples) throws IOException {
try (Stream<Path> 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<Path> documents(Path directory) throws IOException {
try (Stream<Path> 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<Diagnostic> 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<Object> triples(List<Diagnostic> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> everyCasesPairedFile(Path examples) throws IOException {
try (Stream<Path> tree = Files.walk(examples)) {
return tree.flatMap(oracle -> pairedFiles(examples, oracle))
.sorted()
.toList();
}
}

private static Stream<String> 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<Path> 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<String> files(Path root) throws IOException {
try (Stream<Path> 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<Path> tree = Files.walk(root)) {
for (Path path : tree.sorted(Comparator.reverseOrder()).toList()) {
Files.delete(path);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions emf/metamodel/pom.xml → emf/bundles/metamodel/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<groupId>dev.jorisjonkers.deploykit.emf</groupId>
<artifactId>emf-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<artifactId>dev.jorisjonkers.deploykit.emf.metamodel</artifactId>
Expand Down
Loading
Loading