candor-ts 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,7 +12,7 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
14
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.16)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.17)."*
16
16
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
package/README.md CHANGED
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
184
184
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
185
185
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
186
186
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
187
- | `{ candor: { version, toolchain, spec: "0.16" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.17" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
188
188
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
189
189
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
190
190
 
@@ -202,7 +202,7 @@ read the Rust source".
202
202
 
203
203
  ## Status
204
204
 
205
- 0.16.x, speaking candor-spec 0.16: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.17.x, speaking candor-spec 0.17: the analysis core, the gate (`--policy` / `--gate-json` /
206
206
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
207
207
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
208
208
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.16.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.16)",
3
+ "version": "0.17.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.17)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query.mjs CHANGED
@@ -41,6 +41,96 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
41
41
  matches as coreMatches, gainsCoverage,
42
42
  loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
43
43
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
44
+ // The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
45
+ // with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
46
+ const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
47
+
48
+ // ---- #8 output mode: PROSE at a TTY, JSON when piped or `--json` — so interactive `candor where Db` reads
49
+ // like candor-java/-rust instead of dumping raw JSON, while a pipe/redirect (never a TTY) still yields the
50
+ // pinned JSON untouched. MCP/LSP call query-core directly (not this CLI), so they're unaffected; conformance
51
+ // passes `--json` or captures over a pipe → JSON. `--json` forces JSON; `--text`/`--human` forces prose. -----
52
+ const wantJsonOut = (a) =>
53
+ a.includes("--json") || (!a.includes("--text") && !a.includes("--human") && !process.stdout.isTTY);
54
+ // Emit the pinned JSON, or render prose via proseFn(data). Returns data so the caller can still exit on it.
55
+ const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data); else proseFn(data); return data; };
56
+ const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
57
+ const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
58
+ // Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
59
+ // terse and scannable, in candor's voice (cf. the existing `tour`/`path` human forms).
60
+ const P = {
61
+ where: (d) => {
62
+ const n = d.directly.length + d.inherited.length;
63
+ if (n === 0) { console.log(`candor: 0 functions perform ${d.effect} in this report.`); return; }
64
+ console.log(`candor where ${d.effect} — ${n} function${n === 1 ? "" : "s"}:`);
65
+ if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
66
+ if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
67
+ },
68
+ callers: (d) => {
69
+ if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
70
+ console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
71
+ console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
72
+ console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
73
+ },
74
+ show: (d) => {
75
+ if (!d.length) { console.log("candor: no effectful function matches that name (pure functions are omitted from the report)."); return; }
76
+ d.forEach((e, i) => {
77
+ if (i) console.log("");
78
+ console.log(`${e.fn}`);
79
+ console.log(` effects: ${csv(e.inferred)}${e.direct && e.direct.length ? ` (direct: ${e.direct.join(", ")})` : ""}`);
80
+ if (e.hosts?.length) console.log(` hosts: ${e.hosts.join(", ")}`);
81
+ if (e.cmds?.length) console.log(` cmds: ${e.cmds.join(", ")}`);
82
+ if (e.paths?.length) console.log(` paths: ${e.paths.join(", ")}`);
83
+ if (e.tables?.length) console.log(` tables: ${e.tables.join(", ")}`);
84
+ });
85
+ },
86
+ map: (d) => {
87
+ const mods = Object.entries(d);
88
+ if (!mods.length) { console.log("candor: no effectful modules in this report."); return; }
89
+ console.log("candor map — effects by module:");
90
+ for (const [m, v] of mods) console.log(` ${m} — ${csv(v.effects)} (${v.functions} fn${v.functions === 1 ? "" : "s"})`);
91
+ },
92
+ containment: (d) => {
93
+ if ("leaks" in d) { // ratchet (a baseline was given)
94
+ if (!d.leaks.length) console.log("candor containment — no boundary effect reached a new layer vs the baseline. ✓");
95
+ else { console.log(`candor containment — ${d.leaks.length} boundary effect(s) reached a NEW layer (leak):`); rows(d.leaks); }
96
+ if (d.cleanups && d.cleanups.length) { console.log(` no longer present (${d.cleanups.length}):`); rows(d.cleanups); }
97
+ return;
98
+ }
99
+ if (!d.contained.length && !Object.keys(d.ambient).length) { console.log("candor containment — no boundary effects in this report."); return; }
100
+ console.log("candor containment — how well each boundary effect stays in one layer:");
101
+ for (const c of d.contained)
102
+ console.log(` ${c.effect}: ${c.containmentPct}% in \`${c.owner}\` (spread across ${c.layers} layer${c.layers === 1 ? "" : "s"})`);
103
+ const amb = Object.entries(d.ambient);
104
+ if (amb.length) console.log(` ambient (reported, not scored): ${amb.map(([e, n]) => `${e}×${n}`).join(", ")}`);
105
+ },
106
+ reachable: (d) => {
107
+ const effs = Object.entries(d.effects);
108
+ console.log(`candor reachable — what the ${d.entryPoints} entry point${d.entryPoints === 1 ? "" : "s"} do at runtime:`);
109
+ if (!effs.length) { console.log(" no effect reaches an entry point."); return; }
110
+ for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
111
+ },
112
+ impact: (d) => {
113
+ console.log(`candor impact — the blast radius of \`${d.fn}\`:`);
114
+ console.log(` ${d.affectedCount} effectful function(s) transitively call it${d.affected.length ? ":" : "."}`);
115
+ if (d.affected.length) rows(d.affected);
116
+ if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
117
+ },
118
+ blindspots: (d) => {
119
+ if (!d.sources.length) { console.log(`candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`); return; }
120
+ console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
121
+ for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
122
+ },
123
+ gains: (d) => {
124
+ if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
125
+ console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
126
+ for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
127
+ },
128
+ diff: (d) => {
129
+ if (!d.changes.length) { console.log("candor diff — no effect changes vs the baseline. ✓"); return; }
130
+ console.log(`candor diff — ${d.changes.length} function(s) changed vs the baseline:`);
131
+ for (const c of d.changes) console.log(` \`${c.fn}\`${c.gained.length ? ` +${c.gained.join(",")}` : ""}${c.lost.length ? ` -${c.lost.join(",")}` : ""}`);
132
+ },
133
+ };
44
134
 
