Skip to content

Commit bc067d4

Browse files
Merge main into reshape: the dispatch envelope (#474) lands under graph/
Rename detection carried every edited file from src/ to graph/ on its own. One conflict, in the bundle test: reshape renamed the bundle's CSV output directory graph/ -> csv/ (the repository now HAS a top-level graph/), and #474 added dispatch_candidates to the same core-table loop. Both, not either. The new files — 37 .envelope goldens and test/tools/envelope_report.py — needed placing by hand: git can infer where an EDITED file went when its directory was renamed, but not where a file created on the other branch belongs. Verified on the merged tree: bundle-test.sh passes (3 languages, one schema across them, canonical queries run, schema doc current), the Python literal gate passes, all 37 goldens are under graph/test/*/expected, and the three rule files carry method_dispatch_candidate.
2 parents a4f26c4 + 69a0383 commit bc067d4

57 files changed

Lines changed: 546 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎graph/bundle/SCHEMA.md‎

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ Identifiers are the parser's hashes and are opaque; join them to `methods` / `ty
2626
1. This is a call graph of one codebase, derived by a type-directed Datalog engine. Start with `SELECT value FROM run WHERE key='language'` — every language-specific fact below is keyed on it.
2727
2. The graph is `call_edges`: one row per (call site, possible target). Rows join to `methods` (names, files, lines) on `caller_id` / `callee_method_id`, and to `call_sites` on `call_site_id` for where the call is written. Identifiers are opaque hashes — never parse them, always join.
2828
3. Trust is explicit. `tier` says what kind of claim a row is: `known_edge` (one resolved target), `multi_inferred` (a sound set — every row of the set is a real possibility), `boundary_lib` (leaves the client; not expanded further), `ambiguous_*` (a declared unknown: callee is NULL). Pick the tiers your question tolerates and filter on them; never treat an `ambiguous_*` row as an edge.
29-
4. Before answering "nothing calls X" or "X cannot reach Y", check `unresolved_sites` for the methods on the path: a caller listed there has a call the engine could not resolve, so the answer is a lower bound and should say so.
30-
5. Library targets (`callee_provenance = lib`) are named in `methods` with `provenance = lib` but their bodies were not analysed; a Python `builtin`/`external` target has no methods row and lives in `callee_label`.
31-
6. `schema_vocab` lists every value a column can hold FOR THIS LANGUAGE with its meaning — filter on `language = (SELECT value FROM run WHERE key='language')`. `schema_notes` lists the caveats for this language (empty tables, what an id may point at). Read both before interpreting `kind`, `tier` or an empty table.
32-
7. `schema_queries` holds tested SQL for the common questions (callers, callees, blast radius, entry reachability, the method at a file:line, the blind spots of a method). Bind the named parameters and run.
33-
8. Tables named `ext_<relation>` are the language's raw engine relations with positional columns c0…cN; `schema_tables` carries each one's description lifted from its rule. Use them only when a core table does not hold what you need.
34-
9. When you report a result, carry the tier and the unresolved count with it. A consumer who cannot see the confidence of an edge cannot use it.
29+
4. `call_edges` is what the engine CONCLUDED; `dispatch_candidates` is what the hierarchy ADMITTED. Read the second when you need an upper bound rather than a best answer — a candidate whose owner is absent from `type_instantiated` is admitted by the hierarchy but never constructed in this run, which is how you narrow it yourself. `basis` separates a declared relationship from a shape match.
30+
5. Before answering "nothing calls X" or "X cannot reach Y", check `unresolved_sites` for the methods on the path: a caller listed there has a call the engine could not resolve, so the answer is a lower bound and should say so.
31+
6. Library targets (`callee_provenance = lib`) are named in `methods` with `provenance = lib` but their bodies were not analysed; a Python `builtin`/`external` target has no methods row and lives in `callee_label`.
32+
7. `schema_vocab` lists every value a column can hold FOR THIS LANGUAGE with its meaning — filter on `language = (SELECT value FROM run WHERE key='language')`. `schema_notes` lists the caveats for this language (empty tables, what an id may point at). Read both before interpreting `kind`, `tier` or an empty table.
33+
8. `schema_queries` holds tested SQL for the common questions (callers, callees, blast radius, entry reachability, the method at a file:line, the blind spots of a method, the dispatch envelope of a method). Bind the named parameters and run.
34+
9. Tables named `ext_<relation>` are the language's raw engine relations with positional columns c0…cN; `schema_tables` carries each one's description lifted from its rule. Use them only when a core table does not hold what you need.
35+
10. When you report a result, carry the tier and the unresolved count with it. A consumer who cannot see the confidence of an edge cannot use it.
3536

3637
## Canonical queries (`schema_queries`)
3738

@@ -62,15 +63,16 @@ WHERE caller.qualified_name = :qualified_name
6263
ORDER BY s.start_line, target
6364
```
6465

65-
**`blast_radius`** — If this method changes, which methods are transitively affected, up to :depth hops, through sound edges only (known_edge and multi_inferred)? _(:qualified_name, :depth)_
66+
**`blast_radius`** — If this method changes, which methods are transitively affected, up to :depth hops, through RESOLVED client edges only (a declared unknown is not traversed, and the count of them is returned alongside)? _(:qualified_name, :depth)_
6667

6768
```sql
6869
WITH RECURSIVE up(id, depth) AS (
6970
SELECT id, 0 FROM methods WHERE qualified_name = :qualified_name
7071
UNION
7172
SELECT e.caller_id, up.depth + 1
7273
FROM call_edges e JOIN up ON e.callee_method_id = up.id
73-
WHERE e.tier IN ('known_edge', 'multi_inferred') AND up.depth < :depth
74+
WHERE e.tier IN ('known_edge', 'multi_inferred', 'ambient_terminal', 'intrinsic_terminal')
75+
AND up.depth < :depth
7476
)
7577
SELECT MIN(up.depth) AS depth, m.qualified_name, m.file_path, m.start_line,
7678
(SELECT count(*) FROM unresolved_sites u WHERE u.caller_id = m.id) AS unresolved_calls_inside
@@ -108,6 +110,18 @@ WHERE m.qualified_name = :qualified_name
108110
ORDER BY s.start_line
109111
```
110112

113+
**`dispatch_envelope_of`** — What else might actually run at a call that resolves to this method — the set the graph narrowed from, and whether each candidate is a declaration or a shape match? _(:qualified_name)_
114+
115+
```sql
116+
SELECT cand.qualified_name AS candidate, cand.file_path, cand.start_line, d.basis,
117+
EXISTS (SELECT 1 FROM type_instantiated i WHERE i.type_id = cand.owner_type_id) AS owner_instantiated
118+
FROM dispatch_candidates d
119+
JOIN methods base ON base.id = d.base_method_id
120+
JOIN methods cand ON cand.id = d.candidate_method_id
121+
WHERE base.qualified_name = :qualified_name
122+
ORDER BY d.basis, cand.qualified_name
123+
```
124+
111125
**`subtypes_of`** — Which types extend or implement this type (transitively)? _(:qualified_name)_
112126

113127
```sql
@@ -342,7 +356,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h
342356
| value | languages | meaning |
343357
|---|---|---|
344358
| `known_edge` | all | Exactly one target resolved. The strongest claim. |
345-
| `multi_inferred` | all | A sound SET of possible targets (virtual dispatch over instantiated subtypes); each member is one row. The set over-approximates; no member is a guess. |
359+
| `multi_inferred` | all | A sound SET of possible targets; each member is one row. The set over-approximates — every member is a real possibility, but not every member runs. HOW WIDE the set is differs by language: see the per-language notes on this table for whether the fan is narrowed by the instantiation set. |
346360
| `boundary_lib` | all | The target is outside the client (library, builtin, or unstaged external). The chain is not expanded past it here. |
347361
| `ambiguous_unknown` | all | Declared blind spot: the engine could not resolve the site (unresolved receiver, missing type, reflection…). callee is NULL. Never dropped. |
348362
| `ambiguous_anon` | java | Known structural gap: an anonymous-class creation has no candidate rule yet. callee is NULL. |
@@ -402,6 +416,10 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h
402416

403417
- **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row.
404418
- **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail).
419+
- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge, multi_inferred, boundary_lib, and in TypeScript ALSO ambient_terminal and intrinsic_terminal. BLIND SPOT (callee is NULL): ambiguous_unknown, and in Java ALSO ambiguous_anon. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in TypeScript and nowhere else — derive the set from this note or from unresolved_sites, never from a hardcoded list.
420+
- **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query.
421+
- **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.
422+
- **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.
405423
- **all** — The raw relation has a seventh column, ToExpr, that is always `-` (reserved). It is dropped here.
406424
- **all** — An unresolved site (tier ambiguous_*) has NULL callee_method_id, callee_label and callee_provenance. The raw relation writes `-` in those slots.
407425

