candor-ts 0.9.2 → 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.9)."*
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.9" }, 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.9.x, speaking candor-spec 0.9: 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.9.2",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.9)",
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
@@ -9,19 +9,25 @@
9
9
  * but its author had by then read the reference engines; the ongoing guarantee for it is the
10
10
  * conformance differential, not clean-room provenance.
11
11
  *
12
- * node query.mjs parsepolicy <file>
13
- * node query.mjs show <prefix> <query> <0|1>
14
- * node query.mjs where <prefix> <Effect> <0|1>
15
- * node query.mjs callers <prefix> <query> <0|1>
16
- * node query.mjs map <prefix> <0|1>
17
- * node query.mjs whatif <prefix> <fn> <Effect> [policy-file] [0|1]
12
+ * CANONICAL grammar (candor-spec §3.3.1 ⟨0.10⟩ — one shape, every engine):
13
+ * node query.mjs <verb> <verb-args…> [--report <locator>] [--policy <file>] [--json] [--strict] [--include-unknown]
14
+ * The report is DISCOVERED (walk up from CWD for a `.candor/` dir → `<that>/.candor/report`; CANDOR_REPORT
15
+ * overrides) unless --report gives a locator (a dir → `<dir>/.candor/report`; a `.json` path → that report
16
+ * path; else a prefix). diff/gains are the exception: two positional locators <current> <baseline>.
17
+ *
18
+ * DEPRECATED aliases (kept accepted through the 0.10 line, stderr-noted — candor-spec §3.3.1 / PART 17):
19
+ * node query.mjs <verb> <PREFIX> <verb-args…> [0|1] (leading-positional report + trailing 0|1 sentinel)
20
+ * node query.mjs whatif/fix <prefix> <fn> <Effect> [policy-file] [0|1] (positional policy)
18
21
  */
19
22
  import fs from "node:fs";
20
23
  import path from "node:path";
21
24
  import { fileURLToPath } from "node:url";
22
25
 
23
- import { parsePolicy, scopeMatches } from "./policy.mjs";
26
+ import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
27
+ import { hasReport } from "./query-core.mjs";
24
28
  import { printAgents } from "./contract.mjs";
29
+ import { bestFinds } from "./surface.mjs";
30
+ import { isTestPath } from "./scan-core.mjs";
25
31
  // ONE source of truth for loading + name-matching — query.mjs kept DRIFTED local copies that didn't
26
32
  // merge sibling reports, didn't tolerate a corrupt report (bare JSON.parse → uncaught crash), and used
27
33
  // a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
@@ -32,35 +38,263 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
32
38
  containment as coreContainment, diff as coreDiff,
33
39
  where as coreWhere, map as coreMap, whatif as coreWhatif,
34
40
  fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
35
- loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
41
+ matches as coreMatches,
42
+ loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
36
43
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
37
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
+
38
89
  // ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
