candor-ts 0.10.0 → 0.11.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.10)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.11)."*
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
  >
@@ -93,6 +93,7 @@ Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affe
93
93
  Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
94
94
  Q callers $P <fn-query> --include-unknown 1 # + possibleViaUnknownDispatch: the unresolved-dispatch frontier
95
95
  Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
96
+ Q tour [N] --report $P # the N (default 10) most surprising transitive reaches
96
97
  Q map $P 1 # {module: {effects, functions}}
97
98
  Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
98
99
  Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
@@ -142,9 +143,9 @@ want-JSON flag.
142
143
  "classifier" paragraph is the ONE current list (this file deliberately doesn't duplicate it — a
143
144
  vendored copy here drifted a full generation once).
144
145
  An unlisted package contributes nothing — an effect through it is invisible, not `Unknown`. The
145
- scanner **names these per scan**: the receipt's `κ doesn't know N packages…` line lists every npm
146
- package the code demonstrably calls that κ neither classifies nor has reviewed-pure — read it
147
- before concluding "no effect" through anything it names.
146
+ scanner **names these per scan**: the receipt's coverage-ledger line (marker: `classifier doesn't
147
+ cover`) lists every npm package the code demonstrably calls that candor's classifier neither
148
+ classifies nor has reviewed-pure — read it before concluding "no effect" through anything it names.
148
149
  - **`process.env.X` reads are `Env`** (a property read, not a call); `Date.now()` is `Clock`.
149
150
  - **DI-style code reads `Unknown` a lot, by design**: a function-typed parameter or field being
150
151
  called is genuinely indeterminate (rimraf's injected-fs style yields many `Unknown`s — that's the
@@ -180,12 +181,12 @@ present — a callback value, an `any`-typed callee, resolution landing on a typ
180
181
  body), the set may be incomplete: read the source for *that* function before relying on it. Never
181
182
  conclude a function is pure while it is marked unresolved. The literal surfaces (`hosts`/`tables`/
182
183
  `cmds`/`paths`) are the decidable subset only — absence is never a claim of absence. **And the
183
- curated-κ caveat cuts the other way:** a call into an npm package κ doesn't know contributes
184
- NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
185
- know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
184
+ curated-classifier caveat cuts the other way:** a call into an npm package the classifier doesn't
185
+ cover contributes NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name
186
+ (the coverage ledger, marker: `classifier doesn't cover`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
186
187
  "no effect" through a package that line names (the documented weaker edge of the
187
188
  never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
188
- carries an `invisible` list — the κ-unknown packages it (transitively) reaches — so `inferred` is
189
+ carries an `invisible` list — the uncovered packages it (transitively) reaches — so `inferred` is
189
190
  never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
190
191
  far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
191
192
  dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
package/README.md CHANGED
@@ -88,9 +88,10 @@ posthog-node, bull/bullmq), the database drivers (pg/mysql2/mongodb/redis/ioredi
88
88
  better-sqlite3/knex) **and the ORM tier** (TypeORM — with `@Entity("…")` table extraction —
89
89
  Prisma, Mongoose, Sequelize, drizzle-orm), plus execa/cross-spawn/shelljs/open, fs-extra/
90
90
  graceful-fs/rimraf/glob/chokidar, dotenv, winston/pino/bunyan. An unlisted package contributes
91
- nothing — candor never guesses an effect — but the scan **names it**: the receipt's `κ doesn't
92
- know N packages…` line lists every package the code demonstrably calls that κ neither classifies
93
- nor has reviewed-pure, and each function carries the `invisible` list it (transitively) reaches.
91
+ nothing — candor never guesses an effect — but the scan **names it**: the receipt's coverage-ledger
92
+ line (marker: `classifier doesn't cover`) lists every package the code demonstrably calls that
93
+ candor's classifier neither classifies nor has reviewed-pure, and each function carries the
94
+ `invisible` list it (transitively) reaches.
94
95
 
95
96
  ## MCP server — candor as agent ground truth
96
97
 
@@ -155,7 +156,7 @@ field being called, an `any`-typed callee, resolution landing on a type rather t
155
156
  An **uncurated dependency** can opt out of `Unknown`/silent-pure by **declaring its effects** in its
156
157
  `package.json` — `"candorEffects": ["Net"]` (spec §5.1, the effect manifest). candor-ts reads it as
157
158
  the declared-not-verified tier: the package's calls classify to the declared set, and it stops being