45
135
  // Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
46
136
  // Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
@@ -94,7 +184,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
94
184
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
95
185
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
96
186
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
97
- const SPEC_VERSION = "0.16";
187
+ const SPEC_VERSION = "0.17";
98
188
 
99
189
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
100
190
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -186,7 +276,8 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
186
276
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
187
277
  policyFile = rawArgs[++i]; continue;
188
278
  }
189
- if (a === "--json") { continue; } // JSON is candor-ts's only output; accept + ignore
279
+ if (a === "--json" || a === "--text" || a === "--human") { continue; } // output-mode flags (#8) — consumed by
280
+ // wantJsonOut(rawArgs), never a positional
190
281
  if (strict && a === "--strict") { wantStrict = true; continue; }
191
282
  if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
192
283
  positionals.push(a);
@@ -353,7 +444,8 @@ OPTIONS (uniform across every engine)
353
444
  --report <locator> use this report instead of discovering .candor/
354
445
  --policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
355
446
  unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
356
- --json machine-readable output
447
+ --json machine-readable JSON (the default when output is piped/redirected)
448
+ --text, --human human-readable prose (the default at a terminal)
357
449
  --include-unknown callers: also list the unresolved-dispatch frontier
358
450
  --strict unverified: exit 1 on an unverified hole (advisory otherwise)
359
451
  -V, --version print the installed version + upgrade line (offline)
@@ -399,7 +491,7 @@ switch (cmd) {
399
491
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
400
492
  // `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
401
493
  if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
402
- emit(coreShow(loadReportOrDie(prefix), q));
494
+ put(args, coreShow(loadReportOrDie(prefix), q), P.show);
403
495
  break;
404
496
  }
