Skip to content

feat(knowledge): route the two questions a signature cannot answer - #664

Merged
DemchaAV merged 18 commits into
developfrom
feat/routing-icon-and-rule
Sep 8, 2026
Merged

DemchaAV merged 18 commits into
developfrom
feat/routing-icon-and-rule

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Why

A generated surface says what exists; it cannot say which of several ways is right. Two questions the pack could not answer produced two of the three dominant defects in an AI Flow CV run:

A row with no column spec splits its inner width into equal shares, one per child — content plays no part. So row.add(icon.node(13)).addParagraph(...) gives a 13pt icon half the row and wraps the text into twice the lines. Nothing looks broken: the icon still draws at 13pt, it merely occupies five times the width it needs, and the paragraph pays in line count. In a 135.7pt sidebar that is a 63.85pt slot each.

LineBuilder.horizontal(width) takes points, not a percentage — and nothing clips a line to the space it was given. Every neighbouring number is a ratio (weights(0.34, 0.66), leftColumn(0.34, ...)), so horizontal(100) reads as "the full width"; it is drawn at 100pt, 32.15pt past the column, across whatever is beside it, with a clean render and no warning.

Neither fact is visible in a signature, and knowledge/routing/tasks.json is the only layer that can carry it.

What the routes say

  • layout.icon-beside-text recommends columns(auto(), weight(1)): auto() is the icon's own width, weight(1) is everything left, so the icon keeps its own size whatever the column is resized to. weights(...) is listed as the alternative it is not — a weight is a share, not a size, so weights(0.1, 0.9) at 135.7pt hands a 13pt icon a 12.77pt slot, and the figure moves with the row. (fixed(13) is as stable, and is the alternative for a rail of icons that must line up.)
  • layout.rule-beside-a-heading recommends fill() in a weight column, with the two repairs that look right and are not: a flexSpacer() moves a fixed rule without sizing it, and fill() in a flex row takes the row's whole available width and ends further out than the rule it replaced.
  • Both carry the failure modes as constraints, including one the recipe's own prose had never stated: a centre- or right-aligned heading claims the whole auto() column, leaving the weight column zero — the rule is not misplaced, it is not drawn.

docs/recipes/layered-page-design.md gains both sections with compiled fences (doc-example, not doc-example-ignore), and LineBuilder.fill()'s Javadoc now names the flex-row case it used to promise away — that Javadoc is the only prose an API-first reader gets, since the surfaces carry signatures and nothing else.

What the gate now does

Writing the routes exposed that the gate admitting them was wrong in ways no existing route happened to trip.

  • The anchor rule was not GitHub's. It collapsed whitespace runs where GitHub does not, so it disagreed with the rendered page on 121 of ~790 headings under docs/ — failing CI on a link that works and passing one that 404s. It is now measured, not derived: headings were posted to api.github.com/markdown and the generated ids read back. GitHub keeps \p{L} \p{M} \p{Nd} \p{Nl} \p{Pc}, - and the ASCII space, drops everything else including every other kind of whitespace, hyphenates only the ASCII space, and trims nothing. Zebra — alternating row fills is #zebra--alternating-row-fills; A<TAB>B is #ab; <EM SPACE><SPACE>A is #-a.
  • The rule exists twice — DocumentationLinkGuardTest.slug and routing/lib/anchors.mjs — and cannot call itself across the language boundary, so both assert the same measured table and a change to either reddens its own fixture.
  • Route citations are checked wherever they are written, not only in docs:: intent, recommendedBecause and every alternative's useWhen/tradeoffs go through one resolver, so a renamed heading cannot be fixed in the gated copy and left dangling in the prose an agent actually reads.
  • Versions are ordered, not compared as strings. At a tag the surfaces are re-stamped 2.4.0 while a route signed during the cycle still reads 2.4.0-SNAPSHOT; string inequality reported the whole file stale on every release, which is the same as reporting none of it. A version that cannot be ordered, or one ahead of the surfaces, is now a failure rather than a note.
  • api-query carries the pack's own version with the answer. It ships in the bundle without check-routes, so a bare checked against: 2.2.3-SNAPSHOT gave a reader nothing to compare against. Its copy of the ordering rule is deliberate — build-bundle publishes that one file as bin/query.mjs with nothing beside it — and pack-version.test.mjs runs the same table through both so the copy cannot drift.
  • Smaller failures-to-report, each reproduced first: a directory in docs: killed the gate with an EISDIR stack trace naming no route; a constraint spelled toString reached Object.prototype and died on it, losing every error collected so far; a docs: written as a bare string was walked character by character; a mis-cased citation passed on Windows to fail on the runner as "no such page"; a root-relative /docs/x.md#y in prose matched nothing at all and was never checked.
  • proof=test:Class#method — a proof resolved by class name only, so deleting the one method that held a behaviour left the claim reading as proven. The two claims a single method genuinely holds now name it; the rest stay class-level because more than one method holds them.
  • CI discovers the fixture suites and then holds the discovered set against a declared list, so adding, renaming or deleting one goes red; release.yml runs them too, since build-bundle --verify asserts exit codes and cannot see whether the shipped copy orders versions correctly.

