Skip to content

Commit c2899fe

Browse files
authored
test(guards): fail the build on the documentation drift this line kept re-fixing (#478)
* test(guards): fail the build on the documentation drift this line kept re-fixing Four releases of hand-corrections shared one property: nothing would have caught any of them. The guards below close that, each one keyed to a defect that actually shipped. Link liveness reads what the documents point at, so it needs no token list and cannot go stale. It found nine dead source links in implementation-guide.md on its first run. Retired-surface scanning covers README, CONTRIBUTING, SECURITY, SUPPORT, ROADMAP, the examples gallery, docs/ and .github/ — the last four sat outside every existing guard, which is how an issue template could route reporters to a theme class removed in 2.0. The token list is what verification supports: seven types absent from every src/main tree. BusinessTheme and PptxSemanticBackend are deliberately absent from it — both are alive, in 21 and 2 source files, and forbidding them would fail the build on correct text. Historical records are skipped by path prefix rather than a file allowlist, so a new page under docs/archive/ is covered the day it lands. DocsBoldFaceGuardTest pins the font rule: a *_BOLD constant resolves to its base family and the face comes from the decoration, so naming the alias without one renders regular. Its self-check counts every font selection rather than every alias — counting aliases made a clean tree look like a broken scan, because the correct end state has none. The release-status assertion requires a published version and requires the link to point at the tag the text names, so a half-updated block cannot pass. implementation-guide.md loses the two sections built on the execution layer 2.0 removed, and the render-contract references that went with them: Render, RenderPassSession, RenderStream, TextComponent and BlockText are absent from every src/main tree. Deleting them makes the guide less wrong; describing the real pipeline in their place is still open. * docs: archive the engine guide that still teaches the removed execution layer Deleting the dead names from implementation-guide.md left the model behind. The document still walked a contributor through attaching components to an entity, adding a render marker and a container-growth marker, implementing Breakable, registering through build() and checking entity.hasRender() — forty lines of a pipeline 2.0 removed, and one CONTRIBUTING now contradicts point for point. Breakable, ParentComponent and hasRender are absent from every src/main tree. It is archived rather than rewritten. extension-guide.md already walks the live pipeline end to end — a semantic node, a fluent setter, a render handler, a whole backend, snapshot tests — and package-map.md covers the layout; inventing a replacement for an engine guide risks shipping something plausible and wrong, which is worse than pointing at the documents that are correct. The archived copy opens with what it describes, what removed it, and where to go instead. Breakable, ParentComponent and hasRender( join the retired-token list, so the model cannot return to a live document. Two guards were weaker than the defects they were written for. The retired-token scan could not catch BusinessTheme, because the name survives as an examples-local helper and a repository-wide existence check cannot tell "this type is gone" from "this type is an example's private business". The question that matters to a reader is whether a type ships in a published artifact, so API guidance — issue and pull-request templates, CONTRIBUTING, the template docs — is scanned against its own list. examples/README.md is deliberately outside it: the helper it documents is real. The font guard accepted an alias paired with any decoration, so HELVETICA_BOLD with decoration(ITALIC) passed while rendering neither bold nor what the constant claims. Published snippets now name the family outright; the rule has no exception to get subtly wrong, and the builder-chain analysis it needed is gone. The link check now reads .github too. The retired-token scan already covered it, while the issue templates carry the relative links most likely to break silently.
1 parent d615214 commit c2899fe

9 files changed

Lines changed: 431 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ follow semantic versioning; release dates are ISO 8601.
2222
- **`graph-compose-render-pptx` declares PDFBox.** It compiles against
2323
`org.apache.pdfbox` types while declaring only `fontbox`, taking the rest
2424
transitively; the resolved version is unchanged.
25+
- **The contributor guide stops teaching a removed architecture.** The engine
26+
implementation guide still walked a new object through attaching components to an
27+
entity, adding a render marker and a container-growth marker, and implementing
28+
`Breakable` — a model 2.0 removed, and one the current contributing guide
29+
contradicts. It is archived, with a banner naming what replaced it; the extension
30+
guide and the package map are now the route for anyone adding a node, a handler or
31+
a backend.
32+
- **Documentation drift fails the build.** Three checks now cover what four
33+
releases of hand-fixing kept re-breaking: a relative link in the public docs
34+
must resolve, a contributor-facing document must not name a type 2.0 removed,
35+
and a published snippet that names a `*_BOLD` font constant must pair it with a
36+
decoration — the constant resolves to its base family, so without the decoration
37+
the text renders regular. The README release-status block must name a published
38+
version and link the tag it names. `SECURITY.md`, `SUPPORT.md`, `ROADMAP.md` and
39+
`.github/` are scanned for the first time; historical records are skipped by path,
40+
so a new archived page is covered the day it lands.
2541
- **The release publishes the showcase it just built.** `cut-release.ps1` never
2642
ran `GenerateAllExamples`, so the site was synced from whatever happened to be
2743
in `examples/target/generated-pdfs` — nothing at all on a clean checkout, which

CONTRIBUTING.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ Read these files first:
1111
- [docs/architecture/package-map.md](./docs/architecture/package-map.md) — what lives in which package, and which of them are internal
1212
- [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) when you touch benchmark tooling, render hot paths, layout hot paths, or performance-facing docs
1313

14-
[docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md)
15-
goes deeper on the engine, but parts of it still describe the execution layer that
16-
2.0 removed — read `overview.md` and `package-map.md` first and treat the guide as
17-
background until it is rewritten.
14+
[docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md)
15+
walks the extension points end to end — a new semantic node, a fluent setter, a
16+
render handler, a whole backend — and is the guide to follow when adding one.
1817

1918
They explain the current public surface, the engine/template split, and the recommended extension points.
2019

@@ -320,7 +319,7 @@ For text-heavy primitives, also read:
320319

321320
- [TextMeasurementSystem.java](./core/src/main/java/com/demcha/compose/engine/measurement/TextMeasurementSystem.java)
322321
- [docs/architecture/overview.md](./docs/architecture/overview.md)
323-
- [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md)
322+
- [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md)
324323

325324
If the primitive should be available to application developers,
326325
expose it through `DocumentDsl` and a public `DocumentNode`, not a
@@ -364,7 +363,7 @@ If a change affects resolved geometry, pagination, or ordering, prefer adding or
364363
- Keep [README.md](./README.md) aligned with the tested examples.
365364
- Keep benchmark values clearly dated when they are refreshed.
366365
- Keep `assets/readme/*` screenshots consistent with the current render outputs.
367-
- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md) as part of the same change.
366+
- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md) as part of the same change.
368367
- If you change benchmark flow, benchmark artifact layout, or diff selection rules, update [README.md](./README.md) and [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) in the same change.
369368
- Visual PDF artifacts are grouped under `target/visual-tests/clean/*` and `target/visual-tests/guides/*` so guide-line renders are easy to find separately from clean outputs.
370369

