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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,21 @@
- 'name': 'Test'
'run': 'npm run test:coverage'

# Every module under src/, mutated; a surviving mutant below the break
# threshold in stryker.config.json fails the job. The threshold is the
# measured score and only rises, like the coverage ratchet.
'mutation':
'name': 'Mutation'
'runs-on': 'ubuntu-latest'
'timeout-minutes': 20
'permissions':
'contents': 'read'
'steps':
- 'uses': 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # v7.0.1
- 'uses': './.github/actions/setup'
- 'name': 'Mutation'
'run': 'npm run test:mutation'

'package-contents':
'name': 'Package contents'
'runs-on': 'ubuntu-latest'
Expand Down Expand Up @@ -290,6 +305,7 @@
- 'architecture'
- 'contracts'
- 'tests'
- 'mutation'
- 'package-contents'
- 'actionlint'
- 'secret-scan'
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ __pycache__/
# these; the template did not ignore them.
coverage/
.nyc_output/

# Mutation testing: Stryker's report and its sandbox copies of the tree.
reports/
.stryker-tmp/
*.lcov

# Build and tool caches
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Generated
CHANGELOG.md
coverage/
reports/
.stryker-tmp/
**/target/
dist/
node_modules/
Expand Down
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.37%, branches 93.1%, functions 100%, lines 98.25%.
98.46%, branches 93.44%, functions 100%, lines 98.35%.

## Conventions

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fails the gate, so the taxonomy cannot grow entries nothing stands behind.

## Rules

This ledger holds **63** rules, **12** of them pending.
This ledger holds **64** rules, **12** of them pending.

A row is enforced or pending, never both. An enforced row names its enforcer as
`kind:value`: `depcruise:` a rule in
Expand Down Expand Up @@ -139,6 +139,7 @@ moving a live rule to pending fails the gate rather than quietly retiring it.
| RULE-061 | gates | A workflow step that runs the Maven wrapper runs it in a directory holding a POM and the wrapper, and the model-driven reactor names only modules on disk | `file:test/emf-wiring.test.ts` | [test/emf-wiring.test.ts](../test/emf-wiring.test.ts) `which names a POM that does not exist` |
| RULE-062 | gates | CodeQL analyses the model-driven implementation's Java without a build, ignoring build output and generated sources | `file:.github/codeql/codeql-config.yml` | [test/emf-wiring.test.ts](../test/emf-wiring.test.ts) `'language': 'java-kotlin'` |
| RULE-063 | gates | A CodeQL finding of any severity fails `Pipeline Complete`, unless the finding is filtered in the CodeQL configuration | `file:.github/workflows/codeql.yml` | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) `'name': 'Fail on any finding'` |
| RULE-064 | gates | Every module under `src/` is mutated, and a mutation score below the measured break threshold fails the build | `file:stryker.config.json` | [test/mutation-contract.test.ts](../test/mutation-contract.test.ts) `"break": 100` |

## Considered and rejected

Expand Down
4 changes: 3 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 **20** rows. The compiler's behaviours join it as they land.
This ledger holds **22** rows. The compiler's behaviours join it as they land.

| id | a contributor or a consumer can rely on | proved by |
|---|---|---|
Expand All @@ -43,3 +43,5 @@ This ledger holds **20** rows. The compiler's behaviours join it as they land.
| REQ-018 | The compiler's inner rings cannot read the environment, the clock, randomness, a child process or the filesystem synchronously, and only `src/cli/boundary.ts` exits the process or writes output | [test/seams.test.ts](../test/seams.test.ts) |
| REQ-019 | Every committed oracle file is byte-identical to its own RFC 8785 canonicalisation, so a hand edit cannot leave one in a form the other implementation would not produce | [test/oracles.test.ts](../test/oracles.test.ts) |
| REQ-020 | The production implementation's canonical JSON writer sorts keys by UTF-16 code units, formats numbers as ECMAScript does, and refuses null, non-finite numbers, non-JSON values and lone surrogates, on the same cases as the model-driven writer | [test/canonical-json.test.ts](../test/canonical-json.test.ts) |
| 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) |
12 changes: 12 additions & 0 deletions emf/cli/META-INF/MANIFEST.MF
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: deploy-kit model-driven pipeline
Bundle-SymbolicName: dev.jorisjonkers.deploykit.emf.cli;singleton:=true
Bundle-Version: 0.1.0.qualifier
Bundle-RequiredExecutionEnvironment: JavaSE-21
Automatic-Module-Name: dev.jorisjonkers.deploykit.emf.cli
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
3 changes: 3 additions & 0 deletions emf/cli/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source.. = src/main/java/
bin.includes = META-INF/,\
.
16 changes: 16 additions & 0 deletions emf/cli/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>dev.jorisjonkers.deploykit.emf</groupId>
<artifactId>emf-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>

