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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ follow semantic versioning; release dates are ISO 8601.

### Build

- **The Javadoc gate lints the class readers open first.** It ran with
`subpackages` set to `com.demcha.compose.document`, so `GraphCompose` — the entry
point every snippet in the README starts from — sat in the root package outside it,
and had done since the module layout moved. It carried a real doclint error the
whole time: two `<h3>` headings under an implicit `<h1>`, which is the sequence
break `doclint` exists to catch. Nothing else caught it either, because the
published Javadoc jar is built with `doclint=none` so a broken tag never blocks a
release. The gate now covers the whole of `com.demcha.compose`, root package
included, and the two headings are `<h2>`. Widening it also pulls in the
`@Internal` engine package: `excludePackageNames` does not take effect alongside
`subpackages`, and that costs warnings rather than failures, since `failOnError`
fails on errors only.
- **A CI job the gate guard cannot name is a job it cannot report.** The guard that
checks every pull-request job is aggregated by `CI Gate` found those jobs with a
pattern admitting lower case and hyphens — everything today's names happen to use.
A job added as `build_and_test` or `CodeQL` was not matched, and neither was
`security_scan: # nightly` or a name with a space after the colon, because the
pattern also required the line to end there. A job the guard never sees is one it
cannot report missing: it sits outside the gate's `needs`, the test stays green, and
the aggregate check branch protection requires is blind to it. Job names are now
taken structurally, from the YAML's own indentation, rather than from a guess at
GitHub's identifier grammar, and the parser takes the workflow as text so
`CiGateCoverageGuardParsingTest` can drive it with the job spellings this
repository's own workflow does not happen to contain.
- **A recipe nobody links is a recipe nobody reaches.** `docs/recipes.md` is the page
the README and the documentation index both point at, and nothing held it to the
folder it indexes: a new page under `docs/recipes/` left every gate green while
having no inbound link from anywhere. `RecipeCatalogueGuardTest` now fails the build
in both directions — a page the catalogue omits, and a catalogue row pointing at a
file that was renamed or removed.
- **A release re-renders the previews it publishes.** The committed previews record
the version they were rendered at, and until now nothing moved it: a cut bumped
every pom, regenerated the showcase site at the new version, and left
Expand Down
21 changes: 19 additions & 2 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,24 @@
</executions>
</plugin>

<!-- Public API Javadoc validation: canonical document API + public testing-support helpers (testing.layout / testing.visual) -->
<!--
Javadoc validation for the module.

The scope is the whole of `com.demcha.compose`, root package
included. It was `com.demcha.compose.document`, which left
`GraphCompose` — the entry point every README snippet starts
from — as the one public class nothing linted; it had carried a
doclint error since the layout move without anything reporting
it, because the published Javadoc jar is built with
`doclint=none` and cannot report one either.

