From 98a2acf7c338e3a46ad0c99029818506eafc0393 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 1 Aug 2026 08:43:32 +0100
Subject: [PATCH 1/3] docs: stop offering an examples-local type, and drop the
counts that keep rotting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The example catalogue introduced the cover letter as a `BusinessTheme.modern()`
document. That record lives in the examples module and ships in no artifact, so a
reader who adds the dependency and reaches for it finds nothing to import. The
rows and prose now describe what the examples demonstrate; the advanced section
that quotes the helper says it is examples-local and names `BrandTheme` as the
shipping equivalent.
ExamplesLocalTypeGuardTest bans the factory-call form on any README and leaves
the fence alone — a fence quotes an example's own source, and editing the type
out of a quotation would make it false.
Four counts are removed rather than corrected: the catalogue was "~53 PDFs" with
a "curated 39-PDF subset" where it generates 91 and commits 62, the banner
caption stated a line count for the example it links, and it named the module
graph by a version the image no longer shows. Each had been reconciled before.
The commit-message examples pointed at the 1.5 line while the pull-request
template requires a conventional subject and 26 of the last 30 commits are one.
---
CHANGELOG.md | 19 ++++
CONTRIBUTING.md | 2 +-
README.md | 4 +-
examples/README.md | 25 +++--
.../ExamplesLocalTypeGuardTest.java | 104 ++++++++++++++++++
5 files changed, 141 insertions(+), 13 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f5090f7..5eaa1472 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -118,6 +118,25 @@ follow semantic versioning; release dates are ISO 8601.
### Documentation
+- **The example catalogue stops offering a type that ships nowhere.** Its entry-point
+ table introduced the cover letter as a `BusinessTheme.modern()` document, and
+ `BusinessTheme` is a record local to the examples module — a reader adding the
+ dependency and reaching for it finds nothing to import. The rows and prose now say
+ what the examples demonstrate, the advanced section that quotes the helper says
+ plainly that it is examples-local and names `BrandTheme` as the shipping equivalent,
+ and a guard rejects the factory-call form on any README while leaving the quotation
+ of an example's own source alone.
+- **The last hardcoded counts are gone.** The catalogue was described as "~53 PDFs"
+ with a "curated 39-PDF subset"; it generates 91 and commits 62, and 39 is how many
+ are *not* committed. The banner caption stated a line count for the example it links,
+ and named the module graph by a version the image itself no longer shows. All four
+ numbers are removed rather than corrected — each had been reconciled before and
+ drifted again within two months.
+- **The contributing guide's commit examples match the convention it asks for.** It
+ pointed at two subjects from the 1.5 line as the shape to copy, while 26 of the last
+ 30 commits are Conventional Commits and the pull-request template requires that shape
+ in a title. The four that are not are merges and the release commit — machine-made,
+ never a contributor's.
- **Documented headings render bold, and say why.** Six snippets across the
getting-started guide, the root README and the theme, timeline, rich-text and
preset-authoring recipes set `fontName(FontName.HELVETICA_BOLD)` without a
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3372405e..f47981ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -59,7 +59,7 @@ Almost all work targets **`develop`**, the ongoing 2.x line. The `1.x` branch ta
git checkout -b feature/short-description
```
Use `feature/...` for new functionality, `fix/...` for bug fixes, and `docs/...` for documentation-only changes. Issue-prefixed names (`42/fix/short-description`) are also welcome — convenient when the branch closes a specific issue.
-3. **Commit small, focused changes.** Each commit message should describe the *why*, not just the *what*. Recent commits on `develop` (`Prepare v1.5.0 release`, `Align public docs with the canonical surface`) are reasonable length and structure templates.
+3. **Commit small, focused changes.** Subjects follow Conventional Commits — `type(scope): the concrete effect` — the same shape the [pull request template](.github/pull_request_template.md) asks of a PR title. Say the *why*, not only the *what*. `git log --oneline -20` on `develop` is the reference; `fix(font): the donut-centre KPI renders the weight it declares` and `ci(guards): run every guard the job names, and compile docs-only pull requests` are the shape.
4. **Run the validation gate locally** before opening a PR:
```bash
./mvnw -B -ntp clean verify
diff --git a/README.md b/README.md
index 1d28f0df..7f49c0fc 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@
- ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the 2.0 module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing.
+ ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing.
## One source → a PDF and an editable PowerPoint deck
@@ -77,7 +77,7 @@ try (DocumentSession doc = GraphCompose.document(Path.of("twin-output.pdf"))
- ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, ~370 lines, page included).
+ ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, one page, source included).
## Why GraphCompose
diff --git a/examples/README.md b/examples/README.md
index b1597033..534fc735 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -34,10 +34,10 @@ Generated PDFs land in `examples/target/generated-pdfs/`. The same
`GenerateAllExamples` renders the whole catalogue in one pass — the CV and
cover-letter presets plus invoices, proposals, a schedule, the feature
-demos, and the flagships. The showcase site surfaces the full generated
-catalogue (~53 PDFs); a curated 39-PDF subset is committed under
-[`assets/readme/examples/`](../assets/readme/examples/) for the previews
-linked below.
+demos, and the flagships. The showcase site publishes the whole generated
+catalogue; a curated subset is committed under
+[`assets/readme/examples/`](../assets/readme/examples/) so the previews
+linked below open straight from GitHub.
## Gallery — pick by your goal
@@ -58,7 +58,7 @@ are with the canonical DSL, then jump to its detailed section below.
|---|---|---|
| [CV — single template](#cv-single-template) | One CV via `ModernProfessional.create()` on a `CvDocument` | [PDF](../assets/readme/examples/cv-modern-professional.pdf) · [Source](src/main/java/com/demcha/examples/templates/cv/CvFileExample.java) |
| [Invoice — cinematic V2](#invoice-cinematic-v2) | `ModernInvoice + BrandTheme.invoiceModern()` — the recommended invoice path | [PDF](../assets/readme/examples/invoice-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/invoice/InvoiceCinematicFileExample.java) |
-| [Cover Letter](#cover-letter) | One-page `BusinessTheme.modern()` cover letter with section presets | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
+| [Cover Letter](#cover-letter) | One-page cover letter composed in the canonical DSL, section presets carrying the hierarchy | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
| [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) |
| **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) |
| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx(Path)` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) |
@@ -138,10 +138,11 @@ are with the canonical DSL, then jump to its detailed section below.
### Cover letter
-A one-page modern cover letter — `BusinessTheme.modern()` drives every
-colour and font choice; section presets (`softPanel`, `accentLeft`,
-`accentTop`) carry the visual hierarchy; opening rich-text strip
-highlights the candidate's headline.
+A one-page modern cover letter composed straight in the canonical DSL.
+Section presets (`softPanel`, `accentLeft`, `accentTop`) carry the visual
+hierarchy and an opening rich-text strip highlights the candidate's
+headline; the palette and fonts come from a theme record the example
+declares for itself.
```java
@@ -1130,11 +1131,15 @@ The same composition also emits an editable PowerPoint deck (`BusinessReportPptx
### Master showcase
Fictional "Q2 sample report" combining the canonical surface
-end-to-end: `BusinessTheme` + page background + hero with rotated
+end-to-end: a theme record + page background + hero with rotated
shape container + branded QR + executive summary + zebra-striped
totals table + accent-bordered highlight cards + Code 128 footer
barcode. Reference it when composing your own multi-page documents.
+The `BusinessTheme` record it uses is a helper local to this module, not
+library API — the shipping equivalent is `BrandTheme` and the layered
+presets. Declare your own record the same way, or start from `BrandTheme`.
+
The same composition also emits an editable PowerPoint deck (`MasterShowcasePptxExample` — each page becomes one slide; rich text, the advanced table, and chrome stay native shapes, and only the rotated clip-masked seal and the barcodes rasterise).
[📄 View PDF](../assets/readme/examples/master-showcase.pdf) ·
diff --git a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
new file mode 100644
index 00000000..2beb745d
--- /dev/null
+++ b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
@@ -0,0 +1,104 @@
+package com.demcha.documentation;
+
+import com.demcha.compose.qa.RepoPaths;
+
+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 static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Keeps a helper that lives only in the examples module out of the reader's path.
+ *
+ * {@code BusinessTheme} is declared in {@code com.demcha.examples.support.theme} and
+ * ships in none of the published artifacts. Twenty-one examples use it, and quoting it
+ * while explaining those examples is honest — a reader who opens the source will find
+ * it. Writing {@code BusinessTheme.modern()} in an entry-point table is not: the call
+ * form reads as the way to theme a document, and the reader discovers otherwise only
+ * after adding the dependency and failing to import it. That is how the cover-letter
+ * row presented an examples-local record as the theming API.
+ *
+ * So the rule is about the form, not the name: no factory call on a type that ships
+ * nowhere, in prose, on any README. Inside a {@code java} fence the same call is fine —
+ * the fence quotes an example's own source, and stripping the type from it would make
+ * the quotation false.
+ */
+class ExamplesLocalTypeGuardTest {
+
+ private static final Path PROJECT_ROOT = RepoPaths.repoRoot();
+
+ /**
+ * Types a reader cannot import from any published artifact. Shared in spirit with
+ * {@code CanonicalSurfaceGuardTest.FORBIDDEN_IN_API_GUIDANCE}, which bans the name
+ * outright on the pages that teach the API; here only the call form is banned,
+ * because these pages also describe the examples that legitimately use it.
+ */
+ private static final List EXAMPLES_LOCAL_TYPES = List.of("BusinessTheme");
+
+ @Test
+ void noReadmeOffersAFactoryCallOnATypeThatShipsNowhere() throws IOException {
+ List readmes = PublishedDocs.readmes(PROJECT_ROOT);
+
+ assertThat(readmes)
+ .describedAs("no README was resolved — the scan moved and this guard covers nothing")
+ .isNotEmpty();
+
+ Set violations = new TreeSet<>();
+ for (Path readme : readmes) {
+ String prose = withoutFencedBlocks(Files.readAllLines(readme, StandardCharsets.UTF_8));
+ for (String type : EXAMPLES_LOCAL_TYPES) {
+ if (prose.contains(type + ".")) {
+ violations.add(PROJECT_ROOT.relativize(readme).toString().replace('\\', '/')
+ + " offers " + type + ".…()");
+ }
+ }
+ }
+
+ assertThat(violations)
+ .describedAs("a factory call on a type that ships in no artifact reads as the "
+ + "supported way to do something; the reader finds out it is not only "
+ + "after adding the dependency. Describe what the example demonstrates, "
+ + "or name the shipping equivalent")
+ .isEmpty();
+ }
+
+ /**
+ * Guard-the-guard: the scan must actually see prose. A fence-stripper that swallowed
+ * the whole file would leave every assertion above trivially satisfied.
+ */
+ @Test
+ void theScanReadsProseAndNotOnlyFences() throws IOException {
+ int prose = 0;
+ for (Path readme : PublishedDocs.readmes(PROJECT_ROOT)) {
+ prose += withoutFencedBlocks(Files.readAllLines(readme, StandardCharsets.UTF_8)).length();
+ }
+
+ assertThat(prose)
+ .describedAs("stripping the fenced blocks left almost nothing to scan — the "
+ + "fence detection is swallowing prose and the guard is passing vacuously")
+ .isGreaterThan(20_000);
+ }
+
+ /** The document with every fenced block removed, so only prose is searched. */
+ private static String withoutFencedBlocks(List lines) {
+ StringBuilder prose = new StringBuilder();
+ boolean inFence = false;
+ for (String line : lines) {
+ if (line.trim().startsWith("```")) {
+ inFence = !inFence;
+ continue;
+ }
+ if (!inFence) {
+ prose.append(line).append('\n');
+ }
+ }
+ return prose.toString();
+ }
+}
From 9dff48f6debe64e52c6f269d053afb697fada327 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 1 Aug 2026 09:15:29 +0100
Subject: [PATCH 2/3] docs: say what the cover letter actually uses, and police
the form that misled
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The replacement sentence was as untrue as the one it replaced: it said the
palette and fonts come from a record the example declares, and the example
imports the shared examples-module helper and calls it. The section now says
the colours come from a helper local to this module and names BrandTheme as
the shipping equivalent.
Three defects in the rule:
- it matched any dot after the type name, so a markdown link to the type's own
source would have failed the build citing a call that was never written. It
now matches a call.
- the bare name in a table row passed, which is the form the defect was
actually written in. A row naming the type is now rejected too; elsewhere in
prose the bare name stays legal, because these pages describe those examples.
- one of the package's five types was listed. The set is read from the source
tree, so the next helper is covered without anyone remembering this file.
The guard-the-guard compared prose against a fixed floor that the fenced
content clears on its own — an inverted split would have passed it. It compares
the two halves instead.
---
CHANGELOG.md | 12 +-
examples/README.md | 4 +-
.../ExamplesLocalTypeGuardTest.java | 160 +++++++++++++-----
3 files changed, 122 insertions(+), 54 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5eaa1472..2c7e9af6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -127,11 +127,13 @@ follow semantic versioning; release dates are ISO 8601.
and a guard rejects the factory-call form on any README while leaving the quotation
of an example's own source alone.
- **The last hardcoded counts are gone.** The catalogue was described as "~53 PDFs"
- with a "curated 39-PDF subset"; it generates 91 and commits 62, and 39 is how many
- are *not* committed. The banner caption stated a line count for the example it links,
- and named the module graph by a version the image itself no longer shows. All four
- numbers are removed rather than corrected — each had been reconciled before and
- drifted again within two months.
+ with a "curated 39-PDF subset". It generates 91; 62 are committed as previews, and 39
+ of the generated ones are not — the two figures do not subtract, because ten committed
+ previews come from examples the runner no longer produces. Both had been reconciled by
+ hand in June and were wrong again by August. The banner caption stated a line count for
+ the example it links, and named the module graph by a version the image itself no
+ longer shows; those two were written once and were simply never revisited. All four are
+ removed rather than corrected.
- **The contributing guide's commit examples match the convention it asks for.** It
pointed at two subjects from the 1.5 line as the shape to copy, while 26 of the last
30 commits are Conventional Commits and the pull-request template requires that shape
diff --git a/examples/README.md b/examples/README.md
index 534fc735..128c01f1 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -141,8 +141,8 @@ are with the canonical DSL, then jump to its detailed section below.
A one-page modern cover letter composed straight in the canonical DSL.
Section presets (`softPanel`, `accentLeft`, `accentTop`) carry the visual
hierarchy and an opening rich-text strip highlights the candidate's
-headline; the palette and fonts come from a theme record the example
-declares for itself.
+headline. Its colours come from a theme helper local to this module, not
+from library API — the shipping equivalent is `BrandTheme`.
```java
diff --git a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
index 2beb745d..09591928 100644
--- a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
+++ b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
@@ -8,97 +8,163 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Keeps a helper that lives only in the examples module out of the reader's path.
*
- * {@code BusinessTheme} is declared in {@code com.demcha.examples.support.theme} and
- * ships in none of the published artifacts. Twenty-one examples use it, and quoting it
- * while explaining those examples is honest — a reader who opens the source will find
- * it. Writing {@code BusinessTheme.modern()} in an entry-point table is not: the call
- * form reads as the way to theme a document, and the reader discovers otherwise only
- * after adding the dependency and failing to import it. That is how the cover-letter
- * row presented an examples-local record as the theming API.
+ * {@code BusinessTheme} and its neighbours are declared in
+ * {@code com.demcha.examples.support.theme} and ship in none of the published artifacts.
+ * Eighteen examples use them, and quoting one while explaining those examples is honest —
+ * a reader who opens the source will find it. Writing {@code BusinessTheme.modern()} in
+ * an entry-point table is not: the call form reads as the way to theme a document, and
+ * the reader discovers otherwise only after adding the dependency and failing to import
+ * it. That is how the cover-letter row presented an examples-local record as the theming
+ * API.
*
- * So the rule is about the form, not the name: no factory call on a type that ships
- * nowhere, in prose, on any README. Inside a {@code java} fence the same call is fine —
- * the fence quotes an example's own source, and stripping the type from it would make
- * the quotation false.
+ * Two forms are rejected, for different reasons. A call anywhere in prose
+ * offers the type as something to invoke. The bare name in a table row offers it
+ * as what that row's document is made of — the same claim in fewer words, and exactly how
+ * the defect was written. Everywhere else in prose the bare name is allowed, because
+ * these pages have to be able to describe the examples they document.
+ *
+ * Inside a {@code java} fence both forms are fine: a fence quotes an example's own
+ * source, and editing the type out of a quotation would make the quotation false.
*/
class ExamplesLocalTypeGuardTest {
private static final Path PROJECT_ROOT = RepoPaths.repoRoot();
/**
- * Types a reader cannot import from any published artifact. Shared in spirit with
- * {@code CanonicalSurfaceGuardTest.FORBIDDEN_IN_API_GUIDANCE}, which bans the name
- * outright on the pages that teach the API; here only the call form is banned,
- * because these pages also describe the examples that legitimately use it.
+ * The helper package whose types a reader cannot import. Read from the source tree
+ * rather than listed: it holds five public types today, two of them with static
+ * factories, and a list would have covered whichever one was noticed first.
*/
- private static final List EXAMPLES_LOCAL_TYPES = List.of("BusinessTheme");
+ private static final Path EXAMPLES_LOCAL_PACKAGE =
+ PROJECT_ROOT.resolve("examples/src/main/java/com/demcha/examples/support/theme");
@Test
- void noReadmeOffersAFactoryCallOnATypeThatShipsNowhere() throws IOException {
- List readmes = PublishedDocs.readmes(PROJECT_ROOT);
-
- assertThat(readmes)
- .describedAs("no README was resolved — the scan moved and this guard covers nothing")
- .isNotEmpty();
-
+ void noReadmeOffersATypeThatShipsNowhereAsTheWayToDoSomething() throws IOException {
+ List types = examplesLocalTypes();
Set violations = new TreeSet<>();
- for (Path readme : readmes) {
- String prose = withoutFencedBlocks(Files.readAllLines(readme, StandardCharsets.UTF_8));
- for (String type : EXAMPLES_LOCAL_TYPES) {
- if (prose.contains(type + ".")) {
- violations.add(PROJECT_ROOT.relativize(readme).toString().replace('\\', '/')
- + " offers " + type + ".…()");
+
+ for (Path readme : PublishedDocs.readmes(PROJECT_ROOT)) {
+ List prose = prose(Files.readAllLines(readme, StandardCharsets.UTF_8));
+ String rel = relative(readme);
+ for (String type : types) {
+ Pattern call = Pattern.compile("\\b" + Pattern.quote(type) + "\\s*\\.\\s*\\w+\\s*\\(");
+ Pattern bareName = Pattern.compile("\\b" + Pattern.quote(type) + "\\b");
+ for (String line : prose) {
+ if (call.matcher(line).find()) {
+ violations.add(rel + " calls " + type + " in prose");
+ } else if (line.stripLeading().startsWith("|") && bareName.matcher(line).find()) {
+ violations.add(rel + " names " + type + " in a table row");
+ }
}
}
}
assertThat(violations)
- .describedAs("a factory call on a type that ships in no artifact reads as the "
- + "supported way to do something; the reader finds out it is not only "
- + "after adding the dependency. Describe what the example demonstrates, "
- + "or name the shipping equivalent")
+ .describedAs("a type that ships in no artifact — offered as something to call, or "
+ + "as what a row's document is made of — reads as the supported way to do "
+ + "it, and the reader finds out otherwise only after adding the dependency. "
+ + "Describe what the example demonstrates, or name the shipping equivalent")
.isEmpty();
}
/**
- * Guard-the-guard: the scan must actually see prose. A fence-stripper that swallowed
- * the whole file would leave every assertion above trivially satisfied.
+ * Guard-the-guard: prose is what gets scanned, and it is the larger half.
+ *
+ * Comparing the two halves rather than checking a threshold is deliberate. A fixed
+ * floor is satisfied by the fenced content on its own — some 23k characters of it
+ * across these READMEs — so a stripper that kept the fences and dropped the prose
+ * would clear any floor low enough to be safe, and the rule would then be policing
+ * the one place it exists to leave alone.
*/
@Test
- void theScanReadsProseAndNotOnlyFences() throws IOException {
+ void theScanReadsProseAndNotFences() throws IOException {
int prose = 0;
+ int fenced = 0;
for (Path readme : PublishedDocs.readmes(PROJECT_ROOT)) {
- prose += withoutFencedBlocks(Files.readAllLines(readme, StandardCharsets.UTF_8)).length();
+ List lines = Files.readAllLines(readme, StandardCharsets.UTF_8);
+ prose += length(prose(lines));
+ fenced += length(fenced(lines));
}
+ assertThat(fenced)
+ .describedAs("no fenced content found across the READMEs — fence detection is not "
+ + "detecting fences, nothing is being exempted, and the comparison below "
+ + "proves nothing")
+ .isPositive();
assertThat(prose)
- .describedAs("stripping the fenced blocks left almost nothing to scan — the "
- + "fence detection is swallowing prose and the guard is passing vacuously")
- .isGreaterThan(20_000);
+ .describedAs("the scanned half must be the prose, which outweighs the code on "
+ + "these pages by roughly five to one. A smaller number here means the "
+ + "split is inverted: the rule would be reading the quotations it exists "
+ + "to permit and skipping the sentences it exists to police")
+ .isGreaterThan(fenced);
+ }
+
+ @Test
+ void theHelperPackageIsWhereTheGuardThinksItIs() throws IOException {
+ assertThat(examplesLocalTypes())
+ .describedAs("no types found in %s — the examples-local helpers moved and this "
+ + "guard is checking the READMEs against an empty list",
+ relative(EXAMPLES_LOCAL_PACKAGE))
+ .isNotEmpty()
+ .contains("BusinessTheme");
+ }
+
+ /** The types declared in the examples module's theme helper package. */
+ private static List examplesLocalTypes() throws IOException {
+ if (!Files.isDirectory(EXAMPLES_LOCAL_PACKAGE)) {
+ return List.of();
+ }
+ try (Stream files = Files.list(EXAMPLES_LOCAL_PACKAGE)) {
+ return files.map(path -> path.getFileName().toString())
+ .filter(name -> name.endsWith(".java"))
+ .map(name -> name.substring(0, name.length() - ".java".length()))
+ .filter(name -> !name.equals("package-info"))
+ .sorted()
+ .toList();
+ }
+ }
+
+ private static List prose(List lines) {
+ return partition(lines, false);
+ }
+
+ private static List fenced(List lines) {
+ return partition(lines, true);
}
- /** The document with every fenced block removed, so only prose is searched. */
- private static String withoutFencedBlocks(List lines) {
- StringBuilder prose = new StringBuilder();
+ /** The lines inside fenced blocks, or the lines outside them. Fence markers are neither. */
+ private static List partition(List lines, boolean wantFenced) {
+ List kept = new ArrayList<>();
boolean inFence = false;
for (String line : lines) {
- if (line.trim().startsWith("```")) {
+ if (line.stripLeading().startsWith("```")) {
inFence = !inFence;
continue;
}
- if (!inFence) {
- prose.append(line).append('\n');
+ if (inFence == wantFenced) {
+ kept.add(line);
}
}
- return prose.toString();
+ return kept;
+ }
+
+ private static int length(List lines) {
+ return lines.stream().mapToInt(line -> line.length() + 1).sum();
+ }
+
+ private static String relative(Path path) {
+ return PROJECT_ROOT.relativize(path).toString().replace('\\', '/');
}
}
From ceb79000cfb010103511f84c1460d2a8bf932b30 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 1 Aug 2026 09:48:23 +0100
Subject: [PATCH 3/3] docs: scan every published page, and every code block
that is not a quotation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rule read one README per module and skipped every fenced block, which left
the two forms most likely to mislead outside it: a nested page under docs/, and
a tutorial snippet — the thing a reader copies rather than reads.
- The scan covers the published documentation, historical records aside: pages
under docs/adr, docs/archive, docs/migration, docs/roadmaps and the v1-classic
templates exist to record what a past release contained, and the decision guide
between the removed and layered surfaces names the old one beside its
replacement on purpose.
- Code blocks are scanned. The one exemption is a fence introduced by a
doc-example-ignore marker, which already carries a written reason and is how a
page quotes an example's own source verbatim. A test asserts that path is still
exercised, so the escape hatch cannot quietly become dead code.
- Construction, method references and member access join the call form. Three
unguarded ways to make the same offer is not a rule.
- An unclosed fence is caught per page. It exempts everything below it, and the
totals cannot see one file going quiet.
The entry describing the removed counts had itself acquired five of them, in a
bullet titled "the last hardcoded counts are gone"; every figure that can drift
before the release is out.
---
CHANGELOG.md | 22 +-
.../ExamplesLocalTypeGuardTest.java | 210 ++++++++++++------
2 files changed, 150 insertions(+), 82 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c7e9af6..ec0c6471 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -126,19 +126,17 @@ follow semantic versioning; release dates are ISO 8601.
plainly that it is examples-local and names `BrandTheme` as the shipping equivalent,
and a guard rejects the factory-call form on any README while leaving the quotation
of an example's own source alone.
-- **The last hardcoded counts are gone.** The catalogue was described as "~53 PDFs"
- with a "curated 39-PDF subset". It generates 91; 62 are committed as previews, and 39
- of the generated ones are not — the two figures do not subtract, because ten committed
- previews come from examples the runner no longer produces. Both had been reconciled by
- hand in June and were wrong again by August. The banner caption stated a line count for
- the example it links, and named the module graph by a version the image itself no
- longer shows; those two were written once and were simply never revisited. All four are
- removed rather than corrected.
+- **The last hardcoded counts are gone.** The example catalogue named a number of
+ generated documents and a number of committed previews; both sat well below the real
+ inventory, having been reconciled by hand once already and drifted again since. The
+ banner caption stated a line count for the example it links, and named the module graph
+ by a version the image itself no longer shows. All four are removed rather than
+ corrected — a count in prose has no owner and nothing to keep it true, so correcting
+ one only resets the clock.
- **The contributing guide's commit examples match the convention it asks for.** It
- pointed at two subjects from the 1.5 line as the shape to copy, while 26 of the last
- 30 commits are Conventional Commits and the pull-request template requires that shape
- in a title. The four that are not are merges and the release commit — machine-made,
- never a contributor's.
+ pointed at two subjects from the 1.5 line as the shape to copy, while the repository
+ has moved to Conventional Commits and the pull-request template requires that shape in
+ a title. The examples are two real recent subjects instead.
- **Documented headings render bold, and say why.** Six snippets across the
getting-started guide, the root README and the theme, timeline, rich-text and
preset-authoring recipes set `fontName(FontName.HELVETICA_BOLD)` without a
diff --git a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
index 09591928..13a2455f 100644
--- a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
+++ b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
@@ -18,25 +18,27 @@
import static org.assertj.core.api.Assertions.assertThat;
/**
- * Keeps a helper that lives only in the examples module out of the reader's path.
+ * Keeps helpers that live only in the examples module out of the reader's path.
*
* {@code BusinessTheme} and its neighbours are declared in
* {@code com.demcha.examples.support.theme} and ship in none of the published artifacts.
- * Eighteen examples use them, and quoting one while explaining those examples is honest —
- * a reader who opens the source will find it. Writing {@code BusinessTheme.modern()} in
- * an entry-point table is not: the call form reads as the way to theme a document, and
- * the reader discovers otherwise only after adding the dependency and failing to import
- * it. That is how the cover-letter row presented an examples-local record as the theming
- * API.
+ * Explaining an example that uses one is honest — a reader who opens the source will find
+ * it. Writing {@code BusinessTheme.modern()} in an entry-point table is not: it reads as
+ * the way to theme a document, and the reader discovers otherwise only after adding the
+ * dependency and failing to import it. That is how the cover-letter row presented an
+ * examples-local record as the theming API.
*
- * Two forms are rejected, for different reasons. A call anywhere in prose
- * offers the type as something to invoke. The bare name in a table row offers it
- * as what that row's document is made of — the same claim in fewer words, and exactly how
- * the defect was written. Everywhere else in prose the bare name is allowed, because
- * these pages have to be able to describe the examples they document.
+ * What is rejected is using the type: a call, a constructor, a method
+ * reference, a member access. The bare name in prose stays legal, because these pages
+ * have to be able to name the thing they are describing — except in a table row, where a
+ * bare name is a claim about what that row's document is made of, which is the same
+ * offer in fewer words and is how the defect was written.
*
- * Inside a {@code java} fence both forms are fine: a fence quotes an example's own
- * source, and editing the type out of a quotation would make the quotation false.
+ * Code blocks are scanned like everything else. A fenced snippet is the most
+ * copy-pasted thing on the page, so exempting fences wholesale would leave the rule
+ * policing only the form nobody copies. The single exemption is a fence introduced by a
+ * {@code doc-example-ignore} marker, which already has to carry a written reason and is
+ * how a page quotes an example's own source verbatim.
*/
class ExamplesLocalTypeGuardTest {
@@ -44,26 +46,45 @@ class ExamplesLocalTypeGuardTest {
/**
* The helper package whose types a reader cannot import. Read from the source tree
- * rather than listed: it holds five public types today, two of them with static
- * factories, and a list would have covered whichever one was noticed first.
+ * rather than listed: it holds five public types today, and a list would have covered
+ * whichever one was noticed first.
*/
private static final Path EXAMPLES_LOCAL_PACKAGE =
PROJECT_ROOT.resolve("examples/src/main/java/com/demcha/examples/support/theme");
+ /** Pages whose subject is what a past release contained. Mirrors the canonical guard. */
+ private static final List HISTORICAL_RECORD_PREFIXES = List.of(
+ "CHANGELOG.md", "docs/adr/", "docs/archive/", "docs/migration/",
+ "docs/private/", "docs/roadmaps/", "docs/templates/v1-classic/");
+
+ /**
+ * The decision guide between the removed template surfaces and the layered ones. It
+ * names {@code BusinessTheme.X()} beside its replacement {@code BrandTheme.X()} on
+ * purpose — and the type it means is the canonical record 2.0 removed, which happens
+ * to share a name with the examples-local helper. Naming the old surface is the
+ * document's whole job; the canonical guard allows it here for the same reason.
+ */
+ private static final Set MIGRATION_INVENTORY =
+ Set.of("docs/templates/which-template-system.md");
+
+ /** A marker that exempts the fence below it, with a reason, on a published page. */
+ private static final Pattern QUOTED_SOURCE_MARKER =
+ Pattern.compile("^\\s*$");
+
@Test
- void noReadmeOffersATypeThatShipsNowhereAsTheWayToDoSomething() throws IOException {
+ void noPublishedPageOffersATypeThatShipsNowhere() throws IOException {
List types = examplesLocalTypes();
Set violations = new TreeSet<>();
- for (Path readme : PublishedDocs.readmes(PROJECT_ROOT)) {
- List prose = prose(Files.readAllLines(readme, StandardCharsets.UTF_8));
- String rel = relative(readme);
+ for (Path page : scannedPages()) {
+ Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8));
+ String rel = relative(page);
for (String type : types) {
- Pattern call = Pattern.compile("\\b" + Pattern.quote(type) + "\\s*\\.\\s*\\w+\\s*\\(");
+ Pattern used = usage(type);
Pattern bareName = Pattern.compile("\\b" + Pattern.quote(type) + "\\b");
- for (String line : prose) {
- if (call.matcher(line).find()) {
- violations.add(rel + " calls " + type + " in prose");
+ for (String line : scan.scanned()) {
+ if (used.matcher(line).find()) {
+ violations.add(rel + " uses " + type);
} else if (line.stripLeading().startsWith("|") && bareName.matcher(line).find()) {
violations.add(rel + " names " + type + " in a table row");
}
@@ -72,55 +93,102 @@ void noReadmeOffersATypeThatShipsNowhereAsTheWayToDoSomething() throws IOExcepti
}
assertThat(violations)
- .describedAs("a type that ships in no artifact — offered as something to call, or "
- + "as what a row's document is made of — reads as the supported way to do "
- + "it, and the reader finds out otherwise only after adding the dependency. "
- + "Describe what the example demonstrates, or name the shipping equivalent")
+ .describedAs("a type that ships in no artifact — called, constructed, referenced, "
+ + "or offered as what a row's document is made of — reads as the supported "
+ + "way to do it, and the reader finds out otherwise only after adding the "
+ + "dependency. Describe what the example demonstrates, or name the shipping "
+ + "equivalent. To quote an example's own source verbatim, introduce the "
+ + "fence with a doc-example-ignore marker saying so")
.isEmpty();
}
/**
- * Guard-the-guard: prose is what gets scanned, and it is the larger half.
+ * Every use of the type: a call, a constructor, a method reference, a member access.
+ *
+ * Restricting the rule to {@code Type.method(} would leave
+ * {@code new BusinessTheme(…)}, {@code BusinessTheme::modern} and
+ * {@code BusinessTheme.DEFAULT} as three unguarded ways to make the same offer. A
+ * member named {@code java} is the one exception — that is a link to the type's own
+ * source file, which is a reader following the trail, not an API being suggested.
+ */
+ private static Pattern usage(String type) {
+ String name = Pattern.quote(type);
+ return Pattern.compile("\\b" + name + "\\s*\\.\\s*(?!java\\b)\\w+"
+ + "|new\\s+" + name + "\\s*[(<]"
+ + "|\\b" + name + "\\s*::");
+ }
+
+ /**
+ * A fence left open swallows the rest of its page.
*
- * Comparing the two halves rather than checking a threshold is deliberate. A fixed
- * floor is satisfied by the fenced content on its own — some 23k characters of it
- * across these READMEs — so a stripper that kept the fences and dropped the prose
- * would clear any floor low enough to be safe, and the rule would then be policing
- * the one place it exists to leave alone.
+ * Checked per file, because the totals cannot see it: one unbalanced marker turns
+ * everything below it into an exempt block, and the repository's remaining pages keep
+ * every aggregate comfortably in range.
+ */
+ @Test
+ void noPageLeavesAFenceOpen() throws IOException {
+ Set unbalanced = new TreeSet<>();
+ int scanned = 0;
+ for (Path page : scannedPages()) {
+ Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8));
+ scanned += scan.scanned().size();
+ if (scan.fenceLeftOpen()) {
+ unbalanced.add(relative(page));
+ }
+ }
+
+ assertThat(unbalanced)
+ .describedAs("an unclosed code fence exempts every line after it, so the page "
+ + "stops being checked from that point on and nothing says so")
+ .isEmpty();
+ assertThat(scanned)
+ .describedAs("almost nothing was scanned — the page set or the fence handling "
+ + "moved and this guard is reading an empty corpus")
+ .isGreaterThan(1_000);
+ }
+
+ /**
+ * The exemption path is exercised. If no page quotes an example's own source any
+ * more, the fence handling is dead code and the next reader will not know it was ever
+ * needed — and a guard whose only escape hatch is untested tends to acquire a wider
+ * one the first time it fires inconveniently.
*/
@Test
- void theScanReadsProseAndNotFences() throws IOException {
- int prose = 0;
- int fenced = 0;
- for (Path readme : PublishedDocs.readmes(PROJECT_ROOT)) {
- List lines = Files.readAllLines(readme, StandardCharsets.UTF_8);
- prose += length(prose(lines));
- fenced += length(fenced(lines));
+ void quotingAnExamplesOwnSourceStaysPossible() throws IOException {
+ int exempt = 0;
+ for (Path page : scannedPages()) {
+ exempt += scan(Files.readAllLines(page, StandardCharsets.UTF_8)).exempted().size();
}
- assertThat(fenced)
- .describedAs("no fenced content found across the READMEs — fence detection is not "
- + "detecting fences, nothing is being exempted, and the comparison below "
- + "proves nothing")
+ assertThat(exempt)
+ .describedAs("no fence is exempted anywhere — either the marker mechanism changed "
+ + "or nothing quotes example source, and the exemption below is untested")
.isPositive();
- assertThat(prose)
- .describedAs("the scanned half must be the prose, which outweighs the code on "
- + "these pages by roughly five to one. A smaller number here means the "
- + "split is inverted: the rule would be reading the quotations it exists "
- + "to permit and skipping the sentences it exists to police")
- .isGreaterThan(fenced);
}
@Test
void theHelperPackageIsWhereTheGuardThinksItIs() throws IOException {
assertThat(examplesLocalTypes())
.describedAs("no types found in %s — the examples-local helpers moved and this "
- + "guard is checking the READMEs against an empty list",
+ + "guard is checking the documentation against an empty list",
relative(EXAMPLES_LOCAL_PACKAGE))
.isNotEmpty()
.contains("BusinessTheme");
}
+ /** The published pages this rule applies to. */
+ private static List scannedPages() throws IOException {
+ List pages = new ArrayList<>();
+ for (Path page : PublishedDocs.all(PROJECT_ROOT)) {
+ String rel = relative(page);
+ boolean historical = HISTORICAL_RECORD_PREFIXES.stream().anyMatch(rel::startsWith);
+ if (!historical && !MIGRATION_INVENTORY.contains(rel)) {
+ pages.add(page);
+ }
+ }
+ return pages;
+ }
+
/** The types declared in the examples module's theme helper package. */
private static List examplesLocalTypes() throws IOException {
if (!Files.isDirectory(EXAMPLES_LOCAL_PACKAGE)) {
@@ -136,32 +204,34 @@ private static List examplesLocalTypes() throws IOException {
}
}
- private static List prose(List lines) {
- return partition(lines, false);
- }
-
- private static List fenced(List lines) {
- return partition(lines, true);
+ /** What a page contributes: the lines the rule reads, the lines a marker excused, and balance. */
+ private record Scan(List scanned, List exempted, boolean fenceLeftOpen) {
}
- /** The lines inside fenced blocks, or the lines outside them. Fence markers are neither. */
- private static List partition(List lines, boolean wantFenced) {
- List kept = new ArrayList<>();
+ private static Scan scan(List lines) {
+ List scanned = new ArrayList<>();
+ List exempted = new ArrayList<>();
boolean inFence = false;
+ boolean fenceIsExempt = false;
+ String previous = "";
for (String line : lines) {
- if (line.stripLeading().startsWith("```")) {
- inFence = !inFence;
+ String trimmed = line.stripLeading();
+ if (trimmed.startsWith("```")) {
+ if (inFence) {
+ inFence = false;
+ fenceIsExempt = false;
+ } else {
+ inFence = true;
+ fenceIsExempt = QUOTED_SOURCE_MARKER.matcher(previous.strip()).matches();
+ }
continue;
}
- if (inFence == wantFenced) {
- kept.add(line);
+ (inFence && fenceIsExempt ? exempted : scanned).add(line);
+ if (!trimmed.isEmpty()) {
+ previous = line;
}
}
- return kept;
- }
-
- private static int length(List lines) {
- return lines.stream().mapToInt(line -> line.length() + 1).sum();
+ return new Scan(scanned, exempted, inFence);
}
private static String relative(Path path) {