chore(knowledge): generate the public API surface from the classes and gate it - #630
Conversation
|
All three findings are fixed in 1. Constructor annotations — fixedConfirmed exactly as described: Checked whether anything shipped is affected: nothing is. The only annotated constructors in 2.2.2 are two The fix is not a branch at the call site. The key rule now lives in 2. Fail-open annotation reads — fixedAbsent stays fine — most packages have no 4. A class javap could not read simply vanished — found while testing 2Corrupting a class to exercise the new fail-closed path did not trigger it. javap exited 1, the extractor exited 0, and That is the same shape as the drift this pack exists to end, arriving from inside its own tooling, and it sat one layer below where the review was looking. Every candidate that goes into javap must now come back out; a corrupt class fails the run and names itself. 3. Release integrity — fixed
One deviation from the suggestion: VerificationReactor slice Knowledge gates: Both new fail-closed paths were verified by causing them, then reverted: a corrupt class file makes the run exit non-zero naming No generated surface changed. |
The allow-list agents author against was produced by a regex parser that emits one section per file, so every nested public type lost its receiver: `GraphCompose` listed 25 methods of which 7 are real, the other 18 belonging to the nested `DocumentBuilder`, which had no section at all. It also omitted the testing, render-docx and render-pptx modules entirely and published `internal` packages as callable. It lived outside version control, so nothing noticed while it drifted through a whole major version. Replace it with a generator that reads the compiled classes. `knowledge/tools/api-surface/extract-api.mjs` reads `*/target/classes` with `javap` (`--from-reactor`) or a published release resolved through Maven (`--from-release`), and writes one document per surface: authoring, templates, backends, testing, extension-spi. Classification runs in three stages, because admission and stability are different questions and `@Beta` answers only the second: a type enters `extension-spi` by an authored SPI list, never by being `@Beta`. `docs/api-stability.md` gives Extension SPI and Experimental the same annotation and puts the distinction in the docstring, so no classifier can route between those surfaces on its own. Annotations are read from the class file rather than the source. Both forms occur here — `svg/SvgIcon.java` writes `@Beta`, `layout/package-info.java` writes `@com.demcha.compose.document.api.Internal` — and a grep for either form finds the wrong set. Members are keyed by erased parameter types, not by arity: `ShapeContainerBuilder` declares two `path` overloads with three parameters each and only the `SvgPath` one is `@Beta`. A public type matching no rule fails the run. That is what surfaced `engine.components`, which despite its name holds `TextStyle`, `DocumentMetadata`, `ImageData`, `Padding` and `Anchor` — value types the public API makes you construct, and absent from the old hand-written package list. They are admitted because admitted signatures mention them; their engine-side twins, which nothing public mentions, are not. Every exclusion is written to `knowledge/api/excluded.json` with its reason. `knowledge/manifest.json` carries only stable facts. Commit, timestamp and artifact digests go to `target/knowledge/provenance.json` and are never committed: a tracked file cannot hold the SHA of the commit it belongs to, and class digests are not reproducible across machines, so a gate comparing them would be red on every run. CI runs `--check` inside `build-and-test` on JDK 17, where `target/classes` exists — a separate job gets a fresh runner and would have nothing to read. `knowledge/**` joins the `code` path filter so a PR touching only the pack still runs the gate that guards it. `classifier.test.mjs` compiles Java fixtures during the test and covers each rung of the ladder; all three mutations tried against it turn it red. Treat a failing gate as an unregenerated lockfile, not as a strict check: AGENTS.md documents the regenerate command.
"Which pages does this API change invalidate" is answered today by reading
them. That does not scale, and it is the question the whole pack exists to
turn into a lookup: a symbol lands in a changeset, an index says which pages
assert it, and only those get reviewed.
A claim is an HTML comment beside the prose that makes it:
<!-- claim: symbol=TableBuilder.zebra -->
<!-- claim: capability=table.zebra-striping -->
<!-- claim: behavior=table.explicit-row-style-beats-zebra
proof=test:TableBuilderZebraAndTotalsTest -->
Three storage options were weighed. Front-matter loses on evidence: none of
the 80 tracked public pages carries any and there is no Jekyll or Pages
config, so those pages are read straight from GitHub, where front-matter
renders as a rule and literal key: value text at the top of every one of
them. An external file keyed by page path plus anchor loses because the key
breaks silently when a heading is edited, and it would need a YAML parser in
a repository that ships no dependencies. A marker is invisible to readers,
travels with the paragraph, costs one regex — and is the convention
doc-example already established here.
Enforcement is asymmetric on purpose. A claimed symbol that no surface has
fails the build: the page describes API a reader will try to compile. A
symbol an example calls but does not claim is only reported. Symmetric
enforcement would demand a claim for every incidental type in every snippet,
and the predictable result is that nobody reads the output. A missing claim
costs coverage; a false claim costs trust.
Proofs are resolved, not just parsed. test: must name a real JUnit class and
snippet: a real doc-example id, so a proof left behind by a rename fails here
rather than quietly holding nothing up. probe: and render: are accepted but
unresolved until their registries exist — refusing them now would only push
authors toward the two kinds that happen to be finished.
A behaviour claim without a proof is refused outright. Signatures are checked
against the surfaces and intents are resolved by routing, but a claim that the
engine *does* something — throws, refuses to nest, falls back — is invisible
in every signature and has nothing holding it up but a test.
Seeded on docs/recipes/tables.md: 15 claims whose four behaviours point at the
three tests that page already names in prose. The suggestion channel then
proposes seven more symbols it calls without claiming.
Both suites are plain Node, no framework, consistent with hand-writing zip.mjs
rather than adding a dependency. Three mutations were tried against each and
all six turn them red, including reverting the resolver to split on the first
dot — which credits GraphCompose with DocumentBuilder's members, the defect
this pack replaced.
… exist"
The surfaces say what exists. They cannot say that a skills list in two
columns is a row with weights and not a table — nothing in a signature says
so, and that is where wrong-API choices actually come from. Answering it
today means reading a guide.
$ node knowledge/tools/api-query/api-query.mjs --task layout.two-columns
use: row-with-weights
layered-page-design.md#sidebar-page-background-vs-row states it
directly: a column that holds content is a row column.
instead, when:
page-background-column
when: the column is a tint that repeats and holds no content
costs: free at layout time, but nothing can be placed in it
table
when: the columns are a data grid with a header row
costs: paginates per row, sizes by table rules not weights
...
constraints: row.rejects-a-nested-row
read: docs/recipes/layered-page-design.md#sidebar-page-background-vs-row
Alternatives are objects, never bare names: a name says a second way exists
without saying when it wins, which is the gap this layer exists to close. The
answer ends the choice and hands over one anchor — it does not restate the
guide, because a fourth copy of the prose is what keeping prose in docs/
exists to prevent.
A wrong route is more dangerous than a wrong signature: it does not fail to
compile, it sends readers down the wrong path with the authority of a
generated artifact. So a route is served only once its anchor resolves to a
heading that exists, every symbol is in a surface, and every constraint names
a documented behaviour that some test holds up. Seven mutations were tried
against that gate — invented symbol, renamed anchor, unclaimed constraint,
bare-string alternative, alternative missing tradeoffs, missing
verifiedAgainst, duplicate id — and all seven turn it red.
The routes are derived from docs/ and from tests in this repository, not
seeded from the AI Flow loading map or the wiki decision tree: the audit that
started this work found drift in both, and importing on their authority would
launder it into the one artifact meant to be trustworthy. Authoring the first
route also caught a symbol that does not exist — the canvas builder is
CanvasLayerBuilder, not CanvasBuilder.
The one gate a tool cannot close is whether the recommendation is right.
`confirmedBy` is null on all three and the CLI says so in its own output
rather than presenting an unreviewed opinion as settled.
tasks.json rather than tasks.yaml: the explanatory content lives in useWhen
and tradeoffs fields rather than comments, so YAML buys nothing here that
would justify hand-rolling a parser in a repository that ships no
dependencies.
A tracked knowledge/ directory is not a published pack. Until there is an archive, "the plugin consumes GraphCompose's knowledge" quietly means "the plugin needs a GraphCompose checkout on the machine" — which is the offline problem this work exists to remove, restated rather than solved. graph-compose-knowledge-<version>.zip carries manifest.json, provenance.json, api/, routing/, claims/, a README — and bin/query.mjs, a query CLI of its own. That last part is not convenience. The acceptance test is that the pack answers on a machine with no GraphCompose source, and without a bundled --exists / --task there is nothing on that machine to run the test with, so "self-sufficient" would be asserted instead of demonstrated. --verify unpacks the archive into a scratch directory well away from the repository and runs three queries there: a nested receiver resolves, an intent resolves, and an invented method exits 3. To make that possible the CLI now finds its own knowledge root by walking up rather than assuming a fixed depth — the bundle is exactly the case where a wrong assumption has no source to fall back on. Checksums at two levels, the outer one outside: <bundle>.zip.sha256 is published beside the archive, because a hash stored in the file it describes is rewritten by whatever rewrote the file; bundle-checksums.json inside then says which entry is wrong once the outer hash says something is. zip-write.mjs is the companion to the zip.mjs the extractor already reads with, written by hand for the same reason that one was — a build tool that pulls in a dependency to make an archive puts a node_modules install on the critical path of a release. Every build round-trips the result back through that reader before reporting success. Timestamps inside are fixed rather than taken from the clock: two builds of the same content must produce the same archive, or the checksum beside it describes the moment it was built instead of what is in it. The release workflow already runs clean verify, so the classes the pack is read from are there. It now --checks the committed pack before building, so a tag cannot ship a pack that disagrees with the code it claims to describe. Distribution is a GitHub Release asset, not a Maven artifact. Maven would give a stable coordinate, but the consumer is a Node plugin that resolves nothing through Maven and no Java build compiles against a documentation pack — it would mean adding an artifact to the Central staging path, and its allow-list, for a file nothing on that path needs.
The last piece. Everything before this made the pack correct; this is what it
was for — turning "which documentation does this change invalidate" from a
reading task into an intersection.
$ node knowledge/tools/changeset/changeset.mjs --base HEAD
API: +0 -2 ~0
- TableBuilder.zebra(DocumentColor,DocumentColor)
- TableBuilder.zebra(DocumentTableStyle,DocumentTableStyle)
pages to review (1):
docs/recipes/tables.md (Zebra — alternating row fills)
removed: TableBuilder.zebra(DocumentTableStyle,DocumentTableStyle)
That output is from a real replay: @internal was put on both zebra overloads,
the class recompiled, and the pipeline named one page, one heading and the
reason — no false positives, and nothing to read that did not need reading.
It costs one `git show` per surface and no build, because both sides of the
comparison are already tracked. That is the direct payoff of committing the
generated surfaces in the first place.
Behavioural signals are ranked rather than pooled. A changed layout snapshot
or visual baseline is proof: the file IS the recorded output, so a diff in it
is drift by construction. A changed example is a hint — examples move for many
reasons. "A test file changed" is not a signal at all: tests change for
stylistic reasons constantly, and a channel that fires on every refactor is
one people learn to ignore.
The set is bounded, and the two ways out of it are explicit rather than
accidental: a surface that does not exist at the base, and a proof-strength
change no claim covers. Run against origin/develop — where knowledge/ does not
exist yet — the pipeline escalates all five surfaces instead of presenting
4160 bogus additions as a changeset, which is the difference between a tool
that reports and a tool that is trusted.
All three are the same shape: something the extractor could not read, or could not match, degraded into "nothing to report" — and a closed set whose silence cannot be trusted is worse than no closed set at all. **Constructor annotations never matched.** A class file calls a constructor `<init>`; javap renames it to the simple type name. So annotations recorded under `<init>(int)` were looked up as `Foo(int)`, and the two can never meet. `@Internal` and `@Beta` both list `ElementType.CONSTRUCTOR`, so an `@Internal` constructor would have reached the surface as public API and a `@Beta` one read as settled. No annotated constructor ships today — only two `@Deprecated` ones, which the classifier does not act on — so no surface on disk was wrong; the contract allowed it, and that was enough. The fix is not a branch at the call site. The key rule now lives in `memberKeyForMember`, which the extractor and the tests both go through, because building the key at each call site is precisely how the rule came to be missing at the only site that mattered. Three fixtures cover `@Internal`, `@Beta` and unannotated constructors, and two assertions state the trap directly: javap's spelling finds nothing, and the class file says `<init>`. **Annotation read errors failed open.** A class the reader could not parse became a class with no annotations. The reader throws on an unknown constant-pool tag *because* it cannot know what it is looking at, and swallowing that turned "I could not tell" into "there is nothing here" — which for a package carrying `@Internal` publishes every type inside it. Absent is still fine: most packages have no `package-info` at all. Present-but-unreadable now stops the run with the class or package named. **A class javap could not read simply vanished.** Found while testing the above: javap runs in batches, and a corrupt class does not stop the batch — it reports on stderr and returns the rest, so the surface came out one type smaller and the run still exited 0. `SvgIcon` disappeared with no complaint. That is the shape of the drift this pack exists to end, arriving from inside its own tooling. Every candidate that goes into javap must now come back out. Also: the release workflow re-checks claims and routes before building the bundle. The bundle carries `claims/` and `routing/`, so a correct API surface was not enough to publish it — a stale claims index or a route whose anchor had been renamed would have shipped as a release asset unchallenged. `check-routes` no longer accepts `--check`. It was parsed and never read, so the flag advertised a mode the tool does not have; routes are hand-authored with no generated counterpart, and validating them is the check. No generated surface changes: the constructor fix alters no output today, which is what latent means.
…t be one `confirmedBy` is the one gate in the six a machine cannot close: every other check is mechanical, but whether `row-with-weights` is the *right* answer for two columns is a judgement. All three routes now carry a name instead of null: layout.two-columns -> row-with-weights layout.choose-the-layer -> page-flow table.header-on-every-page -> repeat-header The checker no longer treats "not null" as "confirmed". A whitespace string, a boolean, or any non-name would have read as a signature while recording nobody — and this is the field whose whole purpose is to say a human looked. It must be a name or an explicit `null`; omitting it fails, because an absent field is indistinguishable from one nobody has reached yet. The human output stays silent on a confirmed route and warns on an unconfirmed one. The warning is the actionable half; printing "confirmed by X" on every lookup would be noise on the common path, and the field is in `--json` for anyone who needs the provenance.
Retargeting the PR from `develop` to `chore/open-2.3.0` moved the base past
`chore(release): open 2.3.0-SNAPSHOT`, and the committed pack still described
2.2.3-SNAPSHOT. The gate caught it and named all twelve files:
[extract-api] out of date: knowledge/api/authoring.json, … (12)
regenerate: node knowledge/tools/api-surface/extract-api.mjs --from-reactor
Worth recording that this was not a synthetic test. A base change is an
ordinary thing to do to a PR, it produces no source diff, and every check on
the PR stayed green because GitHub does not re-run them when the base moves.
Without the gate the pack would have merged claiming a version the tree does
not have.
Only the version stamps move — `verifiedAgainst`, `targetVersion` and
`generatedFrom`. The surface is unchanged at 227 / 161 / 69 / 9 / 5 types with
163 exclusions, which is what a pom-only base commit should produce, and is
itself a check on the extractor: a version bump that shifted an API line would
mean something read the version where it should read the classes.
2aaf7d1 to
2d5694a
Compare
|
Rebased onto The base change made the gate fire, and it was right to. Retargeting moved the base past Worth noting because it is the first time the gate caught something ordinary rather than something I broke on purpose. A base change produces no source diff and looks harmless; without the gate the pack would have merged claiming a version the tree does not have. Only the version stamps moved — Rebase rather than a merge commit because this repository squash-merges — zero merge commits in the last 30 on Verification on the new base: reactor slice |
…thing did The claims index says a page asserts something and names its proof. On its own that is a promissory note: a consumer holding the bundle could read that a claim is proven and have no way to look at the thing proving it. `knowledge/proofs/index.json` resolves the note — for every proof a claim cites, the kind, the file it lives in, and everything it holds up. Five proofs hold six claims today, and one of them (`TableBuilderRepeatHeaderTest`) holds two, which is why the registry is keyed by proof rather than by claim. It is derived, not authored, so it cannot drift: a claim whose proof does not resolve already fails, and a proof nothing cites never appears. It is deliberately not a list of every test in the repository — that would be a directory listing, and the question is "what holds this claim up", not "what tests exist". **`probe:` collapsed into `test:` on contact with reality.** The plan had contract probes as a separate harness, blocked on repairing snippet-smoke and render-proof. But a contract probe *is* a JUnit test: it runs in the reactor gate and needs no harness of its own, so keeping two schemes would have been two names for one mechanism — and would have blocked this registry on repairing tooling it never needed. **The first probe for a behaviour nothing tested.** `ComposedCellSnapshotContractTest` holds both halves of a contract `docs/recipes/tables.md` now claims: a composed cell *paints* — the paragraph from `DocumentTableCell.node(...)` reaches the fragment stream — and is *invisible to the snapshot*, where no node carries its content though the table itself is recorded. The asymmetry is the point. A regression that emptied every composed cell would leave the layout snapshot byte-identical, so the snapshot gate — the usual guard for "did the geometry move" — would stay green over a document rendering blank cells. The test fails in both directions: if composed cells ever do reach the snapshot, the claim and the snapshot-testing guidance need revisiting, and this says so. Verified by mutation rather than by watching it pass: flipping the assertion to expect a snapshot node turns it red. `--check` now covers the registry as well as the claims index and names which of the two went stale. The bundle carries `proofs/`, so an agent that has only the archive can still answer "and what backs that".
The last of the three behaviours the plan named. A right-aligned paragraph claims the full row width so the alignment has somewhere to happen; an `auto()` column sizes to its content and cannot grant that, and the two demands do not reconcile — so the row refuses to lay out. It surfaces at render time, not compile time, which is what makes it worth a test rather than a sentence. Nothing in the builder's types says `auto()` and `align(RIGHT)` are incompatible, and the combination reads perfectly sensible right up until a document is produced. The assertion is on the message, not the throw. "Something failed" would pass for any unrelated breakage; the contract is that the engine says the columns do not fit — `fixed and auto columns need 626pt but only 595pt is available` — and tells you what to change. Both sides are pinned. A test that only proved the refusal would pass just as well if `auto()` columns stopped working altogether, so the working form is asserted beside it: drop the alignment and the same row lays out and produces fragments. In a column sized to its own text the alignment was never doing anything anyway — the text already ends at the column's right edge. Both halves were mutation-tested: breaking the expected message turns it red, and so does breaking the working-form assertion. That brings the registry to 6 proofs holding 7 claims. Of the three behaviours the plan named, one already had a test (`RowBuilderTest`), one got a probe last commit, and this is the third.
…aims Coverage was 4 of 80 pages carrying `doc-example`, and the obvious reading is neglect. It is not: almost every recipe fence is deliberately a *fragment* — `addTable(table -> …)` on an ambient builder, `.zebra(…)` continuing a chain above it, a style that reads `rule` and `ink` from the surrounding recipe. The guard compiles self-contained statements, and none of these are that. Making them compile means giving each one a receiver and a session, which is a rewrite of the published prose that would make it worse: a snippet showing `.zebra(…)` mid-chain is clearer about the chain than the same call wrapped in boilerplate, and the contrast in layered-page-design between a tinted background and a content row works precisely because both are shown as the two lines that differ. So every fence on the two pages that carry claims now states its position instead of being silent. One compiles; nine say why they cannot, each in terms of the specific thing it is showing rather than a shared formula. The number to watch was never "pages with markers" — it is fences whose status somebody has decided, and on these pages that is now all of them. The consequence worth having: a compilable snippet added to either page from here on is conspicuous by its missing marker, where before it would have joined an undifferentiated silence. The claims and proofs indexes are regenerated because they record the line a claim sits on, and inserting the markers moved them. The gate caught that rather than letting the indexes point at the wrong lines — which is the same coupling working in the direction it was built for.
Summary
GraphCompose's public API was described authoritatively outside GraphCompose. Agents authored against
.llm-wiki/00-api-surface.md— a gitignored file, generated by a regex parser, quoted to them as a closed set ("if a method is not listed here, it does not exist"). Being gitignored, it was invisible to CI, and it drifted through the whole 2.0 major with nothing watching.It was also wrong in a way that reads as authoritative. Sections are emitted per file, so every nested public type loses its receiver:
### GraphComposelists 25 methods of which 7 are real — the other 18 belong to the nestedGraphCompose.DocumentBuilder, which has no section at all. It omitsgraph-compose-testing,-render-docxand-render-pptxentirely, and publishesdocument.dsl.internalas callable.This replaces it with a tracked
knowledge/pack generated from the compiled classes and gated in CI, plus the three layers that make it useful: what each page promises, which way to do a thing, and which pages a change invalidated.Five commits, reviewable in order.
Commits
ea3a3ff5— the surfaces, and the gate.knowledge/tools/api-surface/extract-api.mjsreads*/target/classeswithjavap(--from-reactor) or a published release through Maven (--from-release), and writes one document per surface:authoring(227 types),templates(161),backends(69),extension-spi(9),testing(5).Classification runs in three stages, because admission and stability are different questions and
@Betaanswers only the second: a type entersextension-spiby an authored SPI list, never by carrying@Beta.docs/api-stability.md:33-34gives Extension SPI and Experimental the same annotation and puts the distinction in the docstring, so no classifier can route between those surfaces on its own.Annotations are read from the class file, not the source. Both spellings occur here —
svg/SvgIcon.javawrites@Beta,layout/package-info.javawrites@com.demcha.compose.document.api.Internal— and a grep for either form returns the wrong set. Members are keyed by erased parameter types rather than arity:ShapeContainerBuilderdeclares twopathoverloads with three parameters each and only theSvgPathone is@Beta.A public type matching no rule fails the run. That is what surfaced
engine.components, which despite its name holdsTextStyle,DocumentMetadata,ImageData,PaddingandAnchor— value types the public API makes you construct, and absent from the old hand-written package list. They are admitted because admitted signatures mention them; their engine-side twins (HeaderFooterConfigbeside the publicDocumentHeaderFooter) are mentioned by nothing and are not. Every exclusion is written toknowledge/api/excluded.jsonwith its reason.knowledge/manifest.jsoncarries only stable facts. Commit, timestamp and artifact digests go totarget/knowledge/provenance.jsonand are never committed: a tracked file cannot hold the SHA of the commit it belongs to, and class digests are not reproducible across machines, so a gate comparing them would be red on every run.f86b1f2d— claims. A page states what it promises, in a marker beside the prose that makes it:Front-matter was not an option: none of the 80 tracked public pages carries any and there is no Jekyll config, so
docs/renders straight from GitHub, where it would appear as a rule and literalkey: valueat the top of every page. An external file keyed by path + anchor breaks silently when a heading is edited. A marker is invisible, moves with the paragraph, and is the conventiondoc-examplealready set.Enforcement is asymmetric. A claimed symbol no surface has fails — the page describes API a reader will try to compile. A symbol an example calls without claiming is reported only; demanding a claim for every incidental type is churn people learn to skip.
test:andsnippet:proofs are resolved, so a proof orphaned by a rename fails rather than quietly holding nothing up, and abehaviorclaim with no proof is refused outright.445ba508— routing. The surfaces say what exists; they cannot say that a skills list in two columns is a row with weights and not a table.Alternatives are objects, never bare names: a name says a second way exists without saying when it wins. A route is served only once its anchor resolves to a heading that exists, every symbol is in a surface, and every constraint names a documented behaviour some test holds up. Authoring the first route caught a symbol that does not exist — the canvas builder is
CanvasLayerBuilder, notCanvasBuilder.641bf490— the bundle. A tracked directory is not a published pack; without an archive, "the plugin consumes GraphCompose's knowledge" means "the plugin needs a checkout".graph-compose-knowledge-<version>.zipcarries the surfaces, routing, claims, provenance — andbin/query.mjs, a query CLI of its own, because the acceptance test is that it answers on a machine with no GraphCompose source and there would otherwise be nothing there to run the test with.<bundle>.zip.sha256is published beside the archive: a hash stored in the file it describes is rewritten by whatever rewrote the file.bundle-checksums.jsoninside then says which entry is wrong. Zip timestamps are fixed, so two builds of identical content produce an identical archive.Distribution is a GitHub Release asset, not a Maven artifact: the consumer resolves nothing through Maven and no Java build compiles against a documentation pack.
77ffd5f7— the changeset. Both sides of an API comparison are now tracked, sogit show <ref>:knowledge/api/<surface>.jsongives the base and the diff costs no build at all.Behavioural signals are ranked: a changed layout snapshot or visual baseline is proof — the file is the recorded output. A changed example is a hint. "A test file changed" is not a signal: tests move for stylistic reasons and a channel that fires on every refactor is one people stop reading.
Testing
./mvnw -B -ntp clean verifyover the reactor slice (the ten modulesci.ymlbuilds) →BUILD SUCCESS in 2:28, 2098 tests across 375 classes, 0 failures, 0 errors.
Documentation and CI guards run separately, since this touches
AGENTS.mdandci.yml:EnginePdfBoundaryTest, DocumentationCoverageTest, CanonicalSurfaceGuardTest, PackageMapGuardTest, VersionConsistencyGuardTest, CiGuardListGuardTest, CiGateCoverageGuardTest, CodeQlScopeGuardTest, AgentsGuideGuardTest, BenchmarkDependencyInstallGuardTest→ 52 tests, 0 failures.New checks, all plain Node with no test framework — the repository ships no dependencies and
zip.mjswas hand-written rather than add one:classifier.test.mjs— 18 cases. Compiles Java fixtures during the test against the real@Internal/@Betafromcore/target/classes, and covers each rung: unruled type → unclassified; package-@Internalexcludes; a@Betatype in an@Internalpackage is not admitted by its@Beta; type-level and nested-type stability; a nested type not infected by its enclosing type; member@Internaldropped; member@Beta; the same-name-same-arity overload pair discriminated.claims.test.mjs— 18 cases, mostly negative, including that a nested receiver resolves to the nested type rather than crediting its outer.Each suite was mutation-tested rather than watched passing. Disabling package-
@Internalexclusion turns two cases red; disabling the member drop turns one red; reverting the member key to name/arity turns three red, including the overload discrimination that motivated it; reverting the resolver to split on the first dot turns the nested-receiver case red.The gate itself was seen failing end to end: a throwaway public class compiled straight into
core/target/classesmade--from-reactor --checkexit 1, nameknowledge/api/authoring.jsonandauthoring.md, and print the regenerate command; removing the class returned it to 0.The changeset pipeline was verified by replaying a real removal —
@Internalon bothTableBuilder.zebraoverloads, recompiled and regenerated — and it named one page and one heading with no false positives:The claims checker failed independently on the same state, without sharing a code path. Both the source edit and the recompile were reverted.
build-bundle.mjs --verifyunpacks into a scratch directory away from the repository and queries it there: a nested receiver resolves, an intent resolves, and an invented method exits 3.Where the gate runs, and why there
Three steps inside
build-and-test, guardedif: matrix.java == '17', between the Maven build and Javadoc. Every CI job gets a fresh runner, so a standalone job would have notarget/classesto read; the JDK guard keeps a JDK-independent surface from being computed three times.knowledge/**joins thecodepath filter — without it a PR touching only the extractor or a generated surface matches no filter, skipsbuild-and-test, and skips the gate with it, so the gate would be absent on exactly the PRs that change it. It is deliberately not injvm, so such a PR runs one JDK 17 job rather than the full matrix.ci-gateneeded no change and no new job was added, soCiGateCoverageGuardTestis untouched.Notes for review
knowledge/and the workflows areAGENTS.mdand twodocs/recipes/pages, which gain invisible HTML comments.DocumentationSnippetCompileTeststays green with the markers present. No CHANGELOG entry, on that basis.tasks.json, nottasks.yaml. The rationale this schema carries lives inuseWhen/tradeoffsfields rather than comments, so YAML buys nothing that would justify hand-rolling a parser here.confirmedBy: null, and both the checker and the CLI say so in their own output rather than presenting an unreviewed opinion as settled. These need your sign-off:layout.two-columns→row-with-weights,layout.choose-the-layer→page-flow,table.header-on-every-page→repeat-header.docs/recipes/tables.mdtells readersTableResolvedCell.yOffset"is negative for spanning cells".TableResolvedCellis engine-side and in no surface, so a reader cannot observe it. Seeding it as a claim would commit a known-failing marker; it belongs in a separate docs fix..llm-wiki/tools/api-index/api-index.pystill exists. It is removed once no consumer reads it;~/.claude/CLAUDE.mdand both skill trees already carry a warning that its output is not authoritative.document/svg/package-info.javaclaims beta in prose but carries no@Beta. So an unannotated type added to that package would classifystableagainst the package's stated intent. That is a source fix, not a classifier one — a prose-scraping rule would make the classifier's input exactly as untrustworthy as the file this pack replaces.doc-examplemarkers against 50 pages holding 215 fenced Java blocks. The suggestion channel now makes the gap visible per page; widening it is documentation work.probe:andrender:proofs parse but do not yet resolve — their registries depend on repairing the snippet-smoke and render-proof harnesses, which the module split broke.Lane: test — new guards, generated artefacts and CI wiring; no engine, canonical API or render output touched.