candor-ts 0.15.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.15)."*
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.15" }, 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.15.x, speaking candor-spec 0.15: 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.15.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.15)",
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.15";
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);
@@ -307,7 +398,7 @@ const SUBCOMMANDS = [
307
398
  const usage = () => {
308
399
  const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
309
400
  const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
310
- lines.push(` ${"-V, --version".padEnd(w)} print the build and spec version (offline)`);
401
+ lines.push(` ${"-V, --version".padEnd(w)} print the installed version + upgrade line (offline)`);
311
402
  lines.push(` ${"-h, --help".padEnd(w)} show this help`);
312
403
  return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
313
404
  };
@@ -321,12 +412,55 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
321
412
  }
322
413
 
323
414
  // -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
324
- // mistaken for a command). Banner + USAGE + the full described subcommand list + the github footer.
415
+ // mistaken for a command). House-style page: identity + model paragraph + COMMON/ALL ACTIONS
416
+ // (the action names derived from SUBCOMMANDS, so the list can never go stale) + OPTIONS + footer.
417
+ // The exit-2 error path keeps the denser fully-described usage() above.
325
418
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
326
- console.log(`candor-ts-query ${PKG_VERSION} — read-only queries over a candor report (candor-spec ${SPEC_VERSION})
419
+ const names = SUBCOMMANDS.map(([n]) => n);
420
+ const allActions = [names.slice(0, 9), names.slice(9)].map((row) => ` ${row.join(" ")}`).join("\n");
421
+ console.log(`candor-ts-query — read-only queries over a candor report.
422
+
423
+ Answers come from the report candor-ts wrote — discovered by walking up from the
424
+ cwd to a .candor/ dir (CANDOR_REPORT overrides; --report pins a locator). No
425
+ re-scan, no network. Every engine speaks the same grammar, so these actions and
426
+ flags match the rest of the family.
427
+
428
+ USAGE
429
+ candor-ts-query <action> [args] [options]
327
430
 
328
- ${usage()}
431
+ COMMON ACTIONS
432
+ where <Effect> the functions that perform an effect
433
+ path <fn> <Effect> the call path by which a function reaches an effect
434
+ callers <fn> who calls a function, direct and transitive
435
+ tour [N] the N most surprising transitive reaches (default 10)
436
+ blindspots the Unknown sources worth resolving, ranked by reach
437
+ gains <current> <base> what a new version newly reaches (the supply-chain diff)
438
+ fix <fn> <Effect> the boundary hoist that would clear a violation
329
439
 
440
+ ALL ACTIONS
441
+ ${allActions}
442
+
443
+ OPTIONS (uniform across every engine)
444
+ --report <locator> use this report instead of discovering .candor/
445
+ --policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
446
+ unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
447
+ --json machine-readable JSON (the default when output is piped/redirected)
448
+ --text, --human human-readable prose (the default at a terminal)
449
+ --include-unknown callers: also list the unresolved-dispatch frontier
450
+ --strict unverified: exit 1 on an unverified hole (advisory otherwise)
451
+ -V, --version print the installed version + upgrade line (offline)
452
+ -h, --help show this help
453
+
454
+ diff and gains take two positional report locators: <current> <baseline>. Run
455
+ candor-ts-query with no action for the full per-action argument list.
456
+
457
+ EXAMPLES
458
+ candor-ts-query where Db
459
+ candor-ts-query path app.orders.render Net
460
+ candor-ts-query gains new/.candor/report.json old/.candor/report.json
461
+ candor-ts-query fix-gate --policy candor.policy
462
+
463
+ Docs: candor.poly.io · Verify an install: candor doctor
330
464
  See https://github.com/tombaldwin/candor`);
331
465
  process.exit(0);
332
466
  }
@@ -354,7 +488,10 @@ switch (cmd) {
354
488
  // written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
355
489
  // the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
356
490
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
357
- emit(coreShow(loadReportOrDie(prefix), q));
491
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
492
+ // `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
493
+ if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
494
+ put(args, coreShow(loadReportOrDie(prefix), q), P.show);
358
495
  break;
359
496
  }
