]> Repositorios git - classgraph.git/commitdiff
Rewrite INTERNALS.md to match current data processing
authorJavier Sagredo <[email protected]>
Thu, 18 Jun 2026 22:58:28 +0000 (00:58 +0200)
committerJavier Sagredo <[email protected]>
Thu, 18 Jun 2026 22:58:28 +0000 (00:58 +0200)
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) <[email protected]>
docs/INTERNALS.md

index df2ebf0f14b1ca8fdf9a4d3144b0067419d0c423..3be129ade8c53857e0a93fb9d669d654ac789968 100644 (file)
-# 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 "<reptv>"` 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>` (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-<hash>`, … → `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 `</script>` inside a
+string (e.g. a Haddock comment) can't terminate the data `<script>` or inject
+markup.
+
+## The type core (`typecore.js`)
+
+The unification / matching logic is a DOM-free module so it can be unit-tested
+(`test/typecore.test.js`, plus `test/views.test.js` driving the real builders
+through a stub harness; `npm test`). `viewer.js` binds it to the program with
+`makeTypeCore({ famInstsByFamily, pdInstances })`. The pieces:
+
+- `matchTypeArg` — **one-sided** structural match. A variable in an instance
+  *head* is polymorphic and matches anything; a variable left in the
+  *requirement* is abstract and only matches another variable. (A concrete
+  `Eq Int` instance does not discharge an abstract `Eq a` requirement.)
+- `unify` — one-sided unification (binds the pattern's vars only), used by…
+- `reduceTypeArg` — one rewrite step for a `FamilyApp`: find a family instance
+  whose LHS unifies and return its substituted RHS, recursing. It walks the
+  family index `famInstsByFamily`, which includes closed-family `tfEquations`,
+  and skips `data` instances (their RHS equals their LHS, so reducing would
+  loop). For closed families the equations are in declaration order, matching
+  GHC's first-match rule; open families can't overlap, so order is irrelevant.
+- `biUnify` / `replaceFamilyApp` — bidirectional unification used to ask "if
+  this family resolved via *this* instance, what would the predicate become?"
+- `findMatchingInstances` — instances of a class whose head matches given args.
+
+## Resolution chains in the instance view
+
+This is the one reconstruction. In an instance view for class `C`, each
+superclass `S` of `C` is instantiated with the focused instance's args, giving
+a requirement like `S (F a)`. For each family `F` mentioned
+(`addFamilyLinksFromArgs`):
+
+1. For each instance of `F`, `replaceFamilyApp` tries to substitute it into
+   the requirement. If its LHS doesn't unify with the use site, it's
+   irrelevant — skip it (this is what stops `F Bool`'s equation from matching
+   an `F Int` use). Unification is on the args *as written*; we don't
+   pre-reduce nested families, which would over-match.
+2. The substituted requirement is `reduceTypeArg`'d, then
+   `findMatchingInstances` looks for a class instance of `S` that satisfies
+   it. Each match gets a `fam-resolves` edge from the family-instance node.
+3. If no class instance is in the dumps, the edge instead points back at the
+   predicate node, so the family instance isn't left orphaned.
+
+The chain reads: focused instance → predicate → family instance → satisfying
+instance.
+
+**Placeholders.** If *no* instance of `F` unifies with the use site — usually
+because `F` is defined in a package we didn't extract — a synthetic
+`F args = ?` node is drawn per use site so the chain still terminates
+somewhere (`_unresolved: true`, styled grey-dashed). Use sites where the
+family is unapplied (passed higher-kinded, empty args) are skipped — a bare
+`F = ?` would say nothing the family node doesn't.
+
+**Predicate nodes** are a viewer-only abstraction (no on-disk form). A context
+constraint and an unmatched superclass that are structurally identical
+(class + JSON-encoded args, so tyvar *names* don't matter) collapse to one
+node with two incoming edges. The first role observed sticks. They're rebuilt
+per view and not persisted.
+
+## What's deterministic
+
+- **The transcribed data is ground truth**: same source + same GHC → identical
+  JSON. No heuristics, no overlap resolution on our side.
+- **The merge and render order are reproducible** (dumps are sorted).
+- **The chains are best-effort hints.** They can legitimately show several
+  alternatives (e.g. both `Eq Int` and a polymorphic `Eq a` match a concrete
+  requirement), and they stop at a dependency boundary when the next equation
+  or the satisfying instance lives in a package you didn't extract. When a
+  chain edge looks wrong, the transcribed data is the thing to trust.
+
+## What the viewer does *not* do
+
+- No instance overlap resolution — `iiOverlap` is recorded as text, not acted
+  on; all applicable instances are shown.
+- No constraint solving — only direct context predicates and one hop of
+  superclass requirements; transitive resolution is left to clicking through.
+- No built-in family evaluation — `GHC.TypeLits` arithmetic, `If`, `==`, etc.
+  are transcribed but not computed.
+- No quantified-constraint (`ForAllPred`) display.
+- No re-typechecking — the viewer trusts the JSON as-is.
+
+To extend any of this: the schema and extraction live in `Classgraph.Extract`
+/ `Classgraph.Schema`; the in-viewer reconstruction is `typecore.js` and
+`buildInstanceView` in `viewer.js`.