Skip to content

chore(knowledge): generate the public API surface from the classes and gate it - #630

Merged
DemchaAV merged 11 commits into
chore/open-2.3.0from
chore/knowledge-pack
Aug 31, 2026
Merged

DemchaAV merged 11 commits into
chore/open-2.3.0from
chore/knowledge-pack

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

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: ### GraphCompose lists 25 methods of which 7 are real — the other 18 belong to the nested GraphCompose.DocumentBuilder, which has no section at all. It omits graph-compose-testing, -render-docx and -render-pptx entirely, and publishes document.dsl.internal as 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

  1. ea3a3ff5 — the surfaces, and the gate. knowledge/tools/api-surface/extract-api.mjs reads */target/classes with javap (--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 @Beta answers only the second: a type enters extension-spi by an authored SPI list, never by carrying @Beta. docs/api-stability.md:33-34 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, not the source. Both spellings occur here — svg/SvgIcon.java writes @Beta, layout/package-info.java writes @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: 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 (HeaderFooterConfig beside the public DocumentHeaderFooter) are mentioned by nothing and 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.

  2. f86b1f2d — claims. A page states what it promises, in a marker beside the prose that makes it:

    <!-- claim: symbol=TableBuilder.zebra -->
    <!-- claim: behavior=table.explicit-row-style-beats-zebra proof=test:TableBuilderZebraAndTotalsTest -->

    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 literal key: value at 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 convention doc-example already 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: and snippet: proofs are resolved, so a proof orphaned by a rename fails rather than quietly holding nothing up, and a behavior claim with no proof is refused outright.

  3. 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.

    $ node knowledge/tools/api-query/api-query.mjs --task layout.two-columns
    use: row-with-weights
    instead, when: table — 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. 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, not CanvasBuilder.

  4. 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>.zip carries the surfaces, routing, claims, provenance — and bin/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.sha256 is published beside the archive: 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. 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.

  5. 77ffd5f7 — the changeset. Both sides of an API comparison are now tracked, so git show <ref>:knowledge/api/<surface>.json gives 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 verify over the reactor slice (the ten modules ci.yml builds) →
BUILD SUCCESS in 2:28, 2098 tests across 375 classes, 0 failures, 0 errors.

Documentation and CI guards run separately, since this touches AGENTS.md and ci.yml: EnginePdfBoundaryTest, DocumentationCoverageTest, CanonicalSurfaceGuardTest, PackageMapGuardTest, VersionConsistencyGuardTest, CiGuardListGuardTest, CiGateCoverageGuardTest, CodeQlScopeGuardTest, AgentsGuideGuardTest, BenchmarkDependencyInstallGuardTest52 tests, 0 failures.

New checks, all plain Node with no test framework — the repository ships no dependencies and zip.mjs was hand-written rather than add one:

  • classifier.test.mjs18 cases. Compiles Java fixtures during the test against the real @Internal/@Beta from core/target/classes, and covers each rung: unruled type → unclassified; package-@Internal excludes; a @Beta type in an @Internal package is not admitted by its @Beta; type-level and nested-type stability; a nested type not infected by its enclosing type; member @Internal dropped; member @Beta; the same-name-same-arity overload pair discriminated.
  • claims.test.mjs18 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-@Internal exclusion 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/classes made --from-reactor --check exit 1, name knowledge/api/authoring.json and authoring.md, and print the regenerate command; removing the class returned it to 0.

The changeset pipeline was verified by replaying a real removal — @Internal on both TableBuilder.zebra overloads, recompiled and regenerated — and it named one page and one heading with no false positives:

API: +0 -2 ~0
  - TableBuilder.zebra(DocumentColor,DocumentColor)
  - TableBuilder.zebra(DocumentTableStyle,DocumentTableStyle)
pages to review (1):
  docs/recipes/tables.md  (Zebra — alternating row fills)

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 --verify unpacks 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, guarded if: matrix.java == '17', between the Maven build and Javadoc. Every CI job gets a fresh runner, so a standalone job would have no target/classes to read; the JDK guard keeps a JDK-independent surface from being computed three times.

knowledge/** joins the code path filter — without it a PR touching only the extractor or a generated surface matches no filter, skips build-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 in jvm, so such a PR runs one JDK 17 job rather than the full matrix. ci-gate needed no change and no new job was added, so CiGateCoverageGuardTest is untouched.

Notes for review

  • No public Java API changes. The only edits outside knowledge/ and the workflows are AGENTS.md and two docs/recipes/ pages, which gain invisible HTML comments. DocumentationSnippetCompileTest stays green with the markers present. No CHANGELOG entry, on that basis.
  • tasks.json, not tasks.yaml. The rationale this schema carries lives in useWhen/tradeoffs fields rather than comments, so YAML buys nothing that would justify hand-rolling a parser here.
  • Three routing recommendations are unconfirmed. Every mechanical gate passes, but whether a recommendation is the right one is a maintainer's call. All three carry 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-columnsrow-with-weights, layout.choose-the-layerpage-flow, table.header-on-every-pagerepeat-header.
  • A documentation defect is left unfixed on purpose. docs/recipes/tables.md tells readers TableResolvedCell.yOffset "is negative for spanning cells". TableResolvedCell is 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.py still exists. It is removed once no consumer reads it; ~/.claude/CLAUDE.md and both skill trees already carry a warning that its output is not authoritative.
  • document/svg/package-info.java claims beta in prose but carries no @Beta. So an unannotated type added to that package would classify stable against 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.
  • Claim and proof coverage is thin by design at this point. 25 claims on 2 pages, and 4 of 80 tracked pages carry doc-example markers against 50 pages holding 215 fenced Java blocks. The suggestion channel now makes the gap visible per page; widening it is documentation work.
  • probe: and render: 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.

@DemchaAV

Copy link
Copy Markdown
Owner Author

All three findings are fixed in 1965e28d, and the second one led to a fourth that was not in the review.

1. Constructor annotations — fixed

Confirmed exactly as described: javap.mjs:208 renames a constructor to the simple type name while annotations.mjs keys it <init>, so the two spellings can never meet.

Checked whether anything shipped is affected: nothing is. The only annotated constructors in 2.2.2 are 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 to fix.

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 deriving the key at each call site is precisely how it came to be missing at the only site that mattered. 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>(int).

2. Fail-open annotation reads — fixed

Absent stays fine — most packages have no package-info at all. Present-but-unreadable now stops the run with the class or package named. 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".

4. A class javap could not read simply vanished — found while testing 2

Corrupting a class to exercise the new fail-closed path did not trigger it. javap exited 1, the extractor exited 0, and SvgIcon was gone from the surface with no complaint — javap runs in batches, and a bad class does not stop the batch: it reports on stderr and returns the rest.

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

check-claims.mjs --check and check-routes.mjs now run before build-bundle. The bundle carries claims/ and routing/, so a correct API surface was never enough to publish it.

One deviation from the suggestion: check-routes is called without --check. The flag was parsed and never read, so it advertised a mode the tool does not have. Routes are hand-authored with no generated counterpart, so validating them is the check; the flag is removed rather than left as a no-op.

Verification

Reactor slice clean verifyBUILD SUCCESS, 2098 tests across 375 classes, 0 failures, 0 errors.

Knowledge gates: classifier.test 23 passed (up from 18), claims.test 18, extract-api --check current, check-claims --check current, check-routes 3 routes, build-bundle --verify answers standalone.

Both new fail-closed paths were verified by causing them, then reverted: a corrupt class file makes the run exit non-zero naming com.demcha.compose.document.svg.SvgIcon, and the tree returns to green afterwards.

No generated surface changed. knowledge/api/* differ only in line endings — the constructor fix alters no output today, which is what latent means.

@DemchaAV
DemchaAV changed the base branch from develop to chore/open-2.3.0 August 31, 2026 18:37
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.
@DemchaAV
DemchaAV force-pushed the chore/knowledge-pack branch from 2aaf7d1 to 2d5694a Compare August 31, 2026 18:44
@DemchaAV

Copy link
Copy Markdown
Owner Author

Rebased onto chore/open-2.3.0 and regenerated the pack against 2.3.0-SNAPSHOT (2d5694ae).

The base change made the gate fire, and it was right to. Retargeting moved the base past chore(release): open 2.3.0-SNAPSHOT, so the committed pack still described 2.2.3-SNAPSHOT. Every check on this PR stayed green through that, because GitHub does not re-run them when the base moves — the run above was triggered by a push against develop. Locally:

[extract-api] out of date: knowledge/api/authoring.json, … (12 files)
  regenerate: node knowledge/tools/api-surface/extract-api.mjs --from-reactor

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 movedverifiedAgainst, targetVersion, 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 reads the version where it should read the classes.

Rebase rather than a merge commit because this repository squash-merges — zero merge commits in the last 30 on develop, and #625#631 each landed as a single-parent commit. The seven commits collapse to one at merge either way, so preserving their SHAs buys nothing and a merge commit would only add noise to the squash diff.

Verification on the new base: reactor slice clean verify → BUILD SUCCESS, 2098 tests across 375 classes, 0 failures, 0 errors. Knowledge gates: classifier.test 23, claims.test 18, extract-api --check current, check-claims --check current, check-routes 3 routes, build-bundle --verify answers standalone.

…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.
@DemchaAV
DemchaAV merged commit db6507d into chore/open-2.3.0 Aug 31, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the chore/knowledge-pack branch August 31, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant