Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ npm run verify # lint, format, typecheck, ADR contract, tests + coverage
`npm run lint:adrs` alone runs the decision-record contract, and `npm test`
runs the suite without enforcing coverage. `npm run test:coverage` (part of
`npm run verify`) enforces the ratchet in `vitest.config.ts`: statements
98.6%, branches 93.8%, functions 100%, lines 98.51%.
98.67%, branches 94.3%, functions 100%, lines 98.58%.

## Conventions

Expand Down
3 changes: 2 additions & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test file and holds at least one test; ids are unique; the count this
document states matches the number of rows it holds; and every id cited
anywhere in the tracked tree resolves to a row here.

This ledger holds **23** rows. The compiler's behaviours join it as they land.
This ledger holds **24** rows. The compiler's behaviours join it as they land.

| id | a contributor or a consumer can rely on | proved by |
|---|---|---|
Expand All @@ -46,3 +46,4 @@ This ledger holds **23** rows. The compiler's behaviours join it as they land.
| REQ-021 | An authored Project Intent file parses to its committed intent oracle byte for byte, and YAML outside the one-document, anchor-free subset or a field outside the language is refused with a diagnostic rather than guessed at | [test/model/project-intent.test.ts](../test/model/project-intent.test.ts) |
| REQ-022 | Every module under `src/` is mutation-tested, and a surviving mutant that takes the score below the measured threshold fails the build | [test/mutation-contract.test.ts](../test/mutation-contract.test.ts) |
| REQ-023 | The Project Intent metamodel's structure is committed as a descriptor both implementations are held to, and the JSON Schema an editor completes a project file against regenerates from the metamodel without a diff | [test/model/descriptor.test.ts](../test/model/descriptor.test.ts) |
| REQ-024 | A Project Intent document that breaks a model constraint is refused with the code and the JSON Pointer its committed diagnostics oracle names, and every refusal fixture carries one | [test/model/refusals.test.ts](../test/model/refusals.test.ts) |
4 changes: 3 additions & 1 deletion emf/cli/META-INF/MANIFEST.MF
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ Export-Package: dev.jorisjonkers.deploykit.emf.cli
Require-Bundle: dev.jorisjonkers.deploykit.emf.metamodel,
dev.jorisjonkers.deploykit.emf.syntax,
org.eclipse.emf.ecore,
org.eclipse.xtext
org.eclipse.xtext,
org.eclipse.ocl.pivot,
org.eclipse.ocl.xtext.completeocl
19 changes: 19 additions & 0 deletions emf/cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,23 @@
<artifactId>dev.jorisjonkers.deploykit.emf.cli</artifactId>
<packaging>eclipse-plugin</packaging>
<name>deploy-kit model-driven cli</name>

<build>
<plugins>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<configuration>
<!--
Registering the OCL language is a one-off for the JVM, so a second
test in the same JVM hides the removal of the call. The mutation is
equivalent here rather than uncovered.
-->
<avoidCallsTo>
<avoidCallsTo>org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup</avoidCallsTo>
</avoidCallsTo>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package dev.jorisjonkers.deploykit.emf.cli;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.emf.common.util.Diagnostic;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EPackage;
import org.eclipse.emf.ecore.EValidator;
import org.eclipse.emf.ecore.util.Diagnostician;
import org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup;
import org.eclipse.ocl.xtext.completeocl.validation.CompleteOCLEObjectValidator;

