Skip to content

Support the figure_group node: outline, panel crossrefs, new lint codes - #83

Merged
dereuromark merged 6 commits into
mainfrom
feat/79-composite-sat-6436a4ec
Aug 15, 2026
Merged

Support the figure_group node: outline, panel crossrefs, new lint codes#83
dereuromark merged 6 commits into
mainfrom
feat/79-composite-sat-6436a4ec

Conversation

@dereuromark

Copy link
Copy Markdown
Contributor

Closes #79.

What was already done

Re-measured against main at 9efaca0 before planning, because part of this
ticket had landed. #82 already added folding, the semantic-token case scoping
the opener's kind word and collecting the group caption, the hover description,
and figure in ::: completion - plus the engine pin that unblocked the
ticket. None of that is repeated here.

The diagnostics bullet also needed no implementation. The engine emits all
five §4c codes and the server passed them through verbatim already; nothing
pinned that, so a passthrough that stopped working would have been silent. Each
of the five now has a test, with a well-formed group as the control.

The schema ingest bullet has no work in this repo: there is no
resources/ast-schema.json here and nothing validates wire payloads. The only
hits are inside the tests/spec submodule, which is the spec repo's own copy.

The premise that moved, and what it changed

The ticket reads as though panel crossrefs are the gap. They are not the gap;
they are one case of it. The server resolved no caption crossref at all -
not a group's, not a panel's, and not the plain captioned figure's that predates
composite figures entirely. Measured on the pinned build before any change:

{#fig}
![p](p.png)
^ Figure #: Plain

See </#fig>.

definitionAt on that </#fig> returned null; </# completion offered
nothing for it; find-references answered nothing; and hovering it reported
Heading with the #-through-###### blurb, because no case existed for the
reference and the lexical fallback matched the # inside it. The engine
resolves it fine - carveToHtml writes <a href="#fig">Figure 1</a> - so this
was a server gap, not a dependency one.

That changed the shape of the work rather than its size: implementing "a panel
id resolves as the group's number plus a letter" on a server that resolves no
caption ids would have been building the second floor. This PR makes crossrefs
reach captioned hosts generally, with the group and panel rules from §4c as the
cases that motivated it. That measurement is recorded on the ticket alongside
the plan.

What this adds

src/captions.ts collects the captioned hosts a </#id> can name - figure,
table, figure_group, and a group's panels - each with its id, its host
position, and the text the reference resolves to. Completion, definition,
references and hover all read it.

The NUMBER is the engine's own resolved caption_number, not a re-derived
sequence: PART 9R R5 lives in the engine, and a second copy would drift the
first time a label sequence changed. Only the panel LETTER is derived here
(panel order among panels, a..z then aa), because no engine API exposes it - and
the tests pin it against the anchor text carveToHtml writes for the same id,
so a change to the letter scheme upstream fails here rather than diverging
quietly in an editor.

Panels are a group's DIRECT figure and table children, so stray content
between them takes no letter and does not shift the letters around it. An
unnumbered group registers anchors without crossref text for itself or its
panels, matching what the engine renders and what §4c states - and completion
leaves such an id out rather than offering a reference that renders as literal
text.

The outline gains the group: named by its caption, with its panel count as
the detail and its panels nested under it. It takes no heading level, so it
hangs under the section it appears in and never closes one. A panel with no
caption of its own falls back to the letter a crossref would use for it. A
panel yields no entry itself, but the walk still descends through it, so a
heading inside a quoted panel stays in the outline where it was.

Review findings

codex review --base origin/main ran after each step. Four findings, all
verified against the code and all real:

  1. Completion offered ids that cannot resolve. An unnumbered host's id is a
    real anchor and a [text](#id) fragment link reaches it, but a crossref to
    it renders as literal text - so it was the one entry in a list of working
    references that quietly did not work. Filtered out; the "Unnumbered figure"
    detail string went with it rather than staying as a branch nothing reaches.
  2. Find-references answered from a usage but not from the declaration, which is
    half of what the module documents for every other family. Added - and while
    adding it, the shape test I first put on the declaration line
    (/^\s*\{.*\}\s*$/) turned out to be a check that could only reject a right
    answer: a host has an id only because a block-attribute line gave it one, so
    the line above it IS that line, and the pattern only rejected spellings of it
    such as > {#fig} inside a block quote. Removed, with the weak control that
    passed either way replaced by two that do not.
  3. The declaration lookup is matched by LINE, so running it inside the heading
    family put it ahead of link references: a host line carrying ![alt][img]
    answered the FIGURE's references for a cursor on img. It moved to the end
    of the chain, where a line-matched family belongs.
  4. Reusing the heading usage scan reported every [text][] whose slug matched a
    caption id. That collapsed fallback is heading-only (PART 9R R1) - [foo][]
    beside a figure {#foo} renders as literal text - so the scan is now scoped
    to a heading declaration, with the same spelling against a heading as the
    control.

The fifth pass came back clean.

…tline

Two halves of #79, and the first is wider than the
ticket reads.

CROSSREFS. The ticket asks for panel ids to resolve as the group's number
plus a letter. Measuring first showed panels are not the gap - they are one
case of it. Every crossref feature walked HEADINGS only, so `</#fig>` naming
a plain captioned figure, a construct that predates composite figures
entirely, offered no completion, jumped nowhere, and hovered as a heading
because no case existed for the reference and the lexical fallback matched
the `#` inside it. Implementing the panel rule on top of that would have been
building the second floor.

`captions.ts` collects the captioned hosts a `</#id>` can name - figure,
table, figure_group, and a group's panels - and completion, definition,
references and hover all read it. The number is the engine's own resolved
`caption_number` rather than a re-derived sequence: PART 9R R5 lives in the
engine and a second copy would drift the first time a label sequence changed.
Only the panel LETTER is derived here, because no engine API exposes it, and
the tests pin it against the anchor text `carveToHtml` writes for the same id
- so a change to the letter scheme upstream fails here rather than diverging
quietly in an editor.

Panels are a group's DIRECT figure and table children, so stray content
between them takes no letter and does not shift the letters around it. An
unnumbered group registers anchors without crossref text for itself or its
panels, matching what the engine renders and what §4c states.

OUTLINE. A composite figure is a container an author folds and navigates, so
it earns an outline entry the way a heading does: named by its caption, with
its panel count as the detail and its panels nested under it. It takes no
heading level, so it hangs under the section it appears in and never closes
one. A panel with no caption of its own falls back to the letter a crossref
would use for it, rather than to a number it does not have.

The five §4c lint codes needed no server work - the engine emits them and the
server already published them verbatim - but nothing pinned that, so a
passthrough that stopped would have been silent. Each now has a test, with a
well-formed group as the control.
…answer at the declaration

Two findings from the review pass, both real.

An unnumbered host is no longer offered for `</#`. Its id is a real anchor
and a `[text](#id)` fragment link reaches it, but a CROSSREF to it renders as
literal text (PART 9 §4c), so offering it was the one entry in a list of
working references that quietly did not work. The "Unnumbered figure" detail
string goes with it rather than staying as a branch nothing can reach.

Find-references now answers from the DECLARATION as well as from a usage,
which is what this module documents for every other family. A heading
declares its id on its own line and asking there already worked; a captioned
host declares it on the block-attribute line above itself, which sits outside
the host's span and so is matched by adjacency rather than by containment.
The control is an ordinary line inside the group, which declares nothing.
…ght answer

The declaration lookup tested the line above a host against
`/^\s*\{.*\}\s*$/` before accepting it. That reads like a safety check and is
not one: a host appears in this list only because a block-attribute line gave
it an id, so the line above it IS that line by construction. The pattern
therefore cannot reject a wrong answer, and it does reject a right one -
`> {#fig}` inside a block quote does not match an anchored brace, so asking
for references there answered nothing.

The weak control went with it. "An ordinary line declares nothing" pointed at
a line adjacent to no host at all, so it passed whether the guard was there
or not. Its replacements are the quoted declaration, which the guard broke,
and a host with no id, which is what the lookup actually keys on.
…tion

The declaration lookup is matched by LINE rather than by a construct under
the cursor, so it answers for every column on that line. Running it inside
the heading family put it ahead of the link-reference, citation and wikilink
families: a host line carrying a reference image (`![alt][img]`) answered the
FIGURE's references for a cursor sitting on `img`, which is a regression on
behavior that predates this branch.

It moves to the end of the chain, where a family matched by line belongs, and
the reason is written where the function is so the ordering is not restored
by accident. The control is that same document, asked on the label and then
on a column of the line nothing else claims.
Filtering panels out of the group's traversal removed more than the panel
entries: a panel can wrap a quote holding headings, and the walk descended
through the panel to find them before the group had an entry at all. Those
headings vanished from document symbols and from the included-file symbol
list with them.

A panel still yields no outline entry of its own - it rides on the group's
symbol - but the walk descends through it again. The control is a heading
inside a quoted panel, which lands under the section the group sits in.
Reusing the heading usage scan for a captioned host reported every `[text][]`
whose slug matched the id. That fallback is heading-only (PART 9R R1): beside
a figure `{#foo}`, `[foo][]` renders as literal text, so the server was
offering a usage the author cannot follow and did not write.

The lookup now says WHAT declares an id, not only where, and the collapsed
scan runs for a heading declaration alone. The other two usage forms - the
crossref and the fragment link - reach both kinds and are unchanged. The
control is the same spelling against a heading, where it still counts.
@dereuromark
dereuromark merged commit 1df61d7 into main Aug 15, 2026
1 check passed
@dereuromark
dereuromark deleted the feat/79-composite-sat-6436a4ec branch August 15, 2026 18:28
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.

Support the figure_group node: outline, panel crossrefs, new lint codes

1 participant