core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import java.util.List;
99
import java.util.Set;
1010
import java.util.TreeSet;
11+
import java.util.regex.Matcher;
12+
import java.util.regex.Pattern;
1113
import java.util.function.Predicate;
1214
import java.util.stream.Collectors;
1315

@@ -26,6 +28,62 @@ class CanonicalSurfaceGuardTest {
2628
"ModuleYml",
2729
"ModuleSummary");
2830

31+
/**
32+
* Types the 2.0 line removed. Every one is absent from every {@code src/main}
33+
* tree, so a contributor-facing document naming one is teaching code that cannot
34+
* compile — which is how the template-authoring and engine-primitive sections of
35+
* CONTRIBUTING went stale for a full release cycle.
36+
*
37+
* <p>Deliberately absent: {@code BusinessTheme} survives as an examples-local
38+
* theme helper used by twenty-one example sources, and {@code PptxSemanticBackend}
39+
* still ships beside the fixed-layout PPTX backend. Forbidding either would fail
40+
* the build on text that is correct.</p>
41+
*/
42+
private static final List<String> RETIRED_IN_2_0_TOKENS = List.of(
43+
"InvoiceTemplateV2",
44+
"ProposalTemplateV2",
45+
"WeeklyScheduleTemplateV1",
46+
"EntityBounds",
47+
"ParentContainerUpdater",
48+
"ParentComponent",
49+
"Breakable",
50+
"hasRender(",
51+
"CvSpec",
52+
"CvBuilder");
53+
54+
/**
55+
* Types a contributor must not be pointed at when told how to build against the
56+
* library, <em>even though the name still resolves somewhere in this repository</em>.
57+
*
58+
* <p>{@code BusinessTheme} is the case that motivated the list: 2.0 removed it from
59+
* the published surface, but the examples keep a local helper of the same name, so a
60+
* repository-wide existence check cannot tell "this type is gone" from "this type is
61+
* an example's private business". The distinction that matters to a reader is
62+
* whether the type ships in a Maven artifact, and for API guidance it does not.</p>
63+
*
64+
* <p>Scanned over API guidance only — issue templates, the contributing guide, the
65+
* template docs — and deliberately not over {@code examples/README.md}, which
66+
* documents that local helper correctly.</p>
67+
*/
68+
private static final List<String> FORBIDDEN_IN_API_GUIDANCE = List.of(
69+
"BusinessTheme");
70+
71+
/**
72+
* Documents whose job is to record history. A migration guide, an ADR, an archived
73+
* page or the changelog names retired surface on purpose, so the retired-token scan
74+
* skips them by path rather than by a file list — a new historical page is then
75+
* covered the day it is added, instead of failing the build until someone
76+
* remembers to allowlist it.
77+
*/
78+
private static final List<String> HISTORICAL_RECORD_PREFIXES = List.of(
79+
"CHANGELOG.md",
80+
"docs/adr/",
81+
"docs/archive/",
82+
"docs/migration/",
83+
"docs/private/",
84+
"docs/roadmaps/",
85+
"docs/templates/v1-classic/");
86+
2987
private static final Set<String> MAIN_CANONICAL_SOURCE_ALLOWLIST = Set.of();
3088