Verification

./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -amBUILD SUCCESS.

Knowledge gates: check-routes exit 0 (5 routes), check-claims --check current (46 claims, 8 proofs), build-bundle --verify answers with no repository present. Fixture suites 23 / 18 / 35 / 33.

New tests, and what each pins:

  • RowWidthDistributionContractTest (7) — the even split and its cost in line count; auto() + weight(1) as the repair; a grow spacer and every non-START arrangement entering the same intrinsic-sizing path; START as the boundary that does not.
  • LineWidthUnitsContractTest (8) — horizontal(n) is points and is not clipped; fill() spans its slot; a spacer neither shrinks a fixed rule nor gives fill() a slot; a flex row and a weight column are refused together; an aligned heading collapses the rule to zero width.
  • DocumentationLinkGuardTest (+2 methods) — the one docs/*.md#anchor cited from production Java resolves (the guard is fail-closed on the scan root, not on citation count), and the anchor rule agrees with its JavaScript twin on the measured table.
  • anchors.test.mjs, pack-version.test.mjs — new suites for the two libraries.

The geometry assertions are slot arithmetic, not font metrics: the left-hand child of a flex row is a fixed-width shape, and where a coordinate genuinely is font-dependent — a non-START arrangement spreads the leftover as a leading offset — only the widths are asserted, and the class says so.

Notes for review

  • The series reads as an audit trail on purpose. The anchor rule was wrong three times before it was measured, and the commits keep that visible rather than presenting a rule that looks like it was right first time.
  • Three routes still carry verifiedAgainst: 2.2.3-SNAPSHOT. The new ordering makes them announce themselves as not re-read, which is honest — re-stamping them without actually re-reading would be a false attestation. Left for a deliberate pass.
  • fill() in a flex row still overflows. It is documented, claimed and pinned by a test rather than fixed: RowSlots.growOf only recognises SpacerNode, so a repair is a new engine rule that moves rendered output for existing documents (TocBuilder already composes a fill() leader) — a behaviour decision, not an omission.
  • Public API is unchanged; the only src/main edit is Javadoc, so knowledge/api/ needed no regeneration. The CHANGELOG entry sits under develop's existing ## v2.4.0 — Planned, beside its ### Layout.

Lane: canonical (knowledge/ routing + docs/recipes) with a shared-engine read-only touch — the two contract tests assert RowSlots behaviour without changing it.

A row with no column spec, no grow spacer and the default START arrangement
splits its inner width into equal shares, one per child, so
add(icon.node(13)).addParagraph(...) hands a 13pt icon half the row and wraps
the text into twice the lines. horizontal(100) is 100 points and not 100
percent, and nothing clips a line to the slot it was given -- in a 135.7pt
sidebar column that rule is drawn 32.15pt past the column edge, with no
warning and a clean render.

Neither fact is in a signature, and neither had a route, so the pack could
only answer "this method exists".

layout.icon-beside-text recommends columns(auto(), weight(1)) and
layout.rule-beside-a-heading recommends fill() in a weight column; both carry
the alternatives with what each one costs, and both name the ways the repair
fails. Two sections in the layered-page-design recipe hold the claims, with
fences written in the session form so a reader who copies one gets a document
rather than a builder that goes nowhere.

The proofs are arithmetic wherever the engine allows it: the left-hand child
of a flex row is a fixed-width shape rather than a heading, so no assertion
rests on the default font's metrics, and widths are pinned rather than only
the direction of an overflow. Where a coordinate genuinely is font-dependent
-- a non-START arrangement spreads the leftover as a leading offset, and the
leftover is the row minus the paragraph's measured width -- only the widths
are asserted, and the class says so. Three failure modes are pinned alongside
the happy path: a grow spacer neither shrinks a fixed rule nor gives fill() a
slot, a non-START arrangement enters the same flex path with no spacer
present, and an aligned heading in the auto() column leaves the weight column
zero, so the rule is not misplaced but absent.

Both routes ship confirmedBy: null. Whether the recommendation is the right
one is not something the mechanical checks can answer.
confirmedBy is the one gate check-routes cannot close: the anchors resolve,
the symbols exist and the constraints are proven, but whether fill() in a
weight column is the right answer for a rule beside a heading is a reading of
the engine, not a fact about it. Both routes were served with their status
attached until that reading was made.

It has been. Recorded here rather than in the commit that wrote them, so the
field means what the file says it means -- the same shape as the first three
routes, which shipped null and were confirmed separately.
The three routes written first recorded confirmedBy: "DemchaAV", the GitHub
handle; the two confirmed since recorded "Artem Demchyshyn", the name already
used as @author throughout the sources and in the pom's <developers>. Both are
real committer identities on the same account, so nothing changes hands here
-- but two spellings of one person in a five-entry file read as two reviewers,
and check-routes cannot tell them apart: it only asks that the field name
somebody.

All five now say "Artem Demchyshyn". Kept as its own commit because it edits
three attestations that predate this branch.
LineBuilder.fill() promised "in a row column the line spans the whole slot"
with no exception, and that javadoc is the only prose an API-first reader
gets: the knowledge surface carries the signature and nothing else, so an IDE
tooltip and javadoc.io are the whole story.

A flex row has no slot. With a grow spacer or a non-START arrangement the row
sizes every non-grow child to its content, a filled line answers with the
row's whole available width, and it is then placed after the children before
it and runs past the edge -- the opposite of what the sentence promised, and
now pinned by LineWidthUnitsContractTest.

Javadoc only; no signature moves, so the surfaces are unchanged.
Two gaps between what the routing gate claimed and what it did, and the
apparatus to keep them closed.

check-routes' header said the recommendation "is checked as far as a machine
can -- the recommendedBecause must cite a real anchor". It did not: the field
was tested for emptiness only. That check now exists, over every prose field
api-query prints -- intent, recommendedBecause and each alternative's useWhen
and tradeoffs -- sharing one resolve function with the `docs:` list so a rule
added to the anchor check cannot land in one copy and miss the other.

The rule it resolves with is GitHub's, which the previous one was not.
anchorOf collapsed whitespace runs to a single hyphen while GitHub emits one
per whitespace character, so the gate disagreed with the rendered page on
every heading with a spaced em dash -- red on a link that works, green on a
link that 404s. It also read a "#" comment inside a fenced block as a heading,
and matched the docs/ tail of an absolute URL as a repo path, rejecting a
route for a page that exists. All of it is now lib/anchors.mjs with fixtures,
and the Java implementation in DocumentationLinkGuardTest was brought onto the
same rule: it left a non-breaking space in the anchor as a literal U+00A0 and
dropped non-decimal numerals. The four inputs on which the two disagreed are
asserted from both sides, so changing either reddens its own fixture.

Staleness compared version strings, so the SNAPSHOT-to-release flip at tag
time reported every route as older than the pack -- including routes signed
against exactly that code. Versions are ordered by their numeric head in
lib/pack-version.mjs, and the two states that are not age are failures rather
than notes: a version that cannot be ordered, and one ahead of the surfaces. A
pack carrying no version failed open and now does not, and the pack version is
read from a sorted listing rather than from whichever file the filesystem
happened to yield first.

api-query printed a route's version with nothing to compare it against, and
then compared it as a raw string -- reintroducing, in the copy that ships,
exactly the false staleness the gate had just stopped doing. It cannot import
the shared module: build-bundle publishes that one file as bin/query.mjs with
nothing beside it. So the duplicate is held by pack-version.test.mjs, which
runs the same table through both. Its `read:` line now also says when the page
it names is not inside the bundle.

A malformed citation used to kill the gate with an EISDIR stack trace naming
no route, and a mis-cased one passed on Windows to fail on the runner as "no
such page"; both now report what is actually wrong. The fixture suites are
found by glob rather than listed one step each, because the suite added in
this commit would otherwise have run only if someone remembered to edit the
workflow -- and a suite that never runs looks exactly like one that passes.
LineBuilder.fill() points a reader at a section of the recipe page, and it is
the only citation of that shape in production Java. Nothing resolved it: the
markdown half of this guard reads [text](target) links, which is every
reference a page can make and none a Javadoc block can write. So renaming that
heading fixes the page's own links and both copies in the routing table, and
leaves the Javadoc one dangling -- in the copy with the widest audience, since
the knowledge surfaces carry signatures and no prose.

The scan is held to its root rather than to a citation count: there may
legitimately be none one day, and "the tree moved and this read nothing" must
not look like "every citation resolves". Its pattern carries the same leading
boundary as the routing gate's, so the docs/ tail of a branch-pinned GitHub
URL is not validated against the current worktree -- three sources already
write such links, and this class deliberately leaves https:// targets alone.

Verified by renaming the heading in the Javadoc and watching it go red naming
both the source file and the page.
The cycle's entry was opened on develop by the composed-table-cell fix; this
adds a Documentation subsection beside its Layout one. Without it the recipe
sections and the LineBuilder.fill() Javadoc caveat would be invisible when the
2.4.0 release notes are assembled from this file, and both are the class of
change this changelog already logs: v2.3.0's Documentation subsection carries
a Javadoc-only preset-caps entry.
The rule was rewritten twice on reasoning and was wrong both times. This
version was measured: each heading below was posted to api.github.com/markdown
and the id it generated was read back.

    Zebra<NBSP>alternating rows   -> zebraalternating-rows
    Row<EM SPACE>span             -> rowspan
    A<TAB>B                       -> ab
    Item <U+2460> first           -> item--first
    Cafe<COMBINING ACUTE> rules   -> café-rules
    Zebra — alternating row fills -> zebra--alternating-row-fills

So GitHub *drops* every kind of whitespace except the ASCII space, rather than
hyphenating it, and drops a numeral that is not a decimal digit while keeping
a combining mark. The previous version hyphenated all Unicode whitespace and
kept every numeral, on the theory that GitHub would -- it does neither, and
the widening to \p{N} made `Item ① first` worse than the rule it replaced,
under a Javadoc paragraph that asserted the opposite. Both implementations now
carry the measured rule, and the table is asserted from both sides with the
values GitHub returned rather than the ones the code produces.

The em-dash case that started all of this is unaffected and still correct.

Two consequences fall out. Because non-ASCII whitespace is now removed by the
same class on both sides, the trim difference between JS and Java -- Unicode
one side, ASCII the other -- no longer changes any anchor, which closes the
last measured divergence between the twins. And both matchers were widened to
accept what the producers can now emit: an ASCII-only anchor group truncated
`#café-rules` to `#caf`, so the gate rejected a route whose link opens, while
the identical reference written in `docs:` passed because that path never went
through the regex.

The class contract that still described the deleted \w rule now describes this
one, and the orphaned comment left above the field is gone.
…pository

Three defects in the path handling the previous commit added, all of them in
the direction of failing a citation that is fine.

A valid non-canonical spelling -- "./docs/x.md", "docs//x.md", both of which
anchorRefsIn emits from prose -- was compared raw against canonical output and
reported as "the citation's casing does not match" when nothing was mis-cased.
The citation is normalised first now.

The casing check realpath'd the cited file but not the repository root, so a
checkout reached through a symlink, a bind mount or a Windows subst drive made
every citation in the file look mis-cased: run under `subst W:` the gate
reported ten problems and exited 1 against a clean run. Both sides of the
comparison go through the same resolution.

And nothing confined a citation to the checkout: "../docs/x.md#y" resolved
outside it and, with a sibling tree present, was validated against a file in
another repository while the gate printed "routes hold up". It is refused
before the filesystem is touched, and again after realpath, so a symlink
inside the tree cannot smuggle one out either.
…ctory

The note that tells a bundle reader the cited page is not in the archive was
suppressed by any directory named `docs` one level above the pack. The bundle
is normally unpacked inside another project, and a project with documentation
of its own is the common case -- so the note disappeared exactly where it was
needed and handed the reader a GraphCompose path that appeared to resolve
against their own tree. The self-sufficiency fixture could not see it either:
it unpacks into an empty scratch directory.

Each cited page is now resolved instead, which is what the note is about.
Replacing three named steps with a glob fixed hand-registration and opened two
new ways to run nothing: the glob was one level deep, so a suite under
test/<sub>/ would never run, and its only floor was "at least one exists", so
deleting or moving a suite left the step green on the survivors. Since this
step is now the only thing in the repository that executes a *.test.mjs, both
failures look exactly like passing.

Discovery is recursive, and the floor is a count that has to be raised
deliberately: drop a suite and the step goes red instead of quietly covering
less. Verified both directions locally against the real block.
Three symbols the new routes hand over -- DocumentRowColumn.fixed,
RowBuilder.arrangement and ParagraphBuilder.align -- were taught in the page's
prose and claimed nowhere, so the reverse index would not name that page when
they change. A rename would still have gone red on the route's own symbol
check; what was missing is the "which pages does this API change invalidate"
answer the claims index exists to give.

The tools README listed two of six directories and named the routing gate
nowhere -- not check-routes.mjs, which decides whether a route merges, nor the
two libraries it now rests on. It lists them, and says how a fixture suite gets
run, since that is no longer visible from the workflow by name.
The last pass measured six headings against GitHub and got the rule right for
all six. It was still wrong twice, because the six all put the odd character
between two words.

Asking GitHub about the edges and the two numeral classes:

    <EM SPACE><SPACE>A   -> -a      (not `a`: nothing is trimmed away)
    A<SPACE><EM SPACE>   -> a-
    Chapter <U+2160> here -> chapter-ⅰ-here   (a letter numeral survives)
    a<U+203F>f b          -> a‿f-b            (a connector survives)

So the keep-set was too narrow -- \p{Nl} and \p{Pc} belong in it, and dropping
them was a regression over the \w rule this replaced -- and the JavaScript
trim() was wrong: it strips Unicode whitespace, GitHub strips none, and Java's
String.trim() stops at U+0020. That last one is the divergence the previous
commit's message claimed to have closed. It had not: the twins disagreed on
every heading with a non-ASCII space at either end, and the ten pinned inputs
could not see it because none of them put the space at an edge.

Both implementations now carry the measured rule, both tables pin the edges
and the numerals, and anchorOf's output was diffed against all eighteen
answers GitHub gave -- it matches every one. The prose and Javadoc matchers
were widened to accept what the producers can emit, since a citation of a
heading in any other script was being truncated.
Five ways the routing tools reported the wrong thing, none of which any
existing route trips.

api-query's packVersion() wrapped every surface in one try with a bare catch,
so a single unparseable file returned null and the staleness note vanished
from every answer while four good surfaces still carried the stamp --
fail-open in the copy that ships, where the fail-closed gate does not travel.
The try is now per file. And renderTask forwarded only the "behind" verdict,
so an unorderable version and one ahead of the surfaces printed exactly like a
route re-read today; both are named now.

check-routes reached Object.prototype for a constraint spelled `toString`,
then died on it with a TypeError naming no route and losing every error
collected so far. A `docs:` written as a bare string was iterated character by
character -- one bogus "no such page" per letter, and the "no docs" guard
satisfied by a non-empty string's length. Both say what is actually wrong now.

And a root-relative citation (`/docs/x.md#y`) in prose matched nothing at all,
so a dangling anchor spelled that way was never checked while the gate said
every route holds up -- the identical string in `docs:` meanwhile failed as a
casing mismatch. A single leading slash is accepted and normalised; `//` is
still refused, because that is how the authority half of a URL gets in.
Discovery replaced hand-registration and traded one silent-skip mode for
another: nothing pinned a suite by name any more, and the floor equalled the
count, so renaming one out of the glob while adding another kept the
arithmetic and stopped a suite running. The set is now compared against a
declared list, so any change -- add, rename, delete -- goes red until the list
says so too. That is hand-maintenance again, but gated: the previous version
was hand-maintenance that failed open.

release.yml ran none of the suites. build-bundle --verify asserts exit codes,
which cannot see whether the version comparison inside the shipped
bin/query.mjs is right, and that copy exists precisely because the bundle
cannot import the shared module -- so at a tag the one thing pinning it never
ran. It runs there now, beside the claims and routing gates that are already
re-checked at the tagged commit for the same reason.
A `proof=test:` target resolved by class name only, so the gate caught a
renamed class and nothing else: delete the one method whose assertions hold a
behaviour and the claim goes on reading as proven, because the file is still
there. That is the shape of rot this pack exists to stop -- a claim that looks
held and is not.

`test:Class#method` is now resolved too, and stays optional so no existing
claim has to change. Verified by pointing a claim at a method that does not
exist and watching it name both the method and the file.
…rything

The check/failures/passes harness had been copy-pasted byte-identically into
all four fixture suites -- four places to improve a failure message, and four
chances for one to drift into saying something slightly different about the
same kind of failure. It is one module now; the suites report identically and
their counts are unchanged (23 / 18 / 35 / 33).

The tools README's index listed twelve of the twenty-two modules. The six
missing ones included the whole changeset tool, named in no tracked file at
all, and api-surface/lib/surfaces.mjs, which knowledge/manifest.json points
readers at by path. Every non-test module is listed now, and the note about
how a fixture suite gets run says what the workflow actually does.
Two claims that were broader than the code behind them.

The icon route called columns(auto(), weight(1)) "the only form that stays
right when the column is resized". Measured at 135.7pt and 260pt,
columns(fixed(13), weight(1)) gives 13.00pt at both — exactly as stable. The
route's own alternative already objects to fixed() on duplication grounds, not
stability, so the sentence contradicted the entry two lines below it. It now
says what auto() is actually better than: weights(...), where the share is
fixed and the icon's slot moves with the row.

And `proof=test:Class#method` shipped with no claim using it, so the defect it
was added for — a claim still reading as proven after the assertion holding it
is deleted — was live for all eight proofs. The two claims that a single method
genuinely holds now name it. Verified by renaming one away and watching
check-claims report the claim false; the rest stay class-level because more
than one method holds them, which is the honest shape.
@DemchaAV
DemchaAV merged commit c44b97d into develop Sep 8, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/routing-icon-and-rule branch September 8, 2026 06:35
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