/**
* The Complete OCL constraints of a metamodel, evaluated over one parsed document. An invariant is
* named by the diagnostic code it emits and its context is the object the diagnostic points at, so a
* violation becomes a {@link dev.jorisjonkers.deploykit.emf.cli.Diagnostic} without a lookup table
* (emf/docs/architecture.md#constraints). Every failed invariant is reported, never only the first.
*/
public final class Constraints {

/** The invariant name inside the message Eclipse OCL builds for a violation. */
private static final Pattern VIOLATED = Pattern.compile("'[^']*::([A-Za-z0-9_]+)' constraint is violated");

/** The constraints the metamodel carries, as the build puts them beside its classes. */
public static URI beside(Class<?> metamodel, String file) {
return URI.createURI(metamodel.getResource("/" + file).toString());
}

private Constraints() {}

/** Evaluates {@code document} against the constraints at {@code constraints}, in document order. */
public static List<dev.jorisjonkers.deploykit.emf.cli.Diagnostic> check(EObject document, URI constraints) {
CompleteOCLStandaloneSetup.doSetup();
EPackage metamodel = document.eClass().getEPackage();
EValidator previous = EValidator.Registry.INSTANCE.getEValidator(metamodel);
EValidator.Registry.INSTANCE.put(metamodel, new CompleteOCLEObjectValidator(metamodel, constraints));
try {
return refusals(Diagnostician.INSTANCE.validate(document));
} finally {
EValidator.Registry.INSTANCE.put(metamodel, previous);
}
}

private static List<dev.jorisjonkers.deploykit.emf.cli.Diagnostic> refusals(Diagnostic diagnostic) {
List<dev.jorisjonkers.deploykit.emf.cli.Diagnostic> refusals = new ArrayList<>();
Matcher violated = VIOLATED.matcher(diagnostic.getMessage());
if (violated.find()) {
// A violation carries the object it refused as its first datum.
EObject refused = (EObject) diagnostic.getData().get(0);
refusals.add(new dev.jorisjonkers.deploykit.emf.cli.Diagnostic(
violated.group(1), Pointer.of(refused), diagnostic.getMessage()));
}
for (Diagnostic child : diagnostic.getChildren()) {
refusals.addAll(refusals(child));
}
return refusals;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.ArrayList;
import java.util.List;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EPackage;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.xtext.resource.XtextResourceSet;
Expand All @@ -16,6 +17,9 @@
*/
public final class Pipeline {

/** The Complete OCL file the metamodel carries, beside its classes. */
private static final String CONSTRAINTS = "project-intent.ocl";

private Pipeline() {}

/** The parsed intent of the project file at {@code path}, or the diagnostics refusing it. */
Expand All @@ -34,8 +38,12 @@ public static Parsed intent(Path path) {
if (resource.getContents().isEmpty()) {
refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", path.getFileName() + " holds no document"));
}
return refusals.isEmpty()
? Parsed.of(IntentJson.of(resource.getContents().get(0)))
: Parsed.refused(refusals);
if (!refusals.isEmpty()) {
return Parsed.refused(refusals);
}
EObject document = resource.getContents().get(0);
List<Diagnostic> broken =
Constraints.check(document, Constraints.beside(ProjectIntentPackage.class, CONSTRAINTS));
return broken.isEmpty() ? Parsed.of(IntentJson.of(document)) : Parsed.refused(broken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.jorisjonkers.deploykit.emf.cli;

import java.util.List;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EStructuralFeature;

/**
* The RFC 6901 JSON Pointer of an object inside its document, read off its containment chain: the
* name of the feature that holds it, and its index where the feature holds many
* (docs/architecture.md#the-parity-contract). The root is the empty pointer.
*/
public final class Pointer {

private Pointer() {}

/** The pointer of {@code object} in the document it belongs to. */
public static String of(EObject object) {
EObject owner = object.eContainer();
if (owner == null) {
return "";
}
EStructuralFeature feature = object.eContainingFeature();
String step = escape(feature.getName());
if (feature.isMany()) {
step = step + "/" + ((List<?>) owner.eGet(feature)).indexOf(object);
}
return of(owner) + "/" + step;
}

/** A feature name as a pointer segment: the two characters a pointer spells differently. */
private static String escape(String name) {
return name.replace("~", "~0").replace("/", "~1");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package dev.jorisjonkers.deploykit.emf.cli;

import static org.assertj.core.api.Assertions.assertThat;

import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/** The constraints a parsed document answers, and the diagnostics a violation becomes. */
class ConstraintsTest {

private static final String REFUSED = """
apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
project: refusals
owner: joris
applications:
- id: unwired
observability:
alertClass: page
processes:
- name: unwired-worker
lifecycle: application
image: unwired-worker
runtime: node
placement:
memory: 128Mi
cpu: 25m
cutover: rolling
""";

private static Path file(Path directory, String text) throws IOException {
Path file = directory.resolve("unwired.project.yml");
Files.writeString(file, text);
return file;
}

@Test
void aViolationCarriesTheInvariantsNameAndThePointerOfWhatItRefused(@TempDir Path directory) throws IOException {
Parsed parsed = Pipeline.intent(file(directory, REFUSED));

assertThat(parsed.ok()).isFalse();
assertThat(parsed.intent()).isEmpty();
assertThat(parsed.diagnostics()).singleElement().satisfies(diagnostic -> {
assertThat(diagnostic.code()).isEqualTo("E_ALERT_CLASS_WITHOUT_SIGNAL");
assertThat(diagnostic.path()).isEqualTo("/applications/0/observability");
assertThat(diagnostic.message()).contains("E_ALERT_CLASS_WITHOUT_SIGNAL");
});
}

private static final String ACCEPTED = """
apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
project: refusals
owner: joris
applications:
- id: wired
observability:
alertClass: page
scrape:
process: wired-worker
surface: http
path: /metrics
processes:
- name: wired-worker
lifecycle: application
image: wired-worker
runtime: node
provides:
http: 8080
placement:
memory: 128Mi
cpu: 25m
cutover: rolling
""";

@Test
void aDocumentThatBreaksNoConstraintCarriesNoDiagnostic(@TempDir Path directory) throws IOException {
Parsed parsed = Pipeline.intent(file(directory, ACCEPTED));

assertThat(parsed.diagnostics()).isEmpty();
assertThat(parsed.intent()).containsKey("applications");
}

@Test
void theConstraintsAreTheFileTheMetamodelCarries() {
assertThat(Constraints.beside(ProjectIntentPackage.class, "project-intent.ocl")
.toString())
.endsWith("project-intent.ocl");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package dev.jorisjonkers.deploykit.emf.cli;

import static org.assertj.core.api.Assertions.assertThat;

import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Application;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Observability;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Project;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory;
import org.junit.jupiter.api.Test;

/** Where an object sits in its document, as the pointer a diagnostic carries. */
class PointerTest {

private static final ProjectIntentFactory MODEL = ProjectIntentFactory.eINSTANCE;

@Test
void theRootIsTheEmptyPointer() {
assertThat(Pointer.of(MODEL.createProject())).isEmpty();
}

@Test
void aFeatureHoldingManyValuesCarriesTheIndex() {
Project project = MODEL.createProject();
Application first = MODEL.createApplication();
Application second = MODEL.createApplication();
project.getApplications().add(first);
project.getApplications().add(second);
Process process = MODEL.createProcess();
second.getProcesses().add(process);

assertThat(Pointer.of(first)).isEqualTo("/applications/0");
assertThat(Pointer.of(second)).isEqualTo("/applications/1");
assertThat(Pointer.of(process)).isEqualTo("/applications/1/processes/0");
}

@Test
void aFeatureHoldingOneValueCarriesItsNameAlone() {
Project project = MODEL.createProject();
Application application = MODEL.createApplication();
Observability observability = MODEL.createObservability();
project.getApplications().add(application);
application.setObservability(observability);

assertThat(Pointer.of(observability)).isEqualTo("/applications/0/observability");
}
}
9 changes: 9 additions & 0 deletions emf/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ offending object, computed from its containment chain: each containing feature's
name, and the index for a many-valued feature. Validation reports every failed
invariant, never only the first.

Two consequences of evaluating OCL over Ecore, both recorded here because they
shaped the metamodel. The constraints import the metamodel by its `nsURI`, not
by file, so they bind to the classes the parser instantiates rather than to a
second copy. And EMF reads an unset enumeration as its first literal, so a
vocabulary an invariant tests for absence carries a literal with no spelling:
`Engine::absent` is what an unset `engine` reads as, a document cannot write it,
and the descriptor leaves it out because a literal the language cannot write is
not part of the vocabulary.

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
ledger constraint has no invariant, or an invariant names a code no ledger row
Expand Down
3 changes: 2 additions & 1 deletion emf/docs/witnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ names the JUnit test that proves the same behaviour here
has no witness here, when a witness names an id that is not a model row, or
when it names a test method that does not exist.

This list holds **2** witnesses.
This list holds **3** witnesses.

| id | JUnit test |
|---|---|
| REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` |
| REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` |
| REQ-024 | `ParityTest#aRefusedDocumentEqualsItsCommittedDiagnostics` |
1 change: 1 addition & 0 deletions emf/metamodel/build.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
source.. = src/main/java/,\
model/,\
target/generated-sources/emf/
bin.includes = META-INF/,\
.,\
Expand Down
Loading
Loading