@@ -414,9 +432,27 @@ Transitive supertype closure: (type, ancestor) for every ancestor reachable thro
414432
| 0 | `type_id` 🔑 | TEXT | | FK → types.id. |
415433
| 1 | `ancestor_type_id` 🔑 | TEXT | | FK → types.id. |
416434

435+
### `dispatch_candidates`
436+
437+
THE DISPATCH ENVELOPE: (base method, method that may run instead) for every call that statically resolves to the base. This is the set `call_edges` narrowed FROM — the difference between "these are the targets" and "these are the targets, out of these possibilities". Populated in every language; `basis` says what admitted the pair, because the three front ends admit by different means.
438+
439+
| # | column | type | null | meaning |
440+
|---|---|---|---|---|
441+
| 0 | `base_method_id` 🔑 | TEXT | | FK → methods.id — the method a call resolves to statically. |
442+
| 1 | `candidate_method_id` 🔑 | TEXT | | FK → methods.id — a method that may run instead at such a call. |
443+
| 2 | `basis` 🔑 | TEXT | | What admitted the pair — see vocabulary. Filter on it to trust only declarations. |
444+
445+
**`dispatch_candidates.basis` values**
446+
447+
| value | languages | meaning |
448+
|---|---|---|
449+
| `nominal` | java, typescript | A written extends/implements reaches the candidate's owner from the base's owner. The strongest evidence there is: the author declared the relationship. |
450+
| `structural` | typescript | No declaration; the candidate's owner satisfies the base's owner by SHAPE. Emitted only for supertypes with no nominal implementor at all, so it never competes with a declared answer — but it is a heuristic, and a consumer that wants declarations only filters it out. |
451+
| `mro` | python | The subtype's C3 linearisation picks the candidate for that attribute name. Not merely "the subtype declares this name" — a name a sibling base wins is attributed to that sibling. |
452+
417453
### `overrides`
418454