158
- a κ-ledger blind spot. A name outside the §1 vocabulary voids the declaration loudly (a typo must not
159
+ a coverage-ledger blind spot. A name outside the §1 vocabulary voids the declaration loudly (a typo must not
159
160
  silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
160
161
  delta — the effects a surface *gained* between two reports.
161
162
  Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
@@ -176,14 +177,14 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
176
177
  | Piece | Spec source |
177
178
  |---|---|
178
179
  | Resolve every call via the compiler API (`getResolvedSignature`), never syntax | CLASSIFIER §1 |
179
- | κ classifies the resolved target's module (`node:fs`→Fs, `node:net`→Net, …) | CLASSIFIER §2, TS notes |
180
+ | The classifier maps the resolved target's module (`node:fs`→Fs, `node:net`→Net, …) | CLASSIFIER §2, TS notes |
180
181
  | `process.env` property read → Env; `Date.now` → Clock | SPEC §1 |
181
182
  | Local edges (cross-file) + least-fixpoint propagation | SEMANTICS §5a |
182
183
  | Closure bodies attribute to the nearest enclosing function | SEMANTICS §2 |
183
184
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
184
- | Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
185
+ | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
185
186
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
186
- | `{ candor: { version, toolchain, spec: "0.10" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.11" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
188
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
188
189
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
189
190
 
@@ -201,7 +202,7 @@ read the Rust source".
201
202
 
202
203
  ## Status
203
204
 
204
- 0.10.x, speaking candor-spec 0.10: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.11.x, speaking candor-spec 0.11: the analysis core, the gate (`--policy` / `--gate-json` /
205
206
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
206
207
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
207
208
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
@@ -228,5 +229,5 @@ node scan.mjs <dir | file.ts | tsconfig.json> --out .candor/report # scan a pr
228
229
  ```
229
230
 
230
231
  The pure cores are factored into importable modules — `query-core.mjs` (the §3.1 queries),
231
- `policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the κ classifier + the SQL/
232
+ `policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the classifier + the SQL/
232
233
  command/host extractors) — so they're unit-tested directly; the TS-compiler-driven walk stays in `scan.mjs`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.10.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.10)",
3
+ "version": "0.11.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.11)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -52,6 +52,7 @@
52
52
  "LICENSE-APACHE",
53
53
  "query-core.mjs",
54
54
  "scan-core.mjs",
55
+ "surface.mjs",
55
56
  "mcp.mjs",
56
57
  "watch.mjs",
57
58
  "lsp.mjs"
package/query-core.mjs CHANGED
@@ -86,21 +86,89 @@ export function reportVersion(prefix) {
86
86
  return null;
87
87
  }
88
88
 
89
+ /** The report's §2 envelope `package` name — meaningful and locator-independent, so every engine and
90
+ * every --report form print the same crate in the `tour` header. null when absent/unreadable (the
91
+ * caller falls back to the prefix basename). Mirrors surface.rs/tour.rs::report_package. */
92
+ export function reportPackage(prefix) {
93
+ const files = fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport);
94
+ for (const f of files) {
95
+ try {
96
+ const doc = JSON.parse(fs.readFileSync(f, "utf8"));
97
+ const p = doc?.package;
98
+ if (typeof p === "string" && p) return p;
99
+ // The `packages` PLURAL envelope — the JVM shape (SPEC §2): one entry names it verbatim; several
100
+ // name their longest common dotted prefix (`com.a.x` + `com.a.y` → `com.a`); none shared → null.
101
+ if (Array.isArray(doc?.packages)) {
102
+ const label = packagesLabel(doc.packages.filter((x) => typeof x === "string" && x));
103
+ if (label) return label;
104
+ }
105
+ } catch { /* unreadable sibling — keep looking */ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // The longest common dot-separated prefix of a plural `packages` list — whole segments only (`com.ab` +
111
+ // `com.ac` share `com`, not `com.a`); null when nothing is shared. Mirrors Rust's packages_label (tour.rs).
112
+ function packagesLabel(pkgs) {
113
+ if (pkgs.length === 0) return null;
114
+ if (pkgs.length === 1) return pkgs[0];
115
+ const first = pkgs[0].split(".");
116
+ let n = first.length;
117
+ for (const p of pkgs.slice(1)) {
118
+ const segs = p.split(".");
119
+ let i = 0;
120
+ while (i < Math.min(n, segs.length) && segs[i] === first[i]) i++;
121
+ n = i;
122
+ if (n === 0) return null; // nothing shared — the basename fallback is more honest
123
+ }
124
+ return first.slice(0, n).join(".");
125
+ }
126
+
127
+ // The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
128
+ // yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
129
+ // doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
130
+ // tell "the report we found was corrupt" (never an all-clear) apart from a well-formed EMPTY report.
131
+ const tagHardFail = (fns, hardFail) => { Object.defineProperty(fns, "hardFail", { value: hardFail, enumerable: false }); return fns; };
132
+
133
+ // A well-formed report that legitimately lists ZERO functions — the ONLY empty result that is NOT a
134
+ // corruption (parity with the Rust engine, which returns Ok(empty) for a valid empty envelope). A §2
135
+ // envelope with `functions: []`, or a legacy bare `[]`. Anything else empty is malformed → hard fail.
136
+ const isCleanEmptyReport = (parsed) =>
137
+ (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.functions) && parsed.functions.length === 0)
138
+ || (Array.isArray(parsed) && parsed.length === 0);
139
+
140
+ // Load ONE report file → { entries, hardFail }. A read/parse throw, or an empty result over a doc that
141
+ // is NOT a clean-empty report, is a hard fail (the file was found but carries no trustworthy functions —
142
+ // letting it read as [] would be the §4 false all-clear). Discloses every failure mode on stderr.
143
+ function loadOneReport(file, label) {
144
+ let parsed;
145
+ try { parsed = JSON.parse(fs.readFileSync(file, "utf8")); }
146
+ catch { console.error(`candor-ts: report ${label} failed to parse — its functions are OMITTED from this query (corrupt or mid-write); re-run the scan`); return { entries: [], hardFail: true }; }
147
+ const entries = normFns(parsed, label);
148
+ // normFns already DISCLOSED any malformation (no functions array / dropped entries). If nothing usable
149
+ // survived AND the doc wasn't a clean-empty report, the report is corrupt — fail loud, never empty.
150
+ if (entries.length === 0 && !isCleanEmptyReport(parsed)) {
151
+ console.error(`candor-ts: report ${label} yielded no usable functions — OMITTED (malformed report); re-run the scan`);
152
+ return { entries, hardFail: true };
153
+ }
154
+ return { entries, hardFail: false };
155
+ }
156
+
89
157
  export function loadReport(prefix) {
90
158
  if (fs.existsSync(`${prefix}.json`)) {
91
- // The PRIMARY report parse must DISCLOSE-and-tolerate like the sibling path — a bare JSON.parse here
92
- // threw an uncaught stack trace on the CLI for a corrupt `<prefix>.json` (asymmetric with siblings).
93
- try { return normFns(JSON.parse(fs.readFileSync(`${prefix}.json`, "utf8")), `${prefix}.json`); }
94
- catch { console.error(`candor-ts: report ${prefix}.json failed to parse — OMITTED (corrupt or mid-write); re-run the scan`); return []; }
159
+ const { entries, hardFail } = loadOneReport(`${prefix}.json`, `${prefix}.json`);
160
+ return tagHardFail(entries, hardFail);
95
161
  }
96
162
  // No exact <prefix>.json — merge the multi-report siblings (the Rust/workspace form).
97
163
  const fns = [];
164
+ let hardFail = false;
98
165
  for (const f of siblings(prefix, isReport)) {
99
166
  // DISCLOSE a malformed sibling — never silently drop it (a vanished report reads as "no effect").
100
- try { fns.push(...normFns(JSON.parse(fs.readFileSync(f, "utf8")), f)); }
101
- catch { console.error(`candor-ts: report ${f} failed to parse — its functions are OMITTED from this query (corrupt or mid-write); re-run the scan`); }
167
+ const r = loadOneReport(f, f);
168
+ fns.push(...r.entries);
169
+ if (r.hardFail) hardFail = true;
102
170
  }
103
- return fns;
171
+ return tagHardFail(fns, hardFail);
104
172
  }
105
173
  export function loadCallgraph(prefix) {
106
174
  // A `null`/non-object parse (a `null` callgraph, an array, a number) must NOT reach Object.entries —
package/query.mjs CHANGED
@@ -26,6 +26,8 @@ import { fileURLToPath } from "node:url";
26
26
  import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
27
27
  import { hasReport } from "./query-core.mjs";
28
28
  import { printAgents } from "./contract.mjs";
29
+ import { bestFinds } from "./surface.mjs";
30
+ import { isTestPath } from "./scan-core.mjs";
29
31
  // ONE source of truth for loading + name-matching — query.mjs kept DRIFTED local copies that didn't
30
32
  // merge sibling reports, didn't tolerate a corrupt report (bare JSON.parse → uncaught crash), and used
31
33
  // a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
@@ -36,14 +38,59 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
36
38
  containment as coreContainment, diff as coreDiff,
37
39
  where as coreWhere, map as coreMap, whatif as coreWhatif,
38
40
  fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
39
- loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
41
+ matches as coreMatches,
42
+ loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
40
43
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
41
44
 
45
+ // Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
46
+ // Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
47
+ // UNTOUCHED (conformance PART 5 pins `{effect, fn, path:[{fn,loc,source}]}` four-way): this path is
48
+ // only taken when the caller did NOT pass --json, and it reads the SAME `path` array corePath computes.
49
+ // Prints to stdout and returns nothing (matches the JSON-only verbs' fire-and-forget style).
50
+ function renderPathHuman(fns, cg, fnQ, eff) {
51
+ // Resolve the start over the REPORT entries (as Rust does) — that's where `inferred` lives, and the
52
+ // no-effect wording quotes it. corePath resolves over the callgraph keys for the chain; the two agree
53
+ // on any fn that has an entry, which every graphed fn does.
54
+ const start = coreMatches(fns.map((e) => e.fn), fnQ)[0];
55
+ if (start === undefined) {
56
+ // No matching function at all — parity with Rust/Java's "no function matching" (stderr, exit 2).
57
+ console.error(`candor-query path: no function matching '${fnQ}'`);
58
+ process.exit(2);
59
+ }
60
+ const startEntry = fns.find((e) => e.fn === start);
61
+ const inferred = startEntry?.inferred ?? [];
62
+ if (!inferred.includes(eff)) {
63
+ // The effect is not even inferred — the honest "does not perform" answer (SPEC §3.1), NOT an error.
64
+ // `inferred` is printed in Rust's `{:?}` debug shape: each name quoted, ", "-joined, in `[...]`,
65
+ // in the report's original order (unsorted). An empty set prints `[]`.
66
+ const dbg = `[${inferred.map((e) => `"${e}"`).join(", ")}]`;
67
+ console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
68
+ return;
69
+ }
70
+ const r = corePath(fns, cg, fnQ, eff);
71
+ if (r.path.length === 0) {
72
+ // Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
73
+ console.log(`${start} performs ${eff} but its source is not a local function `
74
+ + `(cross-crate, or via Unknown) — not statically traceable.`);
75
+ return;
76
+ }
77
+ console.log(`candor path — how \`${start}\` comes to perform ${eff}:\n`);
78
+ r.path.forEach((step, i) => {
79
+ const indent = " ".repeat(i + 1);
80
+ const arrow = i === 0 ? "" : "→ ";
81
+ const isSource = i === r.path.length - 1;
82
+ const tag = isSource
83
+ ? ` [${eff} source${step.loc ? ` @ ${step.loc}` : ""}]`
84
+ : "";
85
+ console.log(`${indent}${arrow}${step.fn}${tag}`);
86
+ });
87
+ }
88
+
42
89
  // ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
43
90
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
44
91
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
45
92
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
46
- const SPEC_VERSION = "0.10";
93
+ const SPEC_VERSION = "0.11";
47
94
 
48
95
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
49
96
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -98,6 +145,21 @@ function requireReport(prefix) {
98
145
  return prefix;
99
146
  }
100
147
 
148
+ // Load a report, but FAIL LOUD (exit 2) when a file was found yet nothing parsed — the disclose-and-
149
+ // tolerate loadReport returns [] there, which every verb would read as "no effects": `tour` prints
150
+ // "nothing hidden", a policy `map`/gate PASSES — the §4 cardinal-sin false all-clear over a corrupt
151
+ // report. A legitimately effect-free crate still writes a report that LISTS its functions, so empty +
152
+ // hardFail is always the corrupt case (mirrors candor-rust load_entries_loud; java/swift already die
153
+ // loud). One corrupt file among several still merges (non-empty → returned), staying tolerant.
154
+ function loadReportOrDie(prefix) {
155
+ const fns = loadReport(prefix);
156
+ if (fns.length === 0 && fns.hardFail) {
157
+ console.error(`candor-ts: every report found at prefix '${prefix}' failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan.`);
158
+ process.exit(2);
159
+ }
160
+ return fns;
161
+ }
162
+
101
163
  // Parse the canonical flags out of a verb's args, leaving the POSITIONAL verb-args behind. Handles the
102
164
  // deprecated `0|1` trailing sentinel (→ noted, dropped; JSON is the default here anyway) so the old
103
165
  // grammar stays green. `flags` names the boolean flags this verb honours (`strict`/`includeUnknown`);
@@ -226,6 +288,7 @@ const SUBCOMMANDS = [
226
288
  ["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
227
289
  ["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
228
290
  ["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
291
+ ["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
229
292
  ["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
230
293
  ["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
231
294
  ["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
@@ -287,7 +350,7 @@ switch (cmd) {
287
350
  // written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
288
351
  // the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
289
352
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
290
- emit(coreShow(loadReport(prefix), q));
353
+ emit(coreShow(loadReportOrDie(prefix), q));
291
354
  break;
292
355
  }
293
356
  case "where": {
@@ -295,7 +358,7 @@ switch (cmd) {
295
358
  // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
296
359
  // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
297
360
  const { prefix, args: [eff] } = resolveReportVerb(args, 1);
298
- emit(coreWhere(loadReport(prefix), eff));
361
+ emit(coreWhere(loadReportOrDie(prefix), eff));
299
362
  break;
300
363
  }
301
364
  case "callers": {
@@ -304,14 +367,14 @@ switch (cmd) {
304
367
  // shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
305
368
  const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
306
369
  const cg = loadCallgraph(prefix);
307
- if (includeUnknown) emit(callersFrontier(cg, loadReport(prefix), loadHierarchy(prefix), q));
370
+ if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
308
371
  else emit(coreCallers(cg, q));
309
372
  break;
310
373
  }
311
374
  case "map": {
312
375
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
313
376
  const { prefix } = resolveReportVerb(args, 0);
314
- emit(coreMap(loadReport(prefix)));
377
+ emit(coreMap(loadReportOrDie(prefix)));
315
378
  break;
316
379
  }
317
380
  case "containment": {
@@ -335,16 +398,16 @@ switch (cmd) {
335
398
  }
336
399
  prefix = requireReport(prefix);
337
400
  if (basePrefix) {
338
- const baseFns = loadReport(basePrefix);
401
+ const baseFns = loadReportOrDie(basePrefix);
339
402
  if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
340
403
  console.error(`candor-ts: no report at baseline prefix '${basePrefix}' — check the path`);
341
404
  process.exit(2);
342
405
  }
343
- const r = coreContainment(loadReport(prefix), baseFns);
406
+ const r = coreContainment(loadReportOrDie(prefix), baseFns);
344
407
  emit(r);
345
408
  process.exit(r.leaks.length ? 1 : 0);
346
409
  }
347
- emit(coreContainment(loadReport(prefix)));
410
+ emit(coreContainment(loadReportOrDie(prefix)));
348
411
  break;
349
412
  }
350
413
  case "diff": {
@@ -359,7 +422,7 @@ switch (cmd) {
359
422
  // the only output). No leading-positional-report alias here: both positionals ARE the reports.
360
423
  const { positionals } = parseCanonical(args, {});
361
424
  const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
362
- const { changes } = coreDiff(loadReport(curPrefix), loadReport(basePrefix));
425
+ const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
363
426
  // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
364
427
  // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
365
428
  // provenance fields as the Rust candor-query (cross-engine parity, item 10).
@@ -379,7 +442,7 @@ switch (cmd) {
379
442
  // what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
380
443
  // shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
381
444
  const { prefix } = resolveReportVerb(args, 0);
382
- const fns = loadReport(prefix);
445
+ const fns = loadReportOrDie(prefix);
383
446
  const roots = fns.filter((e) => e.entryPoint);
384
447
  const byEff = {};
385
448
  for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
@@ -392,14 +455,90 @@ switch (cmd) {
392
455
  // blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
393
456
  // MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
394
457
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
395
- emit(coreImpact(loadReport(prefix), loadCallgraph(prefix), q));
458
+ emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
396
459
  break;
397
460
  }
398
461
  case "blindspots": {
399
462
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
400
463
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
401
464
  const { prefix } = resolveReportVerb(args, 0);
402
- emit(coreBlindspots(loadReport(prefix), loadCallgraph(prefix)));
465
+ emit(coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)));
466
+ break;
467
+ }
468
+ case "tour": {
469
+ // The ON-DEMAND, top-N cold-repo opener (SURFACE-BEST-FIND-DESIGN.md, P2): the N most SURPRISING
470
+ // transitive reaches in an existing report — NO re-scan. Delegates to the SHARED surface.mjs
471
+ // bestFinds (the same heuristic the scan-time note uses, so the ranking can't drift), reading the
472
+ // report + callgraph sidecar the scan already wrote. Port of candor-rust's candor-query tour verb —
473
+ // human + --json output byte-identical (a conformance PART pins it four-way).
474
+ // §3.3.1: `tour [<N>]`, report discovered / --report; the lone OPTIONAL positional is N (default 10).
475
+ // Unlike the JSON-only verbs, tour has BOTH a human default AND a --json form (like the Rust engine),
476
+ // so detect --json explicitly (parseCanonical otherwise silently swallows it).
477
+ const wantJson = args.includes("--json");
478
+ const { prefix, args: tourArgs } = resolveReportVerb(args, 1);
479
+ let n = 10;
480
+ if (tourArgs.length) {
481
+ // N MUST be a positive integer ≥ 1 that fits a safe integer — like the Rust engine, which rejects
482
+ // `tour 0` and a non-usize. `tour 0` printing "nothing hidden" over an effectful crate would be a
483
+ // false all-clear (the §4 cardinal sin), so a non-integer, zero, or out-of-range value → exit 2.
484
+ const parsed = /^\d+$/.test(tourArgs[0]) ? Number(tourArgs[0]) : NaN;
485
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
486
+ console.error("usage: candor-ts-query tour [<N>] [--report <locator>] [--json] (N is a positive integer ≥ 1)");
487
+ process.exit(2);
488
+ }
489
+ n = parsed;
490
+ }
491
+ const fns = loadReportOrDie(prefix);
492
+ const cg = loadCallgraph(prefix);
493
+ // Build the maps the heuristic wants from the report entries + the callgraph sidecar. `inferred`/
494
+ // `direct` come from the report; `loc` maps a function to its "file:line" for the source callout.
495
+ const inferred = new Map(), direct = new Map(), loc = new Map(), calls = new Map();
496
+ for (const e of fns) {
497
+ inferred.set(e.fn, new Set(e.inferred));
498
+ if (e.direct.length) direct.set(e.fn, new Set(e.direct));
499
+ if (e.loc) loc.set(e.fn, e.loc);
500
+ }
501
+ // `calls` prefers the FULL callgraph sidecar (every edge — the graph the scan held in memory). When
502
+ // the sidecar is absent/empty, FALL BACK to each entry's inline `.calls` (mirrors tour.rs:66-77:
503
+ // `if cg.is_empty() { use entry.calls } else { use cg }`). Without this fallback a report whose
504
+ // sidecar was deleted/never-written yields an empty graph, nearestSource finds nothing, and tour
505
+ // prints a FALSE "nothing hidden" at exit 0 — a silent under-report (the §4 cardinal sin). A corrupt
506
+ // sidecar is already disclosed on stderr by loadCallgraph, which then returns {} → we fall back here.
507
+ if (Object.keys(cg).length === 0) {
508
+ for (const e of fns) if (e.calls.length) calls.set(e.fn, e.calls);
509
+ } else {
510
+ for (const [k, v] of Object.entries(cg)) calls.set(k, v);
511
+ }
512
+ // Exclude test scaffolding — a qual is test code iff its recorded loc lies on a test path, the SAME
513
+ // isTestPath predicate the scan-note passes (scan.mjs's isTestQual). Without it `tour` surfaces test
514
+ // functions the scan-note (and every other engine) hides — an inconsistent, noisier reach list.
515
+ const isTestQual = (q) => { const l = loc.get(q); return l ? isTestPath(l) : false; };
516
+ const finds = bestFinds(inferred, direct, calls, loc, n, isTestQual);
517
+ // The header names the report's §2 envelope `package` — meaningful and locator-independent, so every
518
+ // engine and every --report form print the SAME crate. Falls back to the prefix basename.
519
+ const crateName = reportPackage(prefix) ?? path.basename(prefix);
520
+ if (wantJson) {
521
+ // Pure JSON to STDOUT: {"reaches":[{effect,fn,hops,loc,score,source}, …]} — ALPHABETICAL keys, the
522
+ // same order Rust+Swift emit (loc is the SOURCE's file:line, "" when absent).
523
+ const out = { reaches: finds.map((f) => ({
524
+ effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
525
+ })) };
526
+ console.log(JSON.stringify(out));
527
+ break;
528
+ }
529
+ if (finds.length === 0) {
530
+ // Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
531
+ // answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
532
+ console.log("candor: nothing hidden — every effect sits where its name says it should.");
533
+ break;
534
+ }
535
+ console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
536
+ finds.forEach((f, i) => {
537
+ const hopWord = f.hops === 1 ? "hop" : "hops";
538
+ const whereS = f.sourceLoc ? ` (${f.sourceLoc})` : "";
539
+ console.log(` ${i + 1}. \`${f.func}\` performs ${f.effect}, ${f.hops} ${hopWord} away via \`${f.source}\`${whereS}`);
540
+ console.log(` → candor path ${f.func} ${f.effect}`);
541
+ });
403
542
  break;
404
543
  }
405
544
  case "gains": {
@@ -412,12 +551,19 @@ switch (cmd) {
412
551
  const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
413
552
  if (gv && gbv && gv !== gbv)
414
553
  console.error(`candor-ts: ⚠ baseline @${gbv} ≠ engine @${gv} — a "gained capability" may be the engine reclassifying, not the dependency changing. Regenerate both reports with one build to compare releases.`);
415
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReport(curPrefix), loadReport(basePrefix)) });
554
+ emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix)) });
416
555
  break;
417
556
  }
418
557
  case "path": {
558
+ // BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
559
+ // `candor path <fn> <effect>`, so the DEFAULT is the readable indented chain; --json selects the
560
+ // pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
561
+ const wantJson = args.includes("--json");
419
562
  const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
420
- emit(corePath(loadReport(prefix), loadCallgraph(prefix), fn, eff));
563
+ const fns = loadReportOrDie(prefix);
564
+ const cg = loadCallgraph(prefix);
565
+ if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
566
+ else renderPathHuman(fns, cg, fn, eff);
421
567
  break;
422
568
  }
423
569
  case "whatif": {
@@ -466,7 +612,7 @@ switch (cmd) {
466
612
  // The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
467
613
  // it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
468
614
  if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
469
- const r = coreFix(cg, loadReport(prefix), target, eff, parsePolicy(ptext), scopeMatches);
615
+ const r = coreFix(cg, loadReportOrDie(prefix), target, eff, parsePolicy(ptext), scopeMatches);
470
616
  if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
471
617
  emit(r);
472
618
  break;
@@ -482,7 +628,7 @@ switch (cmd) {
482
628
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
483
629
  const cg = loadCallgraph(prefix);
484
630
  if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
485
- emit(coreFixGate(cg, loadReport(prefix), parsePolicy(ptext), scopeMatches));
631
+ emit(coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches));
486
632
  break;
487
633
  }
488
634
  case "unverified": {
@@ -495,7 +641,7 @@ switch (cmd) {
495
641
  let ptext;
496
642
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
497
643
  catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
498
- const r = coreUnverified(loadReport(prefix), parsePolicy(ptext), scopeMatches);
644
+ const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
499
645
  emit(r);
500
646
  process.exit(strict && !r.ok ? 1 : 0);
501
647
  break; // unreachable
package/scan.mjs CHANGED
@@ -30,6 +30,7 @@ import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
30
30
  import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
31
31
  import { printAgents } from "./contract.mjs";
32
32
  import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql } from "./scan-core.mjs";
33
+ import { emitSurface } from "./surface.mjs";
33
34
 
34
35
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
35
36
 
@@ -39,7 +40,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
39
40
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
40
41
  // Reused, never re-littered.
41
42
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
42
- const SPEC_VERSION = "0.10";
43
+ const SPEC_VERSION = "0.11";
43
44
 
44
45
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
45
46
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -1733,7 +1734,7 @@ function visitCalls(node) {
1733
1734
  }
1734
1735
  // unmatched external = (OPAQUE): contributes nothing — the curated-κ caveat C1. The
1735
1736
  // κ-coverage LEDGER makes the caveat per-scan evidence instead of a doc footnote: count
1736
- // every npm package the code demonstrably calls that κ doesn't know and no sibling
1737
+ // every npm package the code demonstrably calls that the classifier doesn't cover ("classifier doesn't cover" marker) and no sibling
1737
1738
  // report covers (the argon2 lesson — the blind spot landed on exactly the call a
1738
1739
  // security review cared about). Builtins are excluded: κ's builtin coverage is the
1739
1740
  // bounded frontier, and an unlisted builtin (path, util) is known-pure, not blind.
@@ -2111,6 +2112,11 @@ for (const [name, rec] of fns) {
2111
2112
  overdeclared: [],
2112
2113
  unresolved: inf.includes("Unknown"),
2113
2114
  };
2115
+ // Inline call edges (§2 `calls`) — the SAME edges the callgraph sidecar carries, embedded per entry so a
2116
+ // consumer without the sidecar (deleted, never-written, an old workspace) can still reconstruct the graph.
2117
+ // `tour` falls back to these when the sidecar is empty (surface robustness — mirrors the Rust report, whose
2118
+ // entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
2119
+ if (rec.edges.size) entry.calls = [...rec.edges].sort();
2114
2120
  if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
2115
2121
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
2116
2122
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
@@ -2187,8 +2193,29 @@ if (unlistedSeen.size > 0) {
2187
2193
  const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2188
2194
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
2189
2195
  const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
2190
- console.error(`candor-ts: κ doesn't know ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
2191
- + `effects through ${top.length === 1 ? "it are" : "them are"} INVISIBLE (not Unknown): ${shown}${more}`);
2196
+ console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
2197
+ + `their effects are INVISIBLE to the scan (absent from the report, NOT a claim they're pure): ${shown}${more}`);
2198
+ }
2199
+
2200
+ // ---- the cold-repo hook: surface the single most SURPRISING transitive reach (surface.mjs) ---------
2201
+ // One extra stderr line after the coverage ledger — the most benign-named function reaching a scary
2202
+ // effect a few hops away + a ready-to-run `candor path`. Deterministic; honest "nothing hidden"
2203
+ // fallback. Ported EXACTLY from candor-rust's surface.rs so every engine surfaces the SAME reach on a
2204
+ // shared fixture. Prefix is `candor:` (brand voice) and the command is `candor path …` — identical on
2205
+ // every engine. STDERR only, so the --json report on stdout stays clean.
2206
+ if (!wantJson) {
2207
+ const directMap = new Map();
2208
+ const callsMap = new Map();
2209
+ const locMap = new Map();
2210
+ for (const [name, rec] of fns) {
2211
+ directMap.set(name, rec.direct);
2212
+ callsMap.set(name, rec.edges);
2213
+ if (rec.loc) locMap.set(name, rec.loc);
2214
+ }
2215
+ // A qual is test code iff its recorded loc (file:line[:col]) lies on a test path — the same predicate
2216
+ // the scan already uses to keep test files out of the report.
2217
+ const isTestQual = (q) => { const l = locMap.get(q); return l ? isTestPath(l) : false; };
2218
+ emitSurface(inferred, directMap, callsMap, locMap, isTestQual);
2192
2219
  }
2193
2220
 
2194
2221
  // ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
package/surface.mjs ADDED
@@ -0,0 +1,233 @@
1
+ // Surface the single most SURPRISING transitive reach (the cold-repo hook).
2
+ //
3
+ // After the effect summary + coverage ledger, candor-ts emits ONE more stderr line: the most surprising
4
+ // transitive reach in the project + a ready-to-run `candor path` command. Port of candor-rust's
5
+ // crates/candor-scan/src/surface.rs — same behavior, idiomatic JS. See SURFACE-BEST-FIND-DESIGN.md.
6
+ //
7
+ // Fully deterministic — pure call-graph + name analysis, NO LLM. A CANDIDATE is a function `F` that
8
+ // INHERITS an effect `E` (E ∈ inferred[F] but E ∉ direct[F]); we BFS to the nearest local direct SOURCE
9
+ // `S` and score by how surprising the reach is (a benign-named function reaching a scary effect). The
10
+ // find is never *wrong*: `candor path` re-derives the chain and the gate is ground truth. When nothing
11
+ // clears the bar we emit an honest "nothing hidden" fallback — never a manufactured surprise.
12
+
13
+ // Name tokens that read as local / pure / config — a function whose leaf is named like this reaching a
14
+ // scary effect is the core surprise signal. Copied verbatim from surface.rs BENIGN.
15
+ const BENIGN = new Set([
16
+ "settings", "config", "conf", "options", "opts", "util", "utils", "helper", "helpers", "model",
17
+ "models", "dto", "entity", "format", "fmt", "parse", "get", "load", "new", "default", "validate",
18
+ "valid", "render", "view", "build", "builder", "item", "entry", "record", "state", "context",
19
+ "ctx", "info", "meta", "data", "value", "node", "field", "name", "key", "id", "path", "kind",
20
+ "type", "status", "check", "init", "setup",
21
+ ]);
22
+
23
+ // Name tokens that are effect-suggestive — a function in/near an effect-flavored context reaching that
24
+ // effect is EXPECTED, not surprising, so we EXCLUDE it. Copied verbatim from surface.rs EFFECTY.
25
+ const EFFECTY = new Set([
26
+ "fetch", "http", "https", "client", "api", "sync", "request", "req", "download", "upload", "query",
27
+ "sql", "store", "save", "persist", "connect", "conn", "socket", "send", "recv", "read", "write",
28
+ "open", "file", "fs", "io", "net", "tcp", "udp", "dns", "url", "host", "port", "cmd", "command",
29
+ "shell", "process", "proc", "exec", "spawn", "env", "clock", "time", "now", "rand", "random",
30
+ "log", "logger", "trace", "db",
31
+ ]);
32
+
33
+ // The qualified-name separator. Rust uses `::`; candor-ts quals are `mod.Class.member`.
34
+ const SEP = ".";
35
+
36
+ // Split a qualified name (or a leaf) into lowercase tokens on the separator, `_`, and camelCase
37
+ // boundaries. Mirrors surface.rs::tokenize (which splits on `_`, `:` and camelCase).
38
+ export function tokenize(name) {
39
+ const out = [];
40
+ let cur = "";
41
+ let prevLower = false;
42
+ for (const ch of name) {
43
+ if (ch === "_" || ch === "." || ch === ":") {
44
+ if (cur) { out.push(cur); cur = ""; }
45
+ prevLower = false;
46
+ continue;
47
+ }
48
+ // Unicode-aware uppercase (matches surface.rs's `ch.is_uppercase()`): a letter that differs from
49
+ // its lowercase form and equals its uppercase form. ASCII-only for the digit check (surface.rs uses
50
+ // `is_ascii_digit`), so a non-ASCII uppercase letter STILL starts a new token.
51
+ const lower = ch.toLowerCase();
52
+ const isUpper = ch !== lower && ch === ch.toUpperCase();
53
+ const isLower = ch !== ch.toUpperCase() && ch === lower;
54
+ // camelCase boundary: a lower/digit followed by an upper starts a new token.
55
+ if (isUpper && prevLower && cur) { out.push(cur); cur = ""; }
56
+ cur += lower;
57
+ prevLower = isLower || (ch >= "0" && ch <= "9");
58
+ }
59
+ if (cur) out.push(cur);
60
+ return out;
61
+ }
62
+
63
+ // The leaf (final segment) of a qualified name.
64
+ function leaf(qual) {
65
+ const i = qual.lastIndexOf(SEP);
66
+ return i < 0 ? qual : qual.slice(i + SEP.length);
67
+ }
68
+
69
+ // The module portion of a qualified name (everything before the leaf).
70
+ function moduleOf(qual) {
71
+ const i = qual.lastIndexOf(SEP);
72
+ return i < 0 ? "" : qual.slice(0, i);
73
+ }
74
+
75
+ // The first token of `name` that appears in `lexicon`, or null.
76
+ function hasToken(name, lexicon) {
77
+ for (const t of tokenize(name)) if (lexicon.has(t)) return t;
78
+ return null;
79
+ }
80
+
81
+ // Salience of an effect — the boundary/security-relevant effects a reviewer cares about score higher.
82
+ // Clock/Log/Rand are DELIBERATELY 0 (not surfaced): a mundane clock/log reach isn't "the most
83
+ // surprising reach", and a repo whose only reaches are mundane should honestly say "nothing hidden".
84
+ // Matches the Rust reference (candor-classify/src/surface.rs) + the java/swift ports.
85
+ function salience(effect) {
86
+ switch (effect) {
87
+ case "Net": case "Exec": case "Db": case "Ipc": return 5;
88
+ case "Fs": case "Env": return 3;
89
+ default: return 0; // Clock/Log/Rand/Unknown/everything-else — mundane, never surfaced
90
+ }
91
+ }
92
+
93
+ function hopsFactor(hops) {
94
+ if (hops === 1) return 2;
95
+ if (hops >= 2 && hops <= 4) return 3;
96
+ if (hops >= 5 && hops <= 6) return 2;
97
+ return 1; // ≥7 (hops is always ≥1 for an inherited reach)
98
+ }
99
+
100
+ // BFS from `func` over `calls` (follow callees, shortest hops) to the nearest function `S` with
101
+ // `effect` ∈ direct[S]. Returns { hops≥1, source } or null. Only traverses through callees that
102
+ // transitively carry the effect, so the frontier stays on-effect (matches `candor path`'s walk).
103
+ function nearestSource(func, effect, direct, inferred, calls) {
104
+ const seen = new Set([func]);
105
+ const q = [[func, 0]];
106
+ let head = 0;
107
+ while (head < q.length) {
108
+ const [cur, d] = q[head++];
109
+ // A direct source found at distance d≥1 is the nearest (BFS). The start `func` itself is an
110
+ // INHERITED reach (E ∉ direct[func]) so it never matches at d==0.
111
+ if (d >= 1 && direct.get(cur)?.has(effect)) return { hops: d, source: cur };
112
+ const cs = calls.get(cur);
113
+ if (cs) {
114
+ // Iterate callees in SORTED order — surface.rs/Java/Swift walk a BTreeSet<String> (sorted), so at
115
+ // an equal-distance tie the SAME source/score/`candor path` is chosen on every engine. Raw Map/JSON
116
+ // insertion order here would let a tie resolve differently (non-determinism vs the reference).
117
+ for (const c of [...cs].sort()) {
118
+ if (!seen.has(c) && inferred.get(c)?.has(effect)) {
119
+ seen.add(c);
120
+ q.push([c, d + 1]);
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // Collect EVERY scored candidate reach (unranked), plus whether the project is effectful at all. The
129
+ // single source of the candidate pool for both bestFind (top-1) and bestFinds (top-N) — one heuristic,
130
+ // no drift. `loc` is a Map<qual, "file:line"> for the source callout ("" when absent). Returns
131
+ // { cands: <Find[]>, anyEffectful }.
132
+ function collectCandidates(inferred, direct, calls, loc, isTest) {
133
+ // Any function carrying a real (non-Unknown) effect makes the project "effectful" — governs
134
+ // whether the caller emits the fallback vs nothing.
135
+ let anyEffectful = false;
136
+
137
+ // Deterministic iteration: sort quals ascending so the tie-break (qual ascending) is stable and
138
+ // Map insertion order never leaks into the result.
139
+ const quals = [...inferred.keys()].sort();
140
+
141
+ const cands = [];
142
+
143
+ for (const f of quals) {
144
+ const inf = inferred.get(f);
145
+ for (const e of inf) if (e !== "Unknown") { anyEffectful = true; break; }
146
+ if (isTest(f)) continue;
147
+ const fLeaf = leaf(f);
148
+ const fMod = moduleOf(f);
149
+ // EXCLUDE the whole function if its leaf OR module reads effecty — its reach is obvious.
150
+ if (hasToken(fLeaf, EFFECTY) || hasToken(fMod, EFFECTY)) continue;
151
+ const dir = direct.get(f) ?? new Set();
152
+ // Candidate effects: inherited (in inferred, not direct), not Unknown; sorted ascending.
153
+ const effects = [...inf].filter((e) => e !== "Unknown" && !dir.has(e)).sort();
154
+ for (const e of effects) {
155
+ const sal = salience(e);
156
+ if (sal === 0) continue;
157
+ const ns = nearestSource(f, e, direct, inferred, calls);
158
+ if (!ns) continue; // no LOCAL direct source — nothing to show
159
+ const benign = hasToken(fLeaf, BENIGN);
160
+ const benignity = benign ? 3 : 1;
161
+ const crossing = moduleOf(ns.source) !== fMod ? 2 : 1;
162
+ const score = sal * benignity * hopsFactor(ns.hops) * crossing;
163
+ if (score === 0) continue;
164
+ cands.push({
165
+ func: f, effect: e, hops: ns.hops, source: ns.source,
166
+ sourceLoc: loc?.get(ns.source) ?? "", benignToken: benign ?? "", score,
167
+ });
168
+ }
169
+ }
170
+ return { cands, anyEffectful };
171
+ }
172
+
173
+ // Compute the top-`n` most surprising reaches, most-surprising first. DEDUPED by function — each
174
+ // function appears at most once (its single highest-scoring reach). The list is empty when nothing
175
+ // clears the bar. Each Find carries { func, effect, hops, source, sourceLoc, benignToken, score }.
176
+ //
177
+ // Ranking (the tie-break, applied to the whole candidate pool before the per-function dedup + take):
178
+ // score DESC → hops ASC → qualified name ASC. With `n === 1` the result is BYTE-IDENTICAL to the old
179
+ // bestFind's winner — the shared candidate pool + this same tie-break, one implementation. Port of
180
+ // surface.rs::best_finds. `loc` is a Map<qual, "file:line"> for the source callout (optional).
181
+ export function bestFinds(inferred, direct, calls, loc, n, isTest = () => false) {
182
+ const { cands } = collectCandidates(inferred, direct, calls, loc, isTest);
183
+ // Rank the whole pool: score DESC, hops ASC, qual ASC. Quals were iterated ascending and effects
184
+ // ascending, so on a full tie the first-pushed (smallest qual) candidate sorts first — matching the
185
+ // old bestFind's "keep the earliest winner on an exact tie" (a stable sort preserves push order).
186
+ cands.sort((a, b) => (b.score - a.score) || (a.hops - b.hops) || (a.func < b.func ? -1 : a.func > b.func ? 1 : 0));
187
+ // DEDUP by function — each appears at most once (its highest-scoring reach, first in ranked order).
188
+ // Then take up to `n` distinct functions.
189
+ const seenFns = new Set();
190
+ const out = [];
191
+ for (const c of cands) {
192
+ if (out.length >= n) break;
193
+ if (!seenFns.has(c.func)) { seenFns.add(c.func); out.push(c); }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // Compute the single most surprising reach (the scan-time note).
199
+ // · returns null — ZERO effectful functions (caller emits nothing)
200
+ // · returns { winner: null } — effectful, but none cleared the bar (honest fallback)
201
+ // · returns { winner: <Find> } — the winning reach
202
+ //
203
+ // `inferred`/`direct` are Map<qual, Set<effect>>; `calls` is Map<qual, Iterable<qual>>; `isTest` is an
204
+ // optional (qual) => bool predicate (defaults to false — the caller supplies path-based test detection).
205
+ // ONE implementation with bestFinds — the winner is exactly bestFinds(…, 1)[0] (the scan-note output
206
+ // stays byte-identical, verified by the surface tests + conformance).
207
+ export function bestFind(inferred, direct, calls, isTest = () => false) {
208
+ const { anyEffectful } = collectCandidates(inferred, direct, calls, undefined, isTest);
209
+ if (!anyEffectful) return null;
210
+ const top = bestFinds(inferred, direct, calls, undefined, 1, isTest);
211
+ return { winner: top.length ? top[0] : null };
212
+ }
213
+
214
+ // Emit the surface note to STDERR. `loc` is a Map<qual, "file:line"> for the source callout; `log` is
215
+ // the sink (defaults to console.error). Mirrors surface.rs::emit exactly.
216
+ export function emitSurface(inferred, direct, calls, loc, isTest = () => false, log = console.error) {
217
+ const res = bestFind(inferred, direct, calls, isTest);
218
+ if (res === null) return; // zero effectful functions — emit nothing
219
+ if (res.winner === null) {
220
+ log("candor: nothing hidden — every effect sits where its name says it should.");
221
+ return;
222
+ }
223
+ const f = res.winner;
224
+ const whereS = loc.get(f.source) ?? "?";
225
+ const hopWord = f.hops === 1 ? "hop" : "hops";
226
+ const benignNote = f.benignToken
227
+ ? ` a "${f.benignToken}"-named function reaching ${f.effect}.\n`
228
+ : "";
229
+ log(
230
+ `candor: most surprising reach — \`${f.func}\` performs ${f.effect}, ${f.hops} ${hopWord} away via `
231
+ + `\`${f.source}\` (${whereS}).\n${benignNote} → candor path ${f.func} ${f.effect}`,
232
+ );
233
+ }