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 +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/query.mjs +123 -17
- package/scan.mjs +34 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
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.
|
|
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; }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
+
put(args, r, P.containment);
|
|
463
569
|
process.exit(r.leaks.length ? 1 : 0);
|
|
464
570
|
}
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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))))
|