ID |
|
|---|---|
Status |
In Progress |
Bucket |
architecture |
Priority |
5 |
Theme |
lsp |
Created |
2026-06-19 |
Updated |
2026-07-01 |
Consolidate graphitron-lsp navigation, dispatch, and result-building
graphitron-lsp grew feature-by-feature (completions, diagnostics, hover, definition, inlay,
code-action), and each feature re-implemented the same three primitives locally: walking the
tree-sitter AST, dispatching on a coordinate’s Behavior, and building an lsp4j result from a
node’s byte range. The result is a module whose spine is sound but whose connective tissue is
copy-pasted. The cost is paid on every new feature and every grammar/directive change: a node-kind
rename is a 69-site edit, a new directive carve-out is a five-site edit, and a navigation bug fixed
in one feature stays live in the ten copies next to it (two copies have already drifted into latent
NPEs).
This item is an umbrella: it does not introduce new LSP behaviour. Every slice is a behaviour-preserving consolidation that removes a duplication axis and leaves one authoritative home for a primitive the features share. The principle:
A primitive that every feature needs (AST navigation, behavior dispatch, result construction, directive policy) lives in exactly one place; features consume it, they do not re-derive it.
The sound spine stays untouched and is the thing each slice consolidates toward: the Behavior /
SchemaCoordinate / CanonicalOverlay dispatch index with its startup invariant
(LspVocabulary.java:62-70), the DeclarationKind pattern (node-kind strings as enum constants
lookup map + uniform traversal entry points), the freshness-aware LspSchemaSnapshot matching, and
the recalculate-listener seam. DeclarationKind already demonstrates the destination shape for the
node-kind work below; this item generalizes that instinct across the module.
What’s wrong (the duplication axes)
Each row is a primitive re-implemented per feature rather than shared. The slice that retires it is named in the last column.
| Smell | Evidence | Slice |
|---|---|---|
|
|
1 |
Other navigation helpers duplicated: |
Hovers, Definitions, InlayHints, ArgNameCompletions, NestedArgs, LspVocabulary |
1 |
Node-kind names are bare string literals (69 occurrences; |
module-wide; |
1 |
The |
|
2 |
Cross-cutting directive policy is duplicated as magic strings + comments rather than carried on the model: the |
|
2 |
Two features still dispatch on directive-name string switches, inconsistent with the coordinate-driven design everywhere else. |
|
2,3 |
10 completion providers share no contract; dispatch is a 40-line manual waterfall with bespoke per-provider signatures and load-bearing ordering encoded only in a comment |
|
3 |
Verbatim provider duplication: |
|
3 |
Each feature re-implements the byte-range → lsp4j |
|
4 |
Test-only |
|
5 |
|
|
5 |
|
|
5 |
|
|
6 |
Slices
Each slice is behaviour-preserving; the order below names dependency edges, not a fixed schedule.
The slices land as sequential phases of this item (R347 carries In Progress until the last
worthwhile slice ships); each phase’s section collapses to a one-line "shipped" note as it lands.
Slice 1 is the beachhead and unblocks the rest; Slices 2-6 are largely independent once it lands.
The acceptance bar for every slice is the same: the existing graphitron-lsp test suite passes
unchanged (it is the behaviour oracle), and the slice reduces duplication (deleting more than it
adds, beachhead infrastructure aside).
Slice 1 (beachhead) — one navigation home — SHIPPED
Shipped: introduced GraphqlNodeKind (sibling to DeclarationKind, settling the open question in
favour of a sibling so DeclarationKind keeps its declaration-only semantics) and grew Nodes into
the single tree-sitter navigation toolkit (childOfKind, nodeContains, sameNode,
innermostObjectFieldContaining). Deleted the 12 verbatim childOfKind copies, the 3
innermostObjectFieldContaining near-duplicates, the 2 nodeContains copies and the stray
sameNode / findInnermostObjectField; routed every structural node-kind comparison
(name/value/object_field/… ) through GraphqlNodeKind.matches(…).
Learnings vs. the original sketch:
-
The latent-NPE count was three, not two:
Definitions:240andTypeContext:164(the two named in the table) plus an unguardedargNode.getType()inTypeContext.stringArg. All three are fixed by the null-safe sharedchildOfKind/matches.NodesTestpins the null-safety contract as the explicit regression oracle. -
GraphqlNodeKindcovers structural / intra-declaration kinds only. The lone declaration-kind literal ("scalar_type_definition"inScalarTypeCompletions) was left for theDeclarationKind-consumer cleanup rather than blurring the sibling split; thecase "field"/case "table"directive-name switches stay for Slice 2. -
Line accounting: tracked deletions exceed additions by 36 lines; including the new
GraphqlNodeKindthe main source is roughly net-neutral (+17), with the duplication axis itself collapsed (12 → 1, 3 → 1, 2 → 1).
Slice 2 — one behavior dispatch, policy on the model — SHIPPED
Shipped: introduced DirectivePolicy (a string-keyed final class in parsing, sibling to
Behavior; the user settled the fork in favour of a standalone table over arm-flags on Behavior,
since the carve-outs key on directive name where a single coordinate is shared across directives).
Two predicates, bindsLiveClass(name) (false only for @record, R307) and bindsLiveMethod(name)
(the former METHOD_VALIDATING_DIRECTIVES set), now own the two constant sets; the five copy-pasted
"record".equals(…) carve-outs (ClassNameCompletions, Diagnostics ×2, Hovers, Definitions)
and the privately-owned method-validating set route through them. DirectivePolicyTest pins the
contract. Collapsed Definitions.compute + bindingDefinition into one coordinate-driven dispatch
(locateAt → behaviorAt → exhaustive Behavior switch, no default), so Definitions joins
Diagnostics / Hovers on the shared dispatch shape and its case "table"/"field"/"reference"
directive-name switch retires; the jOOQ-half helpers now read the cursor’s leaf value. The
exhaustive switch closes the latent gap where the old default → Optional.empty() silently dropped
the three catalog bindings.
Learnings / scope vs. the sketch:
-
Behaviour delta (beneficial, called out): because
Definitionsnow dispatches on the resolved coordinate rather than the directive name, a class binding nested inside a jOOQ directive (e.g.condition.classNameinside@reference(path:)) now resolves goto-definition through the service half instead of being silently ignored. Net-additive; the existing 419-test oracle stays green. -
InlayHints switch deferred to Slice 3, not dropped. The
switch(entry.directiveName())inInlayHints.collectInferredDirectiveHintskeys onInferredDirectiveArgs.Entry, not a coordinateBehavior, so the spec’s "route throughbehaviorAt`" mechanism does not apply. The clean retirement (a present-arm strategy mirroring the existing sealed `AbsentArm) cannot live on the catalogEntry:graphitroncannot depend ongraphitron-lsp, yet the present renderers need LSP-only context (WorkspaceFile,TypeContext, the built snapshot). The only compile-safe home is an LSP-side renderer registry asserted complete by test, which is exactly Slice 3’s provider-contract shape; moved there.
Slice 3 — a completion-provider contract — SHIPPED
Shipped: introduced the CompletionProvider functional seam over a shared CompletionRequest (the
union of the ten providers' bespoke argument tuples), and a Completions dispatcher whose
providersFor(Behavior) is an exhaustive sealed switch over Behavior, replacing the 40-line
hand-ordered completion waterfall that lived in GraphitronTextDocumentService. The load-bearing
ordering (@externalField’s narrowed method list ahead of the generic one) is now the list order in
that switch, not a comment, and a new `Behavior arm is a compile error in the switch until it names
its provider(s). Folded the byte-identical formatSignature (two copies) and the
new CompletionItem + setKind + setTextEdit idiom (ten copies) into a CompletionItems factory,
and hoisted the triple-vs-single quote-delimiter logic to one CompletionContext.openingQuoteLength
shared by the range builder and ArgMappingCompletions.
Retired InlayHints.collectInferredDirectiveHints’s `switch(entry.directiveName()) (moved here from
Slice 2) for a Map<String, InferredDirectiveRenderer> registry keyed by directive name; the old
default → {} that silently dropped an unrendered InferredDirectiveArgs.Entry is gone, and
InlayHintRendererCoverageTest now fails the build when an entry has no renderer, the LSP-side mirror
of the catalog’s sealed AbsentArm.
Learnings / scope vs. the sketch:
-
Behavior guards stayed in the providers, they did not "move into the dispatcher." The dispatcher owns provider selection by arm (the sealed switch), but each provider keeps its
behaviorAt(coordinate) instanceof …guard. The deciding constraint was the behaviour oracle: the per-provider unit tests call eachgenerate(…)directly and pin the guard (e.g.ClassNameCompletionsTest.referencePathTopLevelClassNameDoesNotCompleteasserts emptiness that is the guard’s doing, not the dispatcher’s). Moving the guard out would force re-leveling those negative tests to dispatcher-level tests, a behaviour-risking change disproportionate to the win; the retained guard is now a cheap confirm of the arm the switch already selected, and keeps each provider independently unit-testable. The registry adapts the sharedCompletionRequestto each provider’s unchangedgenerate(…)signature via a lambda, so the ten provider APIs and their tests are untouched.
Slice 4 — one result-builder
A small LspResults/Ranges helper owns the byte-range → Range and node → result construction
that Diagnostics, Hovers, DeclarationHovers, and InlayHints each re-implement. Optionally
consolidate the markdown rendering split between Hovers (catalog metadata) and
DeclarationHovers (classification) behind a HoverContent builder if it earns its keep.
Slice 5 — API hygiene, correctness, concurrency, perf
A grab-bag of independently shippable fixes that share no new abstraction:
-
Make
vocabularya required parameter on the featurecompute()methods; delete theLspVocabulary.load()-calling overloads (a test helper supplies the bundled vocabulary once); threadworkspace.vocabulary()intoSdlActionsso code-action requests stop re-parsing the SDL. -
Bundle
catalog/catalogFacts/sourceIndex/snapshot/validationReportinto one immutableBuildOutputrecord behind a singlevolatilereference, sosetBuildOutputis an atomic swap and readers never see a torn set. Raised stakes since this item opened: the R341/R361graphitron-mcpserver is now a second concurrent reader of the sameWorkspace, on Jetty’s servlet thread pool rather than the LSP’s, and several of its tools read more than one field per call (schemaTool:snapshot+catalog;diagnosticsTool:validationReport+snapshot;edgesTool:snapshot+catalogFacts). The per-fieldvolatile`s (and the R362 comment on `Workspace.catalogFactsclaiming "a single set of volatile reads observes one consistent build state") do not actually make those multi-field reads atomic: a reader interleaving withsetBuildOutput’s separate writes can still pair a new snapshot with an old catalog/facts. The single-reference swap is the fix, and the bundle must include `catalogFactsandsourceIndex, not just the original triple, because the MCP reads those too. -
Give
WorkspaceFileaclose()(free the nativeTree/Parser) and call it ondidClose. -
Materialize
CodeActionsrewrite results once per match, then partition (drop the 3× call). -
Switch the recalc queue dedup to a
LinkedHashSet; collapse the duplicated native-library path probing betweenBundledLibraryLookupandGraphqlLanguage(incl. the identicaladdVcpkgDll) into one parameterized table; remove theArgMapping.parseSpansno-op alias and the staleCodeActions.openUriscomment.
Slice 6 (optional) — split LspVocabulary
Extract CanonicalOverlay (the "what binds where" policy) and DeprecationInfo /
LspStartupException into their own files, and separate the parallel cursor-walk and document-walk
traversal groups. Lowest urgency; do it only if Slices 1-2 leave it still hard to navigate.
Relationships
-
R307 (
@recorddeprecation): the source of the"record"carve-out Slice 2 centralizes. This item does not change the carve-out’s behaviour, only its home. -
Other
lsptheme items (lsp-defaultorder-column-completion,lsp-nodetype-hover-column-scoping,lsp-diagnostic-redundant-splitquery-on-record,parent-context-aware-schema-coordinates, …): feature work that will consume the consolidated primitives. Sequencing is soft, but landing Slice 1 first means those features add one provider/arm against a shared navigation layer rather than forking another copy. No hard dependency either way. -
intellij-lsp-plugin/graphitron-mcp: downstream surfaces of the same server; they benefit from the cleaner seams but are not blocked on this. Since this item opened, the MCP server landed for real: R341 shipped the skeleton (graphitron-mcpmodule), R361 gave it a compile edge ongraphitron-lsp, and R118 is the wider programme. It is "one model, two views": it reads the same liveWorkspacethe LSP reads (catalog/catalogFacts/snapshot/sourceIndex/validationReport/LspVocabulary) and deliberately does not speak LSP or reuse the caret-centric completion providers, so Slice 3’s completion-dispatch rework is orthogonal to it (verified:graphitron-mcpimports onlyWorkspacefrom the LSP, none ofcompletions/Completions/GraphitronTextDocumentService, and the full reactor build stays green). Its two live intersections with the remaining slices are the sharedWorkspaceread surface (Slice 5’s torn-read fix, now a two-consumer concern, above) andLspVocabulary, which itsdirectivesresource reads (so Slice 6’s optional split must keep `LspVocabulary’s read API stable).
Open questions (to settle before / during Ready)
-
Node-kind constants home: extend
DeclarationKind(which already centralizes declaration-level kinds) to cover intra-declaration kinds, or introduce a siblingGraphqlNodeKind? Leaning sibling, soDeclarationKindkeeps its declaration-only semantics. -
Directive policy shape (Slice 2): SETTLED in favour of a standalone
DirectivePolicypredicate table over arm-flags onBehavior. The deciding reason was not record width but axis orthogonality: the carve-outs key on directive name where a singleBehaviorcoordinate is shared across directives (ExternalCodeReference.classNameunder both@recordand@enum), so the policy genuinely cannot hang off the arm. -
Slice independence: confirm Slices 2-5 carry no hidden ordering once Slice 1 lands (expected yes; each touches a disjoint concern). If a real edge surfaces, file it as a
depends-on. -
Whether Slice 6 is worth doing at all after 1-2; defer the decision to the end.
Scope
In scope: behaviour-preserving consolidation of the four shared primitives (AST navigation, behavior
dispatch, result construction, directive policy), plus the API-hygiene / correctness / concurrency /
perf fixes in Slice 5, all under graphitron-rewrite/graphitron-lsp/. The behaviour oracle is the
existing test suite; no new LSP behaviour, no protocol-surface changes, no new completions /
diagnostics / hovers.
Out of scope: any new LSP feature; changes to the Behavior / SchemaCoordinate / CanonicalOverlay
dispatch semantics (only their callers); the legacy modules at the repo root; the IntelliJ plugin
and MCP server surfaces (separate items).
Lineage
Surfaced 2026-06-19 from a structural code review of graphitron-lsp requested to ease future
maintenance. The review walked all ~7700 lines of main source and grouped the findings into the
duplication axes tabulated above; this item slices those findings into behaviour-preserving,
independently shippable consolidations.