405
497
  case "where": {
@@ -410,7 +502,15 @@ switch (cmd) {
410
502
  // A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
411
503
  // never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
412
504
  if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
413
- emit(coreWhere(loadReportOrDie(prefix), eff));
505
+ // A typo'd / unknown effect NAME is a LOUD error (exit 2) — never a false-empty {directly:[],inherited:[]}
506
+ // at exit 0, which reads as an authoritative "nothing performs Net" when the user actually typed "Network"
507
+ // (corpus-audit #3). A KNOWN effect that is simply absent stays a valid 0-result; an unknown name that is
508
+ // PRESENT in the report (a spec extension effect) is allowed — so error only when the name is NEITHER.
509
+ const fnsW = loadReportOrDie(prefix);
510
+ if (!KNOWN_EFFECTS.includes(eff) && !new Set(fnsW.flatMap((e) => e.inferred || [])).has(eff)) {
511
+ console.error(`candor-ts-query where: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`); process.exit(2);
512
+ }
513
+ put(args, coreWhere(fnsW, eff), P.where);
414
514
  break;
415
515
  }
416
516
  case "callers": {
@@ -422,14 +522,20 @@ switch (cmd) {
422
522
  // {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
423
523
  if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
424
524
  const cg = loadCallgraph(prefix);
425
- if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
426
- else emit(coreCallers(cg, q));
525
+ const cres = includeUnknown ? callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(cg, q);
526
+ // A nonexistent function is a LOUD error (exit 2), like path/impact — never an empty {of:[],direct:[],
527
+ // transitive:[]} at exit 0, which reads as an authoritative "nothing calls it" for a fn that doesn't exist
528
+ // (corpus-audit #3). Gated on a NON-empty callgraph so a missing sidecar isn't misreported as "no such fn".
529
+ if (Object.keys(cg).length > 0 && cres.of.length === 0) {
530
+ console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
531
+ }
532
+ put(args, cres, P.callers);
427
533
  break;
428
534
  }
429
535
  case "map": {
430
536
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
431
537
  const { prefix } = resolveReportVerb(args, 0);
432
- emit(coreMap(loadReportOrDie(prefix)));
538
+ put(args, coreMap(loadReportOrDie(prefix)), P.map);
433
539
  break;
434
540
  }
435
541
  case "containment": {
@@ -459,10 +565,10 @@ switch (cmd) {
459
565
  process.exit(2);
460
566
  }
461
567
  const r = coreContainment(loadReportOrDie(prefix), baseFns);
462
- emit(r);
568
+ put(args, r, P.containment);
463
569
  process.exit(r.leaks.length ? 1 : 0);
464
570
  }
465
- emit(coreContainment(loadReportOrDie(prefix)));
571
+ put(args, coreContainment(loadReportOrDie(prefix)), P.containment);
466
572
  break;
467
573
  }
468
574
  case "diff": {
@@ -491,7 +597,7 @@ switch (cmd) {
491
597
  const versionMismatch = engineV && baseV && engineV !== baseV;
492
598
  if (versionMismatch)
493
599
  console.error(`candor-ts: ⚠ baseline @${baseV} ≠ engine @${engineV} — some changes may be the engine reclassifying, not your code. Treat an engine swap as baseline-invalidating: review, then regenerate the baseline.`);
494
- emit({ baseline_version: baseV ?? "", engine_version: engineV ?? "", changes });
600
+ put(args, { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes }, P.diff);
495
601
  // diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
496
602
  // same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
497
603
  // so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
@@ -507,9 +613,9 @@ switch (cmd) {
507
613
  const roots = fns.filter((e) => e.entryPoint);
508
614
  const byEff = {};
509
615
  for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
510
- emit({ entryPoints: roots.length,
616
+ put(args, { entryPoints: roots.length,
511
617
  effects: Object.fromEntries(Object.entries(byEff).sort()
512
- .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) });
618
+ .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable);
513
619
  break;
514
620
  }
515
621
  case "impact": {
@@ -519,14 +625,14 @@ switch (cmd) {
519
625
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
520
626
  // affectedCount:0 blast radius at exit 0 for a function that was never named.
521
627
  if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
522
- emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
628
+ put(args, coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q), P.impact);
523
629
  break;
524
630
  }
525
631
  case "blindspots": {
526
632
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
527
633
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
528
634
  const { prefix } = resolveReportVerb(args, 0);
529
- emit(coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)));
635
+ put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)), P.blindspots);
530
636
  break;
531
637
  }
