diff --git a/.github/scripts/close-issues-on-dev.sh b/.github/scripts/close-issues-on-dev.sh
new file mode 100755
index 000000000..cfd511a13
--- /dev/null
+++ b/.github/scripts/close-issues-on-dev.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# close-issues-on-dev.sh — close the issues named by pull requests that merged into a branch other than
+# dev, once their merge commit is part of dev (see .github/workflows/close-issues-on-dev.yml).
+#
+# DRY_RUN=1 .github/scripts/close-issues-on-dev.sh # list, close nothing
+#
+# Needs: gh (authenticated; GH_TOKEN in CI), a checkout with full history, and origin/dev or HEAD on dev.
+# LOOKBACK: how many recently merged pull requests to consider (default 300).
+set -euo pipefail
+
+DRY_RUN="${DRY_RUN:-0}"
+LOOKBACK="${LOOKBACK:-300}"
+REPO="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
+DEV_REF="${DEV_REF:-HEAD}"
+[ "$DEV_REF" = HEAD ] && [ "$(git rev-parse --abbrev-ref HEAD)" != dev ] && DEV_REF=origin/dev
+
+closed=0; pending=0
+while IFS=$'\t' read -r pr base merge body64; do
+ [ -n "$merge" ] || continue
+ [ "$base" = dev ] && continue # GitHub closes these itself
+ if ! git merge-base --is-ancestor "$merge" "$DEV_REF" 2>/dev/null; then
+ pending=$((pending + 1)); continue # merged, but not on dev yet
+ fi
+ body=$(printf '%s' "$body64" | base64 --decode)
+ for n in $(printf '%s\n' "$body" | grep -oiE '\b(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]+#[0-9]+' \
+ | grep -oE '[0-9]+$' | sort -un); do
+ state=$(gh issue view "$n" --repo "$REPO" --json state -q .state 2>/dev/null || echo MISSING)
+ [ "$state" = OPEN ] || continue
+ if [ "$DRY_RUN" = 1 ]; then
+ echo "would close #$n (pull request #$pr into $base, ${merge:0:9} is on dev)"
+ else
+ gh issue close "$n" --repo "$REPO" \
+ --comment "Closed by #$pr, which merged into \`$base\` and reached \`dev\` in ${merge:0:9}."
+ echo "closed #$n (pull request #$pr into $base)"
+ fi
+ closed=$((closed + 1))
+ done
+done < <(gh pr list --repo "$REPO" --state merged --limit "$LOOKBACK" \
+ --json number,baseRefName,mergeCommit,body \
+ -q '.[] | [.number, .baseRefName, (.mergeCommit.oid // ""), (.body // "" | @base64)] | @tsv')
+
+verb=closed; [ "$DRY_RUN" = 1 ] && verb="to close"
+echo "issues $verb: $closed; merged pull requests not on dev yet: $pending"
diff --git a/.github/workflows/close-issues-on-dev.yml b/.github/workflows/close-issues-on-dev.yml
new file mode 100644
index 000000000..e491bed82
--- /dev/null
+++ b/.github/workflows/close-issues-on-dev.yml
@@ -0,0 +1,44 @@
+# ─────────────────────────────────────────────────────────────────────────────
+# A pull request that merges into a release or integration branch (0.2.1, a
+# perf branch) never closes the issues it names: GitHub acts on "Closes #N" only
+# when the merge lands on the default branch. This closes them when that work
+# reaches `dev`.
+#
+# On every push to dev it looks at pull requests merged into any other branch,
+# keeps those whose merge commit is now part of dev, and closes each open issue
+# their description closes ("Closes", "Fixes", "Resolves" #N), with a comment
+# naming the pull request. "Part of #N" and "Refs #N" are not closing words, so
+# umbrella issues stay open.
+# ─────────────────────────────────────────────────────────────────────────────
+name: close-issues-on-dev
+
+on:
+ push:
+ branches: [dev]
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: list what would be closed, close nothing
+ type: boolean
+ default: true
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: read
+
+jobs:
+ close:
+ name: close issues whose pull request reached dev
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: close the issues
+ env:
+ GH_TOKEN: ${{ github.token }}
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '1' || '0' }}
+ run: .github/scripts/close-issues-on-dev.sh
diff --git a/README.md b/README.md
index ba0d1d453..b68e651f1 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
AxiomCode Graph
- A god's-eye view of your codebase for AI agents. Stop grepping. Ensure correctness and completeness for any task your AI agent performs.
+ Know what each change breaks in your codebase. Stop grepping. Ensure correctness and completeness for any task your AI agent performs. Ship less buggy code.
@@ -167,6 +167,9 @@ Any other agent that speaks MCP takes one entry in its MCP config; see
[Support for agents](#support-for-agents). Start a new agent session afterward: plugins are loaded at startup.
Add `.axiomcode/` to your `.gitignore`; the graph is built there on first use.
+After upgrading the engine or the plugin, **restart the agent session**: a session started before the upgrade keeps the
+old version's MCP server and hooks running beside the new ones, and the two rebuild the graph in turn.
+
### Uninstallation
```bash
diff --git a/bin/axiomcode b/bin/axiomcode
index 71ae8b8cf..b5225e774 100755
--- a/bin/axiomcode
+++ b/bin/axiomcode
@@ -412,7 +412,7 @@ case "$cmd" in
case "$which" in
java|typescript|python|javascript|csharp) run_suite "$which" "$@";;
web) echo "══ web: cli smoke + violation checks + include checks"; bash "$ROOT/graph/test/web/cli-smoke.sh" || rc=1; bash "$ROOT/graph/test/web/violations.sh" || rc=1; bash "$ROOT/graph/test/web/includes.sh" || rc=1; run_suite web "$@";;
- parser) for t in java-extractor-tests typescript-tests python-tests javascript-tests csharp-tests gradle-tests services-tests web-tests web-torture web-plumbing-tests discovery-tests; do
+ parser) for t in java-extractor-tests typescript-tests deno-tests python-tests javascript-tests csharp-tests gradle-tests services-tests web-tests web-torture web-plumbing-tests discovery-tests; do
[ -f "$ROOT/parser/src/test/$t.ts" ] || continue; echo "══ parser: $t"; (cd "$ROOT/parser" && npx tsx "src/test/$t.ts") || rc=1; done;;
all) for l in java typescript python javascript csharp web; do [ -f "$ROOT/graph/test/$l/run-tests.sh" ] && run_suite "$l" "$@"; done; bash "$ROOT/graph/test/web/cli-smoke.sh" || rc=1; "$0" test parser || rc=1;;
*) die "test: unknown suite $which (java | typescript | python | javascript | csharp | web | parser | all)";;
diff --git a/gemini-extension.json b/gemini-extension.json
index 4a44f6eae..dad925927 100644
--- a/gemini-extension.json
+++ b/gemini-extension.json
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.",
"contextFileName": "plugins/axiomcode/AGENTS.md",
"mcpServers": {
diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md
index 6b33b221c..6cd2c4bd2 100644
--- a/graph/bundle/SCHEMA.md
+++ b/graph/bundle/SCHEMA.md
@@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t
- **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start.
- **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site.
- **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls.
-- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, OPERATOR_PROTOCOL, TRUTH_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.
+- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, OPERATOR_PROTOCOL, TRUTH_PROTOCOL, METHOD_REFERENCE, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.
- **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration.
### `call_edges`
@@ -541,6 +541,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h
| `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. |
| `BUILTIN_PROTOCOL` | python | `repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument's class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call. |
| `OPERATOR_PROTOCOL` | python | An operator runs a dunder of its operands: `a + b` the left operand's `__add__` and the right one's `__radd__`, a comparison the left slot and the right one's mirror (`a < b` -> `b.__gt__`, `!=` falls back to `__eq__`), `x in c` the right operand's `__contains__` (or `__iter__`), `-a`/`+a`/`~a` its `__neg__`/`__pos__`/`__invert__`. No written call; the site is the operator expression. |
+| `METHOD_REFERENCE` | python | A method or function READ AS A VALUE, not called: an argument (`pool.submit(self.work, x)`, `map(Cls.convert, rows)`, `partial(svc.cancel, oid)`), a keyword (`Thread(target=svc.run)`), an element, a stored or returned value. The target is what the expression resolves to (a typed receiver through the MRO, a class, a scoped name, a module member); an untyped receiver gives no row. Always tier callback_registered; the site is the reference expression (call-edge-generation/method-reference.dl). |
| `TRUTH_PROTOCOL` | python | A truth test runs the tested value's `__bool__`, or `__len__` when its class has none: `if x:`, `while x:`, `assert x`, `not x`, a conditional or comprehension condition, and an operand of `and`/`or` (the right one only when the whole expression is tested). No written call; the site is the tested expression. |
**`call_edges.tier` values**
@@ -564,6 +565,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h
| `dynamic_terminal` | javascript | `obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL. |
| `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. |
| `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. |
+| `callback_registered` | python | A method or function named as a VALUE and handed over, stored or returned (`pool.submit(self.work)`, `Thread(target=svc.run)`, `sorted(xs, key=self.rank)`, `[h.on_event]`), always kind METHOD_REFERENCE. Its target is resolved the way a call on the same receiver would be; whoever receives the value calls it. A reachability edge, labelled so it is never read as a resolved call. |
| `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. |
| `event_dispatch` | typescript | A publish on a project class's keyed registry reaching each function filed under the same key: `bus.publish('x', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe('x', f)` stored there, or an entry `{ ['x']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl). |
| `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity's type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl). |
@@ -586,7 +588,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h
- **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.
- **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method.
- **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail).
-- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.
+- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript, callback_registered (kind METHOD_REFERENCE) in Python; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits the four shared tiers and callback_registered. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.
- **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. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.
- **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.
- **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.
diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts
index 84c9fcb9e..d066ead75 100644
--- a/graph/bundle/build.ts
+++ b/graph/bundle/build.ts
@@ -10,9 +10,19 @@
import * as fs from 'fs';
import * as path from 'path';
-import { Header, readHeader, readRaw, readRfc4180 } from '@/bundle/csv';
+import { Header, type Pick, readHeader, readRaw, readRfc4180 } from '@/bundle/csv';
import type { LanguageAdapter, RawSource } from '@/bundle/languages';
+/** AXIOM_BUNDLE_PROFILE=1: one stderr line per stage with its wall time, for measuring the bundle. */
+const PROFILE = !!process.env.AXIOM_BUNDLE_PROFILE;
+let lapAt = Date.now();
+export function lap(what: string): void {
+ if (!PROFILE) return;
+ const now = Date.now();
+ process.stderr.write(` [bundle-profile] ${((now - lapAt) / 1000).toFixed(2)}s ${what}\n`);
+ lapAt = now;
+}
+
export type Cell = string | number | null;
export type Row = Cell[];
@@ -114,9 +124,10 @@ async function libSource(inp: BuildInputs, file: string): Promise {
+async function* rowsOf(src: EntitySource, pick?: Pick): AsyncGenerator {
let first = src.hasHeaderRow;
- for await (const r of readRfc4180(src.path)) {
+ // the reader never filters a file's first row: here that is the header, and a headerless file is read whole
+ for await (const r of readRfc4180(src.path, src.hasHeaderRow ? pick : undefined)) {
if (first) { first = false; continue; }
yield r;
}
@@ -136,6 +147,7 @@ export async function buildCore(inp: BuildInputs): Promise {
const rawFieldAccess = A.raw.fieldAccess ? await readSource(rawDir, A.raw.fieldAccess) : [];
const rawTypeUse = A.raw.typeUse ? await readSource(rawDir, A.raw.typeUse) : [];
const rawConfigBinding = A.raw.configBinding ? await readSource(rawDir, A.raw.configBinding) : [];
+ lap('read raw relations');
log(` raw: ${rawEdges.length} edge rows, ${rawAncestors.length} ancestor rows, ${rawOverrides.length} override rows`);
// call_edges: split the raw ToMethod into a method key or a label by provenance
@@ -179,6 +191,7 @@ export async function buildCore(inp: BuildInputs): Promise {
return [ref!, nul(type), context!, int(depth) ?? 0, ownerKind!, owner!, nul(enclMethod), nul(enclType), nul(prov), tier!];
});
+ lap('transform raw rows (call_edges, field_access, type_use)');
// ── 2. which ids the bundle must name ─────────────────────────────────────
const wantMethods = new Set();
const wantTypes = new Set();
@@ -211,6 +224,7 @@ export async function buildCore(inp: BuildInputs): Promise {
if (r[2] === 'field' && r[3] !== null) wantFields.add(r[3] as string);
}
+ lap('collect wanted ids');
// ── 3. client entities (all of them — the client is the subject) ──────────
// The IR spells visibility differently per entity file (PUBLIC on methods/types, PUBLIC_ACCESS on
// fields); normalise to one vocabulary so a rule can match on it. '' when the language has none.
@@ -367,6 +381,7 @@ export async function buildCore(inp: BuildInputs): Promise {
}
}
+ lap('read client modules/methods/types/fields IR');
// ── 4. library entities — only the referenced ones ────────────────────────
const missingMethods = new Set([...wantMethods].filter((id) => !methods.has(id)));
const missingTypesEarly = [...wantTypes].some((id) => !types.has(id));
@@ -447,6 +462,7 @@ export async function buildCore(inp: BuildInputs): Promise {
if (FI?.ownerQualifiedNameFromType) fillOwnerNames(fields, 3, 4);
// A library module's file path is not staged; a library site never occurs (edges start in the client).
+ lap('read library entities + generated/external');
// ── 5. call sites: name + position for every site the edges mention ───────
// row: id, caller_id, kind, callee_name, file_path, start_line, start_column, end_line, end_column
const sites = new Map();
@@ -466,7 +482,7 @@ export async function buildCore(inp: BuildInputs): Promise {
const h = src.header;
const ce = h.col(C.expressionId), cn = h.col(C.calleeName), cl = h.col(C.startLine), cc = h.col(C.startColumn);
const cel = C.endLine ? h.col(C.endLine) : -1;
- for await (const r of rowsOf(src)) {
+ for await (const r of rowsOf(src, { column: ce, has: (id) => sites.has(id) })) {
const row = sites.get(r[ce] ?? '');
if (!row) continue;
fill(row, 3, nul(r[cn])); fill(row, 4, fileOf(C.fileVia, h, r));
@@ -475,6 +491,7 @@ export async function buildCore(inp: BuildInputs): Promise {
}
}
}
+ lap('position sites from call-site IR');
{
const E = A.ir.expressions;
const src = await clientSource(inp.clientIrDir, E.file);
@@ -483,7 +500,7 @@ export async function buildCore(inp: BuildInputs): Promise {
const ci = h.col(E.id), ck = h.col(E.kind), cl = h.col(E.startLine), cc = h.col(E.startColumn), cel = h.col(E.endLine), cec = h.col(E.endColumn);
const cname = E.calleeName ? h.col(E.calleeName.column) : -1;
const nameKinds = new Set(E.calleeName?.kinds ?? []);
- for await (const r of rowsOf(src)) {
+ for await (const r of rowsOf(src, { column: ci, has: (id) => sites.has(id) || fieldSites.has(id) })) {
const id = r[ci] ?? '';
// A FIELD ACCESS site is an expression too, and this is the one pass over a table that
// can be GB-scale, so both are positioned here rather than streaming it twice.
@@ -501,6 +518,7 @@ export async function buildCore(inp: BuildInputs): Promise {
}
}
}
+ lap('position sites from expressions IR');
if (A.ir.decorators) {
const D = A.ir.decorators;
const src = await clientSource(inp.clientIrDir, D.file);
@@ -572,6 +590,7 @@ export async function buildCore(inp: BuildInputs): Promise {
const unplaced = [...sites.values()].filter((r) => r[5] === null).length;
if (unplaced > 0) log(` ! ${unplaced} of ${sites.size} call sites have no position in the IR`);
+ lap('position sites from decorators/locals/field sites + fallbacks');
// ── 5b. what the parser did NOT read ──────────────────────────────────────
// A file the parser skipped has no row in any table above, and until #1148 nothing in the
// bundle said so: a consumer could not tell a file with no calls from a file that was
@@ -599,9 +618,10 @@ export async function buildCore(inp: BuildInputs): Promise {
}
if (skipped.length > 0) log(` skipped: ${skipped.length} file(s) the parser did not read`);
+ lap('read skipped');
// ── 6. assemble ───────────────────────────────────────────────────────────
const run: Row[] = Object.entries(inp.meta).map(([k, v]) => [k, v]);
- return {
+ const core = {
run,
methods: [...methods.values()],
types: [...types.values()],
@@ -619,6 +639,8 @@ export async function buildCore(inp: BuildInputs): Promise {
type_use,
skipped: dedupe(skipped),
};
+ lap('assemble + dedupe');
+ return core;
}
function dedupe(rows: Row[]): Row[] {
diff --git a/graph/bundle/catalog.ts b/graph/bundle/catalog.ts
index 3276e855b..9e216e7d6 100644
--- a/graph/bundle/catalog.ts
+++ b/graph/bundle/catalog.ts
@@ -16,6 +16,21 @@ export interface ExtRelation {
description: string;
}
+/**
+ * The ext relations the query layer reads (plugins/, hooks/: impact, path, context, test-impact, the MCP server).
+ * Only these get the c0 index: an index on a relation nothing queries is time and disk at every build, and the
+ * schema promises the table, not an index on it. Not a filter on what is loaded — every manifest relation is still
+ * an ext table. graph/test/tools/ext-queried-test.sh fails when a script names an ext relation missing here.
+ */
+export const EXT_QUERIED: ReadonlySet = new Set([
+ 'bean_def', 'call_runs_edge', 'call_site_unresolved', 'config_affects_method', 'config_binding', 'config_key_ref',
+ 'config_unresolved', 'ctor_implicit_type', 'decorated_name_target', 'di_edge', 'fn_value_call', 'framework_edge',
+ 'inject_point', 'lib_callback_edge', 'lib_callback_site', 'library_receiver', 'method_returns_method',
+ 'method_returns_param', 'persistence_field', 'remote_edge', 'remote_unsent', 'remote_unserved', 'return_value',
+ 'state_call_alloc', 'state_call_open', 'state_gate', 'state_gate_alloc', 'state_world_of_gated',
+ 'type_base_unresolved', 'unresolved_value_callee',
+]);
+
/** relationfile per line; comments and blanks skipped. */
export function readManifest(langDir: string): { relation: string; file: string }[] {
const p = path.join(langDir, 'souffle', 'export_manifest.tsv');
diff --git a/graph/bundle/cli.ts b/graph/bundle/cli.ts
index c66c43611..fcefeba3a 100644
--- a/graph/bundle/cli.ts
+++ b/graph/bundle/cli.ts
@@ -17,7 +17,7 @@ import * as path from 'path';
process.removeAllListeners('warning');
process.on('warning', (w) => { if (w.name !== 'ExperimentalWarning') console.error(w.stack ?? w.message); });
-import { buildCore } from '@/bundle/build';
+import { buildCore, lap } from '@/bundle/build';
import { catalogExt, readLibMap } from '@/bundle/catalog';
import { adapterFor } from '@/bundle/languages';
import { renderSchemaMarkdown, SCHEMA_VERSION } from '@/bundle/schema';
@@ -95,7 +95,9 @@ export async function main(argv = process.argv.slice(2)): Promise {
const dbPath = path.join(a.out!, 'graph.sqlite');
if (haveSqlite) {
- await writeSqlite({ dbPath, language: adapter.language, core, ext: catalogExt(langDir), rawDir: a.raw!, log });
+ const ext = catalogExt(langDir);
+ lap('lift ext docs (catalogExt)');
+ await writeSqlite({ dbPath, language: adapter.language, core, ext, rawDir: a.raw!, log });
log(`▶ wrote ${dbPath}${a.debug ? ' (+ csv/*.csv, --debug)' : ''}`);
} else {
fs.rmSync(dbPath, { force: true });
diff --git a/graph/bundle/csv.ts b/graph/bundle/csv.ts
index 43fc7ef97..befee56b5 100644
--- a/graph/bundle/csv.ts
+++ b/graph/bundle/csv.ts
@@ -14,43 +14,120 @@ import * as fs from 'fs';
const TAB = '\t';
-/** One RFC 4180 (tab-delimited) file as an async stream of rows. A zero-byte file yields nothing. */
-export async function* readRfc4180(path: string): AsyncGenerator {
+/** One RFC 4180 (tab-delimited) file as an async stream of rows. A zero-byte file yields nothing.
+ *
+ * A LINE WITH NO QUOTE IN IT IS A WHOLE ROW, and nearly every line is one, so it is split on tabs as it
+ * stands: a quote is the only thing that can make a field hold a tab or a newline. Only a line that holds
+ * a quote, and the lines a quoted field carries it across, goes through the character-by-character reader
+ * (`Rfc4180Row`), which is the definition. Reading every character that way cost most of the bundle's time
+ * on a GB-scale expression table. */
+export async function* readRfc4180(path: string, pick?: Pick): AsyncGenerator {
const stream = fs.createReadStream(path, { encoding: 'utf8', highWaterMark: 1 << 20 });
- let field = '';
- let row: string[] = [];
- let quoted = false; // inside a quoted field
- let afterQuote = false; // just saw a quote inside a quoted field — next char decides
- let atFieldStart = true;
+ const slow = new Rfc4180Row();
+ let rest = '';
+ let first = true; // the first row, a header in a headered file, is never filtered
for await (const chunk of stream as AsyncIterable) {
- for (let i = 0; i < chunk.length; i++) {
+ const text = rest.length > 0 ? rest + chunk : chunk;
+ let at = 0;
+ let quote = text.indexOf('"'); // the next quote at or after `at`, -1 when the chunk holds no more
+ for (;;) {
+ const nl = text.indexOf('\n', at);
+ if (nl < 0) break;
+ if (quote >= 0 && quote < at) quote = text.indexOf('"', at);
+ if (slow.open || (quote >= 0 && quote < nl)) {
+ // the line holds a quote, or a quoted field opened on an earlier line runs on into it
+ const row = slow.feed(text, at, nl + 1);
+ if (row) yield row;
+ } else if (first || !pick || pick.has(fieldAt(text, at, nl, pick.column))) {
+ yield splitLine(text, at, nl);
+ }
+ first = false;
+ at = nl + 1;
+ }
+ rest = text.slice(at);
+ }
+ // final row without a trailing newline
+ if (slow.open || rest.indexOf('"') >= 0) {
+ slow.feed(rest, 0, rest.length);
+ const row = slow.end();
+ if (row) yield row;
+ } else if (rest.length > 0 && (first || !pick || pick.has(fieldAt(rest, 0, rest.length, pick.column)))) {
+ yield splitLine(rest, 0, rest.length);
+ }
+}
+
+/** Rows the caller will drop, skipped before they are split: a row is read only when `has` holds for its
+ * `column`th field ('' when the row is shorter, as `row[column] ?? ''` reads it). A row a quote touches is
+ * always returned, so the caller must still test every row it gets; this only saves the split of the rest. */
+export interface Pick { column: number; has: (value: string) => boolean }
+
+/** The `col`th tab-separated field of text[a, b), a line holding no quote; a trailing \r on the last field dropped. */
+function fieldAt(text: string, a: number, b: number, col: number): string {
+ let start = a;
+ for (let i = 0; i < col; i++) {
+ const t = text.indexOf(TAB, start);
+ if (t < 0 || t >= b) return '';
+ start = t + 1;
+ }
+ const t = text.indexOf(TAB, start);
+ if (t >= 0 && t < b) return text.slice(start, t);
+ const v = text.slice(start, b);
+ return v.endsWith('\r') ? v.slice(0, -1) : v;
+}
+
+/** text[a, b) — one line holding no quote, without its newline — as a row: split on tabs, a trailing \r dropped. */
+function splitLine(text: string, a: number, b: number): string[] {
+ const line = text.slice(a, b);
+ const row = line.split(TAB);
+ const last = row[row.length - 1]!;
+ if (last.endsWith('\r')) row[row.length - 1] = last.slice(0, -1);
+ return row;
+}
+
+/** The RFC 4180 reader, a character at a time: fed whole lines, it returns a row when one ends on a newline outside quotes. */
+class Rfc4180Row {
+ private field = '';
+ private row: string[] = [];
+ private quoted = false; // inside a quoted field
+ private afterQuote = false; // just saw a quote inside a quoted field — next char decides
+ private atFieldStart = true;
+ /** a row has begun and not ended: a quoted field crossed a newline */
+ get open(): boolean { return this.quoted || this.afterQuote || this.row.length > 0 || this.field.length > 0 || !this.atFieldStart; }
+ feed(chunk: string, from: number, to: number): string[] | null {
+ let out: string[] | null = null;
+ for (let i = from; i < to; i++) {
const ch = chunk[i]!;
- if (quoted) {
- if (afterQuote) {
- afterQuote = false;
- if (ch === '"') { field += '"'; continue; } // doubled quote → literal quote
- quoted = false; // the closing quote — fall through to unquoted handling
+ if (this.quoted) {
+ if (this.afterQuote) {
+ this.afterQuote = false;
+ if (ch === '"') { this.field += '"'; continue; } // doubled quote → literal quote
+ this.quoted = false; // the closing quote — fall through to unquoted handling
} else {
- if (ch === '"') afterQuote = true; else field += ch;
+ if (ch === '"') this.afterQuote = true; else this.field += ch;
continue;
}
}
- if (ch === '"' && atFieldStart) { quoted = true; atFieldStart = false; continue; }
- atFieldStart = false;
- if (ch === TAB) { row.push(field); field = ''; atFieldStart = true; continue; }
+ if (ch === '"' && this.atFieldStart) { this.quoted = true; this.atFieldStart = false; continue; }
+ this.atFieldStart = false;
+ if (ch === TAB) { this.row.push(this.field); this.field = ''; this.atFieldStart = true; continue; }
if (ch === '\n') {
- row.push(field.endsWith('\r') ? field.slice(0, -1) : field);
- field = ''; atFieldStart = true;
- yield row; row = [];
+ this.row.push(this.field.endsWith('\r') ? this.field.slice(0, -1) : this.field);
+ this.field = ''; this.atFieldStart = true;
+ out = this.row; this.row = [];
continue;
}
- field += ch;
+ this.field += ch;
}
+ return out;
}
- // final row without a trailing newline
- if (field.length > 0 || row.length > 0) {
- row.push(field.endsWith('\r') ? field.slice(0, -1) : field);
- yield row;
+ /** end of input: the row in progress, if any */
+ end(): string[] | null {
+ if (this.field.length > 0 || this.row.length > 0) {
+ this.row.push(this.field.endsWith('\r') ? this.field.slice(0, -1) : this.field);
+ const r = this.row; this.row = []; this.field = '';
+ return r;
+ }
+ return null;
}
}
diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts
index 731fb61b6..9d71c3906 100644
--- a/graph/bundle/schema.ts
+++ b/graph/bundle/schema.ts
@@ -524,6 +524,7 @@ export const VOCAB: readonly VocabSpec[] = [
{ table: 'call_edges', column: 'tier', value: 'dynamic_terminal', languages: S, meaning: '`obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL.' },
{ table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' },
{ table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' },
+ { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: P, meaning: 'A method or function named as a VALUE and handed over, stored or returned (`pool.submit(self.work)`, `Thread(target=svc.run)`, `sorted(xs, key=self.rank)`, `[h.on_event]`), always kind METHOD_REFERENCE. Its target is resolved the way a call on the same receiver would be; whoever receives the value calls it. A reachability edge, labelled so it is never read as a resolved call.' },
{ table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' },
{ table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: T, meaning: 'A publish on a project class\'s keyed registry reaching each function filed under the same key: `bus.publish(\'x\', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe(\'x\', f)` stored there, or an entry `{ [\'x\']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl).' },
{ table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity\'s type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl).' },
@@ -684,6 +685,7 @@ export const VOCAB: readonly VocabSpec[] = [
{ table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' },
{ table: 'call_edges', column: 'kind', value: 'BUILTIN_PROTOCOL', languages: P, meaning: '`repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument\'s class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call.' },
{ table: 'call_edges', column: 'kind', value: 'OPERATOR_PROTOCOL', languages: P, meaning: 'An operator runs a dunder of its operands: `a + b` the left operand\'s `__add__` and the right one\'s `__radd__`, a comparison the left slot and the right one\'s mirror (`a < b` -> `b.__gt__`, `!=` falls back to `__eq__`), `x in c` the right operand\'s `__contains__` (or `__iter__`), `-a`/`+a`/`~a` its `__neg__`/`__pos__`/`__invert__`. No written call; the site is the operator expression.' },
+ { table: 'call_edges', column: 'kind', value: 'METHOD_REFERENCE', languages: P, meaning: 'A method or function READ AS A VALUE, not called: an argument (`pool.submit(self.work, x)`, `map(Cls.convert, rows)`, `partial(svc.cancel, oid)`), a keyword (`Thread(target=svc.run)`), an element, a stored or returned value. The target is what the expression resolves to (a typed receiver through the MRO, a class, a scoped name, a module member); an untyped receiver gives no row. Always tier callback_registered; the site is the reference expression (call-edge-generation/method-reference.dl).' },
{ table: 'call_edges', column: 'kind', value: 'TRUTH_PROTOCOL', languages: P, meaning: 'A truth test runs the tested value\'s `__bool__`, or `__len__` when its class has none: `if x:`, `while x:`, `assert x`, `not x`, a conditional or comprehension condition, and an operand of `and`/`or` (the right one only when the whole expression is tested). No written call; the site is the tested expression.' },
// entry_points.reason
@@ -763,7 +765,7 @@ export const NOTES: readonly NoteSpec[] = [
{ language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' },
{ language: 'typescript', table: 'type_instantiated', note: '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.' },
{ language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' },
- { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, OPERATOR_PROTOCOL, TRUTH_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' },
+ { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, OPERATOR_PROTOCOL, TRUTH_PROTOCOL, METHOD_REFERENCE, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' },
{ language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' },
{ language: 'python', table: 'call_edges', note: '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.' },
{ language: 'java', table: 'call_edges', note: 'A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform\'s own subclasses, are the other possible targets. Stage the library to replace the label with the real method.' },
@@ -771,7 +773,7 @@ export const NOTES: readonly NoteSpec[] = [
{ language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' },
{ language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' },
{ language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' },
- { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' },
+ { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript, callback_registered (kind METHOD_REFERENCE) in Python; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits the four shared tiers and callback_registered. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' },
{ language: 'java', table: 'call_edges', note: '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. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' },
{ language: 'typescript', table: 'call_edges', note: '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.' },
{ language: 'python', table: 'call_edges', note: '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.' },
diff --git a/graph/bundle/write.ts b/graph/bundle/write.ts
index d00899fee..6898777b5 100644
--- a/graph/bundle/write.ts
+++ b/graph/bundle/write.ts
@@ -7,8 +7,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { readRaw, writeTable } from '@/bundle/csv';
-import type { CoreTables, Row } from '@/bundle/build';
-import type { ExtRelation } from '@/bundle/catalog';
+import { lap, type CoreTables, type Row } from '@/bundle/build';
+import { EXT_QUERIED, type ExtRelation } from '@/bundle/catalog';
import { CATALOG_TABLES, CORE_TABLES, GUIDE, LANGUAGES, NOTES, QUERIES, SCHEMA_VERSION, VOCAB, type Language, type TableSpec } from '@/bundle/schema';
export function writeCoreCsv(graphDir: string, core: CoreTables, log: (s: string) => void): void {
@@ -48,6 +48,7 @@ export interface SqliteInputs {
// VALUES list amortizes both; 128 rows per statement keeps the flattened argument array well
// under sqlite's bind limit at the widest table.
const BATCH = Math.max(1, Number(process.env.AXIOM_BUNDLE_BATCH ?? 128) || 128);
+const PAGE_SIZE = [4096, 8192, 16384, 32768, 65536].includes(Number(process.env.AXIOM_BUNDLE_PAGE_SIZE)) ? Number(process.env.AXIOM_BUNDLE_PAGE_SIZE) : 16384;
type DbLike = { prepare(sql: string): { run(...a: (string | number | null)[]): unknown } };
function batchInsert(db: DbLike, table: string, arity: number, verb: string): { push(r: (string | number | null)[]): void; flush(): void } {
const one = `(${Array.from({ length: arity }, () => '?').join(', ')})`;
@@ -72,12 +73,35 @@ function batchInsert(db: DbLike, table: string, arity: number, verb: string): {
};
}
+/**
+ * A keyed table's rows in key order, for loading. A WITHOUT ROWID table is stored in key order whatever order its
+ * rows arrive in, so this changes nothing stored; but rows arriving in hash order land all over the B-tree and
+ * split its pages again and again, while rows in key order are appended. The sort is stable, so of two rows with
+ * one key the first is still the one INSERT OR IGNORE keeps. A copy is sorted: the caller's array is not reordered.
+ */
+function inKeyOrder(t: TableSpec, rows: Row[]): Row[] {
+ const keys = t.columns.flatMap((c, i) => (c.key ? [i] : []));
+ if (keys.length === 0 || rows.length < 2) return rows;
+ const str = (v: Row[number] | undefined) => (v === null || v === undefined ? '' : String(v));
+ return rows.slice().sort((a, b) => {
+ for (const i of keys) {
+ const x = str(a[i]), y = str(b[i]);
+ if (x !== y) return x < y ? -1 : 1;
+ }
+ return 0;
+ });
+}
+
export async function writeSqlite(inp: SqliteInputs): Promise {
// Loaded lazily so the CSV path works on a Node without the module.
const { DatabaseSync } = await import('node:sqlite');
fs.rmSync(inp.dbPath, { force: true });
fs.rmSync(inp.dbPath + '-journal', { force: true });
const db = new DatabaseSync(inp.dbPath);
+ // 16 KiB PAGES, set before the first table (AXIOM_BUNDLE_PAGE_SIZE overrides). The bundle is a bulk load of a file
+ // that reaches GBs, and at SQLite's default 4 KiB every table and index B-tree has four times the pages to split
+ // and write; the queries over the graph read it no slower. The page size changes no row and no answer.
+ db.exec(`PRAGMA page_size = ${PAGE_SIZE};`);
db.exec('PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF; PRAGMA temp_store = MEMORY;');
try {
// ── core ────────────────────────────────────────────────────────────────
@@ -86,11 +110,13 @@ export async function writeSqlite(inp: SqliteInputs): Promise {
db.exec(createSql(t));
const rows = (inp.core as unknown as Record)[t.name] ?? [];
const ins = batchInsert(db, t.name, t.columns.length, 'INSERT OR IGNORE');
- for (const r of rows) ins.push(r as (string | number | null)[]);
+ for (const r of inKeyOrder(t, rows)) ins.push(r as (string | number | null)[]);
ins.flush();
inp.log(` sqlite ${t.name}: ${rows.length} rows`);
+ lap(`insert ${t.name} (${rows.length} rows)`);
}
db.exec('COMMIT;');
+ lap('commit core');
// ── ext ─────────────────────────────────────────────────────────────────
db.exec('BEGIN;');
@@ -107,8 +133,10 @@ export async function writeSqlite(inp: SqliteInputs): Promise {
}
ins.flush();
if (n > 0) inp.log(` sqlite ${name}: ${n} rows`);
+ lap(`insert ${name} (${n} rows)`);
}
db.exec('COMMIT;');
+ lap('commit ext');
// ── catalog ─────────────────────────────────────────────────────────────
db.exec('BEGIN;');
@@ -165,14 +193,17 @@ export async function writeSqlite(inp: SqliteInputs): Promise {
const query = db.prepare('INSERT INTO schema_queries VALUES (?, ?, ?, ?)');
for (const q of QUERIES) query.run(q.name, q.question, q.params, q.sql);
db.exec('COMMIT;');
+ lap('catalog + vocab');
// ── indexes, after the load ─────────────────────────────────────────────
db.exec('BEGIN;');
for (const t of CORE_TABLES) {
- for (const cc of t.columns) if (cc.indexed) db.exec(`CREATE INDEX idx_${t.name}_${cc.name} ON ${t.name}(${cc.name});`);
+ for (const cc of t.columns) if (cc.indexed) { db.exec(`CREATE INDEX idx_${t.name}_${cc.name} ON ${t.name}(${cc.name});`); lap(`index ${t.name}.${cc.name}`); }
}
- for (const e of inp.ext) if (e.arity > 0) db.exec(`CREATE INDEX idx_ext_${e.relation}_c0 ON ext_${e.relation}(c0);`);
+ // an ext table the tools query keeps its c0 index; the rest are loaded and left unindexed (catalog.ts EXT_QUERIED)
+ for (const e of inp.ext) if (e.arity > 0 && EXT_QUERIED.has(e.relation)) { db.exec(`CREATE INDEX idx_ext_${e.relation}_c0 ON ext_${e.relation}(c0);`); lap(`index ext_${e.relation}.c0`); }
db.exec('COMMIT;');
+ lap('commit indexes');
db.exec(`PRAGMA user_version = ${Number(SCHEMA_VERSION)};`);
} finally {
db.close();
diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh
index f903e654b..b0ce60a1f 100755
--- a/graph/pipeline/run-souffle.sh
+++ b/graph/pipeline/run-souffle.sh
@@ -88,6 +88,8 @@ ENG="$SRC/$LANG_ARG/engine"; ENG2="$SRC/$LANG_ARG/engine-ii"; DL="$SRC/$LANG_ARG
[ -d "$ENG" ] || { echo "no rule set for --language=$LANG_ARG (looked in $ENG)" >&2; exit 1; }
# shellcheck source=souffle-include.sh
. "$SRC/pipeline/souffle-include.sh"
+# shellcheck source=stage-rows.sh
+. "$SRC/pipeline/stage-rows.sh"
# shellcheck source=engine.conf
. "$SRC/pipeline/engine.conf"
# Staging config is PER-LANGUAGE (IR marker + which relations are signatures vs bodies).
@@ -817,7 +819,7 @@ while IFS=$'\t' read -r rel csv; do
# deterministically on some inputs — takes down a run of tens of thousands of sites. The
# header's field count is the contract; a row that does not meet it is dropped and
# COUNTED, so the loss is visible rather than fatal and never silent.
- if [ -f "$CLIENT/$csv.csv" ]; then awk -F'\t' 'NR==1{n=NF; next} NF==n{print; next} {bad++} END{if(bad>0) printf " ! dropped %d malformed row(s) from %s\n", bad, FILENAME > "/dev/stderr"}' "$CLIENT/$csv.csv" > "$FACTS/$rel.facts"
+ if [ -f "$CLIENT/$csv.csv" ]; then stage_rows "$CLIENT/$csv.csv" > "$FACTS/$rel.facts"
else : > "$FACTS/$rel.facts"; fi
CLIENT_INPUTS="$CLIENT_INPUTS$rel"$'\n'
done < <(read_map "$TPL/client-ir.map"; if [ -f "$TPL/client-extra.map" ]; then read_map "$TPL/client-extra.map"; fi)
@@ -879,7 +881,7 @@ if [ ! -d "$LIBDIR" ]; then
: > "$TMPDIR_L/$rel.facts"
for root in "${LIB_ROOTS[@]}"; do
while IFS= read -r mod; do
- [ -f "$mod/$csv.csv" ] && awk -F'\t' 'NR==1{n=NF; next} NF==n{print; next} {bad++} END{if(bad>0) printf " ! dropped %d malformed row(s) from %s\n", bad, FILENAME > "/dev/stderr"}' "$mod/$csv.csv" >> "$TMPDIR_L/$rel.facts"
+ [ -f "$mod/$csv.csv" ] && stage_rows "$mod/$csv.csv" >> "$TMPDIR_L/$rel.facts"
done < <(lib_modules "$root")
done
done < <(printf '%s' "$LIB_SIG_RELS")
diff --git a/graph/pipeline/stage-rows.sh b/graph/pipeline/stage-rows.sh
new file mode 100644
index 000000000..200ff7670
--- /dev/null
+++ b/graph/pipeline/stage-rows.sh
@@ -0,0 +1,50 @@
+# stage-rows.sh: copy one IR CSV into a Soufflé fact file, dropping torn rows. Sourced by run-souffle.sh.
+#
+# stage_rows FILE print FILE's rows without its header, keeping only the rows whose tab-separated
+# field count equals the header's; report how many were dropped on stderr.
+#
+# The filter is line based: a row is one newline-terminated line, a field count is the number of tabs
+# plus one (an empty line has none), and a last line without a newline is printed with one. Those are
+# exactly awk -F'\t' semantics, which is what this step used to run.
+#
+# PERL FIRST, AWK AS THE FALLBACK. The two print byte-identical output, but BSD awk (macOS) spends most
+# of a build's staging time splitting fields it never reads: 13.6 CPU-s on a 352 MB expressions file,
+# against 0.65 s for perl, which only counts tabs. perl ships with macOS, every mainstream Linux
+# distribution and Git for Windows' bash (the shell this runs under on win32); a minimal image without
+# it (Alpine, distroless) gets the same rows from awk, just slower. graph/test/tools/stage-rows-test.sh
+# holds both to the same bytes on the edge cases (trailing tabs, empty lines, CRLF, no final newline).
+#
+# AXIOM_STAGE_WITH=awk forces the fallback (the test uses it; so can anyone bisecting a staging change).
+
+if [ "${AXIOM_STAGE_WITH:-}" != awk ] && command -v perl >/dev/null 2>&1 && perl -e 1 >/dev/null 2>&1; then
+ STAGE_ROWS_TOOL=perl
+else
+ STAGE_ROWS_TOOL=awk
+fi
+
+stage_rows(){
+ if [ "$STAGE_ROWS_TOOL" = perl ]; then
+ # Bytes in, bytes out (no PerlIO layers, whatever PERL_UNICODE says), and $/ pinned to "\n".
+ perl -e '
+ my $f = $ARGV[0];
+ open(my $h, "<:raw", $f) or die "cannot read $f: $!\n";
+ binmode STDOUT, ":raw";
+ local $/ = "\n";
+ my $hdr = <$h>;
+ exit 0 unless defined $hdr;
+ my $hl = length($hdr) - (substr($hdr, -1) eq "\n" ? 1 : 0);
+ my $n = $hl ? ($hdr =~ tr/\t//) + 1 : 0;
+ my $bad = 0;
+ while (my $l = <$h>) {
+ my $nl = substr($l, -1) eq "\n";
+ my $nf = (length($l) - ($nl ? 1 : 0)) ? ($l =~ tr/\t//) + 1 : 0;
+ if ($nf == $n) { print($nl ? $l : "$l\n") } else { $bad++ }
+ }
+ close($h);
+ close(STDOUT) or die "cannot write the staged rows of $f: $!\n";
+ printf STDERR " ! dropped %d malformed row(s) from %s\n", $bad, $f if $bad > 0;
+ ' "$1"
+ else
+ awk -F'\t' 'NR==1{n=NF; next} NF==n{print; next} {bad++} END{if(bad>0) printf " ! dropped %d malformed row(s) from %s\n", bad, FILENAME > "/dev/stderr"}' "$1"
+ fi
+}
diff --git a/graph/python/engine/call-edge-generation/method-reference.dl b/graph/python/engine/call-edge-generation/method-reference.dl
new file mode 100644
index 000000000..c3bcf5da2
--- /dev/null
+++ b/graph/python/engine/call-edge-generation/method-reference.dl
@@ -0,0 +1,123 @@
+// ============================================================================
+// CALL-EDGE-GENERATION · METHOD REFERENCES (a callable named as a VALUE, not called)
+//
+// ── THE GAP ─────────────────────────────────────────────────────────────────
+// executor.submit(self._do_work, item) threading.Thread(target=svc.run_job)
+// loop.run_in_executor(None, svc.cancel, oid) sorted(xs, key=self._rank)
+// callbacks = [handler.on_event] functools.partial(svc.cancel, oid)
+// app.add_url_rule("/x", view_func=views.index) return self._handle
+// Each hands a method or function over as a value; whoever receives it calls it, later, from
+// a frame the graph cannot see (a worker thread, a builtin, a library). There is no call site
+// for it, so before this file the method had NO caller here at all, and `impact
+// OrderService.cancel` did not name the function that queued it. The engine already KNEW what
+// each of these expressions denotes -- expr_denotes_method resolves `self._do_work` through the
+// receiver's MRO exactly as it resolves `self._do_work()` -- and dropped the answer because no
+// CALL wrapped it.
+//
+// ── WHAT IT EMITS ───────────────────────────────────────────────────────────
+// call_chain_edge(Expr, Caller, "-", Target, "client", "callback_registered", "METHOD_REFERENCE")
+// Like PROPERTY_READ and the protocol rows in call_chain.dl, an edge with no call site: Site is
+// the reference expression itself, and the kind is its own so a consumer can always tell it
+// from a written call. The tier is the one every other front end uses for a function handed
+// over (`xs.forEach(f)`, a JSX handler attribute): HANDED OVER, never `resolved`, so an answer
+// says "handed over as a value" instead of "calls it".
+//
+// ── WHAT IT REFUSES ─────────────────────────────────────────────────────────
+// - ONLY WHAT IS RESOLVED. The target comes from the same resolution an ordinary call's
+// callee gets: a receiver typed through expr_type and the MRO, a class named by its
+// declaration, a name bound by its scope, a module member. `thing.cancel` on an untyped
+// parameter denotes nothing, so it emits nothing -- the same blind spot `thing.cancel()`
+// has. No rule here matches by name.
+// - NOT A FLOWED VALUE. `def run(cb): pool.submit(cb)` denotes, through
+// param_arg_method, every function any caller passed in. The reference that matters is
+// the one the caller WROTE (`run(self._work)`), which already has its own row; crediting
+// `run` as well would make it reach the union of every callable handed to it. The same
+// holds for an attribute that holds a function (see (b) below).
+// - NOT A CALLEE, NOT A WRITE, NOT AN OBJECT OF ANOTHER ATTRIBUTE. `self.m()` is a call
+// (call_chain.dl owns it); `self.m = x` is a store; `self.m.__name__` and
+// `self.m.cache_clear()` read the function object and never run it.
+// - NOT A PROPERTY, NOT A DECORATOR. `order.total` on a @property (or @cached_property)
+// reads the VALUE the getter computes: call_chain.dl already emits that as PROPERTY_READ,
+// and the function object is never handed anywhere. `@audited` is a decorator
+// application, which call_chain.dl emits as DECORATOR_*. Before these two guards they
+// were the two largest wrong classes in a sampled review.
+// - NOT THE ARGUMENT OF A ONE-DUNDER BUILTIN. `str(fn)`, `repr(fn)`, `hash(fn)`
+// (builtins.dl's builtin_protocol_slot) run a dunder of the FUNCTION OBJECT and never call
+// it; a test asserting on `str(svc.cancel)` is not a caller of cancel. Other inspecting
+// builtins (`id`, `callable`, `isinstance`) are not catalogued and still count.
+// - NOT A COMPARISON OPERAND. `assert job.on_done is svc.cancel` tests identity.
+// - ONLY NAMES AND ATTRIBUTE READS. A lambda, a subscript (`table[k]`) and a call that
+// returns a function (`partial(...)`, a factory) also denote methods; each already has its
+// own edge where it is invoked, or IS a call with its own row.
+// The receiver-typing, decorator-chain and import rules this reads all live in resolution/;
+// nothing here widens them.
+// ============================================================================
+
+.decl method_ref_expr(e:symbol, m:symbol)
+// (a) a NAME: what resolution/ says it denotes -- a def bound in scope, an import, a
+// decorated name's wrapper -- minus a name that holds a flowed VALUE: a parameter, or a
+// local or module name assigned from an expression (`cb = job.on_done`). That
+// assignment's right-hand side is credited where it is written when it is a reference
+// itself; when it is a field read, what the name holds is the union of every write to that
+// field, which was the largest wrong shape in a sampled review (`assert cb is not None`
+// credited with every callback any job was ever given).
+method_ref_expr(e, m) :-
+ expr_node("client", "NAME_REFERENCE", _, _, e),
+ expr_denotes_method("client", e, m),
+ !expr_names_param("client", e, _),
+ !method_ref_name_holds_value(e).
+.decl method_ref_name_holds_value(e:symbol)
+method_ref_name_holds_value(e) :-
+ expr_node("client", "NAME_REFERENCE", _, _, e),
+ expr_binding("client", b, ctx, e), ctx != "STORE",
+ binding_lookup("client", b, b2), binding_value_method("client", b2, _).
+// (b) an ATTRIBUTE: a method found through the typed receiver's MRO (`svc.cancel`,
+// `self._work`), or a module member (`views.index`, decorated or not). NOT an attribute
+// that merely HOLDS a function some write put there (`job.on_done`): that is a flowed
+// value like a parameter's, and a sampled review found exactly that shape wrong:
+// `job.on_done is NO_OP`, credited to whatever any write stored.
+method_ref_expr(e, m) :- expr_denotes_bound_method("client", e, m).
+method_ref_expr(e, m) :-
+ expr_node("client", "ATTRIBUTE_ACCESS", _, _, e),
+ expr_attr_names_method("client", e, _),
+ expr_denotes_method("client", e, m).
+// (c) an UNBOUND method read off its class: `map(OrderService.convert, rows)`. The class
+// object types the receiver, and the MRO names the method -- the rule
+// callee-resolution.dl uses for `OrderService.convert(svc, row)`, without the call.
+method_ref_expr(e, m) :-
+ expr_node("client", "ATTRIBUTE_ACCESS", _, n, e),
+ expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj),
+ expr_type_class_object("client", obj, t),
+ mro_lookup("client", t, n, m).
+
+// the shape guards: read as a value, not called, not a write, not the object of another read
+.decl method_ref_site(e:symbol, m:symbol)
+method_ref_site(e, m) :-
+ method_ref_expr(e, m),
+ !call_callee_expr(_, e),
+ !expr_name_context("client", "STORE", e), !expr_name_context("client", "DEL", e),
+ !expr_parent("client", _, "ATTRIBUTE_OBJECT", _, e),
+ !decorator_expr("client", e, _),
+ !builtin_protocol_arg(_, _, e),
+ !method_ref_compared(e),
+ method_kind("client", kind, _, m),
+ kind != "MODULE_INITIALIZER", kind != "CLASS_INITIALIZER", kind != "OVERLOAD_STUB", kind != "LAMBDA",
+ kind != "PROPERTY_GETTER", kind != "PROPERTY_SETTER", kind != "PROPERTY_DELETER".
+
+// an operand of a comparison: `job.on_done is svc.cancel`, `h in self.handlers`, `f == g` test
+// the function object's identity or equality and never call it
+.decl method_ref_compared(e:symbol)
+method_ref_compared(e) :- method_ref_expr(e, _), expr_parent("client", c, _, _, e), expr_node("client", "COMPARISON", _, _, c).
+
+// the caller: the function or lambda whose body evaluates the reference. A module-level
+// reference and a parameter default are owned by the synthetic , a class-body one by
+// -- the parser records that owner on the expression, as it does for a call.
+.decl method_ref_caller(e:symbol, c:symbol)
+method_ref_caller(e, c) :- method_ref_site(e, _), expr_owner("client", "METHOD", c, _, e).
+method_ref_caller(e, c) :- method_ref_site(e, _), expr_owner("client", "LAMBDA", c, _, e).
+method_ref_caller(e, c) :- method_ref_site(e, _),
+ !expr_owner("client", "METHOD", _, _, e), !expr_owner("client", "LAMBDA", _, _, e),
+ expr_ultimate_method("client", e, c).
+
+call_chain_edge(e, c, "-", m, "client", "callback_registered", "METHOD_REFERENCE") :-
+ method_ref_site(e, m), method_ref_caller(e, c).
diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh
index 18db81e1d..e2a48d386 100755
--- a/graph/test/java/run-tests.sh
+++ b/graph/test/java/run-tests.sh
@@ -99,6 +99,13 @@ fi
if ! bash "$ROOT/graph/test/tools/lib-cache-key-test.sh"; then
echo "FAIL: the library-facts cache key is not a function of the staged modules' content (#588)"; exit 1
fi
+# ── Staging must not depend on which row filter the machine has ─────────────
+# graph/pipeline/stage-rows.sh runs perl where it exists and awk where it does not; both must stage
+# the same bytes, or the facts every language solves from would differ by machine.
+if ! bash "$ROOT/graph/test/tools/stage-rows-test.sh"; then
+ echo "aborting: perl and awk stage different rows from the same IR"
+ exit 1
+fi
# ── The -I for soufflé's headers must be the one that actually compiles ──────
# Also a preflight, and for the same reason: the old resolution returned a path that only built on
# the machine it was written on, and every run here compiles the engine through it.
diff --git a/graph/test/python/expected/01-plain-calls.edges b/graph/test/python/expected/01-plain-calls.edges
index c37ab9bcd..9df3ca724 100644
--- a/graph/test/python/expected/01-plain-calls.edges
+++ b/graph/test/python/expected/01-plain-calls.edges
@@ -1,4 +1,5 @@
boundary_lib SIMPLE_CALL main.main -> builtin:print
+callback_registered METHOD_REFERENCE main.make_adder -> main.make_adder..add
known_edge SIMPLE_CALL main. -> main.main
known_edge SIMPLE_CALL main.main -> main.make_adder
known_edge SIMPLE_CALL main.main -> main.make_adder..add
diff --git a/graph/test/python/expected/01-plain-calls.tiers b/graph/test/python/expected/01-plain-calls.tiers
index 5b235883c..0c7ce9f47 100644
--- a/graph/test/python/expected/01-plain-calls.tiers
+++ b/graph/test/python/expected/01-plain-calls.tiers
@@ -1,10 +1,12 @@
-distinct call sites emitted: 12
+distinct call sites emitted: 13
--- by tier: edge ROWS, and the distinct SITES they cover ---
3 rows 3 sites boundary_lib
+ 1 rows 1 sites callback_registered
9 rows 9 sites known_edge
--- edge rows by call kind ---
+ 1 METHOD_REFERENCE
12 SIMPLE_CALL
--- unresolved reasons ---
@@ -16,7 +18,7 @@ distinct call sites emitted: 12
9 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 12
+ edge rows 13
minus extra rows from multi-target sites 0
- = tier/site pairs 12
+ = tier/site pairs 13
engine's conserved site total 12
diff --git a/graph/test/python/expected/07-decorators.edges b/graph/test/python/expected/07-decorators.edges
index 41e8ab4f3..fa1975f0d 100644
--- a/graph/test/python/expected/07-decorators.edges
+++ b/graph/test/python/expected/07-decorators.edges
@@ -8,6 +8,11 @@ boundary_lib SIMPLE_CALL main.inner_deco..inner_w -> builtin:str
boundary_lib SIMPLE_CALL main.main -> builtin:print
boundary_lib SIMPLE_CALL main.outer_deco..outer_w -> builtin:str
boundary_lib SIMPLE_CALL main.wrapping..wrapper -> builtin:str
+callback_registered METHOD_REFERENCE main.inner_deco -> main.inner_deco..inner_w
+callback_registered METHOD_REFERENCE main.outer_deco -> main.outer_deco..outer_w
+callback_registered METHOD_REFERENCE main.replacing -> main.replacing..substitute
+callback_registered METHOD_REFERENCE main.route -> main.route..register
+callback_registered METHOD_REFERENCE main.wrapping -> main.wrapping..wrapper
known_edge DECORATOR_APPLICATION main. -> main.route..register
known_edge DECORATOR_BARE main. -> main.inner_deco
known_edge DECORATOR_BARE main. -> main.outer_deco
diff --git a/graph/test/python/expected/07-decorators.tiers b/graph/test/python/expected/07-decorators.tiers
index e9686fea6..33c95f323 100644
--- a/graph/test/python/expected/07-decorators.tiers
+++ b/graph/test/python/expected/07-decorators.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 30
+distinct call sites emitted: 35
--- by tier: edge ROWS, and the distinct SITES they cover ---
3 rows 3 sites ambiguous_unknown
11 rows 11 sites boundary_lib
+ 5 rows 5 sites callback_registered
16 rows 16 sites known_edge
--- edge rows by call kind ---
4 DECORATOR_APPLICATION
5 DECORATOR_BARE
4 DECORATOR_CALL
+ 5 METHOD_REFERENCE
16 SIMPLE_CALL
1 SUBSCRIPT_CALL
@@ -22,7 +24,7 @@ distinct call sites emitted: 30
16 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 30
+ edge rows 35
minus extra rows from multi-target sites 0
- = tier/site pairs 30
+ = tier/site pairs 35
engine's conserved site total 30
diff --git a/graph/test/python/expected/08-callables.edges b/graph/test/python/expected/08-callables.edges
index 5b0c619d2..24c61eb13 100644
--- a/graph/test/python/expected/08-callables.edges
+++ b/graph/test/python/expected/08-callables.edges
@@ -2,6 +2,8 @@ ambiguous_unknown SELF_CALL main.Holder.use_partial -> -
boundary_lib METHOD_CALL main.Holder.__init__ -> external:functools.partial
boundary_lib METHOD_CALL main.main -> external:functools.partial
boundary_lib SIMPLE_CALL main.main -> builtin:print
+callback_registered METHOD_REFERENCE main.Holder.__init__ -> main.add
+callback_registered METHOD_REFERENCE main.main -> main.add
known_edge CHAINED_CALL main.main -> main.Holder.use
known_edge CHAINED_CALL main.main -> main.Holder.use_partial
known_edge SELF_CALL main.Holder.use -> main.add
diff --git a/graph/test/python/expected/08-callables.tiers b/graph/test/python/expected/08-callables.tiers
index 9d83aad23..7a2631ca0 100644
--- a/graph/test/python/expected/08-callables.tiers
+++ b/graph/test/python/expected/08-callables.tiers
@@ -1,13 +1,15 @@
-distinct call sites emitted: 20
+distinct call sites emitted: 23
--- by tier: edge ROWS, and the distinct SITES they cover ---
1 rows 1 sites ambiguous_unknown
8 rows 8 sites boundary_lib
+ 3 rows 3 sites callback_registered
11 rows 11 sites known_edge
--- edge rows by call kind ---
2 CHAINED_CALL
3 METHOD_CALL
+ 3 METHOD_REFERENCE
3 SELF_CALL
11 SIMPLE_CALL
1 SUBSCRIPT_CALL
@@ -22,7 +24,7 @@ distinct call sites emitted: 20
11 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 20
+ edge rows 23
minus extra rows from multi-target sites 0
- = tier/site pairs 20
+ = tier/site pairs 23
engine's conserved site total 20
diff --git a/graph/test/python/expected/09-args-forwarding.edges b/graph/test/python/expected/09-args-forwarding.edges
index d05a79897..1dfc354c7 100644
--- a/graph/test/python/expected/09-args-forwarding.edges
+++ b/graph/test/python/expected/09-args-forwarding.edges
@@ -1,5 +1,6 @@
boundary_lib SIMPLE_CALL main.main -> builtin:print
boundary_lib SIMPLE_CALL main.target -> builtin:len
+callback_registered METHOD_REFERENCE main.main -> main.target
known_edge DYNAMIC_CALL main.main -> main.Delegator.__call__
known_edge SELF_CALL main.Delegator.__call__ -> main.target
known_edge SIMPLE_CALL main. -> main.main
diff --git a/graph/test/python/expected/09-args-forwarding.tiers b/graph/test/python/expected/09-args-forwarding.tiers
index 61fade854..13ce90bc8 100644
--- a/graph/test/python/expected/09-args-forwarding.tiers
+++ b/graph/test/python/expected/09-args-forwarding.tiers
@@ -1,11 +1,13 @@
-distinct call sites emitted: 15
+distinct call sites emitted: 16
--- by tier: edge ROWS, and the distinct SITES they cover ---
5 rows 5 sites boundary_lib
+ 1 rows 1 sites callback_registered
10 rows 10 sites known_edge
--- edge rows by call kind ---
1 DYNAMIC_CALL
+ 1 METHOD_REFERENCE
1 SELF_CALL
13 SIMPLE_CALL
@@ -18,7 +20,7 @@ distinct call sites emitted: 15
10 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 15
+ edge rows 16
minus extra rows from multi-target sites 0
- = tier/site pairs 15
+ = tier/site pairs 16
engine's conserved site total 15
diff --git a/graph/test/python/expected/12-blind-spots.edges b/graph/test/python/expected/12-blind-spots.edges
index 014a110bf..910099c29 100644
--- a/graph/test/python/expected/12-blind-spots.edges
+++ b/graph/test/python/expected/12-blind-spots.edges
@@ -7,6 +7,7 @@ boundary_lib SIMPLE_CALL main.Installer.__new__ -> builtin:super
boundary_lib SIMPLE_CALL main.call_by_computed_name -> builtin:getattr
boundary_lib SIMPLE_CALL main.main -> builtin:print
boundary_lib SUPER_CALL main.Installer.__new__ -> builtin:type.__new__
+callback_registered METHOD_REFERENCE main. -> main.patched
known_edge METACLASS_CREATION main. -> main.Installer.__new__
known_edge METHOD_CALL main.main -> main.patched
known_edge SIMPLE_CALL main. -> main.main
diff --git a/graph/test/python/expected/12-blind-spots.tiers b/graph/test/python/expected/12-blind-spots.tiers
index c2de53b23..77b9517f7 100644
--- a/graph/test/python/expected/12-blind-spots.tiers
+++ b/graph/test/python/expected/12-blind-spots.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 19
+distinct call sites emitted: 20
--- by tier: edge ROWS, and the distinct SITES they cover ---
5 rows 5 sites ambiguous_unknown
10 rows 10 sites boundary_lib
+ 1 rows 1 sites callback_registered
4 rows 4 sites known_edge
--- edge rows by call kind ---
@@ -10,6 +11,7 @@ distinct call sites emitted: 19
1 DYNAMIC_CALL
1 METACLASS_CREATION
2 METHOD_CALL
+ 1 METHOD_REFERENCE
13 SIMPLE_CALL
1 SUPER_CALL
@@ -25,8 +27,8 @@ distinct call sites emitted: 19
3 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 19
+ edge rows 20
minus extra rows from multi-target sites 0
- = tier/site pairs 19
+ = tier/site pairs 20
engine's conserved site total 18
of which 1 are METACLASS_CREATION, an edge with no call site (README decision 5)
diff --git a/graph/test/python/expected/17-rebound-metaclass-slot.edges b/graph/test/python/expected/17-rebound-metaclass-slot.edges
index 8555ff828..01bd9313b 100644
--- a/graph/test/python/expected/17-rebound-metaclass-slot.edges
+++ b/graph/test/python/expected/17-rebound-metaclass-slot.edges
@@ -10,6 +10,7 @@ boundary_lib SUPER_CALL main.EarlyMeta.__init__ -> builtin:type.__init__
boundary_lib SUPER_CALL main.EarlyMeta.__new__ -> builtin:type.__new__
boundary_lib SUPER_CALL main.LateMeta.__init__ -> builtin:type.__init__
boundary_lib SUPER_CALL main.LateMeta.__new__ -> builtin:type.__new__
+callback_registered METHOD_REFERENCE main. -> main._late_new
known_edge CHAINED_CALL main.main -> main.After.run
known_edge CHAINED_CALL main.main -> main.AlsoBefore.run
known_edge CHAINED_CALL main.main -> main.Before.run
diff --git a/graph/test/python/expected/17-rebound-metaclass-slot.tiers b/graph/test/python/expected/17-rebound-metaclass-slot.tiers
index 36fff263c..7b9785d53 100644
--- a/graph/test/python/expected/17-rebound-metaclass-slot.tiers
+++ b/graph/test/python/expected/17-rebound-metaclass-slot.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 20
+distinct call sites emitted: 22
--- by tier: edge ROWS, and the distinct SITES they cover ---
3 rows 3 sites ambiguous_unknown
11 rows 11 sites boundary_lib
+ 2 rows 2 sites callback_registered
8 rows 6 sites known_edge
--- edge rows by call kind ---
3 CHAINED_CALL
5 METACLASS_CREATION
1 METHOD_CALL
+ 2 METHOD_REFERENCE
9 SIMPLE_CALL
4 SUPER_CALL
@@ -23,8 +25,8 @@ distinct call sites emitted: 20
3 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 22
+ edge rows 24
minus extra rows from multi-target sites 2
- = tier/site pairs 20
+ = tier/site pairs 22
engine's conserved site total 17
of which 5 are METACLASS_CREATION, an edge with no call site (README decision 5)
diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.edges b/graph/test/python/expected/21-url-and-signal-dispatch.edges
index 9d07c714d..0d85ec445 100644
--- a/graph/test/python/expected/21-url-and-signal-dispatch.edges
+++ b/graph/test/python/expected/21-url-and-signal-dispatch.edges
@@ -3,6 +3,13 @@ ambiguous_unknown DECORATOR_APPLICATION main. -> -
ambiguous_unknown METHOD_CALL main.not_a_signal_send -> -
boundary_lib SIMPLE_CALL main. -> builtin:object.__init__
boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__
+callback_registered METHOD_REFERENCE main. -> main.detail
+callback_registered METHOD_REFERENCE main. -> main.index
+callback_registered METHOD_REFERENCE main. -> main.legacy
+callback_registered METHOD_REFERENCE main. -> main.on_order_connected
+callback_registered METHOD_REFERENCE main.not_a_route_table -> main.detail
+callback_registered METHOD_REFERENCE urls. -> views.profile
+callback_registered METHOD_REFERENCE urls. -> views.settings_page
known_edge DECORATOR_CALL handlers. -> handlers.receiver
known_edge DECORATOR_CALL main. -> main.receiver
known_edge METHOD_CALL main. -> main.Signal.connect
diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.tiers b/graph/test/python/expected/21-url-and-signal-dispatch.tiers
index 56e700521..03db6da82 100644
--- a/graph/test/python/expected/21-url-and-signal-dispatch.tiers
+++ b/graph/test/python/expected/21-url-and-signal-dispatch.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 32
+distinct call sites emitted: 39
--- by tier: edge ROWS, and the distinct SITES they cover ---
5 rows 5 sites ambiguous_unknown
4 rows 4 sites boundary_lib
+ 7 rows 7 sites callback_registered
23 rows 23 sites known_edge
--- edge rows by call kind ---
4 DECORATOR_APPLICATION
4 DECORATOR_CALL
4 METHOD_CALL
+ 7 METHOD_REFERENCE
20 SIMPLE_CALL
--- unresolved reasons ---
@@ -22,7 +24,7 @@ distinct call sites emitted: 32
23 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 32
+ edge rows 39
minus extra rows from multi-target sites 0
- = tier/site pairs 32
+ = tier/site pairs 39
engine's conserved site total 32
diff --git a/graph/test/python/expected/22-di-providers.edges b/graph/test/python/expected/22-di-providers.edges
index f73dff093..f02424a43 100644
--- a/graph/test/python/expected/22-di-providers.edges
+++ b/graph/test/python/expected/22-di-providers.edges
@@ -3,6 +3,12 @@ ambiguous_unknown DECORATOR_APPLICATION routes. -> -
ambiguous_unknown METHOD_CALL main.read_item -> -
boundary_lib SIMPLE_CALL main. -> builtin:object.__init__
boundary_lib SIMPLE_CALL routes. -> builtin:object.__init__
+callback_registered METHOD_REFERENCE deps. -> deps.get_repo
+callback_registered METHOD_REFERENCE main. -> main.get_service
+callback_registered METHOD_REFERENCE main. -> main.get_settings
+callback_registered METHOD_REFERENCE main. -> main.get_user
+callback_registered METHOD_REFERENCE routes. -> deps.get_page_size
+callback_registered METHOD_REFERENCE routes. -> deps.get_repo
known_edge DECORATOR_CALL main. -> main._App.get
known_edge DECORATOR_CALL main. -> main._App.post
known_edge DECORATOR_CALL routes. -> routes._Router.get
diff --git a/graph/test/python/expected/22-di-providers.tiers b/graph/test/python/expected/22-di-providers.tiers
index 193e49ac0..c1e8f4c11 100644
--- a/graph/test/python/expected/22-di-providers.tiers
+++ b/graph/test/python/expected/22-di-providers.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 22
+distinct call sites emitted: 29
--- by tier: edge ROWS, and the distinct SITES they cover ---
4 rows 4 sites ambiguous_unknown
2 rows 2 sites boundary_lib
+ 7 rows 7 sites callback_registered
16 rows 16 sites known_edge
--- edge rows by call kind ---
3 DECORATOR_APPLICATION
3 DECORATOR_CALL
1 METHOD_CALL
+ 7 METHOD_REFERENCE
15 SIMPLE_CALL
--- unresolved reasons ---
@@ -22,7 +24,7 @@ distinct call sites emitted: 22
16 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 22
+ edge rows 29
minus extra rows from multi-target sites 0
- = tier/site pairs 22
+ = tier/site pairs 29
engine's conserved site total 22
diff --git a/graph/test/python/expected/25-imported-decorated-names.edges b/graph/test/python/expected/25-imported-decorated-names.edges
index 7f267280d..1c4482423 100644
--- a/graph/test/python/expected/25-imported-decorated-names.edges
+++ b/graph/test/python/expected/25-imported-decorated-names.edges
@@ -3,6 +3,8 @@ ambiguous_unknown DECORATOR_ATTRIBUTE pkg.lib. -> -
ambiguous_unknown METHOD_CALL main.run -> -
ambiguous_unknown SIMPLE_CALL main.run -> -
boundary_lib DECORATOR_CALL pkg.lib.wraps_deco -> external:functools.wraps
+callback_registered METHOD_REFERENCE pkg.lib.plain_deco -> pkg.lib.plain_deco..wrapper
+callback_registered METHOD_REFERENCE pkg.lib.wraps_deco -> pkg.lib.wraps_deco..wrapper
known_edge DECORATOR_BARE pkg.lib. -> pkg.lib.passthrough
known_edge DECORATOR_BARE pkg.lib. -> pkg.lib.plain_deco
known_edge DECORATOR_BARE pkg.lib. -> pkg.lib.wraps_deco
diff --git a/graph/test/python/expected/25-imported-decorated-names.tiers b/graph/test/python/expected/25-imported-decorated-names.tiers
index b4fa51e62..2cd48680e 100644
--- a/graph/test/python/expected/25-imported-decorated-names.tiers
+++ b/graph/test/python/expected/25-imported-decorated-names.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 24
+distinct call sites emitted: 26
--- by tier: edge ROWS, and the distinct SITES they cover ---
4 rows 4 sites ambiguous_unknown
1 rows 1 sites boundary_lib
+ 2 rows 2 sites callback_registered
19 rows 19 sites known_edge
--- edge rows by call kind ---
@@ -11,6 +12,7 @@ distinct call sites emitted: 24
3 DECORATOR_BARE
1 DECORATOR_CALL
5 METHOD_CALL
+ 2 METHOD_REFERENCE
13 SIMPLE_CALL
--- unresolved reasons ---
@@ -26,7 +28,7 @@ distinct call sites emitted: 24
19 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 24
+ edge rows 26
minus extra rows from multi-target sites 0
- = tier/site pairs 24
+ = tier/site pairs 26
engine's conserved site total 24
diff --git a/graph/test/python/expected/27-returned-expression.edges b/graph/test/python/expected/27-returned-expression.edges
index fed3df32d..c8699cfe3 100644
--- a/graph/test/python/expected/27-returned-expression.edges
+++ b/graph/test/python/expected/27-returned-expression.edges
@@ -6,6 +6,9 @@ boundary_lib METHOD_CALL main.Expr.copy -> external:copy.copy
boundary_lib SIMPLE_CALL main. -> builtin:object.__init__
boundary_lib SIMPLE_CALL main.Expr.__init__ -> builtin:object.__init__
boundary_lib SIMPLE_CALL main.Expr.label -> builtin:str
+callback_registered METHOD_REFERENCE main.Library.filter -> main.Library.filter..dec
+callback_registered METHOD_REFERENCE main.defer_result -> main.defer_result..decorator
+callback_registered METHOD_REFERENCE main.defer_result..decorator -> main.defer_result..decorator..wrapper
known_edge DECORATOR_APPLICATION main. -> main.Library.filter..dec
known_edge DECORATOR_BARE main. -> main.defer_text
known_edge DECORATOR_CALL main. -> main.Library.filter
diff --git a/graph/test/python/expected/27-returned-expression.tiers b/graph/test/python/expected/27-returned-expression.tiers
index f44e09e11..61c44026e 100644
--- a/graph/test/python/expected/27-returned-expression.tiers
+++ b/graph/test/python/expected/27-returned-expression.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 23
+distinct call sites emitted: 26
--- by tier: edge ROWS, and the distinct SITES they cover ---
2 rows 2 sites ambiguous_unknown
6 rows 6 sites boundary_lib
+ 3 rows 3 sites callback_registered
15 rows 15 sites known_edge
2 rows 1 sites multi_inferred
@@ -12,6 +13,7 @@ distinct call sites emitted: 23
3 DECORATOR_CALL
1 DYNAMIC_CALL
5 METHOD_CALL
+ 3 METHOD_REFERENCE
3 SELF_CALL
10 SIMPLE_CALL
@@ -27,7 +29,7 @@ distinct call sites emitted: 23
1 multi_inferred
--- reconciling rows against the conserved site count ---
- edge rows 25
+ edge rows 28
minus extra rows from multi-target sites 1
- = tier/site pairs 24
+ = tier/site pairs 27
engine's conserved site total 23
diff --git a/graph/test/python/expected/28-assigned-members.edges b/graph/test/python/expected/28-assigned-members.edges
index c1af28b71..355e252d5 100644
--- a/graph/test/python/expected/28-assigned-members.edges
+++ b/graph/test/python/expected/28-assigned-members.edges
@@ -1,6 +1,8 @@
boundary_lib SIMPLE_CALL main. -> external:TypeVar
boundary_lib SIMPLE_CALL main.Schema.__init__ -> builtin:object.__init__
boundary_lib SIMPLE_CALL main.main -> builtin:print
+callback_registered METHOD_REFERENCE main.init -> main.Internals.parse
+callback_registered METHOD_REFERENCE main.init -> main.init.._run
known_edge SIMPLE_CALL main. -> main.main
known_edge SIMPLE_CALL main.main -> main.Schema.__init__
known_edge SIMPLE_CALL main.main -> main.init
diff --git a/graph/test/python/expected/28-assigned-members.tiers b/graph/test/python/expected/28-assigned-members.tiers
index 4f1ba55ef..c4536e576 100644
--- a/graph/test/python/expected/28-assigned-members.tiers
+++ b/graph/test/python/expected/28-assigned-members.tiers
@@ -1,12 +1,14 @@
-distinct call sites emitted: 19
+distinct call sites emitted: 21
--- by tier: edge ROWS, and the distinct SITES they cover ---
4 rows 4 sites boundary_lib
+ 2 rows 2 sites callback_registered
7 rows 7 sites known_edge
30 rows 8 sites multi_inferred
--- edge rows by call kind ---
30 METHOD_CALL
+ 2 METHOD_REFERENCE
11 SIMPLE_CALL
--- unresolved reasons ---
@@ -19,7 +21,7 @@ distinct call sites emitted: 19
8 multi_inferred
--- reconciling rows against the conserved site count ---
- edge rows 41
+ edge rows 43
minus extra rows from multi-target sites 22
- = tier/site pairs 19
+ = tier/site pairs 21
engine's conserved site total 19
diff --git a/graph/test/python/expected/33-url-view-forms.edges b/graph/test/python/expected/33-url-view-forms.edges
index c05006003..0de9e0620 100644
--- a/graph/test/python/expected/33-url-view-forms.edges
+++ b/graph/test/python/expected/33-url-view-forms.edges
@@ -6,6 +6,16 @@ boundary_lib SIMPLE_CALL catalog.urls. -> external:path
boundary_lib SIMPLE_CALL shop.urls. -> external:include
boundary_lib SIMPLE_CALL shop.urls. -> external:path
boundary_lib SIMPLE_CALL shop.urls. -> external:re_path
+callback_registered METHOD_REFERENCE catalog.urls. -> catalog.views.catalog_index
+callback_registered METHOD_REFERENCE shop.decorators.audited -> shop.decorators.audited..wrapper
+callback_registered METHOD_REFERENCE shop.urls. -> shop.decorators.audited..wrapper
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.api_status
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_credit_note
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_draft
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_export
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_legacy
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_list
+callback_registered METHOD_REFERENCE shop.urls. -> shop.views.order_report
known_edge DECORATOR_BARE shop.views. -> shop.decorators.audited
known_edge SIMPLE_CALL shop.decorators.audited..wrapper -> shop.views.order_history
known_edge SIMPLE_CALL shop.views.BaseWidgetView.get -> shop.views._render
diff --git a/graph/test/python/expected/33-url-view-forms.tiers b/graph/test/python/expected/33-url-view-forms.tiers
index f05de8d3f..fd0afb6f1 100644
--- a/graph/test/python/expected/33-url-view-forms.tiers
+++ b/graph/test/python/expected/33-url-view-forms.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 51
+distinct call sites emitted: 61
--- by tier: edge ROWS, and the distinct SITES they cover ---
6 rows 6 sites ambiguous_unknown
27 rows 27 sites boundary_lib
+ 10 rows 10 sites callback_registered
18 rows 18 sites known_edge
--- edge rows by call kind ---
@@ -10,6 +11,7 @@ distinct call sites emitted: 51
5 DECORATOR_BARE
1 DECORATOR_CALL
1 METHOD_CALL
+ 10 METHOD_REFERENCE
43 SIMPLE_CALL
--- unresolved reasons ---
@@ -24,7 +26,7 @@ distinct call sites emitted: 51
18 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 51
+ edge rows 61
minus extra rows from multi-target sites 0
- = tier/site pairs 51
+ = tier/site pairs 61
engine's conserved site total 51
diff --git a/graph/test/python/expected/36-call-through-model-decorators.edges b/graph/test/python/expected/36-call-through-model-decorators.edges
index 266e2812c..69c96d257 100644
--- a/graph/test/python/expected/36-call-through-model-decorators.edges
+++ b/graph/test/python/expected/36-call-through-model-decorators.edges
@@ -6,6 +6,7 @@ boundary_lib DECORATOR_CALL app.orders. -> external:pydantic.validate_ca
boundary_lib METHOD_CALL app.checkout.show -> builtin:float.hex
boundary_lib SIMPLE_CALL app.orders.apply_discount -> builtin:round
boundary_lib SIMPLE_CALL app.orders.apply_tax -> builtin:round
+callback_registered METHOD_REFERENCE app.orders.audit -> app.orders.audit..wrapper
known_edge DECORATOR_BARE app.orders. -> app.orders.audit
known_edge PROPERTY_READ app.checkout.show -> app.orders.Order.grand_total
known_edge PROPERTY_READ app.checkout.show -> app.orders.Order.net_total
diff --git a/graph/test/python/expected/36-call-through-model-decorators.tiers b/graph/test/python/expected/36-call-through-model-decorators.tiers
index 2e14b72cd..e9d9a11ce 100644
--- a/graph/test/python/expected/36-call-through-model-decorators.tiers
+++ b/graph/test/python/expected/36-call-through-model-decorators.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 19
+distinct call sites emitted: 20
--- by tier: edge ROWS, and the distinct SITES they cover ---
4 rows 4 sites ambiguous_unknown
6 rows 6 sites boundary_lib
+ 1 rows 1 sites callback_registered
9 rows 9 sites known_edge
--- edge rows by call kind ---
@@ -10,6 +11,7 @@ distinct call sites emitted: 19
6 DECORATOR_BARE
1 DECORATOR_CALL
1 METHOD_CALL
+ 1 METHOD_REFERENCE
3 PROPERTY_READ
7 SIMPLE_CALL
@@ -24,8 +26,8 @@ distinct call sites emitted: 19
6 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 19
+ edge rows 20
minus extra rows from multi-target sites 0
- = tier/site pairs 19
+ = tier/site pairs 20
engine's conserved site total 16
of which 3 are PROPERTY_READ, an edge with no call site (README decision 5)
diff --git a/graph/test/python/expected/37-model-signals.edges b/graph/test/python/expected/37-model-signals.edges
index 675318292..aed5cf7af 100644
--- a/graph/test/python/expected/37-model-signals.edges
+++ b/graph/test/python/expected/37-model-signals.edges
@@ -9,4 +9,5 @@ boundary_lib DECORATOR_CALL models. -> external:receiver
boundary_lib METHOD_CALL models. -> external:post_save.connect
boundary_lib METHOD_CALL models.Invoice. -> external:models.IntegerField
boundary_lib METHOD_CALL models.Order. -> external:models.CharField
+callback_registered METHOD_REFERENCE models. -> models.audit_order
known_edge METHOD_CALL views.save_cache -> views.Cache.save
diff --git a/graph/test/python/expected/37-model-signals.tiers b/graph/test/python/expected/37-model-signals.tiers
index e87fd3fad..10a3c4b9a 100644
--- a/graph/test/python/expected/37-model-signals.tiers
+++ b/graph/test/python/expected/37-model-signals.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 14
+distinct call sites emitted: 15
--- by tier: edge ROWS, and the distinct SITES they cover ---
8 rows 8 sites ambiguous_unknown
5 rows 5 sites boundary_lib
+ 1 rows 1 sites callback_registered
1 rows 1 sites known_edge
--- edge rows by call kind ---
2 DECORATOR_APPLICATION
2 DECORATOR_CALL
10 METHOD_CALL
+ 1 METHOD_REFERENCE
--- unresolved reasons ---
2 decorator_factory_result_untyped
@@ -23,7 +25,7 @@ distinct call sites emitted: 14
1 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 14
+ edge rows 15
minus extra rows from multi-target sites 0
- = tier/site pairs 14
+ = tier/site pairs 15
engine's conserved site total 14
diff --git a/graph/test/python/expected/38-app-wiring.edges b/graph/test/python/expected/38-app-wiring.edges
index 39d93d28d..8813d217c 100644
--- a/graph/test/python/expected/38-app-wiring.edges
+++ b/graph/test/python/expected/38-app-wiring.edges
@@ -17,6 +17,12 @@ boundary_lib SIMPLE_CALL main. -> external:Starlette
boundary_lib SIMPLE_CALL main. -> external:WebSocketRoute
boundary_lib SIMPLE_CALL shop.jobs.nightly -> external:call_command
boundary_lib SIMPLE_CALL shop.jobs.unknown -> external:call_command
+callback_registered METHOD_REFERENCE main. -> main.cancel
+callback_registered METHOD_REFERENCE main. -> main.feed
+callback_registered METHOD_REFERENCE main. -> main.health
+callback_registered METHOD_REFERENCE main. -> main.lifespan
+callback_registered METHOD_REFERENCE main. -> main.plugin_hook
+callback_registered METHOD_REFERENCE main. -> main.tick
known_edge METHOD_CALL main. -> main.Registry.add_route
known_edge SIMPLE_CALL main. -> main.configure
known_edge SIMPLE_CALL shop.management.commands.close_orders.Command.handle -> shop.orders.close_all
diff --git a/graph/test/python/expected/38-app-wiring.tiers b/graph/test/python/expected/38-app-wiring.tiers
index 8496a4b97..ebe6e2258 100644
--- a/graph/test/python/expected/38-app-wiring.tiers
+++ b/graph/test/python/expected/38-app-wiring.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 26
+distinct call sites emitted: 32
--- by tier: edge ROWS, and the distinct SITES they cover ---
8 rows 8 sites ambiguous_unknown
15 rows 15 sites boundary_lib
+ 6 rows 6 sites callback_registered
3 rows 3 sites known_edge
--- edge rows by call kind ---
@@ -11,6 +12,7 @@ distinct call sites emitted: 26
1 DECORATOR_BARE
4 DECORATOR_CALL
3 METHOD_CALL
+ 6 METHOD_REFERENCE
13 SIMPLE_CALL
--- unresolved reasons ---
@@ -25,7 +27,7 @@ distinct call sites emitted: 26
3 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 26
+ edge rows 32
minus extra rows from multi-target sites 0
- = tier/site pairs 26
+ = tier/site pairs 32
engine's conserved site total 26
diff --git a/graph/test/python/expected/39-model-construction-hooks.edges b/graph/test/python/expected/39-model-construction-hooks.edges
index 3c8b20b60..705a286e3 100644
--- a/graph/test/python/expected/39-model-construction-hooks.edges
+++ b/graph/test/python/expected/39-model-construction-hooks.edges
@@ -14,5 +14,7 @@ boundary_lib SIMPLE_CALL models.Widget.check_size -> builtin:max
boundary_lib SIMPLE_CALL models.Widget.dump_size -> builtin:str
boundary_lib SIMPLE_CALL service.make_gizmo -> builtin:object.__init__
boundary_lib SIMPLE_CALL service.make_plain -> builtin:object.__init__
+callback_registered METHOD_REFERENCE models.Gizmo. -> models.new_tags
+callback_registered METHOD_REFERENCE models.Widget. -> models.new_id
known_edge SELF_CALL models.Gadget.__init__ -> models.Gadget.check_size
known_edge SIMPLE_CALL service.make_gadget -> models.Gadget.__init__
diff --git a/graph/test/python/expected/39-model-construction-hooks.tiers b/graph/test/python/expected/39-model-construction-hooks.tiers
index a4e4b8aff..d1f820899 100644
--- a/graph/test/python/expected/39-model-construction-hooks.tiers
+++ b/graph/test/python/expected/39-model-construction-hooks.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 21
+distinct call sites emitted: 23
--- by tier: edge ROWS, and the distinct SITES they cover ---
8 rows 8 sites ambiguous_unknown
11 rows 11 sites boundary_lib
+ 2 rows 2 sites callback_registered
2 rows 2 sites known_edge
--- edge rows by call kind ---
@@ -10,6 +11,7 @@ distinct call sites emitted: 21
3 DECORATOR_BARE
3 DECORATOR_CALL
2 METHOD_CALL
+ 2 METHOD_REFERENCE
1 SELF_CALL
9 SIMPLE_CALL
@@ -27,7 +29,7 @@ distinct call sites emitted: 21
2 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 21
+ edge rows 23
minus extra rows from multi-target sites 0
- = tier/site pairs 21
+ = tier/site pairs 23
engine's conserved site total 21
diff --git a/graph/test/python/expected/41-di-marker-forms.edges b/graph/test/python/expected/41-di-marker-forms.edges
index 0a449dee2..3cc836455 100644
--- a/graph/test/python/expected/41-di-marker-forms.edges
+++ b/graph/test/python/expected/41-di-marker-forms.edges
@@ -8,3 +8,11 @@ boundary_lib SIMPLE_CALL handlers. -> external:APIRouter
boundary_lib SIMPLE_CALL handlers. -> external:Depends
boundary_lib SIMPLE_CALL handlers. -> external:FastAPI
boundary_lib SIMPLE_CALL other. -> external:Depends
+callback_registered METHOD_REFERENCE deps. -> deps.load_cart
+callback_registered METHOD_REFERENCE deps.ShapeFilter. -> deps.load_shape_size
+callback_registered METHOD_REFERENCE handlers. -> deps.check_app
+callback_registered METHOD_REFERENCE handlers. -> deps.check_quota
+callback_registered METHOD_REFERENCE handlers. -> deps.check_region
+callback_registered METHOD_REFERENCE handlers. -> deps.load_owner
+callback_registered METHOD_REFERENCE handlers. -> deps.load_user
+callback_registered METHOD_REFERENCE other. -> other.check_other
diff --git a/graph/test/python/expected/41-di-marker-forms.tiers b/graph/test/python/expected/41-di-marker-forms.tiers
index 85ad730a4..e2ec4f880 100644
--- a/graph/test/python/expected/41-di-marker-forms.tiers
+++ b/graph/test/python/expected/41-di-marker-forms.tiers
@@ -1,12 +1,14 @@
-distinct call sites emitted: 32
+distinct call sites emitted: 40
--- by tier: edge ROWS, and the distinct SITES they cover ---
7 rows 7 sites ambiguous_unknown
25 rows 25 sites boundary_lib
+ 8 rows 8 sites callback_registered
--- edge rows by call kind ---
7 DECORATOR_APPLICATION
8 DECORATOR_CALL
+ 8 METHOD_REFERENCE
17 SIMPLE_CALL
--- unresolved reasons ---
@@ -18,7 +20,7 @@ distinct call sites emitted: 32
25 boundary_lib
--- reconciling rows against the conserved site count ---
- edge rows 32
+ edge rows 40
minus extra rows from multi-target sites 0
- = tier/site pairs 32
+ = tier/site pairs 40
engine's conserved site total 32
diff --git a/graph/test/python/expected/42-signal-forms.edges b/graph/test/python/expected/42-signal-forms.edges
index 456f0118c..f8aa33ff1 100644
--- a/graph/test/python/expected/42-signal-forms.edges
+++ b/graph/test/python/expected/42-signal-forms.edges
@@ -4,6 +4,7 @@ ambiguous_unknown METHOD_CALL orders.close -> -
ambiguous_unknown METHOD_CALL orders.ship -> -
boundary_lib DECORATOR_CALL handlers. -> external:receiver
boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__
+callback_registered METHOD_REFERENCE handlers. -> handlers.on_closed
known_edge DECORATOR_CALL handlers. -> handlers.remember
known_edge METHOD_CALL orders.not_a_signal -> orders.Outbox.asend
known_edge METHOD_CALL orders.pay -> signals.Signal.asend
diff --git a/graph/test/python/expected/42-signal-forms.tiers b/graph/test/python/expected/42-signal-forms.tiers
index e780b18a8..f608211b9 100644
--- a/graph/test/python/expected/42-signal-forms.tiers
+++ b/graph/test/python/expected/42-signal-forms.tiers
@@ -1,14 +1,16 @@
-distinct call sites emitted: 22
+distinct call sites emitted: 23
--- by tier: edge ROWS, and the distinct SITES they cover ---
8 rows 8 sites ambiguous_unknown
9 rows 9 sites boundary_lib
+ 1 rows 1 sites callback_registered
5 rows 5 sites known_edge
--- edge rows by call kind ---
5 DECORATOR_APPLICATION
5 DECORATOR_CALL
7 METHOD_CALL
+ 1 METHOD_REFERENCE
5 SIMPLE_CALL
--- unresolved reasons ---
@@ -22,7 +24,7 @@ distinct call sites emitted: 22
5 known_edge
--- reconciling rows against the conserved site count ---
- edge rows 22
+ edge rows 23
minus extra rows from multi-target sites 0
- = tier/site pairs 22
+ = tier/site pairs 23
engine's conserved site total 22
diff --git a/graph/test/python/expected/project-name-collision.tiers b/graph/test/python/expected/project-name-collision.tiers
index ee405f873..d47e460f7 100644
--- a/graph/test/python/expected/project-name-collision.tiers
+++ b/graph/test/python/expected/project-name-collision.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 91
+distinct call sites emitted: 92
--- by tier: edge ROWS, and the distinct SITES they cover ---
1 rows 1 sites ambiguous_unknown
34 rows 34 sites boundary_lib
+ 1 rows 1 sites callback_registered
49 rows 49 sites known_edge
25 rows 7 sites multi_inferred
@@ -12,6 +13,7 @@ distinct call sites emitted: 91
1 DECORATOR_BARE
1 DECORATOR_CALL
25 METHOD_CALL
+ 1 METHOD_REFERENCE
5 SELF_CALL
58 SIMPLE_CALL
6 SUPER_CALL
@@ -27,7 +29,7 @@ distinct call sites emitted: 91
7 multi_inferred
--- reconciling rows against the conserved site count ---
- edge rows 109
+ edge rows 110
minus extra rows from multi-target sites 18
- = tier/site pairs 91
+ = tier/site pairs 92
engine's conserved site total 91
diff --git a/graph/test/python/expected/project-two-service-fastapi.tiers b/graph/test/python/expected/project-two-service-fastapi.tiers
index 0b54e3f31..d50f26912 100644
--- a/graph/test/python/expected/project-two-service-fastapi.tiers
+++ b/graph/test/python/expected/project-two-service-fastapi.tiers
@@ -1,8 +1,9 @@
-distinct call sites emitted: 229
+distinct call sites emitted: 239
--- by tier: edge ROWS, and the distinct SITES they cover ---
10 rows 10 sites ambiguous_unknown
71 rows 71 sites boundary_lib
+ 10 rows 10 sites callback_registered
126 rows 126 sites known_edge
59 rows 22 sites multi_inferred
@@ -12,6 +13,7 @@ distinct call sites emitted: 229
8 DECORATOR_BARE
9 DECORATOR_CALL
82 METHOD_CALL
+ 10 METHOD_REFERENCE
2 PROPERTY_READ
27 SELF_CALL
104 SIMPLE_CALL
@@ -29,8 +31,8 @@ distinct call sites emitted: 229
22 multi_inferred
--- reconciling rows against the conserved site count ---
- edge rows 266
+ edge rows 276
minus extra rows from multi-target sites 37
- = tier/site pairs 229
+ = tier/site pairs 239
engine's conserved site total 227
of which 2 are PROPERTY_READ, an edge with no call site (README decision 5)
diff --git a/graph/test/python/tools/literal_gate.py b/graph/test/python/tools/literal_gate.py
index 0e7af6f71..b42121347 100644
--- a/graph/test/python/tools/literal_gate.py
+++ b/graph/test/python/tools/literal_gate.py
@@ -13,7 +13,9 @@
provenance "client" / "lib" -- assigned by which fact file a row is staged
into, not by anything in the source.
confidence tiers "known_edge", "multi_inferred", "boundary_lib",
- "ambiguous_unknown" -- the engine's own output vocabulary.
+ "ambiguous_unknown", "callback_registered" (a method
+ reference, call-edge-generation/method-reference.dl) -- the
+ engine's own output vocabulary.
diagnostic labels "no_rule", "escape_hatch", ... These are OUTPUT arguments of
call_unresolvable / site_reason / expr_type_untypable and are
never matched against input, so they cannot bias resolution.
@@ -48,7 +50,7 @@
raise SystemExit(f"literal gate: engine dir not found at {ENGINE} -- refusing to pass vacuously")
PROVENANCE = {"client", "lib", "external", "builtin"}
-TIERS = {"known_edge", "multi_inferred", "boundary_lib", "ambiguous_unknown"}
+TIERS = {"known_edge", "multi_inferred", "boundary_lib", "ambiguous_unknown", "callback_registered"}
SCAFFOLD = {"", "-", ".", "/", "true", "false", "lib:", "builtin:", "external:",
"untyped_receiver:", "builtin:object.__init__", "_total_sites"}
# "/" joins "." above: both are separators fixed by a specification (a dotted name, a URL
diff --git a/graph/test/python/torture/expected/torture.edges b/graph/test/python/torture/expected/torture.edges
index 557d3208c..54c66e115 100644
--- a/graph/test/python/torture/expected/torture.edges
+++ b/graph/test/python/torture/expected/torture.edges
@@ -350,6 +350,11 @@ boundary_lib SUBSCRIPT_CALL f30_builtin_elements.an_annotated_local -> builtin:s
boundary_lib SUBSCRIPT_CALL f30_builtin_elements.annotated_parameter_indexed -> builtin:str.upper
boundary_lib SUBSCRIPT_CALL f30_builtin_elements.through_any_object -> builtin:str.upper
boundary_lib SUPER_CALL f41_class_attribute_absent.ModelMeta.__new__ -> builtin:type.__new__
+callback_registered METHOD_REFERENCE f12_value_flow.method_alias_on_class -> f12_value_flow.Widget.make
+callback_registered METHOD_REFERENCE f12_value_flow.method_alias_on_instance -> f12_value_flow.Widget.emit
+callback_registered METHOD_REFERENCE rxfacade. -> rxaccel.boost
+callback_registered METHOD_REFERENCE rxfacade. -> rxlib.fallback
+callback_registered METHOD_REFERENCE rxfacade. -> rxlib.slow
known_edge CHAINED_CALL f01_inheritance.client_chain -> f01_inheritance.LocalBase.kind
known_edge CHAINED_CALL f01_inheritance.client_chain -> f01_inheritance.LocalLeaf.kind
known_edge CHAINED_CALL f02_callables.attribute_callables -> f02_callables.HoldsCallables.run
diff --git a/graph/test/tools/bundle-test.sh b/graph/test/tools/bundle-test.sh
index 06ead93d8..8f3245bdf 100755
--- a/graph/test/tools/bundle-test.sh
+++ b/graph/test/tools/bundle-test.sh
@@ -310,6 +310,30 @@ if ! diff -q <(BUNDLE --print-schema) "$ROOT/graph/bundle/SCHEMA.md" >/dev/null;
bad "graph/bundle/SCHEMA.md is stale — run: npm run schema-doc"
fi
+# 8. every ext relation a script queries is in EXT_QUERIED, so it keeps its c0 index (catalog.ts). The others are
+# loaded unindexed: a new query on one of them would still answer, only slower, which no other check would see.
+missing="$(python3 - "$ROOT" <<'PY'
+import os, re, sys
+root = sys.argv[1]
+rels = set()
+for lang in os.listdir(os.path.join(root, 'graph')):
+ m = os.path.join(root, 'graph', lang, 'souffle', 'export_manifest.tsv')
+ if os.path.isfile(m):
+ rels |= {l.split('\t')[0].strip() for l in open(m) if l.strip() and not l.lstrip().startswith('#')}
+cat = open(os.path.join(root, 'graph', 'bundle', 'catalog.ts')).read()
+queried = set(re.findall(r"'(\w+)'", cat[cat.index('EXT_QUERIED'):cat.index(']);', cat.index('EXT_QUERIED'))]))
+used = set()
+for d in ('plugins', 'hooks', 'bin'):
+ for dp, dns, fns in os.walk(os.path.join(root, d)):
+ dns[:] = [x for x in dns if x not in ('node_modules', 'dist', '.git')]
+ for fn in fns:
+ try: text = open(os.path.join(dp, fn), errors='replace').read()
+ except OSError: continue
+ used |= {r for r in re.findall(r'\bext_(\w+)', text) if r in rels}
+print(' '.join(sorted(used - queried)))
+PY
+)"
+[ -z "$missing" ] || bad "a script queries ext relation(s) missing from EXT_QUERIED in graph/bundle/catalog.ts (no c0 index): $missing"
# ── the DEFAULT writes the database and nothing else ────────────────────────
# The deliverable is graph.sqlite. csv/*.csv carries the same core tables, so writing
@@ -323,7 +347,7 @@ if [ -d "$d/csv" ] && [ -n "$(ls -A "$d/csv" 2>/dev/null)" ]; then
fi
if [ "$fail" -eq 0 ]; then
- echo "bundle: ok (3 languages$( [ "$HAVE_SQLITE" = 1 ] && echo ', one schema across them, canonical queries run' || echo ', csv only — no sqlite3 CLI'), schema doc current)"
+ echo "bundle: ok (3 languages$( [ "$HAVE_SQLITE" = 1 ] && echo ', one schema across them, canonical queries run' || echo ', csv only — no sqlite3 CLI'), schema doc current, queried ext relations indexed)"
else
echo "bundle: $fail failure(s)"; exit 1
fi
diff --git a/graph/test/tools/stage-rows-test.sh b/graph/test/tools/stage-rows-test.sh
new file mode 100644
index 000000000..67abcca69
--- /dev/null
+++ b/graph/test/tools/stage-rows-test.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────────────────────
+# Staging copies every IR CSV into a Soufflé fact file and drops the rows whose field count differs
+# from the header's (graph/pipeline/stage-rows.sh). It runs perl where perl exists and awk where it
+# does not, so the two must print the same bytes: a difference would make the facts, and so the
+# graph, a function of which tools the machine happens to have.
+#
+# Each fixture below is staged by both, and the stdout bytes and the stderr report must match. The
+# fixtures are the shapes where a line-based tab counter and awk's field splitting could part: a
+# trailing tab (an empty last field), an empty line, CRLF, a last line with no newline, a header with
+# no newline, an empty file, a quoted field holding a tab (both see two fields: neither parses quotes),
+# bytes that are not UTF-8, and torn rows of both kinds (too few fields, too many).
+# A NUL byte is left out on purpose: BSD awk ends the line at it (and drops the row as torn) while gawk
+# and perl keep the whole line, so awk does not agree with itself there and is no reference.
+# ─────────────────────────────────────────────────────────────────────────────
+set -uo pipefail
+unset AXIOM_STAGE_WITH
+ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")"
+
+command -v perl >/dev/null 2>&1 || { echo "stage-rows-test: FAILED (no perl on PATH; this test compares perl against awk)"; exit 1; }
+
+W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
+fail=0; checks=0
+bad(){ checks=$((checks+1)); printf ' FAIL %s\n' "$1"; fail=1; }
+ok(){ checks=$((checks+1)); }
+
+# The tool the sourced file picks: perl here, awk when forced.
+( . "$ROOT/graph/pipeline/stage-rows.sh"; [ "$STAGE_ROWS_TOOL" = perl ] ) && ok || bad "stage-rows.sh did not pick perl although perl is on PATH"
+( AXIOM_STAGE_WITH=awk; . "$ROOT/graph/pipeline/stage-rows.sh"; [ "$STAGE_ROWS_TOOL" = awk ] ) && ok || bad "AXIOM_STAGE_WITH=awk did not force awk"
+
+printf 'a\tb\tc\n1\t2\t3\n4\t5\t6\n' > "$W/clean.csv"
+printf 'a\tb\tc\n1\t2\t3\n1\t2\n1\t2\t3\t4\n7\t8\t9\n' > "$W/torn.csv"
+printf 'a\tb\tc\n1\t2\t\n\t\t\n1\t2\t3\t\n' > "$W/trailing-tab.csv"
+printf 'a\tb\tc\n1\t2\t3\n\n4\t5\t6\n\n' > "$W/empty-line.csv"
+printf 'a\tb\tc\r\n1\t2\t3\r\n4\t5\r\n' > "$W/crlf.csv"
+printf 'a\tb\tc\n1\t2\t3\n4\t5\t6' > "$W/no-final-newline.csv"
+printf 'a\tb\tc\n1\t2' > "$W/torn-last-line.csv"
+printf 'a\tb\tc' > "$W/header-only-no-newline.csv"
+printf 'a\tb\tc\n' > "$W/header-only.csv"
+: > "$W/empty.csv"
+printf 'a\tb\n"x\ty"\tz\n"p\tq"\n' > "$W/quoted-tab.csv"
+printf 'a\tb\n\xff\xfe\t\xc3\x28\n' > "$W/bytes.csv"
+printf 'a\n1\n\n2\n' > "$W/one-column.csv"
+printf '\n\n1\n\n' > "$W/empty-header.csv"
+
+n=0
+for f in "$W"/*.csv; do
+ n=$((n+1)); name="$(basename "$f")"
+ ( . "$ROOT/graph/pipeline/stage-rows.sh"; stage_rows "$f" ) > "$W/p.out" 2> "$W/p.err"; prc=$?
+ ( AXIOM_STAGE_WITH=awk; . "$ROOT/graph/pipeline/stage-rows.sh"; stage_rows "$f" ) > "$W/a.out" 2> "$W/a.err"; arc=$?
+ [ "$prc" = 0 ] && [ "$arc" = 0 ] && ok || bad "$name: exit status perl=$prc awk=$arc"
+ cmp -s "$W/p.out" "$W/a.out" && ok || { bad "$name: staged rows differ"; od -c "$W/a.out" | sed 's/^/ awk /'; od -c "$W/p.out" | sed 's/^/ perl /'; }
+ cmp -s "$W/p.err" "$W/a.err" && ok || bad "$name: report differs: perl '$(cat "$W/p.err")' awk '$(cat "$W/a.err")'"
+done
+
+# Not vacuous: the fixtures must hold rows that are kept and rows that are dropped.
+( . "$ROOT/graph/pipeline/stage-rows.sh"; stage_rows "$W/torn.csv" ) > "$W/t.out" 2> "$W/t.err"
+[ "$(printf '1\t2\t3\n7\t8\t9\n')" = "$(cat "$W/t.out")" ] && ok || bad "torn.csv: expected the two whole rows, got '$(cat "$W/t.out")'"
+grep -q "dropped 2 malformed row(s) from $W/torn.csv" "$W/t.err" && ok || bad "torn.csv: expected a report of 2 dropped rows, got '$(cat "$W/t.err")'"
+# The control: a clean file comes through as its rows, header removed, nothing reported.
+( . "$ROOT/graph/pipeline/stage-rows.sh"; stage_rows "$W/clean.csv" ) > "$W/c.out" 2> "$W/c.err"
+tail -n +2 "$W/clean.csv" | cmp -s - "$W/c.out" && [ ! -s "$W/c.err" ] && ok || bad "clean.csv: not passed through unchanged"
+
+[ "$n" -ge 14 ] || bad "only $n fixtures were staged"
+[ $fail = 0 ] && echo "stage-rows-test: ok ($checks checks over $n fixtures)" || { echo "stage-rows-test: FAILED"; exit 1; }
diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl
index d8b6b23b4..06396ef12 100644
--- a/graph/typescript/engine/expression-resolution/expr-type.dl
+++ b/graph/typescript/engine/expression-resolution/expr-type.dl
@@ -477,6 +477,10 @@ expr_static_type(e, prov, t) :- property_access_recv(e, n, q),
expr_callable(e, cm) :- property_access_recv(e, n, q),
expr_namespace(q, _, m),
module_export(_, m, n, "METHOD", cm).
+// A namespace exported from a namespace — `z.coerce`, `z.iso` — is itself a module.
+expr_namespace(e, prov, ns) :- property_access_recv(e, n, q),
+ expr_namespace(q, _, m),
+ module_export_namespace(prov, m, n, ns).
// A STATIC METHOD read as a VALUE — `Util.inSorted` on its own, not called. The
// receiver is the class itself, so the member comes from the STATIC table, exactly
diff --git a/graph/typescript/engine/resolution/module-graph.dl b/graph/typescript/engine/resolution/module-graph.dl
index 1fb223ece..628b0b088 100644
--- a/graph/typescript/engine/resolution/module-graph.dl
+++ b/graph/typescript/engine/resolution/module-graph.dl
@@ -382,8 +382,12 @@ module_export_runtime_row(prov, mod, en) :- export_decl(prov, en, _, _, mod, h),
module_export(prov, mod, en, k, h) :- module_export_any(prov, mod, en, k, h),
!module_export_type_only(prov, mod, en).
+// EXPORT_STAR_AS_NAMESPACE is not a star. `export * as core from "./m"` exports ONE
+// name, `core`, whose value is m's namespace; m's members are reachable only as
+// `core.x`. Expanding it here put every member of m on this module's own surface, so
+// a barrel holding `export * as coerce` beside `export * from "./schemas"` exported
+// two `string`s. It is module_export_namespace's rule (b) instead.
export_kind_is_star("EXPORT_STAR").
-export_kind_is_star("EXPORT_STAR_AS_NAMESPACE").
export_kind_is_star("TYPE_ONLY_STAR").
export_kind_is_local_name("NAMED_EXPORT").
export_kind_is_local_name("NAMED_ALIAS").
@@ -725,6 +729,75 @@ import_binds_namespace_any(h, prov, m) :- import_binding("client", ik, _, _, _,
import_target_module(h, prov, m).
import_binds_namespace(h, prov, m) :- import_binds_namespace_any(h, prov, m),
!import_is_type_only("client", h).
+
+// ── module_export_namespace(NsProv, Module, ExportedName, NsModule) ─────────
+// A module can export a NAMESPACE under a name, not only a declaration:
+//
+// import * as z from "./external"; export { z }; export default z;
+// export * as core from "./core";
+//
+// module_export_any cannot carry it — its rows name an entity, and a namespace is a
+// module — so an importer's `import { z } from "pkg"` bound nothing, and every
+// `z.string()` after it had an untyped receiver. Kept as its own relation for the
+// reason import_binds_namespace is: the value is an export table, not a type.
+//
+// (a) a bare `export { z }` / `export { z as y }` / `export default z` whose local
+// name is a namespace import binding in the same module. The import row resolves to
+// its target, so this is a join, as in module_export_any (b2).
+module_export_namespace(prov, mod, en, ns) :- export_decl(eprov, en, ln, ek, mod, h),
+ export_kind_names_local_binding(ek),
+ export_source(eprov, "", _, h),
+ ln != "",
+ import_binding(eprov, ik, ln, _, mod, ih),
+ import_kind_binds_namespace(ik),
+ namespace_import_target(eprov, ih, prov, ns).
+// (b) `export * as core from "./m"`: the row's exported name is the namespace.
+module_export_namespace(prov, mod, en, ns) :- export_decl(_, en, _, "EXPORT_STAR_AS_NAMESPACE", mod, h),
+ en != "",
+ export_target_module(h, prov, ns).
+// (c) the name travels on: `export { z } from "./barrel"` and `export * from "./barrel"`
+// carry a namespace export exactly as they carry a declaration (module_export_any (c), (d)).
+module_export_namespace(prov, mod, en, ns) :- export_decl(_, en, ln, ek, mod, h),
+ export_kind_is_local_name(ek),
+ ln != "",
+ export_target_module(h, _, src),
+ module_export_namespace(prov, src, ln, ns).
+module_export_namespace(prov, mod, en, ns) :- export_decl(_, _, _, ek, mod, h),
+ export_kind_is_star(ek),
+ export_target_module(h, _, src),
+ module_export_namespace(prov, src, en, ns),
+ en != "default".
+// (d) a barrel that imports the namespace by name and exports it bare:
+// `import { z } from "./inner"; export { z }`.
+module_export_namespace(prov, mod, en, ns) :- export_decl(eprov, en, ln, ek, mod, h),
+ export_kind_names_local_binding(ek),
+ export_source(eprov, "", _, h),
+ ln != "",
+ import_binding(eprov, ik, ln, orig, mod, ih),
+ import_kind_binds_named(ik),
+ orig != "",
+ namespace_import_target(eprov, ih, _, m),
+ module_export_namespace(prov, m, orig, ns).
+
+export_kind_names_local_binding(ek) :- export_kind_is_local_name(ek).
+export_kind_names_local_binding("DEFAULT_EXPRESSION").
+
+// A client import row resolves through import_target_module, a library's own through
+// lib_import_target; the barrel above may be either.
+namespace_import_target("client", ih, prov, m) :- import_target_module(ih, prov, m).
+namespace_import_target("lib", ih, prov, m) :- lib_import_target(ih, prov, m).
+
+// The importer's side: a named or default import of a name its target module exports
+// as a namespace binds that namespace, and member calls on it read its export table.
+import_binds_namespace_any(h, prov, ns) :- import_binding("client", ik, _, orig, _, h),
+ import_kind_binds_named(ik),
+ orig != "",
+ import_target_module(h, _, m),
+ module_export_namespace(prov, m, orig, ns).
+import_binds_namespace_any(h, prov, ns) :- import_binding("client", ik, _, _, _, h),
+ import_kind_binds_default(ik),
+ import_target_module(h, _, m),
+ module_export_namespace(prov, m, "default", ns).
// An export-assignment module imported as a namespace still exposes its members by
// name, so it is reachable through the same relation.
import_kind_binds_namespace("NAMESPACE").
diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl
index c8e368d11..be835f2c9 100644
--- a/graph/typescript/souffle/decls_all.dl
+++ b/graph/typescript/souffle/decls_all.dl
@@ -398,6 +398,9 @@
.decl module_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol)
.decl module_default_export(c0:symbol,c1:symbol,c2:symbol)
.decl module_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol)
+.decl module_export_namespace(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
+.decl export_kind_names_local_binding(c0:symbol)
+.decl namespace_import_target(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
.decl module_export_any(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol)
.decl module_export_assignment(c0:symbol,c1:symbol,c2:symbol)
.decl module_export_recovered(c0:symbol,c1:symbol)
diff --git a/package.json b/package.json
index eac44e3dc..17f29b528 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@axiomcode/code-graph",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.",
"repository": {
"type": "git",
@@ -31,11 +31,11 @@
],
"license": "FSL-1.1-Apache-2.0",
"optionalDependencies": {
- "@axiomcode/engine-darwin-arm64": "0.2.0",
- "@axiomcode/engine-darwin-x64": "0.2.0",
- "@axiomcode/engine-linux-x64": "0.2.0",
- "@axiomcode/engine-linux-arm64": "0.2.0",
- "@axiomcode/engine-win32-x64": "0.2.0"
+ "@axiomcode/engine-darwin-arm64": "0.2.1",
+ "@axiomcode/engine-darwin-x64": "0.2.1",
+ "@axiomcode/engine-linux-x64": "0.2.1",
+ "@axiomcode/engine-linux-arm64": "0.2.1",
+ "@axiomcode/engine-win32-x64": "0.2.1"
},
"bin": {
"axiomcode": "bin/axiomcode.js"
diff --git a/parser/src/analysis-types/typescript/TsExpressionRegistry.ts b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
index fafe44dad..56d406ebc 100644
--- a/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
+++ b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
@@ -172,6 +172,17 @@ export class TsExpressionRegistry implements EntityIdentifiable {
this.potentialQualifiedName = potentialQualifiedName;
}
+ /**
+ * The tag of an intrinsic element (`div`, `svg:circle`, `my-el`), written as in the source.
+ *
+ * A component tag is a METHOD_NAME child that resolves; an intrinsic one references nothing, so its
+ * name has no child to live on and goes in this slot, with `referencedEntityKind` left empty. The
+ * engine's projections never read this column, so it cannot reach name resolution.
+ */
+ setJsxTagName(tag: string): void {
+ this.potentialQualifiedName = tag;
+ }
+
getReferencedEntityHash(): string {
return this.referencedEntityHash;
}
diff --git a/parser/src/deno-libraries.ts b/parser/src/deno-libraries.ts
new file mode 100644
index 000000000..f8bb62ed3
--- /dev/null
+++ b/parser/src/deno-libraries.ts
@@ -0,0 +1,128 @@
+/**
+ * The library roots a Deno project's imports reach, for `--library auto`:
+ *
+ * node dist/deno-libraries.js
+ *
+ * prints one JSON object, `{"mirrors": ["", …], "packages": ["", …]}`:
+ *
+ * mirrors each Deno project's mirror, filled by resolving every import: each
+ * `jsr:` and URL module the project reaches, and what those import,
+ * copied from Deno's cache as `vendor/` lays them out
+ * packages every npm package directory an `npm:` import resolved into, in
+ * Deno's npm cache or `node_modules` (real paths, because Deno's
+ * `node_modules` entries are symlinks into `node_modules/.deno`)
+ *
+ * Library discovery runs BEFORE the project is parsed, so the mirror has to be
+ * filled here rather than as a side effect of the parse. Resolution is the same
+ * code the parser uses, so the files staged are the files the imports name.
+ * A repository with no Deno config prints `{"mirrors": [], "packages": []}`.
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { configIn, denoContextForDir, isDenoVendorDirectory } from '@/parsers/typescript/deno/deno-config';
+import { mirrorRoot } from '@/parsers/typescript/deno/deno-cache';
+import { moduleSpecifiersOf, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver';
+
+const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.axiomcode']);
+const SOURCE = /\.(tsx?|mts|cts|jsx?|mjs|cjs)$/;
+
+function sourceFiles(root: string): string[] {
+ const out: string[] = [];
+ const walk = (dir: string): void => {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (!SKIP.has(entry.name) && !entry.name.startsWith('.') && !isDenoVendorDirectory(dir, entry.name)) {
+ walk(full);
+ }
+ } else if (SOURCE.test(entry.name) && !entry.name.endsWith('.d.ts')) {
+ out.push(full);
+ }
+ }
+ };
+ walk(root);
+ return out;
+}
+
+/**
+ * `<…>/zod/3.25.76` for a file inside it: the nearest ancestor whose package.json
+ * names `packageName`. Nearest alone is not enough, because a package can ship a
+ * stub package.json per subpath (`zod/v4/package.json`).
+ */
+function packageDirOf(file: string, packageName: string): string | undefined {
+ let dir = path.dirname(file);
+ for (;;) {
+ const manifest = path.join(dir, 'package.json');
+ if (fs.existsSync(manifest)) {
+ let name: unknown;
+ try {
+ name = (JSON.parse(fs.readFileSync(manifest, 'utf-8')) as { name?: unknown }).name;
+ } catch {
+ name = undefined;
+ }
+ if (name === packageName) {
+ try {
+ return fs.realpathSync(dir);
+ } catch {
+ return dir;
+ }
+ }
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ return undefined;
+ }
+ dir = parent;
+ }
+}
+
+function main(): number {
+ const repo = path.resolve(process.argv[2] ?? '.');
+ const mirrors = new Set();
+ const packages = new Set();
+ // Dependency specifiers that resolve to nothing: most often modules Deno has not
+ // downloaded (no `deno install` yet, or a cache the OS purged).
+ const unresolved = new Set();
+ for (const file of sourceFiles(repo)) {
+ const context = denoContextForDir(path.dirname(file));
+ if (context === undefined) {
+ continue;
+ }
+ let text: string;
+ try {
+ text = fs.readFileSync(file, 'utf-8');
+ } catch {
+ continue;
+ }
+ for (const specifier of moduleSpecifiersOf(text, file)) {
+ const resolved = resolveDenoSpecifier(specifier, file);
+ if (resolved === undefined) {
+ if (!/^(\.{0,2}\/|[a-z][a-z0-9+.-]*:)/i.test(specifier) || /^(jsr|npm|https?):/.test(specifier)) {
+ unresolved.add(specifier);
+ }
+ continue;
+ }
+ if (resolved.kind === 'JSR' || resolved.kind === 'REMOTE') {
+ mirrors.add(mirrorRoot(context.root));
+ } else if (resolved.kind === 'NPM_CACHE' || resolved.kind === 'NODE_MODULES') {
+ const dir = packageDirOf(resolved.resolvedFileName, resolved.packageName);
+ if (dir !== undefined) {
+ packages.add(dir);
+ }
+ }
+ }
+ }
+ // A mirror exists once something was copied into it; one that stayed empty stages nothing.
+ const filled = [...mirrors].filter((m) => configIn(m) !== undefined).sort();
+ process.stdout.write(`${JSON.stringify({ mirrors: filled, packages: [...packages].sort(), unresolved: unresolved.size })}\n`);
+ return 0;
+}
+
+process.exit(main());
diff --git a/parser/src/enums/SkippedFileReason.ts b/parser/src/enums/SkippedFileReason.ts
index 43e650e42..928135fd0 100644
--- a/parser/src/enums/SkippedFileReason.ts
+++ b/parser/src/enums/SkippedFileReason.ts
@@ -66,4 +66,11 @@ export enum SkippedFileReason {
* a consumer could not tell an unparsed file from an empty one (#660).
*/
NO_PROGRAM_CLAIMS_FILE = 'NO_PROGRAM_CLAIMS_FILE',
+
+ /**
+ * A file a tsconfig claims that no program run over the root extracted. The program
+ * that owns it was never reached, so the file is absent from every relation; this
+ * says so instead of leaving it to look like a file with nothing in it.
+ */
+ PROGRAM_NOT_EXTRACTED = 'PROGRAM_NOT_EXTRACTED',
}
diff --git a/parser/src/enums/typescript/expressions/TsExpressionKind.ts b/parser/src/enums/typescript/expressions/TsExpressionKind.ts
index 6d42c2088..c0ada80a2 100644
--- a/parser/src/enums/typescript/expressions/TsExpressionKind.ts
+++ b/parser/src/enums/typescript/expressions/TsExpressionKind.ts
@@ -33,8 +33,10 @@
* A JSX element whose tag names a component IS a call to it, with the whole
* props object as argument 0: JSX_ELEMENT / JSX_SELF_CLOSING, 1:1 with a call
* site, with the tag as its METHOD_NAME child. Attribute values and children are
- * rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element emits
- * no row.
+ * rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element
+ * (``, `
`, ``) is JSX_INTRINSIC_ELEMENT: a row with no
+ * children and no call site, so it exists only as the element its attributes
+ * belong to.
*
* Schema §4.14 c0, §4.15.1.
*/
@@ -99,6 +101,15 @@ export enum TsExpressionKind {
JSX_ELEMENT = 'JSX_ELEMENT',
/** `` naming a component. 1:1 with a call site. */
JSX_SELF_CLOSING = 'JSX_SELF_CLOSING',
+ /**
+ * ``, `
`, `
` -- a tag that names no
+ * declaration. NOT a call: no call site, no METHOD_NAME child, so nothing can
+ * resolve it into `JSX.IntrinsicElements`. The row exists so that a plain
+ * tag's attributes (an event handler, a `ref` callback) have an element to
+ * belong to, as the JavaScript parser's JSX_INTRINSIC_ELEMENT does. The tag,
+ * as written (`div`, `svg:circle`, `my-el`), is its `potentialQualifiedName`.
+ */
+ JSX_INTRINSIC_ELEMENT = 'JSX_INTRINSIC_ELEMENT',
/** `delete a`, `typeof a`, `void a` — `operatorString` says which. */
DELETE_TYPEOF_VOID = 'DELETE_TYPEOF_VOID',
/** `a, b` — the comma operator. */
diff --git a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts
index bc36e385b..a82c0f3a0 100644
--- a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts
+++ b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts
@@ -33,7 +33,7 @@
export enum TsImportResolutionKind {
/** A relative specifier that resolved to a file. */
RELATIVE_FILE = 'RELATIVE_FILE',
- /** Resolved through a tsconfig `paths` mapping. */
+ /** Resolved through a tsconfig `paths` mapping, or a Deno import-map entry onto a project file. */
PATHS_ALIAS = 'PATHS_ALIAS',
/** Resolved to a `.d.ts` inside `node_modules`. */
NODE_MODULES_TYPES = 'NODE_MODULES_TYPES',
@@ -46,6 +46,16 @@ export enum TsImportResolutionKind {
* source its `main` / `types` / `exports` entry is built from.
*/
WORKSPACE_PACKAGE = 'WORKSPACE_PACKAGE',
+ /**
+ * A `jsr:` package (directly, or through a Deno import map), resolved to the
+ * version the lockfile pins and the file its `exports` names. The path is the
+ * module's copy in the project's Deno mirror, laid out as `vendor/` is.
+ */
+ JSR_PACKAGE = 'JSR_PACKAGE',
+ /** An `npm:` package resolved inside Deno's own npm cache rather than `node_modules`. */
+ DENO_NPM_CACHE = 'DENO_NPM_CACHE',
+ /** An `https:` module a Deno project imports by URL, as downloaded (redirects and `x-typescript-types` followed). */
+ REMOTE_MODULE = 'REMOTE_MODULE',
/** Matched a `declare module "x"` in this analysis. No file, and none needed. */
AMBIENT_MODULE = 'AMBIENT_MODULE',
/** A Node builtin, with or without the `node:` prefix. */
diff --git a/parser/src/language-detectors/typescript-detector.ts b/parser/src/language-detectors/typescript-detector.ts
index 753d5d23f..6ad6da2d8 100644
--- a/parser/src/language-detectors/typescript-detector.ts
+++ b/parser/src/language-detectors/typescript-detector.ts
@@ -37,6 +37,10 @@ export class TypeScriptDetector implements LanguageDetector {
'tsconfig.json',
'tsconfig.base.json',
'jsconfig.json',
+ // A Deno project's config defines its module graph the way a tsconfig does, and a
+ // workspace root's lists members that import one another: one project, not one per member.
+ 'deno.json',
+ 'deno.jsonc',
];
private static readonly SKIP = new Set
(TS_SKIP_DIRECTORIES);
@@ -62,6 +66,7 @@ export class TypeScriptDetector implements LanguageDetector {
async detectBuildSystem(projectPath: string): Promise {
try {
const files = await fs.readdir(projectPath);
+ if (files.includes('deno.json') || files.includes('deno.jsonc')) return 'Deno';
if (files.includes('pnpm-lock.yaml')) return 'pnpm';
if (files.includes('yarn.lock')) return 'Yarn';
if (files.includes('bun.lockb') || files.includes('bun.lock')) return 'Bun';
diff --git a/parser/src/parsers/typescript/deno/deno-cache.ts b/parser/src/parsers/typescript/deno/deno-cache.ts
new file mode 100644
index 000000000..7d333d536
--- /dev/null
+++ b/parser/src/parsers/typescript/deno/deno-cache.ts
@@ -0,0 +1,311 @@
+import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import { DenoConfig, readJsonc } from '@/parsers/typescript/deno/deno-config';
+
+/**
+ * Where Deno keeps what a project downloaded, read without running Deno.
+ *
+ * ## The two stores
+ *
+ * - **Remote modules** (`jsr:` packages and `https:` imports) live in
+ * `$DENO_DIR/remote///`: a file with no
+ * extension whose body is the module and whose LAST line is a
+ * `// denoCacheMetadata={…}` comment carrying the response headers. With
+ * `"vendor": true` the same modules sit under `/vendor//`.
+ * - **npm packages** live unpacked in `$DENO_DIR/npm////`
+ * (or in `node_modules` when the project has one).
+ *
+ * ## The mirror
+ *
+ * A hashed, extensionless cache file is not something a parser walk can stage as
+ * a library, and its path names no package. So a remote module a project imports
+ * is copied to a MIRROR laid out exactly as `vendor/` is (`jsr.io/@std/path/1.1.6/
+ * mod.ts`), one mirror per Deno project. An import then resolves to a real `.ts`
+ * file, and the mirror is one library root whose module paths carry the package
+ * and version, which is what keeps `@std/path`'s `mod.ts` apart from `@std/fs`'s
+ * when the engine matches a resolved path against library modules.
+ */
+
+/** `$DENO_DIR`, or the platform default Deno itself uses. */
+export function denoDir(): string {
+ if (process.env.DENO_DIR) {
+ return path.resolve(process.env.DENO_DIR);
+ }
+ const home = os.homedir();
+ if (process.platform === 'darwin') {
+ return path.join(home, 'Library', 'Caches', 'deno');
+ }
+ if (process.platform === 'win32') {
+ return path.join(process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'deno');
+ }
+ return path.join(process.env.XDG_CACHE_HOME ?? path.join(home, '.cache'), 'deno');
+}
+
+export interface RemoteModule {
+ readonly url: string;
+ readonly text: string;
+ /** Response headers, lower-cased. Empty for a vendored file. */
+ readonly headers: Readonly>;
+}
+
+const METADATA_MARKER = '\n// denoCacheMetadata=';
+
+/**
+ * A remote module as the project's Deno would load it: from `vendor/` when the
+ * project vendors, else from `$DENO_DIR`. `undefined` when it was never downloaded.
+ */
+export function readRemote(url: string, root: DenoConfig): RemoteModule | undefined {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return undefined;
+ }
+ if (root.vendor) {
+ const vendored = path.join(root.dir, 'vendor', hostDir(parsed), ...decodedSegments(parsed));
+ const text = readText(vendored);
+ if (text !== undefined) {
+ return { url, text, headers: {} };
+ }
+ }
+ const hostCache = path.join(denoDir(), 'remote', parsed.protocol.replace(/:$/, ''), hostDir(parsed));
+ let cached = '';
+ let raw: string | undefined;
+ for (const key of cacheKeysOf(parsed)) {
+ cached = path.join(hostCache, crypto.createHash('sha256').update(key).digest('hex'));
+ raw = readText(cached);
+ if (raw !== undefined) {
+ break;
+ }
+ }
+ if (raw === undefined) {
+ return undefined;
+ }
+ const at = raw.lastIndexOf(METADATA_MARKER);
+ if (at >= 0) {
+ return { url, text: raw.slice(0, at), headers: headersOf(raw.slice(at + METADATA_MARKER.length)) };
+ }
+ // Deno before the inline trailer kept the headers in a sidecar file.
+ const sidecar = readText(`${cached}.metadata.json`);
+ return { url, text: raw, headers: sidecar === undefined ? {} : headersOf(sidecar) };
+}
+
+/**
+ * The path and query Deno hashed for `url`. Deno's URL parser leaves a `^` in a
+ * path as written (`esm.sh/type-fest@^4.11.1`), where this one escapes it to
+ * `%5E`, so the unescaped spelling is tried as well.
+ */
+function cacheKeysOf(url: URL): string[] {
+ const key = url.pathname + url.search;
+ const unescaped = url.pathname.replace(/%5E/gi, '^') + url.search;
+ return unescaped === key ? [key] : [key, unescaped];
+}
+
+function headersOf(json: string): Record {
+ try {
+ const meta = JSON.parse(json.trim()) as { headers?: Record };
+ const out: Record = {};
+ for (const [k, v] of Object.entries(meta.headers ?? {})) {
+ if (typeof v === 'string') {
+ out[k.toLowerCase()] = v;
+ }
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+/** `deno.land`, or `localhost_PORT8000` as Deno spells a host with a port. */
+function hostDir(url: URL): string {
+ return url.port === '' ? url.hostname : `${url.hostname}_PORT${url.port}`;
+}
+
+function decodedSegments(url: URL): string[] {
+ return url.pathname.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s));
+}
+
+function readText(file: string): string | undefined {
+ try {
+ return fs.readFileSync(file, 'utf-8');
+ } catch {
+ return undefined;
+ }
+}
+
+/** The versions a lockfile pins, per package name, for each registry. */
+export interface DenoLock {
+ readonly jsr: ReadonlyMap;
+ readonly npm: ReadonlyMap;
+ /** Remote URL to the URL it redirected to (`esm.sh/ky@1.7` to `esm.sh/ky@1.7.5`). */
+ readonly redirects: ReadonlyMap;
+ readonly text: string;
+}
+
+const locks = new Map();
+
+/** The lockfile beside `root`, in any of the v3 to v5 layouts, or `undefined`. */
+export function readLock(root: DenoConfig): DenoLock | undefined {
+ if (root.lockPath === false) {
+ return undefined;
+ }
+ const cached = locks.get(root.lockPath);
+ if (cached !== undefined) {
+ return cached ?? undefined;
+ }
+ const text = readText(root.lockPath);
+ let result: DenoLock | undefined;
+ if (text !== undefined) {
+ const json = readJsonc(root.lockPath) as Record | undefined;
+ if (json !== undefined && json !== null && typeof json === 'object') {
+ // v4 and v5 keep `jsr` and `npm` at the top; v3 nests them under `packages`.
+ const packages = (json.packages ?? {}) as Record;
+ result = {
+ jsr: versionsByName(json.jsr ?? packages.jsr),
+ npm: versionsByName(json.npm ?? packages.npm),
+ redirects: redirectsOf(json.redirects),
+ text,
+ };
+ }
+ }
+ locks.set(root.lockPath, result ?? null);
+ return result;
+}
+
+function redirectsOf(section: unknown): Map {
+ const out = new Map();
+ if (section !== null && typeof section === 'object') {
+ for (const [from, to] of Object.entries(section as Record)) {
+ if (typeof to === 'string') {
+ out.set(from, to);
+ }
+ }
+ }
+ return out;
+}
+
+/** `{"@std/path@1.1.6": …, "zod@3.25.76_peer@1.0.0": …}` to name → versions. */
+function versionsByName(section: unknown): Map {
+ const out = new Map();
+ if (section === null || typeof section !== 'object') {
+ return out;
+ }
+ for (const key of Object.keys(section as Record)) {
+ const at = key.indexOf('@', 1);
+ if (at < 0) {
+ continue;
+ }
+ const name = key.slice(0, at);
+ // An npm key carries its peer resolution after `_`; the version is before it.
+ const version = key.slice(at + 1).split('_')[0]!;
+ out.set(name, [...(out.get(name) ?? []), version]);
+ }
+ return out;
+}
+
+/** Every version of an npm package unpacked in `$DENO_DIR/npm`, with its directory. */
+export function cachedNpmVersions(name: string): Map {
+ const out = new Map();
+ const npmRoot = path.join(denoDir(), 'npm');
+ let registries: string[] = [];
+ try {
+ registries = fs.readdirSync(npmRoot);
+ } catch {
+ return out;
+ }
+ // The public registry first, so a mirror registry holding the same version does not win.
+ registries.sort((a, b) => (b === 'registry.npmjs.org' ? 1 : 0) - (a === 'registry.npmjs.org' ? 1 : 0));
+ for (const registry of registries) {
+ const dir = path.join(npmRoot, registry, ...name.split('/'));
+ let versions: string[] = [];
+ try {
+ versions = fs.readdirSync(dir);
+ } catch {
+ continue;
+ }
+ for (const version of versions) {
+ const pkgDir = path.join(dir, version);
+ if (!out.has(version) && fs.existsSync(path.join(pkgDir, 'package.json'))) {
+ out.set(version, pkgDir);
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * The mirror directory for a Deno project: `$AXIOMCODE_DENO_MIRROR` when set,
+ * else one directory per project under the axiomcode cache.
+ */
+export function mirrorRoot(root: DenoConfig): string {
+ // A library parse OF a mirror resolves inside it rather than minting a mirror of the mirror.
+ if (fs.existsSync(path.join(root.dir, MIRROR_MARKER))) {
+ return root.dir;
+ }
+ if (process.env.AXIOMCODE_DENO_MIRROR) {
+ return path.resolve(process.env.AXIOMCODE_DENO_MIRROR);
+ }
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
+ let real = root.dir;
+ try {
+ real = fs.realpathSync(root.dir);
+ } catch {
+ // an unreadable root keeps its written path
+ }
+ const key = crypto.createHash('sha1').update(real).digest('hex').slice(0, 12);
+ return path.join(base, 'axiomcode', 'deno', `${path.basename(real) || 'root'}-${key}`);
+}
+
+/** The file that marks a directory as a mirror this module wrote. */
+export const MIRROR_MARKER = '.axiomcode-deno-mirror';
+
+const preparedMirrors = new Set();
+
+/**
+ * Writes `text` to `/` unless it is already there, and returns
+ * the absolute path. The write is a rename, so two parse workers writing the same
+ * module never leave a torn file.
+ *
+ * The first write to a mirror also gives it a `deno.json` and the project's
+ * lockfile: a library parse of the mirror then resolves the `jsr:` imports INSIDE
+ * the mirrored packages to the same pinned versions the project uses.
+ */
+export function materialize(root: DenoConfig, relative: string, text: string): string | undefined {
+ const mirror = mirrorRoot(root);
+ const target = path.join(mirror, relative);
+ try {
+ if (!preparedMirrors.has(mirror)) {
+ fs.mkdirSync(mirror, { recursive: true });
+ writeIfChanged(path.join(mirror, MIRROR_MARKER), '');
+ writeIfChanged(path.join(mirror, 'deno.json'), '{}\n');
+ const lock = readLock(root);
+ if (lock !== undefined) {
+ writeIfChanged(path.join(mirror, 'deno.lock'), lock.text);
+ }
+ preparedMirrors.add(mirror);
+ }
+ writeIfChanged(target, text);
+ return target;
+ } catch {
+ return undefined;
+ }
+}
+
+function writeIfChanged(file: string, text: string): void {
+ if (readText(file) === text) {
+ return;
+ }
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
+ fs.writeFileSync(temporary, text);
+ fs.renameSync(temporary, file);
+}
+
+/** Forgets cached lockfiles and prepared mirrors; for a test that rewrites them. */
+export function clearDenoCacheState(): void {
+ locks.clear();
+ preparedMirrors.clear();
+}
diff --git a/parser/src/parsers/typescript/deno/deno-config.ts b/parser/src/parsers/typescript/deno/deno-config.ts
new file mode 100644
index 000000000..d1c54492e
--- /dev/null
+++ b/parser/src/parsers/typescript/deno/deno-config.ts
@@ -0,0 +1,336 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+import * as ts from 'typescript';
+
+/**
+ * Which `deno.json` governs a file, and what it says.
+ *
+ * ## Why Deno needs its own reading
+ *
+ * A Deno program has no `tsconfig.json` and no `node_modules` to speak of. Its
+ * module graph is written in `deno.json`: an import map (`imports`, `scopes`, or
+ * an `importMap` file) turns a bare specifier into a file, a `jsr:` / `npm:`
+ * package or a URL, and a `workspace` lists the member packages that import one
+ * another by NAME. `ts.resolveModuleName` knows none of it, so before this file
+ * every `import { Hono } from "hono"` in a Deno service resolved to nothing and
+ * every call across a workspace member was matched by name only.
+ *
+ * ## Member and root
+ *
+ * The NEAREST config is the member that owns the file. A workspace root lists its
+ * members; its import map applies to every member, and the member's own entries
+ * win. A config that no workspace lists is its own root. Both are pure functions
+ * of the file system, so a parse worker reaches the same answer as the analyzer.
+ */
+export const DENO_CONFIG_NAMES = ['deno.json', 'deno.jsonc'] as const;
+
+/** One import map: bare or URL keys to targets, and the scopes that override it. */
+export interface DenoImportMap {
+ /**
+ * Key to target. A target written relative to the map (`./src/`) is stored
+ * ABSOLUTE; `jsr:`, `npm:`, `node:` and URL targets are stored as written.
+ */
+ readonly imports: ReadonlyMap;
+ /** Scope prefix (an absolute directory with a trailing separator, or a URL) to its map. */
+ readonly scopes: ReadonlyArray<{ readonly prefix: string; readonly imports: ReadonlyMap }>;
+}
+
+export interface DenoConfig {
+ /** Absolute path of the `deno.json` / `deno.jsonc`. */
+ readonly path: string;
+ readonly dir: string;
+ /** `name` as published (`@scope/pkg`), or `''`. */
+ readonly name: string;
+ readonly version: string;
+ /** Export subpath (`.`, `./ids`) to the absolute file it names. */
+ readonly exports: ReadonlyMap;
+ readonly importMap: DenoImportMap;
+ /** Absolute member directories, for a workspace root; empty otherwise. */
+ readonly workspaceMembers: readonly string[];
+ /** `"vendor": true`: remote modules are kept under `/vendor`. */
+ readonly vendor: boolean;
+ /**
+ * `nodeModulesDir`: `none` (also `false`) serves npm packages from Deno's cache, `auto`
+ * (also `true`) installs them on use, `manual` expects an installed node_modules.
+ * `undefined` when the config does not say (Deno then expects node_modules beside a package.json).
+ */
+ readonly nodeModulesDir: 'none' | 'auto' | 'manual' | undefined;
+ /** `false` when the config turns the lockfile off; the path otherwise (it may not exist). */
+ readonly lockPath: string | false;
+ readonly compilerOptions: Readonly>;
+}
+
+export interface DenoContext {
+ /** The config nearest the file. */
+ readonly member: DenoConfig;
+ /** The workspace root that lists `member`, or `member` itself. */
+ readonly root: DenoConfig;
+ /** Every member of `root`'s workspace, `root` included when it has a name. */
+ readonly members: readonly DenoConfig[];
+}
+
+const parsedConfigs = new Map();
+const contextByDir = new Map();
+
+/** The Deno config governing `absoluteFilePath`, or `undefined` outside any Deno project. */
+export function denoContextFor(absoluteFilePath: string): DenoContext | undefined {
+ return denoContextForDir(path.dirname(path.resolve(absoluteFilePath)));
+}
+
+export function denoContextForDir(directory: string): DenoContext | undefined {
+ const dir = path.resolve(directory);
+ const cached = contextByDir.get(dir);
+ if (cached !== undefined) {
+ return cached ?? undefined;
+ }
+ let result: DenoContext | undefined;
+ const memberPath = findConfigUpward(dir);
+ const member = memberPath === undefined ? undefined : readDenoConfig(memberPath);
+ if (member !== undefined) {
+ let root: DenoConfig = member;
+ // The workspace root is an ANCESTOR config that lists this member's directory.
+ let up = path.dirname(member.dir);
+ for (;;) {
+ const candidatePath = findConfigUpward(up);
+ if (candidatePath === undefined) {
+ break;
+ }
+ const candidate = readDenoConfig(candidatePath);
+ if (candidate !== undefined && candidate.workspaceMembers.some((m) => samePath(m, member.dir))) {
+ root = candidate;
+ break;
+ }
+ const parent = path.dirname(path.dirname(candidatePath));
+ if (parent === path.dirname(candidatePath)) {
+ break;
+ }
+ up = parent;
+ }
+ const members = root.workspaceMembers
+ .map((m) => {
+ const p = configIn(m);
+ return p === undefined ? undefined : readDenoConfig(p);
+ })
+ .filter((c): c is DenoConfig => c !== undefined);
+ if (root.name !== '' && !members.some((m) => samePath(m.path, root.path))) {
+ members.push(root);
+ }
+ if (member.name !== '' && !members.some((m) => samePath(m.path, member.path))) {
+ members.push(member);
+ }
+ result = { member, root, members };
+ }
+ contextByDir.set(dir, result ?? null);
+ return result;
+}
+
+/** The Deno config file directly in `dir`, if any. */
+export function configIn(dir: string): string | undefined {
+ for (const name of DENO_CONFIG_NAMES) {
+ const candidate = path.join(dir, name);
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ return undefined;
+}
+
+function findConfigUpward(start: string): string | undefined {
+ let dir = start;
+ for (;;) {
+ const found = configIn(dir);
+ if (found !== undefined) {
+ return found;
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ return undefined;
+ }
+ dir = parent;
+ }
+}
+
+function samePath(a: string, b: string): boolean {
+ return path.resolve(a) === path.resolve(b);
+}
+
+/** JSON with comments and trailing commas, which is what `deno.jsonc` (and often `deno.json`) holds. */
+export function readJsonc(file: string): unknown {
+ let text: string;
+ try {
+ text = fs.readFileSync(file, 'utf-8');
+ } catch {
+ return undefined;
+ }
+ const parsed = ts.parseConfigFileTextToJson(file, text);
+ return parsed.error ? undefined : parsed.config;
+}
+
+export function readDenoConfig(configPath: string): DenoConfig | undefined {
+ const absolute = path.resolve(configPath);
+ const cached = parsedConfigs.get(absolute);
+ if (cached !== undefined) {
+ return cached ?? undefined;
+ }
+ const raw = readJsonc(absolute);
+ let result: DenoConfig | undefined;
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const json = raw as Record;
+ const dir = path.dirname(absolute);
+ const inline = importMapOf(json, dir);
+ const external = typeof json.importMap === 'string'
+ ? importMapFile(path.resolve(dir, json.importMap))
+ : undefined;
+ result = {
+ path: absolute,
+ dir,
+ name: typeof json.name === 'string' ? json.name : '',
+ version: typeof json.version === 'string' ? json.version : '',
+ exports: exportsOf(json.exports, dir),
+ importMap: {
+ imports: new Map([...(external?.imports ?? []), ...inline.imports]),
+ scopes: [...inline.scopes, ...(external?.scopes ?? [])],
+ },
+ workspaceMembers: workspaceMembersOf(json.workspace, dir),
+ vendor: json.vendor === true,
+ nodeModulesDir: json.nodeModulesDir === false || json.nodeModulesDir === 'none' ? 'none'
+ : json.nodeModulesDir === true || json.nodeModulesDir === 'auto' ? 'auto'
+ : json.nodeModulesDir === 'manual' ? 'manual' : undefined,
+ lockPath: lockPathOf(json.lock, dir),
+ compilerOptions: json.compilerOptions !== null && typeof json.compilerOptions === 'object'
+ ? json.compilerOptions as Record
+ : {},
+ };
+ }
+ parsedConfigs.set(absolute, result ?? null);
+ return result;
+}
+
+function importMapFile(file: string): DenoImportMap | undefined {
+ const raw = readJsonc(file);
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
+ return undefined;
+ }
+ return importMapOf(raw as Record, path.dirname(file));
+}
+
+function importMapOf(json: Record, baseDir: string): DenoImportMap {
+ const scopes: Array<{ prefix: string; imports: ReadonlyMap }> = [];
+ if (json.scopes !== null && typeof json.scopes === 'object') {
+ for (const [prefix, map] of Object.entries(json.scopes as Record)) {
+ if (map !== null && typeof map === 'object') {
+ scopes.push({ prefix: absoluteTarget(prefix, baseDir), imports: entriesOf(map, baseDir) });
+ }
+ }
+ // The most specific scope is consulted first, as the import-map spec orders them.
+ scopes.sort((a, b) => b.prefix.length - a.prefix.length);
+ }
+ return { imports: entriesOf(json.imports, baseDir), scopes };
+}
+
+function entriesOf(value: unknown, baseDir: string): Map {
+ const out = new Map();
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [key, target] of Object.entries(value as Record)) {
+ if (typeof target === 'string') {
+ out.set(key, absoluteTarget(target, baseDir));
+ }
+ }
+ }
+ return out;
+}
+
+/** `./src/` beside the map becomes an absolute directory, its trailing separator kept. */
+function absoluteTarget(target: string, baseDir: string): string {
+ if (target.startsWith('file://')) {
+ return withTrailing(target, decodeURIComponent(new URL(target).pathname));
+ }
+ if (target.startsWith('./') || target.startsWith('../') || target === '.' || target === '..'
+ || target.startsWith('/')) {
+ return withTrailing(target, path.resolve(baseDir, target));
+ }
+ return target;
+}
+
+function withTrailing(original: string, resolved: string): string {
+ return original.endsWith('/') && !resolved.endsWith(path.sep) ? resolved + path.sep : resolved;
+}
+
+function exportsOf(value: unknown, dir: string): Map {
+ const out = new Map();
+ if (typeof value === 'string') {
+ out.set('.', path.resolve(dir, value));
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [subpath, target] of Object.entries(value as Record)) {
+ if (typeof target === 'string') {
+ out.set(subpath, path.resolve(dir, target));
+ }
+ }
+ }
+ return out;
+}
+
+function workspaceMembersOf(value: unknown, dir: string): string[] {
+ const listed = Array.isArray(value)
+ ? value
+ : value !== null && typeof value === 'object' && Array.isArray((value as { members?: unknown }).members)
+ ? (value as { members: unknown[] }).members
+ : [];
+ const out: string[] = [];
+ for (const entry of listed) {
+ if (typeof entry !== 'string') {
+ continue;
+ }
+ if (entry.endsWith('/*') || entry.endsWith('/*/')) {
+ // `./packages/*`: every directory under it that holds a Deno config.
+ const parent = path.resolve(dir, entry.replace(/\/\*\/?$/, ''));
+ let children: fs.Dirent[] = [];
+ try {
+ children = fs.readdirSync(parent, { withFileTypes: true });
+ } catch {
+ // a glob over a missing directory names no member
+ }
+ for (const child of children) {
+ if (child.isDirectory() && configIn(path.join(parent, child.name)) !== undefined) {
+ out.push(path.join(parent, child.name));
+ }
+ }
+ } else {
+ out.push(path.resolve(dir, entry));
+ }
+ }
+ return out;
+}
+
+function lockPathOf(value: unknown, dir: string): string | false {
+ if (value === false) {
+ return false;
+ }
+ if (typeof value === 'string') {
+ return path.resolve(dir, value);
+ }
+ if (value !== null && typeof value === 'object' && typeof (value as { path?: unknown }).path === 'string') {
+ return path.resolve(dir, (value as { path: string }).path);
+ }
+ return path.join(dir, 'deno.lock');
+}
+
+/** Forgets every parsed config; for a test that rewrites one in place. */
+export function clearDenoConfigCache(): void {
+ parsedConfigs.clear();
+ contextByDir.clear();
+}
+
+/**
+ * Whether `parent/name` is the `vendor/` directory a Deno config beside it keeps
+ * its downloaded modules in. Those are third-party sources: walking them as
+ * project files would put every vendored package in the project's own graph.
+ */
+export function isDenoVendorDirectory(parent: string, name: string): boolean {
+ if (name !== 'vendor') {
+ return false;
+ }
+ const config = configIn(parent);
+ return config !== undefined && readDenoConfig(config)?.vendor === true;
+}
diff --git a/parser/src/parsers/typescript/deno/deno-resolver.ts b/parser/src/parsers/typescript/deno/deno-resolver.ts
new file mode 100644
index 000000000..c3864cba6
--- /dev/null
+++ b/parser/src/parsers/typescript/deno/deno-resolver.ts
@@ -0,0 +1,684 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+import * as ts from 'typescript';
+
+import { DenoConfig, DenoContext, DenoImportMap, denoContextFor } from '@/parsers/typescript/deno/deno-config';
+import {
+ cachedNpmVersions, materialize, readLock, readRemote, RemoteModule,
+} from '@/parsers/typescript/deno/deno-cache';
+import { maxSatisfying, satisfies } from '@/parsers/typescript/deno/deno-semver';
+
+/**
+ * Resolves a module specifier the way Deno does, for a file inside a Deno project.
+ *
+ * Order, as Deno applies it:
+ *
+ * 1. the import map: the member's `scopes` and `imports`, then the workspace
+ * root's (a key ending in `/` maps a prefix; a `jsr:` / `npm:` target also
+ * maps the key's subpaths, `"hono": "jsr:@hono/hono"` serving `hono/cors`);
+ * 2. a workspace member by name (`@acme/shared`, `@acme/shared/ids`), through
+ * its `exports`;
+ * 3. the target's scheme: `jsr:` to the version the lockfile pins (or the
+ * highest cached one in range) and the file its `exports` names; `npm:` to
+ * `node_modules` or Deno's npm cache; `https:` to the downloaded module,
+ * following redirects and an `x-typescript-types` header as Deno does.
+ *
+ * Relative specifiers and bare ones nothing above claims are NOT answered here:
+ * `ts.resolveModuleName` already gets those right (`./a.ts` included) and a Deno
+ * project with a `package.json` resolves its bare npm imports through
+ * `node_modules` exactly as Node does.
+ *
+ * Every answer is a file that exists. A `jsr:` or URL module that was never
+ * downloaded resolves to nothing, which is the honest answer for this machine.
+ */
+export type DenoResolutionKind =
+ /** An import-map entry onto a project file. */
+ | 'IMPORT_MAP'
+ /** A workspace member imported by its name. */
+ | 'WORKSPACE_MEMBER'
+ | 'JSR'
+ /** An npm package in Deno's own cache. */
+ | 'NPM_CACHE'
+ /** An npm package in the project's `node_modules`. */
+ | 'NODE_MODULES'
+ | 'REMOTE'
+ | 'BUILTIN';
+
+export interface DenoResolution {
+ readonly resolvedFileName: string;
+ /** The `ts.Extension` the file carries (`.ts`, `.d.ts`, …); `''` for a builtin. */
+ readonly extension: string;
+ readonly kind: DenoResolutionKind;
+ /** `@std/path`, `zod`, `@acme/shared`; `''` for a project file or a URL. */
+ readonly packageName: string;
+}
+
+const resolutions = new Map();
+
+export function resolveDenoSpecifier(specifier: string, containingFile: string): DenoResolution | undefined {
+ if (specifier === '' || isPathSpecifier(specifier)) {
+ return undefined;
+ }
+ const context = denoContextFor(containingFile);
+ if (context === undefined) {
+ return undefined;
+ }
+ const key = `${path.dirname(path.resolve(containingFile))}\0${specifier}`;
+ const cached = resolutions.get(key);
+ if (cached !== undefined) {
+ return cached ?? undefined;
+ }
+ let result: DenoResolution | undefined;
+ try {
+ result = resolveIn(specifier, path.resolve(containingFile), context);
+ } catch {
+ result = undefined;
+ }
+ // Scopes and member maps are keyed by directory, so the answer is too.
+ resolutions.set(key, result ?? null);
+ return result;
+}
+
+function resolveIn(specifier: string, containingFile: string, context: DenoContext): DenoResolution | undefined {
+ const mapped = mapSpecifier(specifier, containingFile, context);
+ if (mapped !== undefined && path.isAbsolute(mapped)) {
+ const file = existingFile(mapped);
+ return file === undefined ? undefined : { ...fileResolution(file), kind: 'IMPORT_MAP', packageName: '' };
+ }
+ const target = mapped ?? specifier;
+ if (target.startsWith('node:')) {
+ return { resolvedFileName: '', extension: '', kind: 'BUILTIN', packageName: '' };
+ }
+ if (target.startsWith('jsr:')) {
+ return resolveJsr(target, context);
+ }
+ if (target.startsWith('npm:')) {
+ return resolveNpm(target, containingFile, context);
+ }
+ if (target.startsWith('https:') || target.startsWith('http:')) {
+ return resolveRemoteUrl(target, context.root);
+ }
+ return memberExport(target, context) ?? packageJsonDependency(target, containingFile, context);
+}
+
+// ── 2b. a package.json dependency ───────────────────────────────────────────
+
+/**
+ * A bare specifier no import map or member claims, named by the nearest
+ * package.json's dependencies: under `"nodeModulesDir": "none"` (or `auto`) Deno
+ * serves it from its npm cache (`vitest` in a member that declares it). Under
+ * `manual`, Deno's default beside a package.json, it expects node_modules and
+ * refuses otherwise, so this answers nothing and the compiler's resolution does.
+ */
+function packageJsonDependency(specifier: string, containingFile: string, context: DenoContext):
+ DenoResolution | undefined {
+ const mode = context.root.nodeModulesDir;
+ if (mode !== 'none' && mode !== 'auto') {
+ return undefined;
+ }
+ const m = /^((?:@[^/@]+\/)?[^/@]+)(\/.*)?$/.exec(specifier);
+ if (!m) {
+ return undefined;
+ }
+ const name = m[1]!;
+ const range = dependencyRange(name, path.dirname(containingFile), context.root.dir);
+ if (range === undefined) {
+ return undefined;
+ }
+ const installed = ts.resolveModuleName(specifier, containingFile, NPM_OPTIONS, ts.sys).resolvedModule;
+ if (installed !== undefined) {
+ return undefined;
+ }
+ const resolved = resolveNpm(`npm:${name}${range === '' ? '' : `@${range}`}${m[2] ?? ''}`, containingFile, context);
+ return resolved?.kind === 'NPM_CACHE' ? resolved : undefined;
+}
+
+const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
+
+/** `name`'s range in the nearest package.json at or above `dir` (up to `top`) that declares it; '' for a non-semver value. */
+function dependencyRange(name: string, dir: string, top: string): string | undefined {
+ for (let d = dir; ; d = path.dirname(d)) {
+ const manifest = readPackageJson(path.join(d, 'package.json'));
+ for (const field of DEPENDENCY_FIELDS) {
+ const value = (manifest?.[field] as Record | undefined)?.[name];
+ if (typeof value === 'string') {
+ // `workspace:*` is a member (resolved above); `catalog:`, `file:` and tags name no range.
+ return /^[\d^~<>=*xX |.-]/.test(value) && !value.startsWith('workspace:') ? value.trim() : '';
+ }
+ }
+ if (d === top || path.dirname(d) === d) {
+ return undefined;
+ }
+ }
+}
+
+const packageJsons = new Map | null>();
+
+function readPackageJson(file: string): Record | undefined {
+ let known = packageJsons.get(file);
+ if (known === undefined) {
+ try {
+ known = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record;
+ } catch {
+ known = null;
+ }
+ packageJsons.set(file, known);
+ }
+ return known ?? undefined;
+}
+
+// ── 1. the import map ───────────────────────────────────────────────────────
+
+function mapSpecifier(specifier: string, containingFile: string, context: DenoContext): string | undefined {
+ const maps = context.member === context.root
+ ? [context.member.importMap]
+ : [context.member.importMap, context.root.importMap];
+ for (const map of maps) {
+ const hit = applyImportMap(specifier, containingFile, map);
+ if (hit !== undefined) {
+ return hit;
+ }
+ }
+ return undefined;
+}
+
+export function applyImportMap(specifier: string, referrer: string, map: DenoImportMap): string | undefined {
+ for (const scope of map.scopes) {
+ if (referrer.startsWith(scope.prefix)) {
+ const hit = matchImports(specifier, scope.imports);
+ if (hit !== undefined) {
+ return hit;
+ }
+ }
+ }
+ return matchImports(specifier, map.imports);
+}
+
+function matchImports(specifier: string, imports: ReadonlyMap): string | undefined {
+ const exact = imports.get(specifier);
+ if (exact !== undefined) {
+ return exact;
+ }
+ // The longest `prefix/` key, as the import-map spec chooses among several.
+ let best: string | undefined;
+ for (const key of imports.keys()) {
+ if (key.endsWith('/') && specifier.startsWith(key) && (best === undefined || key.length > best.length)) {
+ best = key;
+ }
+ }
+ if (best !== undefined) {
+ return joinTarget(imports.get(best)!, specifier.slice(best.length));
+ }
+ // Deno's own extension: a key that maps to a PACKAGE also serves its subpaths.
+ let packageKey: string | undefined;
+ for (const [key, target] of imports) {
+ if (!key.endsWith('/') && specifier.startsWith(`${key}/`)
+ && (target.startsWith('jsr:') || target.startsWith('npm:'))
+ && (packageKey === undefined || key.length > packageKey.length)) {
+ packageKey = key;
+ }
+ }
+ return packageKey === undefined
+ ? undefined
+ : `${imports.get(packageKey)!}${specifier.slice(packageKey.length)}`;
+}
+
+function joinTarget(target: string, rest: string): string {
+ if (path.isAbsolute(target)) {
+ return path.join(target, ...rest.split('/'));
+ }
+ return target + rest;
+}
+
+// ── 2. workspace members ────────────────────────────────────────────────────
+
+function memberExport(specifier: string, context: DenoContext): DenoResolution | undefined {
+ for (const member of context.members) {
+ if (member.name === '' || (specifier !== member.name && !specifier.startsWith(`${member.name}/`))) {
+ continue;
+ }
+ const subpath = specifier === member.name ? '.' : `.${specifier.slice(member.name.length)}`;
+ const target = member.exports.get(subpath);
+ const file = target === undefined ? undefined : existingFile(target);
+ if (file !== undefined) {
+ return { ...fileResolution(file), kind: 'WORKSPACE_MEMBER', packageName: member.name };
+ }
+ }
+ return undefined;
+}
+
+// ── 3a. jsr: ────────────────────────────────────────────────────────────────
+
+const JSR = /^jsr:\/?(@[^/@]+\/[^/@]+)(?:@([^/]*))?(\/.*)?$/;
+
+interface JsrMeta {
+ readonly exports?: Record;
+}
+
+function resolveJsr(target: string, context: DenoContext): DenoResolution | undefined {
+ const m = JSR.exec(target);
+ if (!m) {
+ return undefined;
+ }
+ const name = m[1]!;
+ const range = m[2] ?? '';
+ const subpath = m[3] ?? '';
+ // `jsr:@acme/shared` inside the workspace that defines it is the member, not a download.
+ const member = memberExport(name + subpath, context);
+ if (member !== undefined) {
+ return member;
+ }
+ const root = context.root;
+ const version = jsrVersion(name, range, root);
+ if (version === undefined) {
+ return undefined;
+ }
+ const meta = readJson(`https://jsr.io/${name}/${version}_meta.json`, root);
+ const exported = meta?.exports?.[subpath === '' ? '.' : `.${subpath}`];
+ if (exported === undefined) {
+ return undefined;
+ }
+ const file = exported.replace(/^\.\//, '');
+ const module = readRemote(`https://jsr.io/${name}/${version}/${file}`, root);
+ const written = module === undefined ? undefined : materializeWithImports(module, root);
+ return written === undefined
+ ? undefined
+ : { ...fileResolution(written), kind: 'JSR', packageName: name };
+}
+
+function jsrVersion(name: string, range: string, root: DenoConfig): string | undefined {
+ const locked = readLock(root)?.jsr.get(name);
+ if (locked !== undefined && locked.length > 0) {
+ const pinned = maxSatisfying(locked, range);
+ if (pinned !== undefined) {
+ return pinned;
+ }
+ }
+ // No lockfile, or it pins no version in range: the highest version the cache holds.
+ const meta = readJson<{ versions?: Record }>(`https://jsr.io/${name}/meta.json`, root);
+ const available = Object.entries(meta?.versions ?? {})
+ .filter(([version, info]) => info?.yanked !== true
+ && readRemote(`https://jsr.io/${name}/${version}_meta.json`, root) !== undefined)
+ .map(([version]) => version);
+ return maxSatisfying(available, range);
+}
+
+function readJson(url: string, root: DenoConfig): T | undefined {
+ const module = readRemote(url, root);
+ if (module === undefined) {
+ return undefined;
+ }
+ try {
+ return JSON.parse(module.text) as T;
+ } catch {
+ return undefined;
+ }
+}
+
+// ── 3b. npm: ────────────────────────────────────────────────────────────────
+
+const NPM = /^npm:\/?((?:@[^/@]+\/)?[^/@]+)(?:@([^/]*))?(\/.*)?$/;
+
+/** What a Deno program resolves an npm package's entry with: ESM, `types` first. */
+const NPM_OPTIONS: ts.CompilerOptions = {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ customConditions: ['deno'],
+ allowImportingTsExtensions: true,
+ noEmit: true,
+};
+
+function resolveNpm(target: string, containingFile: string, context: DenoContext): DenoResolution | undefined {
+ const m = NPM.exec(target);
+ if (!m) {
+ return undefined;
+ }
+ const name = m[1]!;
+ const range = m[2] ?? '';
+ const bare = name + (m[3] ?? '');
+ // A project with node_modules (`nodeModulesDir`, or a package.json) installs there.
+ const installed = ts.resolveModuleName(bare, containingFile, NPM_OPTIONS, ts.sys).resolvedModule;
+ const lockedNpm = readLock(context.root)?.npm.get(name) ?? [];
+ if (installed !== undefined && /[\\/]node_modules[\\/]/.test(installed.resolvedFileName)) {
+ // The version is checked only on the package itself: a package without types
+ // resolves to its `@types/` twin, whose version follows its own line.
+ const installedVersion = installed.packageId?.name === name ? installed.packageId.version : undefined;
+ if (range === '' || installedVersion === undefined || satisfies(installedVersion, range)) {
+ return {
+ resolvedFileName: path.normalize(installed.resolvedFileName),
+ extension: installed.extension,
+ kind: 'NODE_MODULES',
+ packageName: installed.packageId?.name ?? name,
+ };
+ }
+ // The hoisted copy is another major (`npm:tailwindcss@^3` beside a hoisted 4): Deno
+ // keeps each version under node_modules/.deno/@/node_modules/.
+ const side = denoNodeModulesVersions(name, containingFile);
+ const version = maxSatisfying(lockedNpm.filter((v) => side.has(v)), range) ?? maxSatisfying(side.keys(), range);
+ const file = version === undefined ? undefined : resolveInPackage(bare, name, side.get(version)!);
+ if (file !== undefined) {
+ return { resolvedFileName: file.resolvedFileName, extension: file.extension, kind: 'NODE_MODULES', packageName: name };
+ }
+ }
+ const cached = cachedNpmVersions(name);
+ if (cached.size === 0) {
+ return undefined;
+ }
+ const locked = lockedNpm.filter((v) => cached.has(v));
+ const version = maxSatisfying(locked, range) ?? maxSatisfying(cached.keys(), range);
+ const packageDir = version === undefined ? undefined : cached.get(version);
+ if (packageDir === undefined) {
+ return undefined;
+ }
+ const file = resolveInPackage(bare, name, packageDir);
+ if (file === undefined) {
+ return undefined;
+ }
+ // A package that ships only JavaScript is type-checked against its @types twin, as the compiler does.
+ const typed = DECLARATION.test(file.resolvedFileName) ? undefined : typesTwin(bare, name, context);
+ return { ...(typed ?? file), kind: 'NPM_CACHE', packageName: name };
+}
+
+const DECLARATION = /\.d\.[cm]?ts$/;
+
+function typesTwin(bare: string, name: string, context: DenoContext):
+ { resolvedFileName: string; extension: string } | undefined {
+ const twin = `@types/${name.startsWith('@') ? name.slice(1).replace('/', '__') : name}`;
+ const cached = cachedNpmVersions(twin);
+ if (cached.size === 0) {
+ return undefined;
+ }
+ const locked = (readLock(context.root)?.npm.get(twin) ?? []).filter((v) => cached.has(v));
+ const version = maxSatisfying(locked, '') ?? maxSatisfying(cached.keys(), '');
+ return version === undefined ? undefined
+ : resolveInPackage(twin + bare.slice(name.length), twin, cached.get(version)!);
+}
+
+/**
+ * Each version of `name` under the nearest `node_modules/.deno`, with its package
+ * directory: `@scope/pkg` lives at `.deno/@scope+pkg@1.2.3[_peer]/node_modules/@scope/pkg`.
+ */
+function denoNodeModulesVersions(name: string, containingFile: string): Map {
+ const out = new Map();
+ const prefix = name.replace('/', '+') + '@';
+ for (let dir = path.dirname(containingFile); ; dir = path.dirname(dir)) {
+ const store = path.join(dir, 'node_modules', '.deno');
+ let entries: string[] | undefined;
+ try {
+ entries = fs.readdirSync(store);
+ } catch {
+ entries = undefined;
+ }
+ if (entries !== undefined) {
+ for (const entry of entries) {
+ if (entry.startsWith(prefix)) {
+ const version = entry.slice(prefix.length).split('_')[0]!;
+ const packageDir = path.join(store, entry, 'node_modules', ...name.split('/'));
+ if (!out.has(version) && fs.existsSync(packageDir)) {
+ out.set(version, packageDir);
+ }
+ }
+ }
+ return out;
+ }
+ if (path.dirname(dir) === dir) {
+ return out;
+ }
+ }
+}
+
+/**
+ * `bare` resolved inside one unpacked package directory, honouring its
+ * `exports` / `types` the way the compiler does for `node_modules`.
+ *
+ * Deno's cache is not a `node_modules` tree, so the compiler is shown one: a host
+ * that answers for `/node_modules//…` from `packageDir` and for
+ * nothing else under the virtual root.
+ */
+function resolveInPackage(bare: string, name: string, packageDir: string):
+ { resolvedFileName: string; extension: string } | undefined {
+ const virtualRoot = path.join(path.parse(packageDir).root, '__axiomcode_deno_npm__');
+ const virtualPackage = path.join(virtualRoot, 'node_modules', ...name.split('/'));
+ const toReal = (p: string): string | undefined => {
+ const normal = path.normalize(p);
+ if (normal === virtualPackage || normal.startsWith(virtualPackage + path.sep)) {
+ return packageDir + normal.slice(virtualPackage.length);
+ }
+ return undefined;
+ };
+ const host: ts.ModuleResolutionHost = {
+ fileExists: (p) => {
+ const real = toReal(p);
+ return real !== undefined && fs.existsSync(real) && fs.statSync(real).isFile();
+ },
+ readFile: (p) => {
+ const real = toReal(p);
+ return real === undefined ? undefined : ts.sys.readFile(real);
+ },
+ directoryExists: (p) => {
+ const normal = path.normalize(p);
+ if (virtualPackage.startsWith(normal + path.sep) || normal === virtualPackage) {
+ return true;
+ }
+ const real = toReal(p);
+ return real !== undefined && fs.existsSync(real) && fs.statSync(real).isDirectory();
+ },
+ realpath: (p) => p,
+ getCurrentDirectory: () => virtualRoot,
+ };
+ const resolved = ts.resolveModuleName(bare, path.join(virtualRoot, 'index.ts'), NPM_OPTIONS, host).resolvedModule;
+ const real = resolved === undefined ? undefined : toReal(resolved.resolvedFileName);
+ return real === undefined ? undefined : { resolvedFileName: real, extension: resolved!.extension };
+}
+
+// ── 3c. https: ──────────────────────────────────────────────────────────────
+
+function resolveRemoteUrl(url: string, root: DenoConfig): DenoResolution | undefined {
+ let module = followRedirects(url, root);
+ if (module === undefined) {
+ return undefined;
+ }
+ // A CDN that serves JavaScript points at its declarations, and Deno type-checks against those.
+ const types = module.headers['x-typescript-types'];
+ if (types !== undefined) {
+ const declared = followRedirects(new URL(types, module.url).toString(), root);
+ if (declared !== undefined) {
+ module = declared;
+ }
+ }
+ const written = materializeWithImports(module, root);
+ return written === undefined ? undefined : { ...fileResolution(written), kind: 'REMOTE', packageName: '' };
+}
+
+/** URLs already copied to a mirror this process, so a cycle between modules ends. */
+const materializedUrls = new Map();
+
+/**
+ * Copies `module` to the mirror and, transitively, every module it imports that
+ * Deno downloaded: a relative import by its URL, a `jsr:` / `npm:` / URL import
+ * through this resolver from the mirrored file. A library parse of the mirror
+ * then finds a package's whole reachable source, not only the entry the project
+ * imported.
+ */
+function materializeWithImports(module: RemoteModule, root: DenoConfig): string | undefined {
+ const known = materializedUrls.get(module.url);
+ if (known !== undefined) {
+ return known ?? undefined;
+ }
+ const relative = mirrorPathOf(module);
+ const written = relative === undefined ? undefined : materialize(root, relative, module.text);
+ materializedUrls.set(module.url, written ?? null);
+ if (written === undefined) {
+ return undefined;
+ }
+ for (const specifier of moduleSpecifiersOf(module.text, written)) {
+ if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) {
+ let target: string;
+ try {
+ target = new URL(specifier, module.url).toString();
+ } catch {
+ continue;
+ }
+ if (!materializedUrls.has(target)) {
+ const imported = followRedirects(target, root);
+ if (imported !== undefined) {
+ materializeWithImports(imported, root);
+ }
+ }
+ } else if (/^(jsr|npm|https?):/.test(specifier)) {
+ resolveDenoSpecifier(specifier, written);
+ }
+ }
+ return written;
+}
+
+function followRedirects(url: string, root: DenoConfig): RemoteModule | undefined {
+ const redirects = readLock(root)?.redirects;
+ let current = url;
+ for (let hop = 0; hop < 10; hop++) {
+ // Deno 2 records a redirect in the lockfile and may keep no cache entry for it.
+ const locked = redirects?.get(current) ?? redirects?.get(current.replace(/%5E/gi, '^'));
+ if (locked !== undefined && locked !== current) {
+ current = locked;
+ continue;
+ }
+ const module = readRemote(current, root);
+ if (module === undefined) {
+ return undefined;
+ }
+ const location = module.headers.location;
+ if (location === undefined) {
+ return module;
+ }
+ current = new URL(location, current).toString();
+ }
+ return undefined;
+}
+
+const TS_LIKE = /\.(d\.ts|d\.mts|d\.cts|tsx?|mts|cts|jsx?|mjs|cjs)$/;
+
+/** `deno.land/std@0.224.0/fmt/colors.ts`; an extensionless URL takes one from its content type. */
+function mirrorPathOf(module: RemoteModule): string | undefined {
+ let parsed: URL;
+ try {
+ parsed = new URL(module.url);
+ } catch {
+ return undefined;
+ }
+ const host = parsed.port === '' ? parsed.hostname : `${parsed.hostname}_PORT${parsed.port}`;
+ const segments = parsed.pathname.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s));
+ if (segments.length === 0) {
+ segments.push('index');
+ }
+ let last = segments.pop()!;
+ if (parsed.search !== '') {
+ last += `_${Buffer.from(parsed.search).toString('base64url')}`;
+ }
+ if (!TS_LIKE.test(last) || parsed.search !== '') {
+ const type = module.headers['content-type'] ?? '';
+ last += /typescript|\/tsx?\b/.test(type) ? (/tsx/.test(type) ? '.tsx' : '.ts')
+ : /javascript|ecmascript|\/jsx\b/.test(type) ? '.js'
+ : TS_LIKE.exec(parsed.pathname)?.[0] ?? '.ts';
+ }
+ return path.join(host, ...segments, last);
+}
+
+/**
+ * Every module specifier a file names, as the parser's extraction sees them:
+ * imports and re-exports (`export * as ns from` included, which the compiler's
+ * light pre-processor misses), `import x = require()`, `import()` / `require()`
+ * with a literal, `import("x")` types, and triple-slash references. Missing one
+ * here leaves a module out of the mirror until a later library parse copies it,
+ * which changes the mirror and makes every rebuild recompile it.
+ */
+export function moduleSpecifiersOf(text: string, fileName: string): string[] {
+ const out: string[] = [];
+ const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false,
+ /\.tsx$|\.jsx$/.test(fileName) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
+ const literal = (node: ts.Node | undefined): void => {
+ if (node !== undefined && ts.isStringLiteralLike(node)) {
+ out.push(node.text);
+ }
+ };
+ const visit = (node: ts.Node): void => {
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+ literal(node.moduleSpecifier);
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
+ literal(node.moduleReference.expression);
+ } else if (ts.isCallExpression(node)
+ && (node.expression.kind === ts.SyntaxKind.ImportKeyword
+ || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
+ literal(node.arguments[0]);
+ } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
+ literal(node.argument.literal);
+ }
+ ts.forEachChild(node, visit);
+ };
+ ts.forEachChild(sf, visit);
+ for (const reference of [...sf.referencedFiles, ...sf.typeReferenceDirectives]) {
+ out.push(reference.fileName);
+ }
+ return out;
+}
+
+// ── files ───────────────────────────────────────────────────────────────────
+
+function isPathSpecifier(specifier: string): boolean {
+ return specifier.startsWith('./') || specifier.startsWith('../') || specifier === '.' || specifier === '..'
+ || specifier.startsWith('/') || specifier.startsWith('file:') || /^[A-Za-z]:[\\/]/.test(specifier);
+}
+
+/** `target` itself, as Deno requires an explicit extension, else what the compiler's probing finds. */
+function existingFile(target: string): string | undefined {
+ try {
+ if (fs.statSync(target).isFile()) {
+ return path.normalize(target);
+ }
+ } catch {
+ // not a file as written; probe below
+ }
+ const probed = ts.resolveModuleName(target, path.join(path.dirname(target), '__probe__.ts'),
+ NPM_OPTIONS, ts.sys).resolvedModule;
+ return probed === undefined ? undefined : path.normalize(probed.resolvedFileName);
+}
+
+const EXTENSIONS: readonly string[] = [
+ ts.Extension.Dts, ts.Extension.Dmts, ts.Extension.Dcts,
+ ts.Extension.Tsx, ts.Extension.Ts, ts.Extension.Mts, ts.Extension.Cts,
+ ts.Extension.Jsx, ts.Extension.Js, ts.Extension.Mjs, ts.Extension.Cjs, ts.Extension.Json,
+];
+
+function fileResolution(file: string): { resolvedFileName: string; extension: string } {
+ const extension = EXTENSIONS.find((e) => file.endsWith(e)) ?? '';
+ return { resolvedFileName: path.normalize(file), extension };
+}
+
+/**
+ * The package a specifier names in a Deno project, from the config and syntax
+ * alone, for an import that did not resolve: `hono` mapped to `jsr:@hono/hono@^4`
+ * names `@hono/hono`. `undefined` outside a Deno project or for a specifier the
+ * import map and the schemes say nothing about; `''` for a URL or a project file.
+ */
+export function denoPackageNameOf(specifier: string, containingFile: string): string | undefined {
+ if (isPathSpecifier(specifier)) {
+ return undefined;
+ }
+ const context = denoContextFor(containingFile);
+ if (context === undefined) {
+ return undefined;
+ }
+ const target = mapSpecifier(specifier, path.resolve(containingFile), context) ?? specifier;
+ if (path.isAbsolute(target) || /^https?:/.test(target) || target.startsWith('node:')) {
+ return '';
+ }
+ const scheme = target.startsWith('jsr:') ? JSR.exec(target) : target.startsWith('npm:') ? NPM.exec(target) : null;
+ return scheme ? scheme[1] : undefined;
+}
+
+/** Forgets memoised resolutions; for a test that rewrites a project in place. */
+export function clearDenoResolutionCache(): void {
+ resolutions.clear();
+ materializedUrls.clear();
+ packageJsons.clear();
+}
diff --git a/parser/src/parsers/typescript/deno/deno-semver.ts b/parser/src/parsers/typescript/deno/deno-semver.ts
new file mode 100644
index 000000000..aeb3a8ff5
--- /dev/null
+++ b/parser/src/parsers/typescript/deno/deno-semver.ts
@@ -0,0 +1,214 @@
+/**
+ * The slice of semver a Deno import needs: does a locked or cached version satisfy
+ * the range a specifier wrote, and which of several is the highest.
+ *
+ * `jsr:@std/path@^1.0.0` and `npm:zod@~3.23` name RANGES. The version actually
+ * used is in `deno.lock`, or, without a lockfile, is the highest one the cache
+ * holds. The lockfile's own specifier keys cannot be looked up by the written
+ * range (Deno normalises `^1.0.0` to `1` there), so the range is matched against
+ * the locked VERSIONS instead, which is the same answer without depending on how
+ * a given Deno release spells its keys.
+ *
+ * Supported: exact, `^`, `~`, `x`/`*` wildcards, partial versions (`1`, `1.2`),
+ * comparators (`>=1.2 <2`), hyphen ranges and `||`. Pre-release versions satisfy
+ * only a range that names the same pre-release base, as in npm.
+ */
+
+interface Version {
+ readonly major: number;
+ readonly minor: number;
+ readonly patch: number;
+ readonly pre: readonly string[];
+}
+
+const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
+
+export function parseVersion(text: string): Version | undefined {
+ const m = VERSION.exec(text.trim());
+ if (!m) {
+ return undefined;
+ }
+ return {
+ major: Number(m[1]),
+ minor: Number(m[2]),
+ patch: Number(m[3]),
+ pre: m[4] ? m[4].split('.') : [],
+ };
+}
+
+export function compareVersions(a: string, b: string): number {
+ const va = parseVersion(a);
+ const vb = parseVersion(b);
+ if (!va || !vb) {
+ return va ? 1 : vb ? -1 : a.localeCompare(b);
+ }
+ return compare(va, vb);
+}
+
+function compare(a: Version, b: Version): number {
+ if (a.major !== b.major) return a.major - b.major;
+ if (a.minor !== b.minor) return a.minor - b.minor;
+ if (a.patch !== b.patch) return a.patch - b.patch;
+ if (a.pre.length === 0 || b.pre.length === 0) {
+ return (a.pre.length === 0 ? 1 : 0) - (b.pre.length === 0 ? 1 : 0);
+ }
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
+ const x = a.pre[i];
+ const y = b.pre[i];
+ if (x === undefined) return -1;
+ if (y === undefined) return 1;
+ const nx = /^\d+$/.test(x) ? Number(x) : undefined;
+ const ny = /^\d+$/.test(y) ? Number(y) : undefined;
+ if (nx !== undefined && ny !== undefined) {
+ if (nx !== ny) return nx - ny;
+ } else if (nx !== undefined) {
+ return -1;
+ } else if (ny !== undefined) {
+ return 1;
+ } else if (x !== y) {
+ return x < y ? -1 : 1;
+ }
+ }
+ return 0;
+}
+
+/** The highest of `versions` that satisfies `range`, or `undefined`. */
+export function maxSatisfying(versions: Iterable, range: string): string | undefined {
+ let best: string | undefined;
+ for (const v of versions) {
+ if (satisfies(v, range) && (best === undefined || compareVersions(v, best) > 0)) {
+ best = v;
+ }
+ }
+ return best;
+}
+
+type Comparator = { readonly op: '<' | '<=' | '>' | '>=' | '='; readonly v: Version };
+
+export function satisfies(versionText: string, rangeText: string): boolean {
+ const version = parseVersion(versionText);
+ if (!version) {
+ return false;
+ }
+ const range = rangeText.trim();
+ if (range === '' || range === '*' || range === 'latest' || range === 'x') {
+ return version.pre.length === 0;
+ }
+ return range.split('||').some((alternative) => {
+ const comparators = parseAlternative(alternative.trim());
+ if (comparators === undefined) {
+ return false;
+ }
+ if (!comparators.every((c) => test(version, c))) {
+ return false;
+ }
+ // A pre-release only satisfies a range one of whose comparators names a
+ // pre-release on the same major.minor.patch.
+ return version.pre.length === 0 || comparators.some((c) => c.v.pre.length > 0
+ && c.v.major === version.major && c.v.minor === version.minor && c.v.patch === version.patch);
+ });
+}
+
+function test(v: Version, c: Comparator): boolean {
+ const d = compare(v, c.v);
+ switch (c.op) {
+ case '<': return d < 0;
+ case '<=': return d <= 0;
+ case '>': return d > 0;
+ case '>=': return d >= 0;
+ default: return d === 0;
+ }
+}
+
+/** A partial version: `1`, `1.2`, `1.2.3-pre`, with `x`/`*` for a missing part. */
+interface Partial {
+ readonly major?: number;
+ readonly minor?: number;
+ readonly patch?: number;
+ readonly pre: readonly string[];
+}
+
+function parsePartial(text: string): Partial | undefined {
+ const m = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
+ if (!m) {
+ return undefined;
+ }
+ const part = (s: string | undefined): number | undefined =>
+ s === undefined || /^[xX*]$/.test(s) ? undefined : Number(s);
+ const major = part(m[1]);
+ const minor = major === undefined ? undefined : part(m[2]);
+ const patch = minor === undefined ? undefined : part(m[3]);
+ return { major, minor, patch, pre: m[4] ? m[4].split('.') : [] };
+}
+
+const v = (major: number, minor: number, patch: number, pre: readonly string[] = []): Version =>
+ ({ major, minor, patch, pre });
+
+function parseAlternative(text: string): Comparator[] | undefined {
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text);
+ if (hyphen) {
+ const lo = parsePartial(hyphen[1]!);
+ const hi = parsePartial(hyphen[2]!);
+ if (!lo || !hi) return undefined;
+ const out: Comparator[] = [{ op: '>=', v: floor(lo) }];
+ if (hi.major === undefined) return out;
+ if (hi.minor === undefined) return [...out, { op: '<', v: v(hi.major + 1, 0, 0) }];
+ if (hi.patch === undefined) return [...out, { op: '<', v: v(hi.major, hi.minor + 1, 0) }];
+ return [...out, { op: '<=', v: v(hi.major, hi.minor, hi.patch, hi.pre) }];
+ }
+ const out: Comparator[] = [];
+ // `>= 1.2` is legal spelling; glue an operator to the version that follows it.
+ const tokens = text.replace(/(>=|<=|>|<|=)\s+/g, '$1').split(/\s+/).filter((t) => t !== '');
+ for (const token of tokens) {
+ const parsed = comparatorsOf(token);
+ if (parsed === undefined) return undefined;
+ out.push(...parsed);
+ }
+ return out;
+}
+
+function floor(p: Partial): Version {
+ return v(p.major ?? 0, p.minor ?? 0, p.patch ?? 0, p.pre);
+}
+
+function comparatorsOf(token: string): Comparator[] | undefined {
+ const m = /^(\^|~>?|>=|<=|>|<|=)?(.*)$/.exec(token);
+ if (!m) return undefined;
+ const op = m[1] ?? '';
+ const p = parsePartial(m[2]!);
+ if (!p) return undefined;
+ if (p.major === undefined) {
+ return op === '<' || op === '>' ? [{ op: '<', v: v(0, 0, 0) }] : [];
+ }
+ const lo = floor(p);
+ switch (op) {
+ case '^': {
+ if (p.major > 0 || p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }];
+ if (p.minor > 0 || p.patch === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(0, p.minor + 1, 0) }];
+ return [{ op: '>=', v: lo }, { op: '<', v: v(0, 0, p.patch + 1) }];
+ }
+ case '~':
+ case '~>': {
+ if (p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }];
+ return [{ op: '>=', v: lo }, { op: '<', v: v(p.major, p.minor + 1, 0) }];
+ }
+ case '>': {
+ if (p.minor === undefined) return [{ op: '>=', v: v(p.major + 1, 0, 0) }];
+ if (p.patch === undefined) return [{ op: '>=', v: v(p.major, p.minor + 1, 0) }];
+ return [{ op: '>', v: lo }];
+ }
+ case '>=': return [{ op: '>=', v: lo }];
+ case '<': return [{ op: '<', v: lo }];
+ case '<=': {
+ if (p.minor === undefined) return [{ op: '<', v: v(p.major + 1, 0, 0) }];
+ if (p.patch === undefined) return [{ op: '<', v: v(p.major, p.minor + 1, 0) }];
+ return [{ op: '<=', v: lo }];
+ }
+ default: {
+ // A bare partial is an x-range: `1` is >=1.0.0 <2.0.0, `1.2` is >=1.2.0 <1.3.0.
+ if (p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }];
+ if (p.patch === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major, p.minor + 1, 0) }];
+ return [{ op: '=', v: lo }];
+ }
+ }
+}
diff --git a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
index f4183b369..94096b266 100644
--- a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
+++ b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
@@ -47,7 +47,9 @@ import { isVueTemplateTag } from '@/utils/vue-sfc';
*
* A JSX element naming a component is a call (§4.15.1): a JSX_ELEMENT or
* JSX_SELF_CLOSING row with its tag as the METHOD_NAME child, and a
- * JSX_COMPONENT_CALL site. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
+ * JSX_COMPONENT_CALL site. An intrinsic element (``) is a
+ * JSX_INTRINSIC_ELEMENT row with no child and no call site; its tag (`div`)
+ * is the row's `potentialQualifiedName`. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
* attribute values and children are rooted as their own trees by the walker, and
* the gate asserts the emptiness so that switching them on shows up as a failure.
*/
@@ -174,6 +176,9 @@ export class TsExpressionExtractor {
isTypeOnlyReachable: false,
serviceVersionLinkHash: this.options.serviceVersionLinkHash,
});
+ if (kind === TsExpressionKind.JSX_INTRINSIC_ELEMENT) {
+ row.setJsxTagName(jsxTagText(item.node, this.sf));
+ }
this.expressions.push(row);
this.rowByNode.set(nodeId(item.node, this.sf), row);
this.emitted.push({ node: item.node, row });
@@ -453,10 +458,12 @@ export function expressionKindOf(node: ts.Node): TsExpressionKind | undefined {
case ts.SyntaxKind.JsxElement:
case ts.SyntaxKind.JsxSelfClosingElement: {
// `
` IS a call to Badge. `
` is an intrinsic element that no
- // project declaration can bind, so it produces no row -- a call site that
- // can only ever resolve into `JSX.IntrinsicElements` would be noise.
+ // project declaration can bind: a call site that can only ever resolve
+ // into `JSX.IntrinsicElements` would be noise, so it is its own kind,
+ // which is never a call (see `emit`) and carries no tag child. The row is
+ // what a plain tag's attributes -- `onClick={h}`, `ref={r}` -- belong to.
if (jsxTagReference(node) === undefined) {
- return undefined;
+ return TsExpressionKind.JSX_INTRINSIC_ELEMENT;
}
return ts.isJsxElement(node) ? TsExpressionKind.JSX_ELEMENT : TsExpressionKind.JSX_SELF_CLOSING;
}
@@ -909,6 +916,16 @@ export function jsxTagReference(node: ts.Node): ts.Expression | undefined {
return undefined;
}
+/** A JSX element's tag as written: `div`, `svg:circle`, `my-el`, `ui.Card`. */
+export function jsxTagText(node: ts.Node, sf: ts.SourceFile): string {
+ const tagName = ts.isJsxElement(node)
+ ? node.openingElement.tagName
+ : ts.isJsxSelfClosingElement(node)
+ ? node.tagName
+ : undefined;
+ return tagName === undefined ? '' : tagName.getText(sf);
+}
+
/** The receiver of a callee, or `undefined` for an unqualified call. */
export function receiverNodeOf(callee: ts.Node | undefined): ts.Node | undefined {
if (!callee) {
diff --git a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
index 1b82d1018..5fda834bf 100644
--- a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
+++ b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
@@ -2,7 +2,6 @@ import * as ts from 'typescript';
import {
ExpressionOwner,
- jsxTagReference,
TsExpressionExtractor,
} from '@/parsers/typescript/extractors/ts-expression-extractor';
import { TsExpressionOwnerKind, TsRootContext } from '@/enums/typescript/expressions';
@@ -513,11 +512,12 @@ export class TsExpressionWalker {
// attribute values, children, and spreads at once, wherever the JSX sits.
//
// A component ELEMENT is a call in its own right: `
` calls Badge.
- // One that is already a row -- `cond ?
:
`, `x &&
` -- was
- // reached by the worklist as an operand; any other is rooted here, which
- // is what reaches an element nested in another element's children.
+ // An intrinsic one (`
`) is a JSX_INTRINSIC_ELEMENT row and no call,
+ // the element its attributes belong to. One that is already a row --
+ // `cond ?
:
`, `x &&
` -- was reached by the worklist as an
+ // operand; any other is rooted here, which is what reaches an element
+ // nested in another element's children.
if ((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
- && jsxTagReference(child) !== undefined
&& !this.options.extractor.rowByNode.has(nodeId(child, this.sf))) {
this.root(child, TsRootContext.JSX_EMBEDDED_EXPRESSION);
return;
diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts
index d35d7582d..62845254f 100644
--- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts
+++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts
@@ -27,6 +27,7 @@ import { TsDecoratorSystem } from '@/enums/typescript/decorators';
import { TsExportedEntityKind } from '@/enums/typescript/exports';
import { TsParseGapKind } from '@/enums/typescript/parse-gaps';
import { TsModuleResolutionMode } from '@/enums/typescript/modules';
+import { resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver';
import { bindSourceFile, BinderResult, nodeId } from '@/parsers/typescript/extractors/ts-binder';
import { TsDeclarationExtractor } from
'@/parsers/typescript/extractors/ts-declaration-extractor';
@@ -175,13 +176,16 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF
// table. Without this, `Request` in `augmented-base.ts` and `Request` inside
// `declare module "./augmented-base"` are two symbols instead of one.
resolveModuleHash: (specifier) => {
- const resolved = ts.resolveModuleName(
+ // A Deno import map or workspace member first, exactly as the import rows resolve it.
+ const deno = resolveDenoSpecifier(specifier, options.absoluteFilePath);
+ const resolved = deno !== undefined ? undefined : ts.resolveModuleName(
specifier,
options.absoluteFilePath,
options.compilerOptions,
ts.sys
);
- const fileName = resolved.resolvedModule?.resolvedFileName
+ const fileName = (deno?.resolvedFileName || undefined)
+ ?? resolved?.resolvedModule?.resolvedFileName
?? resolveVueSpecifier(specifier, options.absoluteFilePath);
const absolute = fileName ? path.normalize(fileName) : '';
const moduleHash = options.projectModuleHashes.get(absolute);
diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts
index 5536a84fa..d78882095 100644
--- a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts
+++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts
@@ -4,6 +4,7 @@ import * as ts from 'typescript';
import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry';
import { TsImportKind, TsImportResolutionKind } from '@/enums/typescript/imports';
+import { denoPackageNameOf, DenoResolutionKind, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver';
import { nodeId } from '@/parsers/typescript/extractors/ts-binder';
import { resolveVueSpecifier, VUE_EXTENSION } from '@/utils/vue-sfc';
@@ -436,6 +437,30 @@ export class TsImportExtractor {
return result;
}
+ /**
+ * A Deno project's own resolution, tried before the compiler's: an import map,
+ * a workspace member, a `jsr:` / `npm:` package or a URL. `undefined` for a file
+ * outside any Deno project and for a specifier Deno resolves as Node would.
+ */
+ private resolveDeno(specifier: string): ResolvedSpecifier | undefined {
+ const deno = resolveDenoSpecifier(specifier, this.options.absoluteFilePath);
+ if (deno === undefined) {
+ return undefined;
+ }
+ if (deno.kind === 'BUILTIN') {
+ return { ...UNRESOLVED, kind: TsImportResolutionKind.BUILTIN_NODE };
+ }
+ const absolute = path.normalize(deno.resolvedFileName);
+ return {
+ absolutePath: absolute,
+ relativePath: this.options.toProjectRelative(absolute),
+ moduleHash: this.options.projectModuleHashes.get(absolute) ?? '',
+ kind: DENO_RESOLUTION_KINDS[deno.kind](deno.extension),
+ extension: deno.extension,
+ packageName: deno.packageName,
+ };
+ }
+
private doResolve(specifier: string): ResolvedSpecifier {
// A Node builtin, with or without the `node:` prefix. Both forms must be
// classified, and the unprefixed form is the common one: `import * as path
@@ -447,6 +472,10 @@ export class TsImportExtractor {
if (specifier.startsWith('node:') || NODE_BUILTIN_SPECIFIERS.has(specifier)) {
return { ...UNRESOLVED, kind: TsImportResolutionKind.BUILTIN_NODE };
}
+ const deno = this.resolveDeno(specifier);
+ if (deno !== undefined) {
+ return deno;
+ }
const resolved = ts.resolveModuleName(
specifier,
this.options.absoluteFilePath,
@@ -498,7 +527,10 @@ export class TsImportExtractor {
// import, and a consumer had to re-derive it from the specifier to answer
// "which package would close these hops". The name is pure syntax, so it
// is filled here whether resolution succeeded or not.
- return { ...UNRESOLVED, packageName: packageNameOf(specifier) };
+ return {
+ ...UNRESOLVED,
+ packageName: denoPackageNameOf(specifier, this.options.absoluteFilePath) ?? packageNameOf(specifier),
+ };
}
return {
absolutePath: absolute,
@@ -558,6 +590,19 @@ function isDeclarationExtension(extension: string): boolean {
|| extension === ts.Extension.Dcts;
}
+/** Each Deno answer as the `resolutionKind` the engine reads. */
+const DENO_RESOLUTION_KINDS: Record
TsImportResolutionKind> = {
+ IMPORT_MAP: () => TsImportResolutionKind.PATHS_ALIAS,
+ WORKSPACE_MEMBER: () => TsImportResolutionKind.WORKSPACE_PACKAGE,
+ JSR: () => TsImportResolutionKind.JSR_PACKAGE,
+ NPM_CACHE: () => TsImportResolutionKind.DENO_NPM_CACHE,
+ NODE_MODULES: (extension) => isDeclarationExtension(extension)
+ ? TsImportResolutionKind.NODE_MODULES_TYPES
+ : TsImportResolutionKind.NODE_MODULES_SOURCE,
+ REMOTE: () => TsImportResolutionKind.REMOTE_MODULE,
+ BUILTIN: () => TsImportResolutionKind.BUILTIN_NODE,
+};
+
interface ResolvedSpecifier {
readonly absolutePath: string;
readonly relativePath: string;
@@ -596,16 +641,25 @@ const NODE_BUILTIN_SPECIFIERS = new Set([
* Node builtins never reach this: they are classified BUILTIN_NODE before
* resolution is attempted, and they are not packages to stage.
*/
-function packageNameOf(specifier: string): string {
+function packageNameOf(specifierIn: string): string {
+ // `npm:zod@^3/v4` and `jsr:@std/path@1/join` name a package after the scheme,
+ // with a version range glued to it; any other scheme (`https:`, `data:`) names none.
+ const scheme = /^(?:npm|jsr):\/?/.exec(specifierIn);
+ if (scheme === null && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(specifierIn)) {
+ return '';
+ }
+ const specifier = scheme === null ? specifierIn : specifierIn.slice(scheme[0].length);
if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/')
|| specifier.includes('*')) {
return '';
}
const segments = specifier.split('/');
+ const unversioned = (segment: string | undefined): string =>
+ scheme === null || segment === undefined ? segment ?? '' : segment.replace(/(.)@.*$/, '$1');
if (specifier.startsWith('@')) {
- return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : '';
+ return segments.length >= 2 ? `${segments[0]}/${unversioned(segments[1])}` : '';
}
- return segments[0] ?? '';
+ return unversioned(segments[0]);
}
const UNRESOLVED: ResolvedSpecifier = {
diff --git a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts
index de5be2f9d..bf37ec53e 100644
--- a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts
+++ b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts
@@ -179,7 +179,15 @@ export function linkAmbientModuleImports(files: readonly ModuleGraphFacts[]): nu
* parser's own column (§4.13 c8), and this stops one hop short of the call
* graph — at the module, which is where `type-resolution.dl` takes over.
*/
-export function linkReExportSources(files: readonly ModuleGraphFacts[]): number {
+export function linkReExportSources(
+ files: readonly ModuleGraphFacts[],
+ /**
+ * The project-relative file a NON-relative specifier names from `fromFilePath`,
+ * for a resolution the specifier alone cannot give: a Deno import map or
+ * workspace member re-exported by a barrel that never imports it.
+ */
+ resolveBare?: (fromFilePath: string, specifier: string) => string | undefined
+): number {
const modulesByPath = new Map();
for (const facts of files) {
for (const module of facts.modules) {
@@ -206,9 +214,14 @@ export function linkReExportSources(files: readonly ModuleGraphFacts[]): number
continue;
}
const viaImport = resolvedBySpecifier.get(exportRow.sourceSpecifier);
+ const bare = viaImport === undefined && !exportRow.sourceSpecifier.startsWith('.')
+ ? resolveBare?.(facts.filePath, exportRow.sourceSpecifier)
+ : undefined;
const target = viaImport !== undefined
? modulesByPath.get(viaImport)
- : modulesByPath.get(resolveRelative(facts.filePath, exportRow.sourceSpecifier));
+ : bare !== undefined
+ ? modulesByPath.get(stripKnownExtension(bare))
+ : modulesByPath.get(resolveRelative(facts.filePath, exportRow.sourceSpecifier));
if (target !== undefined) {
exportRow.setResolvedSourceModuleLinkHash(target);
linked += 1;
diff --git a/parser/src/parsers/typescript/tsconfig-resolver.ts b/parser/src/parsers/typescript/tsconfig-resolver.ts
index f36937469..d84fa4479 100644
--- a/parser/src/parsers/typescript/tsconfig-resolver.ts
+++ b/parser/src/parsers/typescript/tsconfig-resolver.ts
@@ -4,6 +4,7 @@ import * as path from 'path';
import * as ts from 'typescript';
import { TsDecoratorSystem } from '@/enums/typescript/decorators';
+import { denoContextFor } from '@/parsers/typescript/deno/deno-config';
import { TsModuleResolutionMode } from '@/enums/typescript/modules';
/**
@@ -151,7 +152,7 @@ export class TsConfigResolver {
dir = parent;
}
- const result: GoverningTsConfig = found ?? {
+ const result: GoverningTsConfig = found ?? denoGoverned(normalised) ?? {
configPath: '',
options: DEFAULT_OPTIONS,
moduleResolutionMode: TsModuleResolutionMode.NODE10,
@@ -269,6 +270,42 @@ export class TsConfigResolver {
}
}
+/**
+ * A file no tsconfig claims but a `deno.json` governs compiles under Deno's
+ * defaults, not Node's: ES modules, explicit `.ts` extensions, strict mode, and
+ * whatever `compilerOptions` the config sets (`jsx`, `jsxImportSource`,
+ * `experimentalDecorators`, `strict`). `configPath` stays `""`, because no
+ * tsconfig governs the file and that fact stays visible in `ts_module`.
+ */
+function denoGoverned(file: string): GoverningTsConfig | undefined {
+ const context = denoContextFor(file);
+ if (context === undefined) {
+ return undefined;
+ }
+ const written = { ...context.root.compilerOptions, ...context.member.compilerOptions };
+ const converted = ts.convertCompilerOptionsFromJson(written, context.member.dir).options;
+ const options: ts.CompilerOptions = {
+ target: ts.ScriptTarget.ESNext,
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ allowImportingTsExtensions: true,
+ noEmit: true,
+ strict: true,
+ jsx: ts.JsxEmit.React,
+ ...converted,
+ };
+ return {
+ configPath: '',
+ options,
+ moduleResolutionMode: TsModuleResolutionMode.BUNDLER,
+ decoratorSystem: options.experimentalDecorators === true
+ ? TsDecoratorSystem.LEGACY_EXPERIMENTAL
+ : TsDecoratorSystem.STANDARD_TC39,
+ fileNames: new Set(),
+ references: [],
+ };
+}
+
/**
* `ts.ModuleResolutionKind` to the schema's token.
*
diff --git a/parser/src/schema/typescript/schema.json b/parser/src/schema/typescript/schema.json
index 590c68a26..b91264822 100644
--- a/parser/src/schema/typescript/schema.json
+++ b/parser/src/schema/typescript/schema.json
@@ -829,6 +829,7 @@
"FUNCTION_EXPRESSION",
"IDENTIFIER_REFERENCE",
"JSX_ELEMENT",
+ "JSX_INTRINSIC_ELEMENT",
"JSX_SELF_CLOSING",
"LITERAL",
"NEW_EXPRESSION",
diff --git a/parser/src/test/deno-tests.ts b/parser/src/test/deno-tests.ts
new file mode 100644
index 000000000..af46775dc
--- /dev/null
+++ b/parser/src/test/deno-tests.ts
@@ -0,0 +1,548 @@
+/**
+ * DENO TESTS: module resolution for TypeScript in a Deno project.
+ *
+ * npx tsx src/test/deno-tests.ts # everything
+ * npx tsx src/test/deno-tests.ts --list # what runs, and what it proves
+ *
+ * NO DENO, NO NETWORK. Each check builds a throwaway project, and a fake
+ * `$DENO_DIR` laid out exactly as Deno 2 writes one (remote modules under
+ * `remote/https//` with the `// denoCacheMetadata=` trailer,
+ * npm packages unpacked under `npm/registry.npmjs.org//`), then
+ * runs the real resolver or the real extraction over it.
+ *
+ * The expected answers are what `deno info --json` reported for the same layouts
+ * on Deno 2.9: an import-map key that maps a package also serves its subpaths, a
+ * member's map wins over the root's, the lockfile's pinned version wins over a
+ * newer cached one, and a workspace member is reached by its `name` through its
+ * `exports`.
+ */
+import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import { extractProject } from '@/extract';
+import { clearDenoCacheState } from '@/parsers/typescript/deno/deno-cache';
+import { clearDenoConfigCache } from '@/parsers/typescript/deno/deno-config';
+import { clearDenoResolutionCache, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver';
+import { compareVersions, maxSatisfying, satisfies } from '@/parsers/typescript/deno/deno-semver';
+import { ProjectLanguage } from '@/types/ProjectInfo';
+import { ProjectScanner } from '@/utils/project-scanner';
+
+interface Check {
+ name: string;
+ proves: string;
+ rulesOut: string;
+ run: (tmp: string) => Promise;
+}
+
+function write(root: string, files: Record): string {
+ for (const [relative, contents] of Object.entries(files)) {
+ const target = path.join(root, relative);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, contents);
+ }
+ return root;
+}
+
+/** A module in the fake `$DENO_DIR` remote cache, keyed as Deno keys it. */
+function cacheRemote(denoDir: string, url: string, body: string, headers: Record = {},
+ key?: string): void {
+ const u = new URL(url);
+ // `key`: the path Deno hashed, when it differs from this URL parser's spelling (`^` vs `%5E`).
+ const file = path.join(denoDir, 'remote', u.protocol.replace(':', ''), u.hostname,
+ crypto.createHash('sha256').update(key ?? u.pathname + u.search).digest('hex'));
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${body}\n// denoCacheMetadata=${JSON.stringify({ headers, url, time: 0 })}`);
+}
+
+/** A jsr package version in the fake cache: its `_meta.json` and every file given. */
+function cacheJsr(denoDir: string, name: string, version: string, exports: Record,
+ files: Record): void {
+ const manifest: Record = {};
+ for (const [file, body] of Object.entries(files)) {
+ cacheRemote(denoDir, `https://jsr.io/${name}/${version}/${file}`, body,
+ { 'content-type': 'text/typescript' });
+ manifest[`/${file}`] = { size: body.length, checksum: '' };
+ }
+ cacheRemote(denoDir, `https://jsr.io/${name}/${version}_meta.json`, JSON.stringify({ manifest, exports }));
+}
+
+function cacheJsrIndex(denoDir: string, name: string, versions: Record): void {
+ cacheRemote(denoDir, `https://jsr.io/${name}/meta.json`, JSON.stringify({ versions }));
+}
+
+/** Points the resolver at a fresh fake `$DENO_DIR` and mirror for one check. */
+function freshEnvironment(tmp: string, name: string): { denoDir: string; mirror: string } {
+ const denoDir = path.join(tmp, `${name}-deno-dir`);
+ const mirror = path.join(tmp, `${name}-mirror`);
+ fs.mkdirSync(denoDir, { recursive: true });
+ process.env.DENO_DIR = denoDir;
+ process.env.AXIOMCODE_DENO_MIRROR = mirror;
+ clearDenoConfigCache();
+ clearDenoCacheState();
+ clearDenoResolutionCache();
+ return { denoDir, mirror };
+}
+
+function relativeTo(root: string, file: string | undefined): string {
+ return file === undefined ? '