From: Javier Sagredo Date: Wed, 6 May 2026 22:53:50 +0000 (+0200) Subject: Mark external families and synthesise unresolved-fam-instance nodes X-Git-Url: https://git.sagredo.dev/?a=commitdiff_plain;h=8e2a96e0443dcdad43bec5902453d5b9e0bf09bd;p=classgraph.git Mark external families and synthesise unresolved-fam-instance nodes External families (referenced via FamilyApp but absent from pdTypeFamilies) now render as grey dashed diamonds, matching the external-class styling. When a context predicate or unmatched superclass mentions a FamilyApp that no real fam-instance can resolve — typically because the family is external — synthesise a placeholder fam-instance node "Family args = ?" for each distinct use site. The chain then reads: SigDSIGN ──► SigDSIGN Ed448DSIGN = ? ╶╶► NoThunks (SigDSIGN Ed448DSIGN) (the dashed leg is the fam-resolves edge to the originating predicate node). Side panel for these placeholders explains they're use sites, not equations. Help legend grows two new rows: external family + unresolved fam-instance. addFamilyLinksFromArgs gained two parameters (boundTvs, originPredId) to render the placeholder labels in the right tyvar context and to chain placeholders back to the predicate that needed them. The superclass call site reorders so the unmatched-pred node id is available before the call. Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/data/viewer.css b/data/viewer.css index 69bb852..f575c55 100644 --- a/data/viewer.css +++ b/data/viewer.css @@ -141,6 +141,7 @@ body { display: flex; } .swatch-instance { background: #ecfdf5; color: #065f46; border-color: #10b981; } .swatch-pred { background: #eef2ff; color: #3730a3; border-color: #6366f1; } .swatch-faminst { background: #f5f3ff; color: #5b21b6; border-color: #a78bfa; font-style: italic; } +.swatch-faminst-unres { background: #f3f4f6; color: #374151; border-color: #9ca3af; font-style: italic; border-style: dashed; } .swatch-external { background: #e5e7eb; color: #374151; border-color: #9ca3af; border-style: dashed; } .swatch-ghost { background: #dbeafe; color: #1e3a8a; border-color: #1d4ed8; border-style: dashed; opacity: 0.85; } .swatch-orphan { background: #ecfdf5; color: #065f46; border: 1px dashed #dc2626; } diff --git a/data/viewer.html b/data/viewer.html index d192838..5240767 100644 --- a/data/viewer.html +++ b/data/viewer.html @@ -38,7 +38,9 @@
InstClass instance
Foo aPredicate — a constraint to be discharged (context or unmatched superclass)
f a=bType family instance (type instance F … = …)
+
f a=?Unresolved use site of an external family — equation not in this project
ExtExternal class (referenced but not defined here)
+
FamExternal type family (referenced but not defined here, grey diamond)
ClsGhost — one-hop neighbour in focus mode (click to add it to the focus)
InstOrphan instance (red dashed border)
diff --git a/data/viewer.js b/data/viewer.js index 25684ad..f86a705 100644 --- a/data/viewer.js +++ b/data/viewer.js @@ -603,6 +603,9 @@ kind: 'fam-instance', familyId: qid(fi.fiFamily), famInstance: fi, + // Mirror the synthetic-placeholder flag at the top level so + // cytoscape selectors can pick these out for distinct styling. + unresolved: !!fi._unresolved, }}); return id; } @@ -658,14 +661,25 @@ // the matched class-instance node. This makes the chain // focused-instance → family → concrete fam-instance → Eq instance // visible as one path instead of two unrelated arrows. - function addFamilyLinksFromArgs(args, originId, edgeTag, predClassQn) { + function addFamilyLinksFromArgs(args, originId, edgeTag, predClassQn, + boundTvs, originPredId) { // The "via Bar" arrow that used to connect @originId@ → family // was removed (its meaning wasn't obvious). The function's // remaining job is to surface fam-instance nodes plus the // resolution chain that ends at a matching class instance. + // + // @boundTvs@ is the tyvar context (typically the focused + // instance's iiTyVars) used to render the synthetic fam-instance + // placeholder labels when no relevant fam-instance is found. + // @originPredId@ is the predicate node id this family use was + // observed inside (when called from a context predicate); the + // synthetic placeholder gets a fam-resolves chain edge to it so + // the user can see "this fam application is what the predicate + // needed". const fams = collectFamilyRefs(args); for (const fa of fams) { const famNodeId = ensureFamilyNode(fa); + let anyRelevant = false; // Surface only the type family instances whose LHS *can* describe // the focused instance's family-app — anything else is unrelated // noise. Run the relevance check *before* creating the @@ -676,6 +690,7 @@ resolvedArgs = args.map(a => replaceFamilyApp(a, fa, fi)); if (resolvedArgs.some(a => a === null)) continue; } + anyRelevant = true; const fiNodeId = ensureFamInstanceNode(fi); const fdId = famNodeId + '=>' + fiNodeId; @@ -716,6 +731,55 @@ } } } + + // No fam-instance was relevant — typically because the family is + // defined outside this project and we don't have its equations. + // Synthesize a placeholder fam-instance node "Family args = ?" + // for each distinct use site so the chain has somewhere to + // land. Connect family → placeholder (fam-defines) and, when + // we know the originating predicate node, placeholder → + // predicate (fam-resolves) so the user reads: + // SigDSIGN ──► SigDSIGN Ed448DSIGN = ? ╶╶► NoThunks (SigDSIGN Ed448DSIGN) + if (!anyRelevant) { + const useSites = collectFamilyAppArgs(args, fa); + for (const ua of useSites) { + const synFi = { + fiFamily: fa, + fiArgs: ua, + fiRhs: { tag: 'OtherArg', contents: '?' }, + fiTyVars: boundTvs || [], + fiSrc: null, + fiDoc: null, + fiIsData: false, + fiDefinedIn: null, + _unresolved: true, + }; + const synHint = 'unresfaminst:' + qid(fa) + ':' + JSON.stringify(ua); + const synId = ensureFamInstanceNode(synFi, synHint); + const fdId = famNodeId + '=>' + synId; + if (!seenNodes.has(fdId)) { + seenNodes.add(fdId); + els.push({ group: 'edges', data: { + id: fdId, + source: famNodeId, + target: synId, + kind: 'fam-defines', + }}); + } + if (originPredId) { + const resId = synId + '=>resolves=>' + originPredId; + if (!seenNodes.has(resId)) { + seenNodes.add(resId); + els.push({ group: 'edges', data: { + id: resId, + source: synId, + target: originPredId, + kind: 'fam-resolves', + }}); + } + } + } + } } } @@ -779,7 +843,8 @@ // is a type family that must be defined for that era). For each // fam-instance we surface, we also try to resolve `pred.piClass` // for the fam-instance's RHS and chain to the matching instance. - addFamilyLinksFromArgs(pred.piArgs, instId, 'ctx-fam', pred.piClass); + addFamilyLinksFromArgs(pred.piArgs, instId, 'ctx-fam', + pred.piClass, inst.iiTyVars, pid); }); // Associated type families: when the focused class declares assoc @@ -822,7 +887,6 @@ // already been replaced by some fam-instance's RHS and the chain // never gets a chance to enumerate alternative fam-instances. const subbedArgs = sc.seArgs.map(a => substTypeArgRaw(a, inst.iiArgs)); - addFamilyLinksFromArgs(subbedArgs, instId, 'sc-fam-' + si, sc.seSuperclass); // Direct-match path uses the reduced args as before. const reqArgs = subbedArgs.map(reduceTypeArg); const matched = findMatchingInstances(sc.seSuperclass, reqArgs); @@ -832,6 +896,14 @@ // edge would just duplicate it. const reqLabel = 'superclass constraint'; + // chainTarget gets passed to addFamilyLinksFromArgs further + // down: when we land in the unmatched branch and create a + // predicate node, the synthetic "Family args = ?" placeholders + // will chain to it. For the matched branch there's no single + // chain endpoint (matched instances are heterogeneous), so we + // leave it null. + let chainTarget = null; + if (matched.length === 0) { // No instance was found in our data — but the original // module typechecked, so the constraint must be discharged @@ -860,6 +932,7 @@ kind: 'needs-external', label: reqLabel, }}); + chainTarget = pid; } else { // Local match(es) exist. Connect the focused instance directly // to each matched instance — we deliberately *don't* also pull @@ -880,6 +953,12 @@ }}); } } + + // Family-chain work runs *after* the predicate-node decision so + // we can hand the synthetic "Family args = ?" placeholders the + // pred-node id to chain into. + addFamilyLinksFromArgs(subbedArgs, instId, 'sc-fam-' + si, + sc.seSuperclass, inst.iiTyVars, chainTarget); }); }); @@ -1292,6 +1371,38 @@ return [...seen.values()]; } + // For a given family @qn@, return every distinct argument list it's + // applied to inside @args@. Used to surface "use sites" of an + // unresolvable family — e.g. a constraint `NoThunks (SigDSIGN + // Ed448DSIGN)` mentions `SigDSIGN [Ed448DSIGN]` once, so we want + // exactly one synthetic placeholder for that use. + // + // Dedup is structural (JSON.stringify of the inner args). + function collectFamilyAppArgs(args, qn) { + const targetQid = qid(qn); + const out = []; + const seenStr = new Set(); + function go(t) { + if (!t || !t.tag) return; + if (t.tag === 'FamilyApp') { + const [q, inner] = t.contents; + if (qid(q) === targetQid) { + const key = JSON.stringify(inner || []); + if (!seenStr.has(key)) { + seenStr.add(key); + out.push(inner || []); + } + } + for (const x of (inner || [])) go(x); + } else if (t.tag === 'TyConApp') { + const [, inner] = t.contents; + for (const x of (inner || [])) go(x); + } + } + for (const a of (args || [])) go(a); + return out; + } + // Returns the infix operator string for a TyCon name that should render // infix (currently the various equality and coercion-evidence forms). // Detect whether a TypeFamilyInfo (from familyById) describes a data @@ -1653,6 +1764,14 @@ function renderFamInstPanel(fi) { const head = escape(fi.fiFamily.qnName) + ' ' + escape(renderArgsCompact(fi.fiArgs, fi.fiTyVars)); + if (fi._unresolved) { + return ` +

