From: Javier Sagredo Date: Thu, 18 Jun 2026 22:58:28 +0000 (+0200) Subject: Rewrite INTERNALS.md to match current data processing X-Git-Url: https://git.sagredo.dev/?a=commitdiff_plain;h=cbb691d6b2123a6e20c30dbafaa9c16309042f8f;p=classgraph.git Rewrite INTERNALS.md to match current data processing Rewritten from scratch, much shorter and reflecting reality: - typecore.js as the DOM-free, unit-tested unification/matching core (makeTypeCore), and one-sided matchTypeArg (a concrete instance no longer 'matches' an abstract requirement). - reduceTypeArg walks the family index, which now folds in closed-family tfEquations. - deterministic merge (dumps sorted by package/module before dedup). - data-instance constructor extraction with rep-tyvar substitution. - the <-escaping of the embedded JSON. - typeArg/tyConArg/dataFamilyRepArg split. Dropped the verbose prose and ToC. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index df2ebf0..3be129a 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -1,592 +1,177 @@ -# How classgraph gets its data - -This document explains where every piece of information in the viewer -comes from, how type-family resolution works, and what the user can and -cannot trust about the rendered chains. It's targeted at someone who -wants to know whether a given edge in the graph is "real" GHC information -or an inferred (possibly fragile) reconstruction. - -## Table of contents - -- [Pipeline at a glance](#pipeline-at-a-glance) -- [Where extraction runs](#where-extraction-runs) -- [Classes](#classes) -- [Class instances](#class-instances) -- [Type and data families](#type-and-data-families) -- [Type family instances](#type-family-instances) -- [Type-family resolution in the viewer](#type-family-resolution-in-the-viewer) -- [Are the resolution chains deterministic?](#are-the-resolution-chains-deterministic) -- [Predicate nodes and how they dedup](#predicate-nodes-and-how-they-dedup) -- [Synthetic "Family args = ?" placeholders](#synthetic-family-args---placeholders) -- [What the viewer is *not* doing](#what-the-viewer-is-not-doing) - -## Pipeline at a glance +# How classgraph processes the data -``` - ┌────────────────────┐ typecheck ┌─────────────────────┐ - │ user's source code │ ──────────────► │ TcGblEnv (in GHC) │ - └────────────────────┘ │ tcg_tcs │ - │ tcg_insts │ - │ tcg_fam_insts │ - └──────────┬──────────┘ - │ typeCheckResultAction - ┌──────────▼──────────┐ - │ Classgraph.Extract │ - │ (this repo's lib) │ - └──────────┬──────────┘ - │ JSON - ┌──────────▼──────────┐ - │ classgraph-view │ - │ merge + render │ - └─────────────────────┘ -``` - -All extraction happens **after typechecking** but **before desugaring to -Core**. By that point GHC has already: - -- Resolved all class superclass theta to fully simplified `PredType`s. -- Fully kind-elaborated every `Type`. -- Computed the closed normal form of class instance heads. -- Fully populated `tcg_insts` (the local instances) and - `tcg_fam_insts` (the local family instances). - -That's important: every fact we record is *what GHC itself sees and -uses* once typechecking is done. We don't run our own typechecker, our -own constraint solver, or our own type-family reducer over the source -code. We just *transcribe* GHC's `TcGblEnv` into JSON. - -The single exception — **class-instance resolution chains via type -families** — happens in the viewer (JavaScript), purely against the -already-extracted JSON. That logic *is* a reconstruction, and is -called out explicitly in the -["Type family resolution in the viewer"](#type-family-resolution-in-the-viewer) -section below. - -## Where extraction runs - -The plugin registers a single hook: - -```haskell -plugin = defaultPlugin - { typeCheckResultAction = \_args _summ tcg_env -> do - liftIO (writeJsonForModule tcg_env) - return tcg_env - } -``` - -`typeCheckResultAction :: [CommandLineOption] -> ModSummary -> TcGblEnv -> TcM TcGblEnv` -runs once per module compiled with the plugin, after the typechecker -has finished but *before* GHC desugars to Core. We never modify -`tcg_env`; the plugin is observation-only. - -This is the latest hook at which all the extracted data is reliably -available without us having to do interface-file fishing. In -particular, `tcg_insts` and `tcg_fam_insts` are already populated by -this point with every instance the user wrote in this module, including -deriving-clause instances and standalone-deriving instances. - -## Classes - -Source: `tcg_tcs :: [TyCon]` filtered by `tyConClass_maybe`. For each -`Class` the schema records: - -| Schema field | GHC API | -|-------------------|-----------------------------------------| -| `ciName` | `className cls` — the class's `Name` | -| `ciTyVars` | `classTyVars cls` | -| `ciSuperclasses` | `classSCTheta cls`, decomposed below | -| `ciAssocTypes` | `classATs cls` — the assoc family TyCons| -| `ciMethods` | `classMethods cls` (only the names) | -| `ciSrc` | `nameSrcSpan (className cls)` | - -Superclass theta needs a tiny bit of decomposition because GHC -represents some constraint shapes structurally: - -- We use `classifyPredType :: PredType -> Pred` to split every - superclass into a (`ClassPred`, `EqPred`, `IrredPred`, `ForAllPred`). -- `ClassPred (cTupleClass _) [a, b, ...]` is a constraint tuple — we - recurse into each `a`, `b` and emit one `SuperclassEdge` per element. - This is needed because GHC sometimes hands us a single tuple-shaped - pred where the user wrote `class (Foo a, Bar a) => …`. -- The boxed equality classes `(~)` and `(~~)` are technically - `ClassPred`s (they really are classes in GHC's type theory), but for - rendering we recognise them by their `OccName` and emit them as - equality predicates with two operands rather than as edges to a - synthetic `~` node. -- For all other class predicates we emit one edge. - -Args of class predicates run through a `visibleArgs` filter -(`tyConBinders` + `isInvisibleTyConBinder`), so kind / runtime-rep -arguments don't leak into the rendered superclass list. - -## Class instances - -Source: `tcg_insts :: [ClsInst]`. Each `ClsInst` becomes one -`InstanceInfo`: - -| Schema field | GHC API | -|----------------|------------------------------------------------------| -| `iiClass` | from `instanceSig inst` | -| `iiArgs` | from `instanceSig inst` (the head args) | -| `iiContext` | from `instanceSig inst` (the theta), decomposed | -| `iiTyVars` | from `instanceSig inst` | -| `iiOrphan` | `isOrphan (is_orphan inst)` | -| `iiOverlap` | pretty-print of `is_flag inst` | -| `iiSrc` | `nameSrcSpan (varName (is_dfun inst))` | - -`instanceSig :: ClsInst -> ([TyVar], [Type], Class, [Type])` returns the -fully resolved (`tvs`, `theta`, `cls`, `args`) for the instance's -dictionary function. That theta is whatever GHC stored on the dfun -after typechecking. So the context we display is exactly what the -solver would require to use the instance. - -Two non-obvious choices: - -- **`iiSrc` from the dfun, not the class.** The dfun's `Name` is tagged - with the source location of the `instance …` declaration GHC - synthesised it from. The class's `Name` only has a useful span when - the class was defined in a module compiled in the *current* package — - for classes loaded from another package's `.hi` file the span is - `UnhelpfulSpan` (interface files don't preserve source locations). - Using the dfun span means "Defined at" works uniformly. - -- **`iiContext` decomposition** uses the same `classifyPredType` + - CTuple / equality logic as superclass theta, so a user-written - `instance (Foo a, Bar a) => Baz a` produces two distinct context - predicates rather than one tuple-shaped one. - -## Type and data families - -Source: `tcg_tcs` filtered by `isFamilyTyCon`, plus the union with -every class's `classATs` (associated families belong to a class but -aren't always also in `tcg_tcs` directly). Per family: - -| Schema field | GHC API | -|-----------------|--------------------------------------------------| -| `tfName` | `tyConName tc` | -| `tfTyVars` | `tyConTyVars tc` | -| `tfFlavor` | from `famTyConFlav_maybe tc` and the class index | -| `tfResultKind` | `tyConResKind tc`, pretty-printed | -| `tfSrc` | `nameSrcSpan (tyConName tc)` | -| `tfEquations` | for closed families: branches of the `CoAxiom` | - -Flavor taxonomy: - -- `OpenSynFamilyTyCon` → `OpenFam` -- `ClosedSynFamilyTyCon (Maybe (CoAxiom Branched))` → `ClosedFam`, - with the branches extracted into `tfEquations` -- `BuiltInSynFamTyCon{}` → `ClosedFam` (treated as closed for our purposes) -- `DataFamilyTyCon{}` → `DataFam` -- Anything found under some class's `classATs` → `AssocFam parentClass` - -Closed-family equations don't live in `tcg_fam_insts` — they're -`CoAxBranch`es inside the family's `CoAxiom Branched`. We extract them -via `coAxBranches (co_ax_branches ax)` and treat each branch as a -`FamInstInfo` for rendering purposes. - -## Type family instances - -Source: `tcg_fam_insts :: [FamInst]`. Each becomes one `FamInstInfo`: - -| Schema field | GHC API | -|---------------|------------------------------------------------------------| -| `fiFamily` | `fi_fam fi` | -| `fiTyVars` | `fi_tvs fi` | -| `fiArgs` | `fi_tys fi`, filtered through `visibleArgs (famInstTyCon fi)` | -| `fiRhs` | `fi_rhs fi` (note: for data fam-instances this is the synthetic R: TyCon, which we then rewrite — see below) | -| `fiSrc` | `coAxBranchSpan (coAxiomSingleBranch (fi_axiom fi))` | -| `fiIsData` | `fi_flavor fi == DataFamilyInst _` | -| `fiDataCons` | `tyConDataCons rep_tc`, where `rep_tc` is pulled out of `DataFamilyInst rep_tc` (see below) | -| `fiDefinedIn` | filled in by the merge step from the enclosing `ModuleDump`'s package id (after normalisation) | - -A few extraction choices worth documenting: - -- **Visible-arg filter on `fi_tys`.** GHC stores the kind args of a - poly-kinded family alongside the value args. For - `data family Wrap (l :: K)` we'd otherwise get - `Wrap (((TYPE (BoxedRep Lifted)) -> …) l)` instead of `Wrap l`. - `tyConBinders (famInstTyCon fi)` tells us which positions are - invisible binders; we drop those. - -- **`fiSrc` from the axiom branch, not the family name.** Each - fam-instance has its own `CoAxiom Unbranched` representing the - equation; `coAxBranchSpan` of its single branch points at the - `data instance` / `type instance` declaration in the user's source. - The family's `Name` would point at the `data family` / `type family` - declaration, which is wrong for every instance after the first. - -- **Data-family RHS is rewritten to the abstract family form.** GHC - represents `data instance Foo Args = …` internally as a synthetic - `R:FooArgs` TyCon. When `R:FooArgs` shows up inside a constraint - or instance head, the user reads it as `Foo Args` (the abstract - application). `Classgraph.Extract.typeArg` gates on - `tyConFamInst_maybe` *before* the regular `TyConApp` path: a TyCon - for which it returns `Just (parent, parentArgs)` is emitted as - `FamilyApp parent parentArgs'`, where `parentArgs'` is - `parentArgs` after substituting the rep TyCon's tyvars - (`tyConTyVars rep_tc`) with the use-site args from - `splitTyConApp_maybe`. Without that substitution, `parentArgs` - references the rep's *internal* tyvars and they leak through - `typeArg` as `OtherArg ""` because they're not in the - caller's `boundTvs`. - -- **Data-family RHS is *also* circular by construction.** The - rewrite above means the `fiRhs` of a data fam-instance is - structurally equal to its LHS (e.g. for `data instance Crate - Int = …`, `fiRhs` is `FamilyApp Crate [Int]`). The viewer hides - the RHS for data fam-instances in node labels (and in the side - panel — see below); `reduceTypeArg` skips `fiIsData` rows - entirely to avoid an infinite recursion that would otherwise - loop forever rewriting `Crate Int` → `Crate Int`. - -- **`fiDataCons` from `rep_tc`, not `famInstTyCon`.** For a data - fam-instance, `famInstTyCon` returned the family TyCon (not the - rep) on at least GHC 9.14.1, opposite to what the GHC docs - suggested. We pull the rep TyCon directly out of the - `DataFamilyInst rep_tc` constructor and call `tyConDataCons` on - it. Each `DataCon` becomes a `DataConInfo` with the constructor's - name, its arg types (as `TypeArg`s in the fam-instance's - `fiTyVars` scope), and its field labels (when record syntax is - used; otherwise an empty list). The viewer renders just the - constructor *names* on the node label (`Family args = ConA | - ConB`), and the full constructor declarations (with field types - / record fields) in the side panel. - -## Type-family resolution in the viewer - -So far everything has been *transcription*: we record what GHC told us -and stop. The one place we go beyond transcription is the **resolution -chains** in the instance view. This part is computed in JavaScript -against the already-extracted JSON, *not* by GHC. - -Setup. For an instance view focused on a class `C`, we draw — among -other things — every superclass requirement of `C`'s declaration with -the focused-instance's args substituted in. Concretely, for each -superclass edge `seSuperclass = S, seArgs` of `C`, and each instance -`inst` of `C`, the constraint is `S (subst seArgs inst.iiArgs)`. - -That `subst seArgs inst.iiArgs` may produce something with a -`FamilyApp` in it, e.g. `S (F a) (F b)` where `F` is some type family. -The viewer then asks "for which fam-instances of `F` does this pred -become solvable, and which class instance of `S` would satisfy it?". - -The algorithm: - -1. **Find the family applications.** `collectFamilyRefs` walks the - substituted args and collects every `FamilyApp` qualname. - -2. **For each fam-instance `fi` of that family:** - - 1. **Bidirectional unification** between `fi.fiArgs` and the - `FamilyApp`'s args. `biUnify` allows TyVarRefs on either side - to bind to anything on the other side. *If unification fails, - this `fi` is irrelevant — skip it entirely.* (This is what - stops e.g. a `Foo Int`-shaped predicate from being matched - against a `type instance F Bool = …` equation.) Note that - `biUnify` operates on the args *as written*: we deliberately - do **not** pre-reduce nested type-family applications inside - the pivot's args before unifying. Doing so would let the - outer family match every concrete fam-instance once the - inner reduction collapsed to a tyvar, which produces an - explosion of spurious chain edges (every concrete data - instance "matches" an abstract `F a`). The trade-off: we - miss legitimate chains through nested families like `F (G - a)` where `G a` would reduce to a useful shape — those will - take the placeholder path in the next section. - - 2. **Substitute `fi.fiRhs` into the predicate.** The bidirectional - unification gave us substitutions for both fi's tyvars (used to - rewrite fi's RHS) and the predicate's tyvars (used to specialise - the rest of the predicate). The result is a fully-substituted - predicate with no occurrence of the originating family. - - 3. **Reduce the result.** `reduceTypeArg` walks the resulting - `TypeArg` and tries to apply *any* available fam-instance - (using one-sided unification — predicate vars stay free) to - collapse remaining family applications. This handles chained - reductions like `F (G x)` where `F` and `G` are both reducible. - Data fam-instances are skipped here (their `fiRhs` is - structurally equal to the LHS post-rewrite, so naïve - recursion looped forever). - - 4. **Find a matching class instance.** `findMatchingInstances` - scans `pdInstances` for an instance of the predicate's class - whose `iiArgs` shape-match the reduced args (TyVarRefs on - either side acting as wildcards). If one or more are found, - draw a `fam-resolves` edge from the `fi` node to each. - - 5. **Fall back to the predicate node.** If no class instance - matched (typically because the class's defining package - isn't in the dumps), draw a `fam-resolves` edge from the - `fi` node to the originating *predicate node* (the one - created from the context constraint or unmatched superclass). - Without this fallback the fam-instance ended up visually - orphaned even though we knew exactly which constraint it was - discharging. - - 6. **Draw the chain.** Net result: focused instance → predicate - node → fam-instance(s) → class instance(s) (or back to the - predicate node if no class instance was found). - -If the relevance check kills every `fi` of `fa` — see the next -section on placeholders for what happens in that branch. - -## Are the resolution chains deterministic? - -For the **transcribed data** (classes, instances, type families, -fam-instances, contexts, superclass thetas, source spans, flags) the -answer is unambiguous: it's exactly what GHC observed at the end of -typechecking. No reconstruction, no heuristic, no overlap resolution -on our side. If you re-run the plugin against the same source with the -same GHC version you get bit-identical JSON. - -For the **resolution chains in the instance view**, the picture is -more nuanced. Here's what can and cannot vary: - -- **Open type families with overlapping instances.** GHC forbids - overlapping open-family equations, so for any concrete argument list - there is at most one applicable `type instance`. But the viewer - walks fam-instances by *order in `pdFamInstances`*, which depends on - the order modules were compiled. If you have `type instance F Int = …` - in `pkg-A` and `type instance F Bool = …` in `pkg-B`, both are - presented as alternative resolutions; the user sees both chains - because both are *possible* satisfiers — without further input - (`a := Int` vs `a := Bool`) GHC itself can't pick one. The viewer - is honest about that ambiguity rather than hiding it. - -- **Closed type families with branch order.** GHC's evaluation rule is - "first matching branch wins". The viewer's `reduceTypeArg` mimics - this: it tries fam-instances in extraction order and stops at the - first that unifies. For closed families this is the same order as - the source-code branches, so the result matches GHC's. For open - families across multiple modules the order is build-graph-dependent - but, because open-family overlap is forbidden, the *set* of - applicable equations is unique even if the iteration order isn't. - -- **Ambiguous matches in `findMatchingInstances`.** Our class-instance - matcher treats TyVarRefs on either side as wildcards. That's - intentionally permissive: in the absence of a full constraint solver - we'd rather show the user "any of these instances might apply" than - hide instances behind a stricter check that would miss real - resolutions. The user sees every match as a separate chain edge and - decides which is meaningful in their context. - -- **Reductions stopping early.** If a fam-instance's RHS *itself* - mentions another type family (a common shape: `type Element (Wrap - c) = Element c` inside an associated family with the same name), - the viewer's reducer can't make further progress unless the - next-step fam-instance is also in the dumps. Chains terminate at - whichever fam-instance node the reducer last successfully unified - with — honest about the dependency boundary. - -- **Type-family functions defined by GHC itself** (e.g. - `Data.Type.Equality.==`, `If`, type-level arithmetic from - `GHC.TypeLits`) come through as `BuiltInSynFamTyCon` and are - transcribed but **not** evaluated by the viewer. We don't - reimplement GHC's built-in family rewrites. If a chain involves - `1 + 1` where `(+)` is `GHC.TypeNats.+`, the viewer will stop at - `+` rather than producing `2`. - -- **`UndecidableInstances` / cyclic reduction.** `reduceTypeArg` - recurses on `applySubst(fi.fiRhs, subst)` after a matching - fam-instance is found, so a `type instance F a = G a` / `type - instance G a = F a` pair *would* loop forever if it weren't for - the fact that each step requires structural progress (the RHS - isn't structurally equal to the LHS). In practice such a circular - pair shows up as two separate fam-instance nodes pointing at each - other via `fiRhs` references, with no resolution-chain edge. - -- **Data fam-instances are skipped by `reduceTypeArg` entirely.** - After the data-family R: rewrite at extraction time (see - `Classgraph.Extract`'s `typeArg`), a data fam-instance's `fiRhs` - is structurally equal to its LHS — for `data instance Crate Int`, - `fiRhs = FamilyApp Crate [Int]`. Reducing `FamilyApp Crate [Int]` - through that fam-instance just produces `FamilyApp Crate [Int]` - again, so naïvely recursing through it loops forever. The reducer - guards against this by skipping any fam-instance with - `fiIsData = true`; data-family use sites then fall through - unchanged and are surfaced by the predicate-node + chain machinery - as the use sites they actually are. The synthetic R: TyCon (the - pre-rewrite form) was opaque for the same reason — we just never - hit the loop because the rewriter wasn't yet circular. - -So: *the data is deterministic and faithful, the chains are an honest -best-effort visualization*. When in doubt, the chain edges in the -instance view are hints; the underlying transcribed data -(`pdInstances`, `pdFamInstances`, `tfEquations`, etc.) is ground -truth. - -## Predicate nodes and how they dedup - -The instance view introduces a synthetic node type — `kind: 'predicate'` -— that doesn't appear in the schema. Two cases produce one: - -- **Context constraint** (the predicate sits in `iiContext`). Edge - from focused instance → predicate node, labelled `instance context`. -- **Unmatched superclass** (a `ciSuperclasses` entry whose substituted - args don't match any instance in `pdInstances`). Edge from focused - instance → predicate node, labelled `superclass constraint`. If the - predicate happens to be structurally identical to a context - predicate the same node receives both edges; otherwise a fresh node - with `role: 'extern'` is created (rendered grey). - -This is a viewer-only abstraction. The Haskell side knows nothing about -it; predicate nodes have no on-disk representation. The dedup is what -makes the "context AND unmatched superclass" case collapse into a -single node — which is what a typical reader expects to see when the -same constraint is required twice. - -A few things to be aware of: - -- **Dedup key is a structural hash.** Two predicates collapse to one - node when their class `QualName` and the JSON-stringified - `[TypeArg]` are byte-identical. The hash sees through tyvar names - (TyVarRef carries an integer index, not a name), so two instances - with `[a, b]` vs `[x, y]` produce the same id structurally. Their - *labels* differ (rendered against each instance's own - `iiTyVars`) — first call wins on the label. - -- **Reduction asymmetry between context and superclass.** Context - predicates carry `piArgs` as written. Superclass requirements go - through `reduceTypeArg` so we can match them against - `pdInstances`. If a fam-instance reduction changes the arg shape — - the context says `Foo (F a)`, the reduced superclass says - `Foo Int` — the dedup id differs and the user sees two predicate - nodes for what is morally one constraint. Realistic but rare in - practice. - -- **Tyvar index aliasing across instances.** When the focused class - has many instances and they happen to produce structurally - identical context predicates (different tyvar names, same indices), - the predicate node is shared across all of them. That's *probably* - what the user wants — fewer noisy duplicates — but means the - arrows from several instance nodes land on one shared predicate. - No information is lost; the labels just match the first instance - that contributed. - -- **'extern' role is sticky to the first observation.** If a - predicate is observed first as a context constraint (`role: - 'context'`) and later as an unmatched superclass, the node keeps - the `'context'` role and just gains a second edge. If observed in - the reverse order — superclass first, context second — same thing - happens because context predicates are processed before superclass - requirements in `buildInstanceView`. This is intentional; the - styling shouldn't downgrade once we know the predicate is also a - legitimate context constraint. - -- **Predicate nodes don't survive view changes.** The set is rebuilt - from scratch every time the user enters an instance view; nothing - is persisted. The same predicate, viewed from a different focused - class, gets a different node id (because the focused-instance - arg/tyvar context differs). That's fine — predicate nodes are - scoped to "this view". - -## Synthetic "Family args = ?" placeholders - -When a context predicate or unmatched superclass mentions a `FamilyApp` -that *no* fam-instance can be unified with, the viewer synthesises a -placeholder fam-instance node showing the use-site args and `= ?` for -the RHS. The chain reads: +The pipeline has three stages: ``` -SigDSIGN (grey diamond) ──► SigDSIGN Ed448DSIGN = ? ╶╶► NoThunks (SigDSIGN Ed448DSIGN) + plugin (per module) merge + render one HTML file + TcGblEnv ──► JSON dumps ──► classgraph-view ──► classgraph.html + (.classgraph/*.json) ``` -Where this lives: `addFamilyLinksFromArgs` in `viewer.js`, after the -real-fam-instance loop. If the loop's `anyRelevant` flag is still -false at the end, we walk the args again with `collectFamilyAppArgs` -to find every distinct application of that family and synthesise one -placeholder per use site. - -A few things to know: - -- **Trigger is broader than "the family is external".** The - placeholder fires whenever no fam-instance unifies with the use site - — typically because the family is genuinely external (no equations - in our dumps), but also when a local family has equations for `Int` - and `Bool` and the constraint mentions `F SomeOtherType`. - Structurally honest, just slightly overgenerous in name. - -- **Chain edge to the predicate is conditional.** The synthetic - placeholder gets a `fam-resolves` chain edge `placeholder → - origin-pred-node` *only when* the caller passes an `originPredId`. - That's set when the family use was inside a context predicate (we - always have a pred-node id) or an unmatched superclass requirement - (we just created one). It's `null` for matched superclass calls, - where there's no single pred-node target — the placeholder still - appears connected to the family, just without the chain leg. - -- **Dedup is per (family, use-site args).** If the same `SigDSIGN - Ed448DSIGN` shows up in multiple constraints of the focused - instance, only one placeholder appears, with multiple incoming - chain edges if multiple predicates referred to it. The dedup key - is `'unresfaminst:' + qid(family) + ':' + JSON.stringify(args)`. - -- **The placeholder pretends to be a fam-instance node.** Internally - it's a synthetic `FamInstInfo`-shaped object with `_unresolved: - true`, `fiRhs: { tag: 'OtherArg', contents: '?' }`, and the - focused-instance's tyvars in `fiTyVars` (so its label renders - against the right tyvar context). `ensureFamInstanceNode` mirrors - `_unresolved` to a top-level `data.unresolved` so cytoscape - selectors can target it for the dashed-grey styling. - -- **External families pick up matching styling.** A family node - whose `qid` isn't in `pdTypeFamilies` is tagged `external: true` - by `ensureFamilyNode`; the cytoscape rule - `node[kind = "family"][?external]` paints it grey-dashed. Same - visual language as the placeholder, so the chain reads as one - continuous "outside this project" thread. - -- **Unapplied-family use sites get no placeholder.** When a - constraint references a family with *no* args — passed as a - higher-kinded thing, e.g. `class HasTables l where …` and a - context `HasTables LedgerState` where `LedgerState` is a data - family of kind `Type -> MapKind -> Type` left unapplied — the - use-site args list is empty. A `LedgerState = ?` placeholder - would convey nothing beyond what the family node itself shows, - so the synthesizer skips empty-args use sites entirely. - Concretizing the unapplied form to e.g. `LedgerState - (ShelleyBlock proto era) mk` would require constraint-solver- - like propagation across the constraint set (matching `l ~ - LedgerState (ShelleyBlock proto era)` from elsewhere) — work - GHC does at typecheck time but we don't preserve in the dump. - -- **Side panel honestly explains why a placeholder fired.** The - panel for a placeholder fam-instance lists both common reasons - (external family vs. nested/abstract args we couldn't reduce - through) so the user knows which dump to add or which limitation - is in play, rather than silently looking unresolved. - -## What the viewer is *not* doing - -A few things we deliberately don't do, in case you wonder: - -- **No instance overlap resolution.** GHC's instance solver picks one - instance from a set of applicable ones using overlap pragmas - (`{-# OVERLAPPING #-}` etc.), specificity, and module-context rules. - We record `iiOverlap` as text on each instance but don't simulate - the solver. If two instances of the same class match a given - argument shape, the viewer shows both with the same chain edge. - -- **No constraint solving.** We don't try to compute, given a type, the - full set of class instances reachable through context constraints. - The instance view shows direct context predicates (`iiContext`) and - one hop of superclass requirements; transitive resolution is left - to the user clicking through. - -- **No quantified-constraint handling.** `forall a. Eq a => Eq (T a)` - appearing in a context is `ForAllPred` and we drop it from the - `iiContext` display. Quantified constraints are GHC-9-and-up but - rare; adding them would require rendering the implication shape. - -- **No analysis of `INCOHERENT` / `OVERLAPPABLE` / overlap warnings.** - We just show every instance. - -- **No re-typechecking when JSON is loaded.** The viewer trusts the - JSON. If you hand-edit `.classgraph/*.json`, the viewer will happily - render whatever's there — it has no way to detect inconsistency. - -If any of these matter for your use case and the answer is "we should -do it", the place to start is `Classgraph.Extract` (for fields we'd -need to add to the schema) or `viewer.js`'s `buildInstanceView` / -`reduceTypeArg` / `biUnify` (for the in-viewer reconstruction). +The guiding rule: **the plugin transcribes, it does not infer.** Everything +in the JSON is what GHC itself computed by the end of typechecking. The only +place we go beyond transcription is the type-family *resolution chains* drawn +in the instance view — that runs in the browser against the JSON, and is +called out explicitly below. + +## Extraction (`Classgraph.Extract`) + +The plugin registers `typeCheckResultAction`, which runs once per module +after typechecking (so superclasses, instance heads, and family equations are +fully resolved) and before desugaring. It only reads `TcGblEnv`; it never +modifies it. + +**Classes** — `tcg_tcs` filtered by `tyConClass_maybe`. + +| field | source | +|---|---| +| `ciName` / `ciTyVars` / `ciMethods` | `className` / `classTyVars` / `classMethods` | +| `ciSuperclasses` | `classSCTheta`, decomposed (see below) | +| `ciAssocTypes` | `classATs` | +| `ciSrc` / `ciDoc` | `nameSrcSpan`; Haddock via the docs map (when `-haddock`) | + +Superclass theta is decomposed with `classifyPredType`: constraint tuples +(`class (A x, B x) => …`) expand into one edge each, and the boxed-equality +classes `(~)`/`(~~)` become equality predicates (rendered infix) rather than +edges to a synthetic class node. Class-predicate args are filtered through +`visibleArgs` so kind/runtime-rep arguments don't leak in. + +**Instances** — `tcg_insts`, via `instanceSig` (gives `tvs`, `theta`, class, +head args). The context (`iiContext`) is decomposed the same way as +superclass theta. Two deliberate choices: `iiSrc` uses the *dfun's* name span +(the class span is `UnhelpfulSpan` for classes from other packages' `.hi` +files), and `iiDoc` is looked up by the dfun's name. + +**Families** — `tcg_tcs` filtered by `isFamilyTyCon`, unioned with each +class's `classATs`. Flavor is decided by checking `classATs` membership first +(→ `AssocFam`), then `famTyConFlav_maybe` (open / closed / data; built-in and +abstract-closed families count as closed). Closed families keep their +equations: they live as `CoAxBranch`es in the family's `CoAxiom`, pulled out +with `fromBranches` into `tfEquations` (they are *not* in `tcg_fam_insts`). + +**Family instances** — `tcg_fam_insts`. `fiArgs` is `fi_tys` after +`visibleArgs`; `fiSrc` is the axiom branch span (each `type/data instance`, +not the `family` declaration). For a `data instance`, `fiDataCons` comes from +the representation TyCon (`tyConDataCons rep_tc`) with the constructor arg +types substituted from the rep TyCon's tyvars into the instance's `fiTyVars`, +so positional `TyVarRef`s resolve correctly. + +**Type arguments** (`typeArg` → `tyConArg` → `dataFamilyRepArg`) turn a GHC +`Type` into the structured `TypeArg` the viewer consumes: + +- bound variable → `TyVarRef ` (index into the enclosing scope); +- function arrow → a synthetic `(->)` application (no multiplicity/kind args); +- a `data instance` representation TyCon (`R:Foo…`) → the abstract + `Foo Args` it stands for, substituting the rep tyvars for the use-site args; +- any other saturated TyCon → `TyConApp` / `FamilyApp` (invisible binders + dropped), and a type literal → `LitArg`; +- anything else → `OtherArg` with GHC's pretty-printed text. Printing uses an + `SDocContext` that suppresses runtime-reps, explicit kinds, linearity, + etc., so fallbacks read like source Haskell rather than compiler internals. + +## Merge (`Classgraph.Merge`) + +`classgraph-view` reads every `*.json` under each `--input` dir and merges +them. Because directory listing order is unspecified, dumps are **sorted by +`(package, module)` first**, making the merge — and the rendered element +order — reproducible. Then: + +- classes and families are deduplicated by `QualName` (first occurrence + wins), so a class defined in one dump and seen as `external` in another + collapses to one node; +- package ids are normalised (`pkg-1.0-inplace`, `pkg-1.0-`, … → `pkg`) + before dedup, so cross-dump references actually unify; +- each instance / family-instance is tagged with its defining package + (`iiDefinedIn` / `fiDefinedIn`), which the viewer uses for editor links — + this can't be recovered after the merge for orphan instances. + +## Render (`Classgraph.Render`) + +The page is one self-contained file: Cytoscape + dagre, the styling, the +program JSON, `typecore.js`, and `viewer.js` are all inlined via `file-embed` +into placeholder slots in `viewer.html`. No server, no CDN. In the embedded +JSON every `<` is rewritten to its `\u003c` escape, so a `` inside a +string (e.g. a Haddock comment) can't terminate the data `