39
90
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
40
91
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
41
92
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
42
- const SPEC_VERSION = "0.9";
93
+ const SPEC_VERSION = "0.11";
94
+
95
+ // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
96
+ // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
97
+ // [--strict] [--include-unknown]`. The report is DISCOVERED by default; --report overrides. The old
98
+ // leading-positional-report form, the trailing `0|1` JSON sentinel, and a positional policy stay
99
+ // accepted as DEPRECATED aliases (stderr-noted) so the conformance suite's old-grammar invocations
100
+ // (and every 0.9 caller) keep working — never removed before the next breaking bump.
101
+
102
+ // A one-line deprecation note to STDERR (stdout stays pure JSON — the machine consumer never sees it).
103
+ // De-duplicated so a single invocation prints each distinct note at most once.
104
+ const _deprecated = new Set();
105
+ const deprecate = (msg) => { if (!_deprecated.has(msg)) { _deprecated.add(msg); console.error(`candor-ts-query: [deprecated] ${msg}`); } };
106
+
107
+ // Resolve a --report <locator> by the ONE §3.3.1 rule: a directory → `<dir>/.candor/report`; a path
108
+ // ending `.json` → that full report path (minus the `.json`, since loadReport takes a prefix and adds
109
+ // it back); otherwise a bare prefix. Returns the PREFIX loadReport/loadCallgraph expect.
110
+ function locatorToPrefix(loc) {
111
+ try { if (fs.statSync(loc).isDirectory()) return path.join(loc, ".candor", "report"); } catch { /* not a dir */ }
112
+ if (loc.endsWith(".json")) return loc.slice(0, -".json".length); // full report path → its prefix
113
+ return loc; // bare prefix
114
+ }
115
+
116
+ // DISCOVER the report prefix when no --report: CANDOR_REPORT env wins; else walk UP from CWD for a
117
+ // `.candor/` directory and use its `report` prefix (the §3.4 discovery mechanism, the twin of scan.mjs's
118
+ // config walk-up). Returns null when NEITHER is found — the caller then fails LOUD (exit 2). It must NOT
119
+ // fall back to a bogus `.candor/report` prefix: that made the loaders read ZERO functions and every
120
+ // discovery verb emit an authoritative-empty answer at exit 0 — a false all-clear, the §4 cardinal sin
121
+ // (`where Net` in a dir with no `.candor/` up-tree). Matches the Rust engine's discover_report_prefix.
122
+ function discoverReportPrefix() {
123
+ const env = process.env.CANDOR_REPORT;
124
+ if (env) return locatorToPrefix(env);
125
+ for (let d = process.cwd(); ; d = path.dirname(d)) {
126
+ if (fs.existsSync(path.join(d, ".candor"))) return path.join(d, ".candor", "report");
127
+ if (path.dirname(d) === d) break; // filesystem root
128
+ }
129
+ return null; // no --report, no .candor/ discovered
130
+ }
131
+
132
+ // The report prefix for a discovery verb (no --report): the parsed/explicit locator, else discovery.
133
+ // A null prefix (no --report AND nothing discovered) is a LOUD exit-2 failure — never a silent empty
134
+ // answer. A resolved prefix that names NO report files is likewise loud (hasReport). One helper so every
135
+ // verb's no-report path is identical to the Rust engine's (report_or_discover + the no-files check).
136
+ function requireReport(prefix) {
137
+ if (prefix === null) {
138
+ console.error("candor-ts: no report found (no --report and no .candor/ discovered) — scan the crate first.");
139
+ process.exit(2);
140
+ }
141
+ if (!hasReport(prefix)) {
142
+ console.error(`candor-ts: no report files at prefix '${prefix}' — check the path, or scan the crate first.`);
143
+ process.exit(2);
144
+ }
145
+ return prefix;
146
+ }
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
+
163
+ // Parse the canonical flags out of a verb's args, leaving the POSITIONAL verb-args behind. Handles the
164
+ // deprecated `0|1` trailing sentinel (→ noted, dropped; JSON is the default here anyway) so the old
165
+ // grammar stays green. `flags` names the boolean flags this verb honours (`strict`/`includeUnknown`);
166
+ // `argc` is the verb's CANONICAL positional arity (report excluded) — the sentinel/leading-report peels
167
+ // are gated on the positional count EXCEEDING it, so a canonical arg (`show 1`, `callers 0`, `path fn 0`)
168
+ // is never eaten as a sentinel (matches the Rust grammar's Shape.verb_args arity gate).
169
+ // Returns { positionals, reportPrefix, reportExplicit, policyFile, strict, includeUnknown }.
170
+ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknown = false, argc = 0 } = {}) {
171
+ const positionals = [];
172
+ let reportLocator = null, policyFile = null, wantStrict = false, wantIncludeUnknown = false;
173
+ for (let i = 0; i < rawArgs.length; i++) {
174
+ const a = rawArgs[i];
175
+ if (a === "--report") {
176
+ // A `--report` with no following value is a LOUD usage error (exit 2), never a silent fall-back to
177
+ // discovery and never an uncaught `locatorToPrefix(undefined)` TypeError (`where Fs --report`).
178
+ if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
179
+ reportLocator = rawArgs[++i]; continue;
180
+ }
181
+ if (policy && a === "--policy") {
182
+ if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
183
+ policyFile = rawArgs[++i]; continue;
184
+ }
185
+ if (a === "--json") { continue; } // JSON is candor-ts's only output; accept + ignore
186
+ if (strict && a === "--strict") { wantStrict = true; continue; }
187
+ if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
188
+ positionals.push(a);
189
+ }
190
+ // Deprecated trailing `0|1` JSON sentinel (Rust/TS legacy): if the LAST positional is a bare 0 or 1,
191
+ // strip it (candor-ts emits JSON regardless) and note the deprecation. ARITY-GATED: only when the
192
+ // positional count EXCEEDS the verb's canonical arity — otherwise `show 1` / `callers 0` / `where 1` /
193
+ // `path fn 0` would have their genuine query token eaten and run with a missing arg (degenerate empty
194
+ // result, exit 0). Never strip a positional the canonical form needs (matches Rust's arity gate).
195
+ if (positionals.length > argc && /^[01]$/.test(positionals[positionals.length - 1])) {
196
+ deprecate("the trailing `0|1` JSON sentinel is deprecated — candor-ts emits JSON; use --json to select it explicitly");
197
+ positionals.pop();
198
+ }
199
+ const reportExplicit = reportLocator !== null;
200
+ const reportPrefix = reportExplicit ? locatorToPrefix(reportLocator) : discoverReportPrefix();
201
+ return { positionals, reportPrefix, reportExplicit, policyFile, strict: wantStrict, includeUnknown: wantIncludeUnknown };
202
+ }
203
+
204
+ // A verb that takes ONE report + `argc` verb-positionals (where <Effect>: 1; show/callers/impact <fn>:
205
+ // 1; path <fn> <Effect>: 2; map/reachable/blindspots: 0). Applies discovery + --report, then peels the
206
+ // DEPRECATED leading-positional report: if --report wasn't given AND the first positional resolves to a
207
+ // report AND there's one positional MORE than the verb needs, treat that first token as the report.
208
+ // Returns { prefix, args } — `args` is exactly the verb's own positionals.
209
+ function resolveReportVerb(rawArgs, argc, opts = {}) {
210
+ const p = parseCanonical(rawArgs, { ...opts, argc });
211
+ let { positionals, reportPrefix } = p;
212
+ if (!p.reportExplicit && positionals.length === argc + 1 && hasReport(locatorToPrefix(positionals[0]))) {
213
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>` (a dir, a .json path, or a prefix); the report is discovered from `.candor/` by default");
214
+ reportPrefix = locatorToPrefix(positionals[0]);
215
+ positionals = positionals.slice(1);
216
+ }
217
+ return { ...p, prefix: requireReport(reportPrefix), args: positionals };
218
+ }
219
+
220
+ // whatif/fix share a shape: one report + `<fn> <Effect>` + a policy. Canonical §3.3.1: `<fn> <Effect>
221
+ // [--policy <file>]`, report discovered/--report. DEPRECATED aliases (kept green for the old grammar):
222
+ // a leading-positional report AND a trailing positional policy — `<prefix> <fn> <Effect> [policy]`.
223
+ // Peels both (stderr-noted), then resolves the policy through resolvePolicy (flag > positional >
224
+ // CANDOR_POLICY > .candor/config). Returns { prefix, target, eff, policyFile }.
225
+ function resolveWhatifFix(rawArgs) {
226
+ const p = parseCanonical(rawArgs, { policy: true, argc: 2 });
227
+ let positionals = p.positionals, prefix = p.reportPrefix, positionalPolicy = null;
228
+ // A leading-positional report fires only when --report is absent, there are MORE than the 2 verb args,
229
+ // and the first token resolves to a report (else the extra positional is the deprecated policy).
230
+ if (!p.reportExplicit && positionals.length > 2 && hasReport(locatorToPrefix(positionals[0]))) {
231
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the report is discovered from `.candor/` by default");
232
+ prefix = locatorToPrefix(positionals[0]);
233
+ positionals = positionals.slice(1);
234
+ }
235
+ const [target, eff, posPolicy] = positionals; // a 3rd positional is the deprecated policy
236
+ if (posPolicy) positionalPolicy = posPolicy;
237
+ const { policyFile } = resolvePolicy(p.policyFile, positionalPolicy);
238
+ return { prefix: requireReport(prefix), target, eff, policyFile };
239
+ }
240
+
241
+ // fix-gate/unverified share a shape: one report + a policy + no verb-positionals (unverified also takes
242
+ // --strict). Canonical §3.3.1: `[--policy <file>] [--strict]`, report discovered/--report. DEPRECATED
243
+ // alias: a leading report + a positional policy — `<prefix> <policy-file> [--strict]`. Peels both
244
+ // (stderr-noted), then resolves the policy through resolvePolicy. Returns { prefix, policyFile, strict }.
245
+ function resolveGateVerb(rawArgs, { strict = false } = {}) {
246
+ const p = parseCanonical(rawArgs, { policy: true, strict, argc: 0 });
247
+ let positionals = p.positionals, prefix = p.reportPrefix, positionalPolicy = null;
248
+ if (!p.reportExplicit && positionals.length && hasReport(locatorToPrefix(positionals[0]))) {
249
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the report is discovered from `.candor/` by default");
250
+ prefix = locatorToPrefix(positionals[0]);
251
+ positionals = positionals.slice(1);
252
+ }
253
+ if (positionals[0]) positionalPolicy = positionals[0]; // the remaining positional is the deprecated policy
254
+ const { policyFile } = resolvePolicy(p.policyFile, positionalPolicy);
255
+ return { prefix: requireReport(prefix), policyFile, strict: p.strict };
256
+ }
257
+
258
+ // Resolve the policy for the gate verbs (whatif/fix/fix-gate/unverified): the --policy flag, else the
259
+ // deprecated positional policy, else CANDOR_POLICY, else the `.candor/config` `policy` key (§3.3/§3.4,
260
+ // the same precedence scan.mjs uses). Returns { policyFile, fromPositional } — policyFile null if none.
261
+ function resolvePolicy(policyFlag, positionalPolicy) {
262
+ if (policyFlag) return { policyFile: policyFlag, fromPositional: false };
263
+ if (positionalPolicy) {
264
+ deprecate("a positional policy file is deprecated — pass it as `--policy <file>` (or set CANDOR_POLICY / a .candor/config `policy` key)");
265
+ return { policyFile: positionalPolicy, fromPositional: true };
266
+ }
267
+ if (process.env.CANDOR_POLICY) return { policyFile: process.env.CANDOR_POLICY, fromPositional: false };
268
+ const disc = discoverConfigPolicy(process.cwd());
269
+ if (disc?.policyPath) return { policyFile: disc.policyPath, fromPositional: false };
270
+ return { policyFile: null, fromPositional: false };
271
+ }
43
272
 
44
273
  // The full subcommand catalogue — name + one-line description (derived from the per-subcommand
45
274
  // comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
46
275
  // usage, so the two can never drift back to a stale hand-list again.
276
+ // Grammar per candor-spec §3.3.1 ⟨0.10⟩: the report is a FLAG (--report), discovered from `.candor/`
277
+ // by default; verb args are positional; --json selects JSON; --policy supplies a policy. The old
278
+ // leading-positional/`0|1`/positional-policy forms stay accepted as deprecated aliases (see the parser).
279
+ const REPORT_TAIL = "[--report <locator>] [--json]";
47
280
  const SUBCOMMANDS = [
48
281
  ["parsepolicy", "<file>", "parse a policy file (candor-spec §6.2) and print it as JSON"],
49
- ["show", "<prefix> <query> [0|1]", "the effect record(s) for a function — direct, inferred, surfaces"],
50
- ["where", "<prefix> <Effect> [0|1]", "functions with an effect, split into directly / inherited"],
51
- ["callers", "<prefix> <query> [0|1]", "who reaches a function: {of, direct, transitive} (--include-unknown)"],
52
- ["map", "<prefix> [0|1]", "per-module effect rollup: {effects, functions} by module"],
53
- ["containment", "<prefix> [baseline-prefix]", "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
54
- ["diff", "<cur-prefix> <base-prefix>", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
55
- ["reachable", "<prefix>", "effects unioned over the entry points: what the app DOES at runtime"],
56
- ["impact", "<prefix> <query>", "blast radius of a function (backward dual of reachable)"],
57
- ["blindspots", "<prefix>", "the Unknown sources, ranked by blast radius"],
58
- ["gains", "<cur-prefix> <base-prefix>", "the supply-chain alarm: what the surface gained between two reports"],
59
- ["path", "<prefix> <fn> <Effect>", "a call path from a function to where an effect enters"],
60
- ["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
61
- ["fix", "<prefix> <fn> <Effect> <policy-file>", "the boundary fix: where the effect belongs + the hoist refactor"],
62
- ["fix-gate", "<prefix> <policy-file>", "a fix for EVERY boundary crossing — the loop's block-message remedy"],
63
- ["unverified", "<prefix> <policy-file> [--strict]", "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
282
+ ["show", `<query> ${REPORT_TAIL}`, "the effect record(s) for a function — direct, inferred, surfaces"],
283
+ ["where", `<Effect> ${REPORT_TAIL}`, "functions with an effect, split into directly / inherited"],
284
+ ["callers", `<query> [--include-unknown] ${REPORT_TAIL}`, "who reaches a function: {of, direct, transitive}"],
285
+ ["map", REPORT_TAIL, "per-module effect rollup: {effects, functions} by module"],
286
+ ["containment", `[<baseline>] ${REPORT_TAIL}`, "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
287
+ ["diff", "<current> <baseline> [--json]", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
288
+ ["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
289
+ ["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
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)"],
292
+ ["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
293
+ ["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
294
+ ["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
295
+ ["fix", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the boundary fix: where the effect belongs + the hoist refactor"],
296
+ ["fix-gate", `[--policy <file>] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — the loop's block-message remedy"],
297
+ ["unverified", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
64
298
  ["agents", "", "print the agent contract for this build (AGENTS.md)"],
65
299
  ];
66
300
 
@@ -115,50 +349,65 @@ switch (cmd) {
115
349
  // Was a hand-copy of query-core's show that had DRIFTED — it read the wrong Fs key (`e.fs`, never
116
350
  // written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
117
351
  // the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
118
- const [prefix, q] = args;
119
- emit(coreShow(loadReport(prefix), q));
352
+ const { prefix, args: [q] } = resolveReportVerb(args, 1);
353
+ emit(coreShow(loadReportOrDie(prefix), q));
120
354
  break;
121
355
  }
122
356
  case "where": {
123
357
  // Shared query-core (like show/callers) — the CLI and MCP `candor_where` are ONE implementation.
124
358
  // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
125
359
  // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
126
- const [prefix, eff] = args;
127
- emit(coreWhere(loadReport(prefix), eff));
360
+ const { prefix, args: [eff] } = resolveReportVerb(args, 1);
361
+ emit(coreWhere(loadReportOrDie(prefix), eff));
128
362
  break;
129
363
  }
130
364
  case "callers": {
131
365
  // --include-unknown ⟨0.7⟩ adds the unresolved-dispatch frontier (possibleViaUnknownDispatch); without
132
366
  // it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
133
367
  // shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
134
- const includeUnknown = args.includes("--include-unknown");
135
- const [prefix, q] = args.filter((a) => a !== "--include-unknown");
368
+ const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
136
369
  const cg = loadCallgraph(prefix);
137
- if (includeUnknown) emit(callersFrontier(cg, loadReport(prefix), loadHierarchy(prefix), q));
370
+ if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
138
371
  else emit(coreCallers(cg, q));
139
372
  break;
140
373
  }
141
374
  case "map": {
142
375
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
143
- const [prefix] = args;
144
- emit(coreMap(loadReport(prefix)));
376
+ const { prefix } = resolveReportVerb(args, 0);
377
+ emit(coreMap(loadReportOrDie(prefix)));
145
378
  break;
146
379
  }
147
380
  case "containment": {
148
- // SPEC §6.1 boundary-effect dispersion; with a baseline prefix it's the AS-EFF-010 ratchet (exit 1 on a
149
- // new leak), matching candor-java / candor-query. JSON-only, like every other candor-ts query command.
150
- const [prefix, basePrefix] = args;
381
+ // SPEC §6.1 boundary-effect dispersion; with a baseline it's the AS-EFF-010 ratchet (exit 1 on a new
382
+ // leak), matching candor-java / candor-query. JSON-only, like every other candor-ts query command.
383
+ // Canonical §3.3.1: `containment [<baseline>]` — the main report discovered / --report, the SINGLE
384
+ // canonical positional is the OPTIONAL baseline (verb_args: 1). A lone bare positional is therefore
385
+ // the BASELINE (the gating ratchet), NEVER re-read as the deprecated leading report — which silently
386
+ // dropped to non-gating report-mode (exit 0), the §4 cardinal-sin gate-off this fixes. The deprecated
387
+ // old form (`containment <report> <baseline>`) is ARITY-GATED: the leading-report peel fires only when
388
+ // the positionals EXCEED 1, so `containment P` stays the ratchet and `containment leaky P` still peels
389
+ // `leaky` as the report and leaves `P` the baseline (both old-grammar tests stay green). Matches Rust.
390
+ const p = parseCanonical(args, { argc: 1 });
391
+ let prefix = p.reportPrefix, basePrefix;
392
+ if (!p.reportExplicit && p.positionals.length > 1 && hasReport(locatorToPrefix(p.positionals[0]))) {
393
+ deprecate("a leading-positional report is deprecated — pass it as `--report <locator>`; the baseline stays positional (`containment [<baseline>]`)");
394
+ prefix = locatorToPrefix(p.positionals[0]);
395
+ basePrefix = p.positionals[1] ? locatorToPrefix(p.positionals[1]) : undefined;
396
+ } else {
397
+ basePrefix = p.positionals[0] ? locatorToPrefix(p.positionals[0]) : undefined;
398
+ }
399
+ prefix = requireReport(prefix);
151
400
  if (basePrefix) {
152
- const baseFns = loadReport(basePrefix);
401
+ const baseFns = loadReportOrDie(basePrefix);
153
402
  if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
154
403
  console.error(`candor-ts: no report at baseline prefix '${basePrefix}' — check the path`);
155
404
  process.exit(2);
156
405
  }
157
- const r = coreContainment(loadReport(prefix), baseFns);
406
+ const r = coreContainment(loadReportOrDie(prefix), baseFns);
158
407
  emit(r);
159
408
  process.exit(r.leaks.length ? 1 : 0);
160
409
  }
161
- emit(coreContainment(loadReport(prefix)));
410
+ emit(coreContainment(loadReportOrDie(prefix)));
162
411
  break;
163
412
  }
164
413
  case "diff": {
@@ -168,8 +417,12 @@ switch (cmd) {
168
417
  // core's effectsByFn was rewritten to avoid (merged multi-report siblings sharing a short fn name
169
418
  // dropped one member's effects, so a gained Net could VANISH from diff and its exit-1 contract —
170
419
  // a supply-chain miss, and the CLI disagreeing with MCP `candor_diff` on the same reports).
171
- const [curPrefix, basePrefix] = args;
172
- const { changes } = coreDiff(loadReport(curPrefix), loadReport(basePrefix));
420
+ // §3.3.1: diff/gains are the exception to discovery — two positional locators <current> <baseline>,
421
+ // each resolved by the shared locator rule (dir / .json path / prefix). --json is accepted (JSON is
422
+ // the only output). No leading-positional-report alias here: both positionals ARE the reports.
423
+ const { positionals } = parseCanonical(args, {});
424
+ const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
425
+ const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
173
426
  // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
174
427
  // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
175
428
  // provenance fields as the Rust candor-query (cross-engine parity, item 10).
@@ -188,8 +441,8 @@ switch (cmd) {
188
441
  case "reachable": {
189
442
  // what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
190
443
  // shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
191
- const [prefix] = args;
192
- const fns = loadReport(prefix);
444
+ const { prefix } = resolveReportVerb(args, 0);
445
+ const fns = loadReportOrDie(prefix);
193
446
  const roots = fns.filter((e) => e.entryPoint);
194
447
  const byEff = {};
195
448
  for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
@@ -201,44 +454,133 @@ switch (cmd) {
201
454
  case "impact": {
202
455
  // blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
203
456
  // MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
204
- const [prefix, q] = args;
205
- emit(coreImpact(loadReport(prefix), loadCallgraph(prefix), q));
457
+ const { prefix, args: [q] } = resolveReportVerb(args, 1);
458
+ emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
206
459
  break;
207
460
  }
208
461
  case "blindspots": {
209
462
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
210
463
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
211
- const [prefix] = args;
212
- emit(coreBlindspots(loadReport(prefix), loadCallgraph(prefix)));
464
+ const { prefix } = resolveReportVerb(args, 0);
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
+ });
213
542
  break;
214
543
  }
215
544
  case "gains": {
216
545
  // the supply-chain alarm (SPEC §5.1): {gained:[Effect], byFunction:[{fn,effect}]} — what the
217
546
  // surface gained between two reports (base → cur), the cross-engine machine-readable form.
218
- const [curPrefix, basePrefix] = args;
547
+ // §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
548
+ // the shared locator rule; --json accepted.
549
+ const { positionals } = parseCanonical(args, {});
550
+ const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
219
551
  const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
220
552
  if (gv && gbv && gv !== gbv)
221
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.`);
222
- 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)) });
223
555
  break;
224
556
  }
225
557
  case "path": {
226
- const [prefix, fn, eff] = args;
227
- emit(corePath(loadReport(prefix), loadCallgraph(prefix), fn, eff));
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");
562
+ const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
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);
228
567
  break;
229
568
  }
230
569
  case "whatif": {
231
- const [prefix, target, eff, maybePolicy] = args;
232
- // A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
233
- // a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
234
- // which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
570
+ // §3.3.1: `whatif <fn> <Effect> [--policy <file>]`, report discovered / --report. DEPRECATED aliases:
571
+ // a leading-positional report and a trailing positional policy (`whatif <prefix> <fn> <Effect>
572
+ // [policy]`). resolveWhatifFix peels both (stderr-noted) so the old grammar stays green.
573
+ const { prefix, target, eff, policyFile } = resolveWhatifFix(args);
574
+ // A present policy MUST exist and be readable — a typo'd path must be LOUD, not silently "no policy →
575
+ // ok:true, exit 0" (mirrors scan's --policy, which exits 2 on an unreadable file: a gate that can't
576
+ // read its policy can't certify anything). Flag, positional, CANDOR_POLICY and .candor/config all land here.
235
577
  let pol = null;
236
- if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
578
+ if (policyFile) {
237
579
  let text;
238
580
  try {
239
- text = fs.readFileSync(maybePolicy, "utf8");
581
+ text = fs.readFileSync(policyFile, "utf8");
240
582
  } catch {
241
- console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
583
+ console.error(`candor: policy ${policyFile} could not be read; whatif NOT evaluated against it`);
242
584
  process.exit(2);
243
585
  }
244
586
  pol = parsePolicy(text);
@@ -258,9 +600,11 @@ switch (cmd) {
258
600
  // THE BOUNDARY FIX (integrations/FIX-SPEC.md): where a forbidden effect belongs + the hoist refactor.
259
601
  // The remedial inverse of whatif. A policy is REQUIRED and must be readable (the fix is defined relative
260
602
  // to the boundary the edit crossed) — a typo'd path fails LOUD, never a silently-empty "no crossing".
261
- const [prefix, target, eff, policyFile] = args;
262
- if (!target || !eff) { console.error("usage: candor-ts-query fix <prefix> <fn> <Effect> <policy-file>"); process.exit(2); }
263
- if (!policyFile) { console.error("candor: fix requires a policy file — the fix is the refactor that restores the boundary the edit crossed"); process.exit(2); }
603
+ // §3.3.1: `fix <fn> <Effect> [--policy <file>]`, report discovered / --report; the old
604
+ // `fix <prefix> <fn> <Effect> <policy-file>` form (leading report + positional policy) stays accepted.
605
+ const { prefix, target, eff, policyFile } = resolveWhatifFix(args);
606
+ if (!target || !eff) { console.error("usage: candor-ts-query fix <fn> <Effect> [--policy <file>] [--report <locator>]"); process.exit(2); }
607
+ if (!policyFile) { console.error("candor: fix requires a policy file — the fix is the refactor that restores the boundary the edit crossed (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
264
608
  let ptext;
265
609
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
266
610
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
@@ -268,33 +612,36 @@ switch (cmd) {
268
612
  // The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
269
613
  // it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
270
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); }
271
- const r = coreFix(cg, loadReport(prefix), target, eff, parsePolicy(ptext), scopeMatches);
615
+ const r = coreFix(cg, loadReportOrDie(prefix), target, eff, parsePolicy(ptext), scopeMatches);
272
616
  if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
273
617
  emit(r);
274
618
  break;
275
619
  }
276
620
  case "fix-gate": {
277
621
  // A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
278
- const [prefix, policyFile] = args;
279
- if (!policyFile) { console.error("candor: fix-gate requires a policy file"); process.exit(2); }
622
+ // §3.3.1: `fix-gate [--policy <file>]`, report discovered / --report. DEPRECATED alias: the old
623
+ // `fix-gate <prefix> <policy-file>` (leading report + positional policy).
624
+ const { prefix, policyFile } = resolveGateVerb(args);
625
+ if (!policyFile) { console.error("candor: fix-gate requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
280
626
  let ptext;
281
627
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
282
628
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
283
629
  const cg = loadCallgraph(prefix);
284
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); }
285
- emit(coreFixGate(cg, loadReport(prefix), parsePolicy(ptext), scopeMatches));
631
+ emit(coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches));
286
632
  break;
287
633
  }
288
634
  case "unverified": {
289
635
  // PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
290
636
  // policy is required; `--strict` exits 1 on a hole. Advisory (exit 0) otherwise.
291
- const strict = args.includes("--strict");
292
- const [prefix, policyFile] = args.filter((a) => a !== "--strict");
293
- if (!policyFile) { console.error("candor: unverified requires a policy file"); process.exit(2); }
637
+ // §3.3.1: `unverified [--policy <file>] [--strict]`, report discovered / --report. DEPRECATED alias:
638
+ // the old `unverified <prefix> <policy-file> [--strict]` (leading report + positional policy).
639
+ const { prefix, policyFile, strict } = resolveGateVerb(args, { strict: true });
640
+ if (!policyFile) { console.error("candor: unverified requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
294
641
  let ptext;
295
642
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
296
643
  catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
297
- const r = coreUnverified(loadReport(prefix), parsePolicy(ptext), scopeMatches);
644
+ const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
298
645
  emit(r);
299
646
  process.exit(strict && !r.ok ? 1 : 0);
300
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.9";
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
+ }