419-
Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java only today — see notes for what the other front ends offer instead.
455+
Virtual-dispatch pairs: (base method, overriding method) wherever a call to the base may run the override. Java only, and kept for compatibility — it is exactly `dispatch_candidates` filtered to `basis = nominal`. Prefer `dispatch_candidates`, which is populated in every language.
420456

421457
| # | column | type | null | meaning |
422458
|---|---|---|---|---|
@@ -425,8 +461,8 @@ Virtual-dispatch pairs: (base method, overriding method) wherever a call to the
425461

426462
**Notes**
427463

428-
- **typescript** — EMPTY. TypeScript dispatch is captured directly as multi_inferred edges; the structural and nominal implementor sets are in ext_implementors, ext_structural_implementor and ext_type_satisfies.
429-
- **python** — EMPTY. Python method lookup is by MRO, exported positionally in ext_mro_position (type, ancestor, …, position).
464+
- **typescript** — EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.
465+
- **python** — EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`; the raw linearisation is in ext_mro_position.
430466

431467
### `entry_points`
432468

@@ -475,7 +511,7 @@ The blind spots, attributed to the code that contains them: (caller, site) for e
475511

476512
### `type_instantiated`
477513

478-
Types the client actually creates an instance of — the rapid-type-analysis set that bounds virtual dispatch. (A subtype nothing instantiates cannot receive a dispatched call.)
514+
Types this run creates an instance of — the rapid-type-analysis set that bounds virtual dispatch. (A subtype nothing instantiates cannot receive a dispatched call.) Deliberately an over-approximation: narrowing it on evidence the run does not have would lose real edges. Populated in every language.
479515

480516
| # | column | type | null | meaning |
481517
|---|---|---|---|---|
@@ -486,13 +522,13 @@ Types the client actually creates an instance of — the rapid-type-analysis set
486522

487523
| value | languages | meaning |
488524
|---|---|---|
489-
| `new` | java, python | A constructor call in the client. |
525+
| `new` | all | A constructor call — `new C()` / `C()`. |
490526
| `anonymous` | java | An anonymous class exists only by being instantiated. |
491527
| `enum_constant` | java | An enum's constants are its instances. |
492528

493529
**Notes**
494530

495-
- **typescript** — EMPTY. The TypeScript rule set does not export an instantiation set.
531+
- **typescript** — Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.
496532
- **python** — Every row has how = `new`: the rule set records that some client call constructs the class, not which form.
497533

498534
## Extended tables — `ext_<relation>`

‎graph/bundle/build.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface CoreTables {
2323
call_sites: Row[];
2424
call_edges: Row[];
2525
type_ancestors: Row[];
26+
dispatch_candidates: Row[];
2627
overrides: Row[];
2728
entry_points: Row[];
2829
entry_reachable: Row[];
@@ -124,6 +125,7 @@ export async function buildCore(inp: BuildInputs): Promise<CoreTables> {
124125
const rawEdges = await readSource(rawDir, A.raw.callEdges); // site, caller, callee, prov, tier, kind
125126
const rawAncestors = A.raw.typeAncestors ? await readSource(rawDir, A.raw.typeAncestors) : [];
126127
const rawOverrides = A.raw.overrides ? await readSource(rawDir, A.raw.overrides) : [];
128+
const rawDispatch = A.raw.dispatchCandidates ? await readSource(rawDir, A.raw.dispatchCandidates) : [];
127129
const rawEntry = A.raw.entryPoints ? await readSource(rawDir, A.raw.entryPoints) : [];
128130
const rawReach = A.raw.entryReachable ? await readSource(rawDir, A.raw.entryReachable) : [];
129131
const rawInst = A.raw.typeInstantiated ? await readSource(rawDir, A.raw.typeInstantiated) : [];
@@ -304,6 +306,7 @@ export async function buildCore(inp: BuildInputs): Promise<CoreTables> {
304306
call_sites: [...sites.values()],
305307
call_edges,
306308
type_ancestors: dedupe(rawAncestors),
309+
dispatch_candidates: dedupe(rawDispatch),
307310
overrides: dedupe(rawOverrides),
308311
entry_points: dedupe(rawEntry),
309312
entry_reachable: dedupe(rawReach),

‎graph/bundle/languages.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface LanguageAdapter {
6363
callEdges: RawSource;
6464
typeAncestors?: RawSource;
6565
overrides?: RawSource;
66+
dispatchCandidates?: RawSource;
6667
entryPoints?: RawSource;
6768
entryReachable?: RawSource;
6869
typeInstantiated?: RawSource;
@@ -88,6 +89,7 @@ const JAVA: LanguageAdapter = {
8889
callEdges: CALL_EDGES,
8990
typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [0, 1] },
9091
overrides: { file: 'resolution-virtual-override.csv', columns: [0, 1] },
92+
dispatchCandidates: { file: 'dispatch-candidates.csv', columns: [0, 1, 2] },
9193
entryPoints: { file: 'entry-point.csv', columns: [0, 1] },
9294
entryReachable: { file: 'entry-reachable.csv', columns: [0] },
9395
typeInstantiated: { file: 'type-instantiated.csv', columns: [0, 1] },
@@ -121,6 +123,9 @@ const TYPESCRIPT: LanguageAdapter = {
121123
typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [0, 1] },
122124
entryPoints: { file: 'entry-point.csv', columns: [0, 1] },
123125
entryReachable: { file: 'entry-reachable.csv', columns: [0] },
126+
dispatchCandidates: { file: 'dispatch-candidates.csv', columns: [0, 1, 2] },
127+
// (type, how) — `new` is the only form TypeScript emits
128+
typeInstantiated: { file: 'resolution-type-instantiated.csv', columns: [0, 1] },
124129
},
125130
ir: {
126131
methods: {
@@ -155,6 +160,7 @@ const PYTHON: LanguageAdapter = {
155160
typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [1, 2] },
156161
// (prov, type) — no "how"; every row is a constructor call
157162
typeInstantiated: { file: 'resolution-type-instantiated.csv', columns: [1], constant: 'new' },
163+
dispatchCandidates: { file: 'dispatch-candidates.csv', columns: [0, 1, 2] },
158164
},
159165
ir: {
160166
methods: {

0 commit comments

Comments
 (0)