3189
private static final Set<String> DOCUMENTATION_ALLOWLIST = Set.of(
@@ -117,6 +175,142 @@ void publicMarkdownDocsShouldAvoidLegacySurfaceOutsideHistoricalAuditNotes() thr
117175
PUBLIC_MARKDOWN_ALLOWLIST);
118176
}
119177

178+
/**
179+
* The contributor-facing surface must not teach types 2.0 removed.
180+
*
181+
* <p>Scans wider than the legacy check above: {@code SECURITY.md},
182+
* {@code SUPPORT.md}, {@code ROADMAP.md} and {@code .github/} sit outside every
183+
* existing guard, which is why an issue template could route reporters to a theme
184+
* class that no longer exists and a pull-request template could offer a lane the
185+
* repository dropped.</p>
186+
*/
187+
@Test
188+
void contributorFacingDocsShouldNotNameSurfaceRetiredIn2_0() throws IOException {
189+
List<Path> roots = List.of(
190+
PROJECT_ROOT.resolve("README.md"),
191+
PROJECT_ROOT.resolve("CONTRIBUTING.md"),
192+
PROJECT_ROOT.resolve("SECURITY.md"),
193+
PROJECT_ROOT.resolve("SUPPORT.md"),
194+
PROJECT_ROOT.resolve("ROADMAP.md"),
195+
PROJECT_ROOT.resolve("examples/README.md"),
196+
PROJECT_ROOT.resolve("docs"),
197+
PROJECT_ROOT.resolve(".github"));
198+
199+
Set<String> violations = new TreeSet<>();
200+
for (Path root : roots) {
201+
for (Path doc : markdownUnder(root)) {
202+
String rel = relative(doc);
203+
if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) {
204+
continue;
205+
}
206+
String source = Files.readString(doc);
207+
RETIRED_IN_2_0_TOKENS.stream()
208+
.filter(source::contains)
209+
.forEach(token -> violations.add(rel + " names " + token));
210+
}
211+
}
212+
213+
assertThat(violations)
214+
.describedAs("these documents name a type 2.0 removed, so anyone following "
215+
+ "them writes code that does not compile. A document whose purpose "
216+
+ "is to record the removal belongs under one of %s.",
217+
HISTORICAL_RECORD_PREFIXES)
218+
.isEmpty();
219+
}
220+
221+
/**
222+
* API guidance must not name a type that no longer ships, even when the name still
223+
* resolves inside this repository.
224+
*
225+
* <p>Separate from the retired-token scan because the question is different: not
226+
* "does this identifier exist anywhere" but "can a reader of a published artifact
227+
* use it". {@code BusinessTheme} answers yes to the first and no to the second,
228+
* which is exactly how an issue template came to offer it as the theming entry
229+
* point months after 2.0 removed it.</p>
230+
*/
231+
@Test
232+
void apiGuidanceShouldNotOfferTypesThatNoLongerShip() throws IOException {
233+
List<Path> roots = List.of(
234+
PROJECT_ROOT.resolve("README.md"),
235+
PROJECT_ROOT.resolve("CONTRIBUTING.md"),
236+
PROJECT_ROOT.resolve("docs/templates"),
237+
PROJECT_ROOT.resolve("docs/getting-started.md"),
238+
PROJECT_ROOT.resolve("docs/first-document.md"),
239+
PROJECT_ROOT.resolve(".github"));
240+
241+
Set<String> violations = new TreeSet<>();
242+
for (Path root : roots) {
243+
for (Path doc : markdownUnder(root)) {
244+
String rel = relative(doc);
245+
if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) {
246+
continue;
247+
}
248+
String source = Files.readString(doc);
249+
FORBIDDEN_IN_API_GUIDANCE.stream()
250+
.filter(source::contains)
251+
.forEach(token -> violations.add(rel + " offers " + token));
252+
}
253+
}
254+
255+
assertThat(violations)
256+
.describedAs("these documents tell a reader to build against a type that no "
257+
+ "longer ships in any published artifact. A name that survives as an "
258+
+ "examples-local helper is still unusable by a consumer.")
259+
.isEmpty();
260+
}
261+
262+
/**
263+
* Every relative link in the public documentation resolves on disk.
264+
*
265+
* <p>Needs no token list and cannot go stale: it reads what the documents actually
266+
* point at. Renaming a test, archiving a page or deleting an example breaks the
267+
* links to it here rather than for a reader.</p>
268+
*/
269+
@Test
270+
void publicMarkdownLinksShouldResolve() throws IOException {
271+
List<Path> roots = List.of(
272+
PROJECT_ROOT.resolve("README.md"),
273+
PROJECT_ROOT.resolve("CONTRIBUTING.md"),
274+
PROJECT_ROOT.resolve("SECURITY.md"),
275+
PROJECT_ROOT.resolve("SUPPORT.md"),
276+
PROJECT_ROOT.resolve("ROADMAP.md"),
277+
PROJECT_ROOT.resolve("examples/README.md"),
278+
PROJECT_ROOT.resolve("docs"),
279+
// Issue and pull-request templates carry relative links out of
280+
// .github/ISSUE_TEMPLATE/, two levels deep — the shape most likely
281+
// to break silently when a target moves.
282+
PROJECT_ROOT.resolve(".github"));
283+
284+
Pattern link = Pattern.compile("\\]\\(([^)\\s]+)\\)");
285+
Set<String> broken = new TreeSet<>();
286+
for (Path root : roots) {
287+
for (Path doc : markdownUnder(root)) {
288+
if (isHistoricalRecord(relative(doc))) {
289+
continue;
290+
}
291+
Matcher matcher = link.matcher(Files.readString(doc));
292+
while (matcher.find()) {
293+
String target = matcher.group(1);
294+
if (target.startsWith("http") || target.startsWith("mailto:") || target.startsWith("#")) {
295+
continue;
296+
}
297+
String file = target.split("#", 2)[0];
298+
if (file.isEmpty()) {
299+
continue;
300+
}
301+
if (!Files.exists(doc.getParent().resolve(file).normalize())) {
302+
broken.add(relative(doc) + " -> " + target);
303+
}
304+
}
305+
}
306+
}
307+
308+
assertThat(broken)
309+
.describedAs("a relative link in the public docs points at a file that does "
310+
+ "not exist; the reader gets a 404 on GitHub")
311+
.isEmpty();
312+
}
313+
120314
@Test
121315
void publicAuthoringDocsAndExamplesShouldNotImportEngineInternals() throws IOException {
122316
assertNoForbiddenAuthoringImports(
@@ -140,6 +334,26 @@ void semanticAuthoringValuePackagesShouldNotImportEngineInternals() throws IOExc
140334
PROJECT_ROOT.resolve("core/src/main/java/com/demcha/compose/document/image")));
141335
}
142336