360
497
  case "where": {
@@ -362,7 +499,18 @@ switch (cmd) {
362
499
  // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
363
500
  // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
364
501
  const { prefix, args: [eff] } = resolveReportVerb(args, 1);
365
- emit(coreWhere(loadReportOrDie(prefix), eff));
502
+ // A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
503
+ // never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
504
+ if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
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);
366
514
  break;
367
515
  }
368
516
  case "callers": {
@@ -370,15 +518,24 @@ switch (cmd) {
370
518
  // it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
371
519
  // shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
372
520
  const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
521
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
522
+ // {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
523
+ if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
373
524
  const cg = loadCallgraph(prefix);
374
- if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
375
- 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);
376
533
  break;
377
534
  }
378
535
  case "map": {
379
536
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
380
537
  const { prefix } = resolveReportVerb(args, 0);
381
- emit(coreMap(loadReportOrDie(prefix)));
538
+ put(args, coreMap(loadReportOrDie(prefix)), P.map);
382
539
  break;
383
540
  }
384
541
  case "containment": {
@@ -408,10 +565,10 @@ switch (cmd) {
408
565
  process.exit(2);
409
566
  }
410
567
  const r = coreContainment(loadReportOrDie(prefix), baseFns);
411
- emit(r);
568
+ put(args, r, P.containment);
412
569
  process.exit(r.leaks.length ? 1 : 0);
413
570
  }
414
- emit(coreContainment(loadReportOrDie(prefix)));
571
+ put(args, coreContainment(loadReportOrDie(prefix)), P.containment);
415
572
  break;
416
573
  }