532
638
  case "tour": {
@@ -630,9 +736,9 @@ switch (cmd) {
630
736
  // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
631
737
  // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
632
738
  // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
633
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
739
+ put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
634
740
  ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
635
- ...gainsCoverage(curPrefix, basePrefix) });
741
+ ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
636
742
  break;
637
743
  }
638
744
  case "path": {
package/scan.mjs CHANGED
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
41
41
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
42
42
  // Reused, never re-littered.
43
43
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
44
- const SPEC_VERSION = "0.16";
44
+ const SPEC_VERSION = "0.17";
45
45
 
46
46
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
47
47
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -1574,6 +1574,32 @@ const identIsEnvAlias = (id) => {
1574
1574
  // The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
1575
1575
  const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
1576
1576
 
1577
+ // A bare-identifier call whose callee is DEFAULT- or NAMED-imported from a known HTTP-client package is a
1578
+ // Net call (corpus-audit #13). The κ table lists these packages, but its rule only fires on a MEMBER call
1579
+ // (`axios.get(…)`); a default-imported callable invoked bare — the canonical `import fetch from 'node-fetch';
1580
+ // fetch(url)` — resolves to no signature when the package isn't installed, so it read Unknown (callback:fetch)
1581
+ // instead of Net, the effect users most care about. Resolve the identifier's symbol up to its
1582
+ // ImportDeclaration and match the specifier; used both to CLASSIFY the call Net and to SUPPRESS the spurious
1583
+ // callback-Unknown for the same node.
1584
+ const NET_REQUEST_NAMED = new Set(["fetch", "request", "stream", "pipeline"]); // undici/node-fetch callables
1585
+ const importedFromNetPkg = (id) => {
1586
+ if (!id || !ts.isIdentifier(id)) return false;
1587
+ for (const d of checker.getSymbolAtLocation(id)?.declarations ?? []) {
1588
+ let n = d;
1589
+ while (n && !ts.isImportDeclaration(n)) n = n.parent;
1590
+ if (!(n && ts.isImportDeclaration(n) && ts.isStringLiteralLike(n.moduleSpecifier)
1591
+ && /^(node-fetch|undici|axios|got|superagent|phin)$/.test(n.moduleSpecifier.text))) continue;
1592
+ // Only the package's CLIENT CALLABLE is Net: the DEFAULT import (`import fetch from 'node-fetch'`,
1593
+ // `import got from 'got'`) or a NAMED request function (`import { fetch, request } from 'undici'`). A
1594
+ // named CLASS/utility export — `Headers`, `Response`, `Request`, `CookieJar`, `FormData` — is NOT a
1595
+ // request and must not be over-reported as Net (review finding). Namespace imports resolve via κ member
1596
+ // calls elsewhere, not as a bare callable here.
1597
+ if (ts.isImportClause(d)) return true; // default import = the client
1598
+ if (ts.isImportSpecifier(d) && NET_REQUEST_NAMED.has((d.propertyName ?? d.name).text)) return true;
1599
+ }
1600
+ return false;
1601
+ };
1602
+
1577
1603
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
1578
1604
  function visitCalls(node) {
1579
1605
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -1630,6 +1656,10 @@ function visitCalls(node) {
1630
1656
  }
1631
1657
  if (kEff) {
1632
1658
  rec.direct.add(kEff); // κ-modeled package reached via an uninstalled namespace import
1659
+ } else if (ts.isCallExpression(node) && importedFromNetPkg(node.expression)) {
1660
+ rec.direct.add("Net"); // bare call to an HTTP-client default/named import whose pkg isn't installed
1661
+ // (so its signature didn't resolve) — Net, not Unknown (#13). Host capture
1662
+ // happens in the global/builtin arm below, which fires for the same node.
1633
1663
  } else {
1634
1664
  rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
1635
1665
  const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
@@ -2163,6 +2193,9 @@ function visitCalls(node) {
2163
2193
  };
2164
2194
  if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
2165
2195
  else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
2196
+ else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
2197
+ geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
2198
+
2166
2199
  else if (ts.isIdentifier(callee) && callee.text === "fetch"
2167
2200
  && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
2168
2201
  .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))