${head}

+

${escape(fi.fiFamily.qnPackage)} · ${escape(fi.fiFamily.qnModule)}

+
+
Status
Use site of an external type family — the equation isn't in this project's dumps, so the right-hand side can't be resolved here.
+
`; + } const rhs = escape(renderArg(fi.fiRhs, fi.fiTyVars)); const tvs = fi.fiTyVars.length === 0 ? '
none
' : `
    ${fi.fiTyVars.map(v => @@ -1729,6 +1848,18 @@ 'border-style': 'dashed', }, }, + // External type family — we know the family is referenced but + // it isn't defined in this project (no entry in pdTypeFamilies). + // Render the diamond grey to signal "defined elsewhere". + { + selector: 'node[kind = "family"][?external]', + style: { + 'background-color': '#e5e7eb', + 'border-color': '#9ca3af', + color: '#374151', + 'border-style': 'dashed', + }, + }, // Ghost class node (one-hop neighbour in a focus-filtered classes view) { selector: 'node[kind = "class"][?ghost]', @@ -1833,6 +1964,20 @@ 'font-size': 11, }, }, + // Unresolved fam-instance placeholder — `Family args = ?`. We + // don't have the equation, just the use site. Greyed out to + // signal "fam-instance must exist somewhere outside this + // project", with a dashed border to echo the external-family + // grey-diamond styling. + { + selector: 'node[kind = "fam-instance"][?unresolved]', + style: { + 'background-color': '#f3f4f6', + color: '#374151', + 'border-color': '#9ca3af', + 'border-style': 'dashed', + }, + }, // Edges { selector: 'edge',