417
574
  case "diff": {
@@ -440,7 +597,7 @@ switch (cmd) {
440
597
  const versionMismatch = engineV && baseV && engineV !== baseV;
441
598
  if (versionMismatch)
442
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.`);
443
- emit({ baseline_version: baseV ?? "", engine_version: engineV ?? "", changes });
600
+ put(args, { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes }, P.diff);
444
601
  // diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
445
602
  // same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
446
603
  // so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
@@ -456,23 +613,26 @@ switch (cmd) {
456
613
  const roots = fns.filter((e) => e.entryPoint);
457
614
  const byEff = {};
458
615
  for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
459
- emit({ entryPoints: roots.length,
616
+ put(args, { entryPoints: roots.length,
460
617
  effects: Object.fromEntries(Object.entries(byEff).sort()
461
- .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) });
618
+ .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable);
462
619
  break;
463
620
  }
464
621
  case "impact": {
465
622
  // blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
466
623
  // MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
467
624
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
468
- emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
625
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
626
+ // affectedCount:0 blast radius at exit 0 for a function that was never named.
627
+ if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
628
+ put(args, coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q), P.impact);
469
629
  break;
470
630
  }
471
631
  case "blindspots": {
472
632
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
473
633
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
474
634
  const { prefix } = resolveReportVerb(args, 0);
475
- emit(coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)));
635
+ put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)), P.blindspots);
476
636
  break;
477
637
  }
478
638
  case "tour": {
@@ -576,9 +736,9 @@ switch (cmd) {
576
736
  // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
577
737
  // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
578
738
  // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
579
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
739
+ put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
580
740
  ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
581
- ...gainsCoverage(curPrefix, basePrefix) });
741
+ ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
582
742
  break;
583
743
  }
584
744
  case "path": {
@@ -587,6 +747,10 @@ switch (cmd) {
587
747
  // pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
588
748
  const wantJson = args.includes("--json");
589
749
  const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
750
+ // BOTH positionals are required (`path <fn> <Effect>`) — a missing/empty one is a LOUD usage error
751
+ // (exit 2, like candor-java). Before this gate, one arg slid through as `<fn> undefined` and printed
752
+ // "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
753
+ if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
590
754
  const fns = loadReportOrDie(prefix);
591
755
  const cg = loadCallgraph(prefix);
592
756
  if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
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.15";
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
@@ -55,25 +55,45 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
55
55
  // -h / --help: a print-and-exit MODE (like --version), handled before the arg walk so `-h` (a single
56
56
  // dash) is never mistaken for the scan target by the positional fallthrough below.
57
57
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
58
- console.log(`candor-ts ${PKG_VERSION} — TypeScript/JavaScript effect scanner (candor-spec ${SPEC_VERSION})
58
+ console.log(`candor-ts — the TypeScript/JavaScript effect analyzer.
59
59
 
60
- USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--agents] [--version]
60
+ Reads TS/JS source through the TypeScript compiler API — no build needed. Calls
61
+ are resolved through the checker; a call that cannot be resolved reads Unknown,
62
+ never silently pure. The report lands in .candor/, where candor-ts-query and the
63
+ umbrella \`candor\` CLI discover it.
61
64
 
62
- <target> a dir, a .ts file, or a tsconfig.json to scan
63
- --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
64
- --json print the report as JSON to stdout (instead of writing files)
65
- --policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
66
- violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
67
- --gate-json <f> write the structured gate verdict { spec, ok, violations } as JSON (candor-spec §3.3)
68
- --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
69
- --agents print the agent contract for this build (AGENTS.md)
70
- -V, --version print the build and spec version (offline)
71
- -h, --help show this help
65
+ USAGE
66
+ candor-ts <dir | file.ts | tsconfig.json> [flags]
72
67
 
73
- CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005 regression
74
- guard against a saved same-build report: exit 1 when an existing function gained an effect, exit 2
75
- on an unparseable or different-build baseline (never evaluated), a stderr note when absent.
68
+ The target is a project directory, a single .ts file, or a tsconfig.json.
76
69
 
70
+ OPTIONS
71
+ --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
72
+ --json print the report as JSON to stdout (instead of writing files)
73
+ --policy <file> enforce a policy file (deny/pure/allow/forbid) — exit 1 on a
74
+ violation, 2 if unreadable
75
+ --gate-json <file> write the structured gate verdict { spec, ok, violations } as JSON
76
+ --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
77
+ --agents print the agent contract for this build (AGENTS.md)
78
+ -V, --version print the installed version + upgrade line (offline)
79
+ -h, --help show this help
80
+
81
+ ENVIRONMENT / CONFIG
82
+ CANDOR_POLICY=<file> the policy when --policy is absent (a .candor/config
83
+ \`policy\` key works too)
84
+ CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005
85
+ regression guard against a saved same-build report: exit 1
86
+ when an existing function gained an effect, exit 2 on an
87
+ unparseable or different-build baseline (never evaluated),
88
+ a stderr note when absent
89
+
90
+ EXAMPLES
91
+ candor-ts .
92
+ candor-ts src --allow-js
93
+ candor-ts . --policy candor.policy --gate-json gate.json
94
+ candor-ts-query where Db query the report this scan wrote
95
+
96
+ Docs: candor.poly.io · Verify an install: candor doctor
77
97
  See https://github.com/tombaldwin/candor`);
78
98
  process.exit(0);
79
99
  }
@@ -1554,6 +1574,32 @@ const identIsEnvAlias = (id) => {
1554
1574
  // The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
1555
1575
  const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
1556
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
+
1557
1603
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
1558
1604
  function visitCalls(node) {
1559
1605
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -1610,6 +1656,10 @@ function visitCalls(node) {
1610
1656
  }
1611
1657
  if (kEff) {
1612
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.
1613
1663
  } else {
1614
1664
  rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
1615
1665
  const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
@@ -2143,6 +2193,9 @@ function visitCalls(node) {
2143
2193
  };
2144
2194
  if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
2145
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
+
2146
2199
  else if (ts.isIdentifier(callee) && callee.text === "fetch"
2147
2200
  && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
2148
2201
  .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
@@ -2616,6 +2669,20 @@ let gateViolations = [];
2616
2669
  // · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
2617
2670
  // violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
2618
2671
  // such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
2672
+ //
2673
+ // ⟨0.16⟩ Callgraph-aware existence (SPEC §7 item 5). Reports OMIT pure functions, so a fn that
2674
+ // shipped PURE and now performs an effect is absent from the baseline report and reads as exempt "new
2675
+ // code" — the sharpest supply-chain shape escaping the guard. Fix: key existence on the baseline
2676
+ // CALLGRAPH sidecar (<baseline>.callgraph.json, §2.2 — it lists every project fn INCLUDING pure
2677
+ // leaves), exactly as `gains`'s `origin` existence test does (query-core.mjs `gains`: a fn is
2678
+ // "existing" if it is a baseline-callgraph node — a caller key or a callee):
2679
+ // · sidecar PRESENT + loaded → a fn that is a baseline-callgraph node has baseline effect set ∅
2680
+ // (pure → omitted from the report) and any effect now is a GAIN violation. pure→effectful is caught.
2681
+ // A fn in NEITHER report nor callgraph genuinely did not exist → stays exempt "new".
2682
+ // · sidecar ABSENT → degrade to report-only existence (pre-⟨0.16⟩: a formerly-pure fn reads as new;
2683
+ // still catches an already-effectful fn WIDENING). One stderr note that the guard is weaker.
2684
+ // · sidecar PRESENT-but-CORRUPT → fail closed (exit 2), like a corrupt baseline: a broken sidecar
2685
+ // must not silently NARROW the guard back to report-only.
2619
2686
  if (baselinePath !== null) {
2620
2687
  const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
2621
2688
  if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
@@ -2647,14 +2714,66 @@ if (baselinePath !== null) {
2647
2714
  for (const e of arr) {
2648
2715
  if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
2649
2716
  }
2717
+ // ⟨0.16⟩ Load the baseline callgraph sidecar next to the baseline report. The sidecar for a
2718
+ // report at <stem>.json is <stem>.callgraph.json (scan.mjs writes exactly this pair). Three states:
2719
+ // loaded — a parsed object → its node set (every key + every callee) keys existence, mirroring
2720
+ // the `gains` origin test (query-core.mjs). A baseline-callgraph node whose baseline
2721
+ // effects are ∅ (pure → omitted from the report) that now performs an effect is a GAIN.
2722
+ // absent — no sidecar file → degrade to report-only existence + one stderr note (guard weaker).
2723
+ // corrupt — file present but not parseable / not a plain object → fail closed (exit 2). A broken
2724
+ // sidecar must not silently narrow the guard (SPEC §7 item 5).
2725
+ // shownB may be "(configured empty)"; the real path is baselinePath here (non-null, exists).
2726
+ const sidecarPath = baselinePath.replace(/\.json$/i, "") + ".callgraph.json";
2727
+ let cgNodes = null; // null = sidecar absent (report-only degrade)
2728
+ if (fs.existsSync(sidecarPath)) {
2729
+ let baseCg = null;
2730
+ try { baseCg = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); } catch { baseCg = undefined; }
2731
+ // A non-object parse (null / array / number) is a corrupt sidecar: it cannot list nodes, and
2732
+ // treating it as "absent" would silently narrow the guard — fail closed like a corrupt baseline.
2733
+ if (baseCg === undefined || baseCg === null || typeof baseCg !== "object" || Array.isArray(baseCg)) {
2734
+ console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
2735
+ + `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
2736
+ + `report-only. Regenerate the baseline with this build.`);
2737
+ process.exit(2);
2738
+ }
2739
+ // The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
2740
+ // as `gains` computes cgNodes. Non-array edge values are tolerated (skipped), matching loadCallgraph.
2741
+ cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...(Array.isArray(vs) ? vs : [])]));
2742
+ } else {
2743
+ console.error(`candor-ts: no baseline callgraph sidecar at ${sidecarPath} — the AS-EFF-005 guard is `
2744
+ + `WEAKER: existence falls back to the report, which omits pure functions, so a formerly-PURE fn `
2745
+ + `turning effectful reads as new code and is NOT caught (only an already-effectful fn widening is). `
2746
+ + `Regenerate the baseline with --out so the .callgraph.json is written alongside it.`);
2747
+ }
2748
+ const unknownOnly = []; // ⟨0.16⟩ advisory: fns that gained ONLY Unknown vs the baseline
2650
2749
  for (const name of [...inferred.keys()].sort()) {
2651
2750
  const prior = base.get(name);
2652
- if (prior === undefined) continue; // new function — not a regression
2653
- const gained = [...inferred.get(name)].filter((x) => !prior.has(x)).sort();
2654
- if (gained.length) {
2655
- gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: gained,
2656
- detail: `\`${name}\` gained effect { ${gained.join(", ")} } not present in the baseline` });
2657
- }
2751
+ // ⟨0.16⟩ Existence ladder: in the baseline REPORT → its recorded inferred set is the prior;
2752
+ // else a baseline-callgraph NODE (sidecar present) → it existed and was pure, so prior = ∅ (any
2753
+ // effect now is a gain); else genuinely absent → new code, exempt. Without the sidecar (cgNodes
2754
+ // null) only the report path decides, the pre-⟨0.16⟩ semantics.
2755
+ const priorSet = prior !== undefined ? prior
2756
+ : (cgNodes !== null && cgNodes.has(name)) ? new Set() // baseline-pure node → ∅ prior
2757
+ : null; // new function — not a regression
2758
+ if (priorSet === null) continue;
2759
+ const gained = [...inferred.get(name)].filter((x) => !priorSet.has(x)).sort();
2760
+ if (!gained.length) continue;
2761
+ // ⟨0.16⟩ the ratchet fires only on gaining a REAL boundary effect. An Unknown-ONLY gain is
2762
+ // the §4 trust marker, not an effect (`pure` policies exclude it), and on version bumps it is
2763
+ // dominated by resolution noise — DISCLOSE it (advisory), never fail the gate on it. Mirrors the
2764
+ // reference engine (candor-scan gate.rs check_baseline).
2765
+ const real = gained.filter((x) => x !== "Unknown");
2766
+ if (!real.length) { unknownOnly.push(name); continue; }
2767
+ gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: real,
2768
+ detail: `\`${name}\` gained effect { ${real.join(", ")} } not present in the baseline` });
2769
+ }
2770
+ if (unknownOnly.length) {
2771
+ unknownOnly.sort();
2772
+ const shown = unknownOnly.slice(0, 3).join(", ");
2773
+ const more = unknownOnly.length > 3 ? ` (+${unknownOnly.length - 3} more)` : "";
2774
+ console.error(`candor-ts: note — ${unknownOnly.length} function(s) gained an unresolved call `
2775
+ + `(Unknown) vs the baseline but no real effect — advisory, NOT a regression (Unknown is the §4 `
2776
+ + `trust marker, dominated by resolution noise on version bumps): ${shown}${more}`);
2658
2777
  }
2659
2778
  }
2660
2779
  }
@@ -2721,6 +2840,10 @@ if (gateJsonPath) {
2721
2840
  // gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
2722
2841
  if (gateViolations.length) {
2723
2842
  console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
2843
+ // FAILURE-only pointer at the engine's own remedy verb (append-only, same stream as the summary; a
2844
+ // zero-violation run is byte-identical — the exit code, violation lines and summary text are pinned
2845
+ // by the conformance suite and stay untouched).
2846
+ console.error("→ candor-ts-query fix-gate names the remedy for each");
2724
2847
  process.exit(1);
2725
2848
  }
2726
2849
  if (policyPath !== null) console.error("candor-ts: policy ✓");