<artifactId>dev.jorisjonkers.deploykit.emf.cli</artifactId>
<packaging>eclipse-plugin</packaging>
<name>deploy-kit model-driven cli</name>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dev.jorisjonkers.deploykit.emf.cli;

/**
* A refusal: the code the specification gives it, the JSON Pointer of the authored value it
* concerns, and a message for a human. The code and the path are the parity contract's; the message
* is this implementation's own.
*/
public record Diagnostic(String code, String path, String message) {

/** The code every refusal of a document's shape carries, until a rule gives it its own. */
public static final String SCHEMA = "schema";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package dev.jorisjonkers.deploykit.emf.cli;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.emf.common.util.Enumerator;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EStructuralFeature;

/**
* Reads a parsed model as the JSON value the parity contract compares: every feature named as the
* authored key it holds, a map entry as an object, an enumeration as its literal, and an optional
* feature absent when the document left it out. The metamodel is walked reflectively, so a feature
* added to the {@code .ecore} reaches the intent without a line here.
*/
public final class IntentJson {

private IntentJson() {}

/** The JSON value of {@code root}, as a map of authored key to value. */
public static Map<String, Object> of(EObject root) {
Map<String, Object> json = new LinkedHashMap<>();
for (EStructuralFeature feature : root.eClass().getEAllStructuralFeatures()) {
if (isSet(root, feature)) {
json.put(feature.getName(), value(root, feature));
}
}
return json;
}

/** Whether the document carries {@code feature}: a required feature always, an optional one when set. */
private static boolean isSet(EObject owner, EStructuralFeature feature) {
return feature.isRequired() || owner.eIsSet(feature);
}

private static Object value(EObject owner, EStructuralFeature feature) {
Object value = owner.eGet(feature);
if (feature.isMany()) {
return many(feature, (List<?>) value);
}
return single(value);
}

private static Object many(EStructuralFeature feature, List<?> values) {
if (isMapEntry(feature)) {
Map<String, Object> entries = new LinkedHashMap<>();
for (Object value : values) {
Map.Entry<?, ?> entry = (Map.Entry<?, ?>) value;
entries.put(String.valueOf(entry.getKey()), single(entry.getValue()));
}
return entries;
}
List<Object> items = new ArrayList<>();
for (Object value : values) {
items.add(single(value));
}
return items;
}

/** Whether {@code feature} holds map entries, which are written as one object rather than a list. */
private static boolean isMapEntry(EStructuralFeature feature) {
return Map.Entry.class.getName().equals(feature.getEType().getInstanceClassName());
}

private static Object single(Object value) {
return switch (value) {
case EObject child -> of(child);
case Enumerator literal -> literal.getLiteral();
default -> value;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dev.jorisjonkers.deploykit.emf.cli;

import java.util.List;
import java.util.Map;

/**
* The outcome of reading one authored document: the parsed intent as a JSON value, or the
* diagnostics that refused it. One of the two is always empty.
*/
public record Parsed(Map<String, Object> intent, List<Diagnostic> diagnostics) {

public Parsed {
intent = Map.copyOf(intent);
diagnostics = List.copyOf(diagnostics);
}

public static Parsed of(Map<String, Object> intent) {
return new Parsed(intent, List.of());
}

public static Parsed refused(List<Diagnostic> diagnostics) {
return new Parsed(Map.of(), diagnostics);
}

public boolean ok() {
return diagnostics.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dev.jorisjonkers.deploykit.emf.cli;

import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage;
import dev.jorisjonkers.deploykit.emf.syntax.ProjectIntentStandaloneSetup;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EPackage;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.xtext.resource.XtextResourceSet;

/**
* The pipeline entry: an authored project file in, its parsed intent or the diagnostics that refused
* it out. It is the seam the parity suite runs every case through.
*/
public final class Pipeline {

private Pipeline() {}

/** The parsed intent of the project file at {@code path}, or the diagnostics refusing it. */
public static Parsed intent(Path path) {
// Outside OSGi nothing registers the metamodel, and the grammar's rules return its classes.
EPackage.Registry.INSTANCE.putIfAbsent(ProjectIntentPackage.eNS_URI, ProjectIntentPackage.eINSTANCE);
XtextResourceSet resources = new ProjectIntentStandaloneSetup()
.createInjectorAndDoEMFRegistration()
.getInstance(XtextResourceSet.class);
Resource resource =
resources.getResource(URI.createFileURI(path.toAbsolutePath().toString()), true);
List<Diagnostic> refusals = new ArrayList<>();
for (Resource.Diagnostic error : resource.getErrors()) {
refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", "line " + error.getLine() + ": " + error.getMessage()));
}
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package dev.jorisjonkers.deploykit.emf.cli;

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

import java.util.List;
import org.junit.jupiter.api.Test;

/** What a refusal carries: the code and the path the parity contract compares, and a message. */
class DiagnosticTest {

@Test
void aDiagnosticCarriesItsCodePathAndMessage() {
Diagnostic diagnostic = new Diagnostic(Diagnostic.SCHEMA, "/applications/0/processes/0/runtime", "refused");

assertThat(diagnostic.code()).isEqualTo("schema");
assertThat(diagnostic.path()).isEqualTo("/applications/0/processes/0/runtime");
assertThat(diagnostic.message()).isEqualTo("refused");
}

@Test
void aParsedDocumentIsEitherAnIntentOrRefusals() {
Diagnostic diagnostic = new Diagnostic(Diagnostic.SCHEMA, "", "refused");

assertThat(Parsed.refused(List.of(diagnostic)).ok()).isFalse();
assertThat(Parsed.refused(List.of(diagnostic)).intent()).isEmpty();
assertThat(Parsed.of(java.util.Map.of("project", "notes")).ok()).isTrue();
assertThat(Parsed.of(java.util.Map.of("project", "notes")).diagnostics())
.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package dev.jorisjonkers.deploykit.emf.cli;

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

import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Lifecycle;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Placement;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Probe;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Probes;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process;
import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory;
import java.util.Map;
import org.junit.jupiter.api.Test;

/** What the intent of a parsed model holds: the authored keys, and nothing the document left out. */
class IntentJsonTest {

private static final ProjectIntentFactory MODEL = ProjectIntentFactory.eINSTANCE;

private static Process process() {
Process process = MODEL.createProcess();
process.setName("notes-api");
process.setLifecycle(Lifecycle.APPLICATION);
process.setImage("notes-api");
Placement placement = MODEL.createPlacement();
placement.setMemory("256Mi");
placement.setCpu("50m");
process.setPlacement(placement);
return process;
}

@Test
void aRequiredFeatureIsWrittenEvenWhenItHoldsItsDefault() {
Map<String, Object> intent = IntentJson.of(process());

assertThat(intent).contains(entry("name", "notes-api"), entry("image", "notes-api"));
assertThat(intent).containsKey("cutover");
}

@Test
void anEnumerationIsWrittenAsItsLiteral() {
assertThat(IntentJson.of(process())).contains(entry("lifecycle", "application"));
}

@Test
void anOptionalFeatureTheDocumentLeftOutIsAbsent() {
assertThat(IntentJson.of(process())).doesNotContainKeys("startupBudget", "probes", "provides");
}

@Test
void anOptionalFeatureTheDocumentSetIsWritten() {
Process process = process();
process.setStartupBudget("20s");
Probes probes = MODEL.createProbes();
Probe readiness = MODEL.createProbe();
readiness.setPath("/healthz/ready");
readiness.setPort(8080);
probes.setReadiness(readiness);
process.setProbes(probes);

assertThat(IntentJson.of(process()))
.doesNotContainKey("startupBudget"); // the sample above is a different object
assertThat(IntentJson.of(process))
.contains(
entry("startupBudget", "20s"),
entry("probes", Map.of("readiness", Map.of("path", "/healthz/ready", "port", 8080))));
}

@Test
void aMapEntryIsWrittenAsAnObjectAndAListAsAList() {
Process process = process();
process.getProvides().put("http", 8080);

assertThat(IntentJson.of(process())).doesNotContainKey("provides");
assertThat(IntentJson.of(process())).doesNotContainKey("applications");
assertThat(IntentJson.of(process)).contains(entry("provides", Map.of("http", 8080)));
}
}
Loading
Loading