This also pulls in `com.demcha.compose.engine`, the `@Internal`
surface. That is a consequence rather than a goal: with
`subpackages` set, `excludePackageNames` does not take effect
here (tried both `a.b.engine:a.b.engine.*` and `a.b.engine*` —
the package is emitted either way). It costs warnings, not
failures, since `failOnError` fails on errors only.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
Expand All @@ -470,7 +487,7 @@
<failOnError>true</failOnError>
<show>public</show>
<quiet>true</quiet>
<subpackages>com.demcha.compose.document</subpackages>
<subpackages>com.demcha.compose</subpackages>
</configuration>
</plugin>
</plugins>
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/java/com/demcha/compose/GraphCompose.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
* <li>Call {@link DocumentSession#buildPdf()} or {@link DocumentSession#toPdfBytes()}.</li>
* </ol>
*
* <h3>Build a PDF file with the canonical DSL</h3>
* <h2>Build a PDF file with the canonical DSL</h2>
*
* <pre>{@code
* try (DocumentSession document = GraphCompose.document(outputFile)
Expand All @@ -51,7 +51,7 @@
* }
* }</pre>
*
* <h3>Get bytes instead of writing to disk</h3>
* <h2>Get bytes instead of writing to disk</h2>
*
* <pre>{@code
* try (DocumentSession document = GraphCompose.document()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.demcha.documentation;

import org.junit.jupiter.api.Test;

import java.util.Map;

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

/**
* Drives {@link CiGateCoverageGuardTest}'s job parser with shapes the repository's
* own workflow does not currently contain.
*
* <p>{@link CiGateCoverageGuardTest} reads {@code .github/workflows/ci.yml}, so it
* can only ever exercise the job spellings that file happens to use — today, lower
* case with hyphens and nothing after the colon. Every other spelling GitHub
* accepts is untested there, and the failure mode is not a red build: a job the
* parser does not match is a job the guard cannot report missing from
* {@code ci-gate.needs}. It stays absent, the test stays green, and the aggregate
* check branch protection requires is blind to it.</p>
*
* <p>So each spelling gets a workflow of its own here, and each asserts the parser
* saw the job at all. These are the cases that would otherwise be discovered by a
* new job silently escaping the gate.</p>
*/
class CiGateCoverageGuardParsingTest {

@Test
void aJobNameWithAnUnderscoreOrCapitalsIsSeen() {
Map<String, String> jobs = CiGateCoverageGuardTest.jobBlocks("""
on: [push]

jobs:
build_and_test:
runs-on: ubuntu-latest
CodeQL:
runs-on: ubuntu-latest
""");

assertThat(jobs).containsKeys("build_and_test", "CodeQL");
}

@Test
void aTrailingCommentDoesNotHideTheJob() {
Map<String, String> jobs = CiGateCoverageGuardTest.jobBlocks("""
on: [push]

jobs:
security_scan: # runs the scanners
runs-on: ubuntu-latest
perf-smoke:
runs-on: ubuntu-latest
""");

assertThat(jobs)
.describedAs("a job whose declaration carries an inline comment must still be "
+ "parsed — otherwise it can sit outside the gate's needs list with "
+ "nothing reporting it")
.containsKeys("security_scan", "perf-smoke");
}

@Test
void trailingWhitespaceDoesNotHideTheJob() {
Map<String, String> jobs = CiGateCoverageGuardTest.jobBlocks(
"on: [push]\n\njobs:\n build-and-test: \n runs-on: ubuntu-latest\n");

assertThat(jobs).containsKey("build-and-test");
}

@Test
void keysNestedInsideAJobAreNotMistakenForJobs() {
Map<String, String> jobs = CiGateCoverageGuardTest.jobBlocks("""
on: [push]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Compile
run: ./mvnw -B verify
""");

assertThat(jobs)
.describedAs("only two-space keys are jobs; deeper keys belong to the job above")
.containsOnlyKeys("build-and-test");
}

@Test
void theBlockOfAJobStopsAtTheNextJob() {
Map<String, String> jobs = CiGateCoverageGuardTest.jobBlocks("""
on: [push]

jobs:
first:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
second:
runs-on: ubuntu-latest
""");

assertThat(jobs.get("second"))
.describedAs("a job must not inherit the previous job's condition, or the guard "
+ "excuses it from the gate for a reason that belongs to its neighbour")
.doesNotContain("schedule");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,24 @@ class CiGateCoverageGuardTest {
/** The aggregate check. It cannot depend on itself. */
private static final String GATE = "ci-gate";

/** A job key: two-space indent under {@code jobs:}, nothing else on the line. */
private static final Pattern JOB_KEY = Pattern.compile("(?m)^ ([a-z][a-z0-9-]*):$");
/**
* A job key: two-space indent under {@code jobs:}, then nothing that changes
* what the key is — trailing spaces or a {@code #} comment are allowed.
*
* <p>The name is matched structurally rather than against GitHub's identifier
* grammar. A character class admitting only what today's job names happen to
* use — lower case and hyphens — drops a job called {@code build_and_test} or
* {@code CodeQL} on the floor, and requiring the line to end at the colon drops
* {@code security_scan: # nightly}. Either way the guard reports on the jobs it
* parsed, and one it never saw is one it cannot report missing from the gate:
* the job is absent from {@code needs}, the test is green, and the aggregate
* check is blind to it. That is the silence this guard exists to break, so the
* pattern takes every key the YAML puts at that level and lets the assertions
* decide. {@link #jobBlocks(String)} is package-private for exactly this
* reason — {@code CiGateCoverageGuardParsingTest} feeds it the shapes the real
* workflow does not currently contain.</p>
*/
private static final Pattern JOB_KEY = Pattern.compile("(?m)^ ([^\\s:#]+):[ \\t]*(?:#.*)?$");

/** A job-level {@code if:} — four-space indent, first line only. */
private static final Pattern JOB_IF = Pattern.compile("(?m)^ if: (.*)$");
Expand Down Expand Up @@ -104,12 +120,28 @@ void ciGateDoesNotDependOnAJobThatIsGone() throws IOException {
.containsAll(aggregated);
}

/** Job id to the source block that declares it, in workflow order. */
/** Job id to the source block that declares it, for the workflow on disk. */
private static Map<String, String> jobBlocks() throws IOException {
return jobBlocks(Files.readString(WORKFLOW));
}

/**
* Job id to the source block that declares it, in workflow order.
*
* <p>Package-private, and taking the workflow as text rather than reading it,
* so the parser can be driven with job shapes the repository's own workflow
* does not happen to contain. A parser that only ever sees valid input it
* already handles is one whose blind spots stay theoretical until a new job
* lands on one.</p>
*
* @param workflowText the workflow source
* @return each job id mapped to the block that declares it
*/
static Map<String, String> jobBlocks(String workflowText) {
// The workflow is checked out with CRLF on Windows. Normalise once, so the
// line-anchored patterns below capture ids and conditions without a trailing
// carriage return riding along into every comparison.
String workflow = Files.readString(WORKFLOW).replace("\r\n", "\n");
String workflow = workflowText.replace("\r\n", "\n");
// `on:` carries keys at the same indentation as a job (`push:`, `schedule:`),
// so parsing starts after the `jobs:` key rather than at the top of the file.
int jobsAt = workflow.indexOf("\njobs:\n");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.demcha.documentation;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

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

/**
* Every recipe page is reachable from the catalogue that indexes them.
*
* <p>{@code docs/recipes.md} is the one page the README and the documentation
* index point at, so a recipe missing from it is a recipe with no inbound link:
* it renders, it is correct, and nobody arrives at it. Adding a page under
* {@code docs/recipes/} disturbs nothing when the catalogue is left alone —
* every gate stays green and the omission surfaces only when someone goes
* looking for a topic they were sure had been written up.</p>
*
* <p>The check runs the other way too. A catalogue row pointing at a file that
* was renamed or removed is a dead link on the most-linked documentation page,
* and nothing else in the build reads these paths.</p>
*/
class RecipeCatalogueGuardTest {

private static final Path RECIPES_DIR = RepoRoot.get().resolve("docs/recipes");
private static final Path CATALOGUE = RepoRoot.get().resolve("docs/recipes.md");

/**
* {@code README.md} is what GitHub renders when the directory is opened. It
* points at the catalogue rather than being listed in it.
*/
private static final String FOLDER_INDEX = "README.md";

/**
* A catalogue link, as written: {@code (recipes/<name>.md)}, optionally with an
* anchor.
*
* <p>The file name is anything a path segment can hold, not the lower-case-and-
* hyphens the current pages happen to use. Baking today's spelling in would make
* a correctly linked {@code pdf_export.md} report as unlisted — a false alarm
* rather than a missed page, but a guard that cries wolf over a legal file name
* is one the next person edits until it stops complaining.</p>
*/
private static final Pattern LINK = Pattern.compile("\\(recipes/([^/#)\\s]+\\.md)(?:#[^)]*)?\\)");

@Test
void everyRecipePageIsListedInTheCatalogue() throws IOException {
Set<String> onDisk = recipeFiles();
Set<String> linked = cataloguedFiles();

TreeSet<String> unlisted = new TreeSet<>(onDisk);
unlisted.removeAll(linked);

assertThat(unlisted)
.describedAs("recipe pages under docs/recipes/ that docs/recipes.md never links — "
+ "an unlinked page is one readers cannot arrive at")
.isEmpty();
}

@Test
void theCatalogueLinksNoRecipeThatIsGone() throws IOException {
Set<String> onDisk = recipeFiles();
Set<String> linked = cataloguedFiles();

TreeSet<String> dangling = new TreeSet<>(linked);
dangling.removeAll(onDisk);

assertThat(dangling)
.describedAs("docs/recipes.md links files that no longer exist under docs/recipes/")
.isEmpty();
}

private static Set<String> recipeFiles() throws IOException {
try (Stream<Path> files = Files.list(RECIPES_DIR)) {
Set<String> names = files
.map(p -> p.getFileName().toString())
.filter(name -> name.endsWith(".md"))
.filter(name -> !FOLDER_INDEX.equals(name))
.collect(java.util.stream.Collectors.toCollection(TreeSet::new));

assertThat(names)
.describedAs("no recipe pages found under %s — the guard is reading a folder "
+ "that moved and would cover nothing", RECIPES_DIR)
.isNotEmpty();
return names;
}
}

private static Set<String> cataloguedFiles() throws IOException {
assertThat(CATALOGUE)
.describedAs("the recipe catalogue moved; this guard no longer reads the page it protects")
.exists();

List<String> lines = Files.readAllLines(CATALOGUE, StandardCharsets.UTF_8);
TreeSet<String> linked = new TreeSet<>();
for (String line : lines) {
Matcher link = LINK.matcher(line);
while (link.find()) {
linked.add(link.group(1));
}
}
return linked;
}
}
Loading