337+
/** Markdown files under a root, or the root itself when it is one. */
338+
private static List<Path> markdownUnder(Path root) throws IOException {
339+
if (Files.isRegularFile(root)) {
340+
return root.toString().endsWith(".md") ? List.of(root) : List.of();
341+
}
342+
if (!Files.isDirectory(root)) {
343+
return List.of();
344+
}
345+
try (var paths = Files.walk(root)) {
346+
return paths.filter(Files::isRegularFile)
347+
.filter(path -> path.toString().endsWith(".md"))
348+
.sorted()
349+
.toList();
350+
}
351+
}
352+
353+
private static boolean isHistoricalRecord(String relativePath) {
354+
return HISTORICAL_RECORD_PREFIXES.stream().anyMatch(relativePath::startsWith);
355+
}
356+
143357
private void assertNoForbiddenReferences(Path root, Set<String> allowlist) throws IOException {
144358
assertNoForbiddenReferences(root, path -> true, allowlist);
145359
}

core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,38 @@ void readmeBannerVersionDerivesFromProjectVersion() throws Exception {
216216
.isFalse();
217217
}
218218

219+
/**
220+
* The README release-status block advertises a version that exists.
221+
*
222+
* <p>It used to be a hand-edit no test covered, and the release script demanded it
223+
* already name the version being cut — so between releases {@code develop} carried
224+
* an unpublished version as "latest stable" behind a tag URL that 404s. The script
225+
* rewrites the block now; this pins the result, and pins the link to the text so a
226+
* half-updated block cannot pass.</p>
227+
*/
228+
@Test
229+
void readmeReleaseStatusNamesAPublishedVersion() throws Exception {
230+
Set<String> targets = acceptableTargets();
231+
String readme = Files.readString(PROJECT_ROOT.resolve("README.md"));
232+
233+
Matcher stable = Pattern.compile(
234+
"\\*\\*Latest stable\\*\\*:\\s*\\[v(\\d+\\.\\d+\\.\\d+)]\\(([^)]+)\\)")
235+
.matcher(readme);
236+
assertThat(stable.find())
237+
.describedAs("README must carry a '**Latest stable**: [vX.Y.Z](…)' release-status "
238+
+ "line — cut-release.ps1 rewrites it during the cut and aborts without it")
239+
.isTrue();
240+
241+
assertThat(stable.group(1))
242+
.describedAs("README 'Latest stable' must name a published release, not the one "
243+
+ "in development: its tag page does not exist until the cut (one of %s)", targets)
244+
.isIn(targets);
245+
assertThat(stable.group(2))
246+
.describedAs("the 'Latest stable' link must point at the tag it names, or the badge "
247+
+ "sends readers to a different release than the text claims")
248+
.endsWith("/releases/tag/v" + stable.group(1));
249+
}
250+
219251
@Test
220252
void readmeInstallSnippetsMatchTheProjectVersion() throws Exception {
221253
Set<String> targets = acceptableTargets();

docs/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ back here.
1919
| **Designer / author** wanting a custom visual style for CVs | [Templates v2 (layered) — authoring presets](templates/v2-layered/authoring-presets.md) |
2020
| **Maintainer of a pre-2.0 caller** (classic `*Spec` + builder templates, removed in 2.0) | [Which template system? — migration map](templates/which-template-system.md) |
2121
| **Contributor adding a new template family** to the library | [Templates v2 (layered) — contributor guide](templates/v2-layered/contributor-guide.md) |
22-
| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md)[Implementation guide](contributing/implementation-guide.md) |
22+
| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md)[Package map](architecture/package-map.md) |
2323
| **Operator** running GraphCompose in production | [Production rendering](operations/production-rendering.md)[Performance](operations/performance.md)[Logging](operations/logging.md) |
2424

2525
---
@@ -59,7 +59,7 @@ back here.
5959

6060
### Contributing
6161
- **[contributing/extension-guide.md](contributing/extension-guide.md)** — add a new node type, backend handler, or theme primitive.
62-
- **[contributing/implementation-guide.md](contributing/implementation-guide.md)**internal engine notes for contributors hacking on layout / measurement / pagination.
62+
- **[architecture/pagination-ordering.md](architecture/pagination-ordering.md)**how nodes are paginated and ordered, for contributors working on layout / measurement.
6363
- **[contributing/release-process.md](contributing/release-process.md)** — versioning, tag procedure, Maven Central publication.
6464

6565
### Migrations & roadmap

0 commit comments

Comments
 (0)