candor-ts 0.8.5 → 0.8.7

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
@@ -43,7 +43,12 @@ This writes `<project-dir>/.candor/report.json` and `.candor/report.callgraph.js
43
43
  with `--out <prefix>`). **Install the TARGET's dependencies first** (`npm install` in the project)
44
44
  — without node_modules, imports don't resolve and most functions honestly read `Unknown` (the
45
45
  scanner warns loudly). Add `--policy <file>` (or set `CANDOR_POLICY`) to enforce a §6.2 policy over the
46
- scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable.
46
+ scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable. `--gate-json <file|->`
47
+ additionally writes the structured verdict `{spec, ok, violations:[{rule,fn,effects,detail}]}`
48
+ (spec §3.3) — the machine-readable form CI/SARIF converters consume, from the SAME violations that
49
+ set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `deps <paths>`, one
50
+ key per line, discovered walking UP from the scan target, relative values anchored to the config's
51
+ repo) is the no-env-wiring floor; flag → env → config → default.
47
52
 
48
53
  **Report shape:** the file is `{ "candor": {version, toolchain, spec}, "functions": [...] }`;
49
54
  `functions` is an **array** of entries (not a map — don't index it by name), each carrying **`fn`**
@@ -73,9 +78,11 @@ Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when
73
78
  Q where $P <Effect> 1 # {effect, directly, inherited}
74
79
  Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
75
80
  Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
81
+ Q callers $P <fn-query> --include-unknown 1 # + possibleViaUnknownDispatch: the unresolved-dispatch frontier
76
82
  Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
77
83
  Q map $P 1 # {module: {effects, functions}}
78
84
  Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
85
+ Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
79
86
  Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
80
87
  Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
81
88
  Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
@@ -84,8 +91,14 @@ Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate w
84
91
  ```
85
92
 
86
93
  And as an MCP server, so an agent pulls these as tools instead of shelling out:
87
- `CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…).
88
- `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the edit-delta).
94
+ `CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
95
+ plus `candor_gate`/`candor_whatif` — a given-but-unreadable `policy` is a loud tool error, never a
96
+ clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the
97
+ edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor.
98
+ CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
99
+ `--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
100
+ endpoint — internal state, not a report field), so treat a report-side green as advisory and the
101
+ scan-time gate as the CI truth.
89
102
 
90
103
  Name queries resolve exact > segment-suffix (`db.save` matches `src.db.save`, never
91
104
  `src.db.save_all`) > substring — the same ladder as the other engines. The trailing `1` is the
package/Cases.ts ADDED
@@ -0,0 +1,77 @@
1
+ // The candor-spec conformance cases in TypeScript — paired by bare function name with the Rust and
2
+ // Java fixtures; the expected effect sets are conformance/expected.json (the SAME oracle).
3
+ import * as fsm from "node:fs";
4
+ import * as netm from "node:net";
5
+ import * as cp from "node:child_process";
6
+ import * as cryptom from "node:crypto";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import * as winstonm from "winston";
9
+
10
+ // --- one function per std-only effect ---
11
+ export function fs_read(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
12
+ export function net_connect(): void { try { netm.connect(1, "h"); } catch {} }
13
+ export function exec_spawn(): void { try { cp.spawn("x"); } catch {} }
14
+ // Exec-cliff refinement (spec §4 ⟨0.5⟩): a known literal head adds its effect; all engines must agree.
15
+ export function exec_curl(): void { try { cp.spawn("curl"); } catch {} }
16
+ // Exec-refinement reads the HEAD (argv[0]) only: a dynamic program with a literal ARGUMENT keeps the
17
+ // bare cliff — "curl" in the args array must NOT fabricate Net (spec §4 ⟨0.5⟩: the head is argv[0]).
18
+ export function exec_dyn_head(tool: string): void { try { cp.spawn(tool, ["curl"]); } catch {} }
19
+ export function env_read(): void { void process.env.X; }
20
+ export function clock_now(): void { void Date.now(); }
21
+ // Rand: node:crypto's CSPRNG. candor-ts is syntactic (AST), so the builtin need not resolve at scan time.
22
+ export function rand_gen(): void { void cryptom.randomBytes(16); }
23
+ // Db: node:sqlite's DatabaseSync.exec is the store round-trip (named import — candor-ts tracks the symbol).
24
+ export function db_query(): void { void new DatabaseSync(":memory:").exec("SELECT 1"); }
25
+ export function log_msg(): void { winstonm.info("m"); }
26
+
27
+ // --- purity (negative) ---
28
+ export function pure_fn(): number { return 1 + 2; }
29
+
30
+ // --- the Unknown trust contract: a function-valued field the engine cannot see through ---
31
+ class Holder { cb: () => void = () => {}; }
32
+ const h = new Holder();
33
+ export function unknown_dyn(): void { const cb = h.cb; cb(); }
34
+
35
+ // --- multi-effect union in one body ---
36
+ export function combined(): void { try { fsm.readFileSync("/tmp/x"); netm.connect(1, "h"); } catch {} }
37
+
38
+ // --- transitive propagation across a call ---
39
+ export function transitive_leaf(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
40
+ export function transitive_caller(): void { transitive_leaf(); }
41
+
42
+ // --- an effect inside a closure attributes to the enclosing function (SEMANTICS §2) ---
43
+ export function closure_effect(): void { const f = () => { try { fsm.readFileSync("/tmp/x"); } catch {} }; f(); }
44
+
45
+ // --- Unknown propagates like an effect ---
46
+ export function unknown_propagates(): void { unknown_dyn(); }
47
+
48
+ // --- mixed: a concrete effect AND an Unknown in one transitive set ---
49
+ export function mixed_unknown(): void { try { fsm.readFileSync("/tmp/x"); } catch {} unknown_dyn(); }
50
+
51
+ // --- a 3-hop chain a -> b -> c(Net) ---
52
+ export function hop_c(): void { try { netm.connect(1, "h"); } catch {} }
53
+ export function hop_b(): void { hop_c(); }
54
+ export function hop_a(): void { hop_b(); }
55
+
56
+ // --- a caller unions the effects of two distinct callees ---
57
+ export function union_b(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
58
+ export function union_c(): void { try { netm.connect(1, "h"); } catch {} }
59
+ export function union_a(): void { union_b(); union_c(); }
60
+
61
+ // --- recursion: the fixpoint must terminate AND keep the effect ---
62
+ export function recurse(n: number): void { if (n > 0) { void process.env.X; recurse(n - 1); } }
63
+
64
+ // --- an effect in one branch only is still inferred (over-approximation) ---
65
+ export function conditional(b: boolean): void { if (b) { try { cp.spawn("x"); } catch {} } }
66
+
67
+ // --- transitive purity: a -> b -> c, all pure, stays pure (negative) ---
68
+ export function pure_c(): number { return 3; }
69
+ export function pure_b(): number { return pure_c(); }
70
+ export function pure_a(): number { return pure_b(); }
71
+
72
+ // --- a method call on a concrete LOCAL-type receiver propagates the method's effect ---
73
+ export class Svc { act(): void { try { fsm.readFileSync("/tmp/x"); } catch {} } }
74
+ export function method_call(s: Svc): void { s.act(); }
75
+
76
+ // --- scheduler attribution: an effect inside a scheduled task attributes to the SCHEDULER ---
77
+ export function sched(): void { setTimeout(() => { try { fsm.readFileSync("/tmp/x"); } catch {} }, 0); }
package/README.md CHANGED
@@ -19,6 +19,7 @@ npm install # typescript + @types/node
19
19
  node scan.mjs <project-dir> # tsconfig.json honored; tests excluded; writes
20
20
  # <dir>/.candor/report.json + .callgraph.json
21
21
  node scan.mjs . --policy .candor/policy # the §6.2 gate: exit 1 on violation, 2 if unreadable
22
+ node scan.mjs . --gate-json gate.json # + the structured verdict {spec, ok, violations} (§3.3)
22
23
 
23
24
  node scan.mjs --version # installed build + spec contract (offline), + upgrade line
24
25
 
@@ -26,10 +27,18 @@ node query.mjs show .candor/report db.save 1 # a function's effects (match
26
27
  node query.mjs where .candor/report Net 1 # direct sources vs inheritors
27
28
  node query.mjs callers .candor/report db.save 1 # the blast radius (transitive callers)
28
29
  node query.mjs map .candor/report 1 # module → effects overview
30
+ node query.mjs containment .candor/report # §6.1 boundary-effect dispersion (+ baseline = ratchet)
31
+ node query.mjs blindspots .candor/report # the Unknown SOURCES, ranked by blast radius
29
32
  node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdict (exit 1)
30
- node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain)
33
+ node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain;
34
+ # a baseline from a DIFFERENT build ⇒ disclosed ⚠ + exit 0)
31
35
  ```
32
36
 
37
+ A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
38
+ `deps <report paths>` one per line, discovered by walking up from the scan target; relative values
39
+ resolve against the config's repo, so CI is "point at the repo". A configured-but-unusable
40
+ config/policy fails loud (exit 2), never silently gateless.
41
+
33
42
  **Staying current:** check your installed version and upgrade — [candor/AGENTS.md §2a](https://github.com/tombaldwin/candor/blob/main/AGENTS.md#2a-staying-current--check-the-version-upgrade). `npx -y candor-ts --version` prints the build, the spec, and the upgrade one-liner (offline; candor never phones home).
34
43
 
35
44
  Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
@@ -53,10 +62,16 @@ Nest app this makes table-level policy live: `allow Db in article.service articl
53
62
  the service reaching `user` and `follows`.
54
63
 
55
64
  **The classifier** is curated (the same under-report-and-say-so posture as the other engines): the
56
- Node builtins (`fs`, `net`/`http`/`tls`, `child_process`, `node:sqlite`, `process.env`, the clock)
57
- plus a small npm tier (axios/got/node-fetch/undici/ws, pg/mysql2/mongodb/redis/knex,
58
- execa/cross-spawn, fs-extra/rimraf/glob, dotenv, winston/pino). An unlisted package contributes
59
- nothing — candor never guesses an effect.
65
+ Node builtins (`fs`, `net`/`http`/`tls`, `dns`, `child_process`, `worker_threads`, `node:sqlite`,
66
+ `node:vm`, `process.env`, the clock), the HTTP/queue/mail tier (axios/got/node-fetch/undici/ws/
67
+ socket.io/nodemailer, gaxios + googleapis-common + google-auth-library, stripe, @sentry/*,
68
+ posthog-node, bull/bullmq), the database drivers (pg/mysql2/mongodb/redis/ioredis/sqlite3/
69
+ better-sqlite3/knex) **and the ORM tier** (TypeORM — with `@Entity("…")` table extraction —
70
+ Prisma, Mongoose, Sequelize, drizzle-orm), plus execa/cross-spawn/shelljs/open, fs-extra/
71
+ graceful-fs/rimraf/glob/chokidar, dotenv, winston/pino/bunyan. An unlisted package contributes
72
+ nothing — candor never guesses an effect — but the scan **names it**: the receipt's `κ doesn't
73
+ know N packages…` line lists every package the code demonstrably calls that κ neither classifies
74
+ nor has reviewed-pure, and each function carries the `invisible` list it (transitively) reaches.
60
75
 
61
76
  ## MCP server — candor as agent ground truth
62
77
 
@@ -73,10 +88,21 @@ tracing the call graph by hand (the measured ~700–2000× token win on blast-ra
73
88
 
74
89
  Tools: `candor_impact` (backward blast radius), `candor_reachable` (what runs at runtime),
75
90
  `candor_where` (effect surface), `candor_path` (how an effect is reached), `candor_callers`,
76
- `candor_show`, `candor_map`, `candor_whatif` (pre-edit gate check). Each takes an optional `report`
77
- prefix (else `$CANDOR_REPORT`). The server is **query-only** — it never scans (the analyzer
78
- self-boundary, spec §7.12: an agent or a hook produces the report; the server reads it, Fs only). The
79
- query logic is the shared `query-core.mjs`, the same answers the CLI gives.
91
+ `candor_show`, `candor_map`, `candor_containment`, `candor_blindspots`, `candor_whatif` (pre-edit
92
+ gate check — a given-but-unreadable policy is a loud error, never a clean verdict), `candor_gate`
93
+ (the checked-in `.candor/config` policy verdict), `candor_diff`/`candor_gains` (baseline deltas).
94
+ Each takes an optional `report` prefix (else `$CANDOR_REPORT`); `--root <dir>` locks the server to
95
+ one workspace. The server is **query-only** — it never scans (the analyzer self-boundary, spec
96
+ §7.12: an agent or a hook produces the report; the server reads it, Fs only). The query logic is
97
+ the shared `query-core.mjs`, the same answers the CLI gives.
98
+
99
+ **`candor-lsp`** renders the same report where the code is, for any LSP-native editor (helix,
100
+ neovim; the JetBrains plugin bundles it): a CodeLens per effectful function (`⚡ Db, Net · blast
101
+ radius 12`), hover provenance (the hop chain to where an inherited effect is performed), and the
102
+ repo's policy verdict as diagnostics. Like the MCP server it is a pure report consumer — any
103
+ engine's report — and never scans. (Both report-computed gates are advisory: the engine's own
104
+ `--gate-json` run additionally fails masked/incomplete literal surfaces and is the authoritative
105
+ CI form.)
80
106
 
81
107
  **The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
82
108
  about the *current* code, not a stale snapshot:
@@ -129,7 +155,7 @@ every push to the spec.
129
155
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
130
156
  | Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
131
157
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
132
- | `{ candor: { version, toolchain, spec: "0.5" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
158
+ | `{ candor: { version, toolchain, spec: "0.8" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
133
159
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
134
160
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
135
161
 
@@ -147,13 +173,16 @@ read the Rust source".
147
173
 
148
174
  ## Status
149
175
 
150
- Young product (0.1.x): the analysis core, the gate, and the query surface are real,
151
- behaviorally tested (`node test.mjs`), **soundness-fuzzed with verified teeth** (`node fuzz.mjs` —
152
- spec §7.13: generated effect chains through every encoded call form, any silent-pure = red), and
153
- conformance-held. The npm classifier tier is
154
- deliberately curated and will keep growing case-by-case. Entry points (Nest/Next populations),
155
- `unknownWhy` origins, `reachable`, cross-package inheritance (`CANDOR_DEPS` + the spec §2 `hash`,
156
- version-trusted per §2.1), and `--allow-js` are all in. On npm: `npx -y candor-ts <dir>`.
176
+ 0.8.x, speaking candor-spec 0.8: the analysis core, the gate (`--policy` / `--gate-json` /
177
+ `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
178
+ `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
179
+ real, behaviorally tested (`npm test` — ~360 assertions across six suites), **soundness-fuzzed
180
+ with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
181
+ call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
182
+ npm classifier tier is deliberately curated and keeps growing case-by-case. Entry points
183
+ (Nest/Next populations), `unknownWhy` origins, `reachable`, cross-package inheritance
184
+ (`CANDOR_DEPS` + the spec §2 `hash`, version-trusted per §2.1), and `--allow-js` are all in.
185
+ On npm: `npx -y candor-ts <dir>`.
157
186
 
158
187
  ## Development
159
188
 
package/lsp.mjs CHANGED
@@ -7,6 +7,10 @@
7
7
  * how many functions transitively call it (who is affected if it changes).
8
8
  * • Diagnostics: the repo's architecture-policy verdict (the §6.2 gate, resolved from CANDOR_POLICY
9
9
  * or the checked-in .candor/config — spec §3.4) as squiggles at each violating function's line.
10
+ * CAVEAT — a report-computed gate is WEAKER than the engine's own --gate-json run: the scan-time
11
+ * gate also fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint,
12
+ * kept internal per the java/rust engines — not a report field), so no-squiggle here can still be
13
+ * red in CI. The engine's --gate-json is the authoritative form (same caveat as MCP candor_gate).
10
14
  * • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
11
15
  * performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
12
16
  *
@@ -40,14 +44,7 @@ try { VERSION = createRequire(import.meta.url)("./package.json").version; } catc
40
44
  let rootPath = null;
41
45
  let reportPrefix = process.env.CANDOR_REPORT || process.argv[2] || null;
42
46
 
43
- function hasReport(p) {
44
- if (!p) return false;
45
- if (fs.existsSync(`${p}.json`)) return true;
46
- const base = nodePath.basename(p);
47
- try {
48
- return fs.readdirSync(nodePath.dirname(p) || ".").some((f) => f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
49
- } catch { return false; }
50
- }
47
+ const hasReport = Q.hasReport; // single-sourced with the loader predicate (query-core) — see mcp.mjs
51
48
 
52
49
  // ---- fn → document mapping --------------------------------------------------------------------------
53
50
  // A report `loc` is `<file>:<line>[:col…]` where <file> is either a repo-relative PATH (the scan-source
@@ -70,29 +67,41 @@ function docMatches(docPath, fn, file) {
70
67
  const norm = docPath.split(nodePath.sep).join("/");
71
68
  return candidatePaths(fn, file).some((c) => norm === c || norm.endsWith("/" + c));
72
69
  }
73
- /** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call. */
74
- function entriesInDoc(docPath) {
70
+ /** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call
71
+ * (the per-request re-read is the freshness design); a caller that already has the report passes
72
+ * it via `fns` so one request never parses the same file twice. */
73
+ function entriesInDoc(docPath, fns = null) {
75
74
  if (!hasReport(reportPrefix)) return null;
76
75
  const out = [];
77
- for (const e of Q.loadReport(reportPrefix)) {
76
+ for (const e of (fns ?? Q.loadReport(reportPrefix))) {
78
77
  const lp = e.loc && locParts(e.loc);
79
78
  if (lp && docMatches(docPath, e.fn, lp.file)) out.push({ entry: e, line: lp.line });
80
79
  }
81
80
  return out;
82
81
  }
83
82
 
83
+ // The transitive-caller COUNT for an exact fn name over an already-inverted graph. The lenses used
84
+ // Q.callers per entry, and every callers() call rebuilt reverseGraph from scratch — a 50-fn document
85
+ // over a JVM-scale callgraph did 50 full graph inversions PER codeLens request (review find). One
86
+ // inversion per request + a plain BFS; report fn names are exact cg keys, so no match ladder needed.
87
+ function transitiveCallerCount(rev, fn) {
88
+ const seen = new Set([fn]);
89
+ const queue = [fn];
90
+ while (queue.length) {
91
+ const n = queue.pop();
92
+ for (const c of rev.get(n) ?? []) if (!seen.has(c)) { seen.add(c); queue.push(c); }
93
+ }
94
+ return seen.size - 1; // minus the target itself
95
+ }
96
+
84
97
  // ---- CodeLens ---------------------------------------------------------------------------------------
85
98
  function codeLenses(docPath) {
86
99
  const found = entriesInDoc(docPath);
87
100
  if (found === null) return [];
88
- const cg = Q.loadCallgraph(reportPrefix);
101
+ let rev = null;
102
+ try { rev = Q.reverseGraph(Q.loadCallgraph(reportPrefix)); } catch { /* no callgraph — effects-only lens */ }
89
103
  return found.map(({ entry, line }) => {
90
- let blast = "";
91
- try {
92
- const c = Q.callers(cg, entry.fn);
93
- const n = (c && c.transitive && c.transitive.length) || 0;
94
- blast = ` · blast radius ${n}`;
95
- } catch { /* no callgraph — effects-only lens */ }
104
+ const blast = rev ? ` · blast radius ${transitiveCallerCount(rev, entry.fn)}` : "";
96
105
  const eff = (entry.inferred || []).join(", ") || "pure";
97
106
  return {
98
107
  range: { start: { line, character: 0 }, end: { line, character: 0 } },
@@ -107,12 +116,13 @@ function codeLenses(docPath) {
107
116
  // needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
108
117
  // chain to the direct source. unknownWhy rides along when the fn introduces opacity.
109
118
  function hoverAt(docPath, line) {
110
- const found = entriesInDoc(docPath);
119
+ if (!hasReport(reportPrefix)) return null;
120
+ const fns = Q.loadReport(reportPrefix); // ONE load per request (entriesInDoc reuses it)
121
+ const found = entriesInDoc(docPath, fns);
111
122
  if (!found || !found.length) return null;
112
123
  const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
113
124
  if (!at) return null;
114
125
  const { entry } = at;
115
- const fns = Q.loadReport(reportPrefix);
116
126
  const cg = Q.loadCallgraph(reportPrefix);
117
127
  const lines = [`**${entry.fn}** — ⚡ { ${(entry.inferred || []).join(", ") || "pure"} }`];
118
128
  for (const eff of entry.inferred || []) {
@@ -139,9 +149,16 @@ function hoverAt(docPath, line) {
139
149
  }
140
150
 
141
151
  // ---- Diagnostics (the live gate) ---------------------------------------------------------------------
152
+ const warned = new Set();
153
+ function warnOnce(message) { if (!warned.has(message)) { warned.add(message); logMessage(message); } }
142
154
  function activePolicy() {
143
155
  const env = process.env.CANDOR_POLICY;
144
- if (env && fs.existsSync(env)) return fs.readFileSync(env, "utf8");
156
+ if (env) {
157
+ if (fs.existsSync(env)) return fs.readFileSync(env, "utf8");
158
+ // Set-but-missing must be LOUD (the family's configured-but-unusable posture — scan exits 2 here).
159
+ // This is an advisory surface, so: disclose the policy-source swap, then fall through to discovery.
160
+ warnOnce(`candor-lsp: CANDOR_POLICY is set but ${env} does not exist — falling back to .candor/config discovery (diagnostics may reflect a different policy than you configured)`);
161
+ }
145
162
  const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
146
163
  const cfg = from ? discoverConfigPolicy(from) : null;
147
164
  if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
package/mcp.mjs CHANGED
@@ -23,41 +23,57 @@ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from
23
23
 
24
24
  const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
25
25
 
26
- const DEFAULT_PREFIX = process.env.CANDOR_REPORT || process.argv[2]
26
+ // CLI: [prefix] [--root <dir>]. `--root` LOCKS the server to a workspace: every report prefix (and
27
+ // therefore every policy read, whose confinement root derives from the prefix) must live inside it.
28
+ // Without it the confinement is only RELATIVE — the `report` arg is client-chosen, so a client that can
29
+ // also plant a parseable report in a target tree could anchor policy reads there (review find).
30
+ const CLI_ARGS = process.argv.slice(2);
31
+ let WORKSPACE_ROOT = null;
32
+ {
33
+ const i = CLI_ARGS.indexOf("--root");
34
+ if (i >= 0) { WORKSPACE_ROOT = nodePath.resolve(CLI_ARGS[i + 1] ?? "."); CLI_ARGS.splice(i, 2); }
35
+ }
36
+ const DEFAULT_PREFIX = process.env.CANDOR_REPORT || CLI_ARGS[0]
27
37
  || (fs.existsSync(".candor") ? ".candor/report" : null); // the engines' default --out convention
28
38
 
29
- // A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
30
- // `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so
31
- // the MCP server serves a report from ANY engine, not just candor-ts's.
32
- function hasReport(p) {
33
- if (fs.existsSync(`${p}.json`)) return true;
34
- const base = nodePath.basename(p);
35
- try {
36
- // SAME predicate (Q.isReport) the loader uses — a prefix whose only sibling is `.encountered-*` /
37
- // `.calibrated.json` must NOT pass here, else loadReport finds zero functions and the tool returns an
38
- // authoritative-empty result instead of "no report" (a silent under-report — review find).
39
- return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
40
- f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
41
- } catch { return false; }
42
- }
39
+ const within = (abs, root) => abs === root || abs.startsWith(root + nodePath.sep);
40
+ // hasReport is the shared query-core check — the SAME predicate (Q.isReport) the loader uses, so a
41
+ // prefix whose only sibling is `.encountered-*`/`.calibrated.json` can't pass existence yet load zero
42
+ // functions (an authoritative-empty result — a silent under-report; review find).
43
43
  function resolvePrefix(args) {
44
44
  const p = args?.report || DEFAULT_PREFIX;
45
45
  if (!p) throw new Error("no report prefix: pass `report`, set $CANDOR_REPORT, or give one as the CLI arg");
46
- if (!hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
46
+ if (WORKSPACE_ROOT && !within(nodePath.resolve(p), WORKSPACE_ROOT))
47
+ throw new Error(`report prefix \`${clip(p)}\` is outside the served workspace (--root ${WORKSPACE_ROOT}) — refusing`);
48
+ if (!Q.hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
47
49
  return p;
48
50
  }
49
51
  // Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
50
52
  // verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
51
53
  const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
52
- // Read a caller-supplied policy file CONFINED to the report's directory tree. The MCP surface is
54
+ // The confinement root for a caller-supplied policy path: the repo the report belongs to — the
55
+ // .candor/config-discovered repo root when there is one, else the parent of a `.candor/` report
56
+ // directory, else the report's own directory. The old default (always dirname(prefix)) was the
57
+ // `.candor/` dir itself under the standard `.candor/report` layout, so a legitimate repo-root policy —
58
+ // the very layout candor_gate resolves via cfg.repoRoot — was refused (review find).
59
+ function policyRoot(prefix) {
60
+ const cfg = configPolicy(prefix);
61
+ if (cfg) return cfg.repoRoot;
62
+ const dir = nodePath.resolve(nodePath.dirname(prefix));
63
+ return nodePath.basename(dir) === ".candor" ? nodePath.dirname(dir) : dir;
64
+ }
65
+ // Read a caller-supplied policy file CONFINED to the report's repo tree. The MCP surface is
53
66
  // report-query-only (spec §7.12); an arbitrary `policy` path (/etc/passwd, ~/.aws/credentials) whose
54
67
  // parsed deny-rule scopes are reflected back in violations[].rule is an arbitrary-file-read exfiltration
55
- // channel — tie the policy to the project it gates.
56
- function confinedPolicyRead(policyPath, prefix, root = nodePath.resolve(nodePath.dirname(prefix))) {
68
+ // channel — tie the policy to the project it gates. FAIL CLOSED on an unreadable path: the thrown error
69
+ // surfaces as the tool-level isError — a typo'd policy must be LOUD, never a clean no-policy verdict
70
+ // (the gateless-green shape the CLI's whatif exits 2 on).
71
+ function confinedPolicyRead(policyPath, prefix, root = policyRoot(prefix)) {
57
72
  const abs = nodePath.resolve(policyPath);
58
- if (abs !== root && !abs.startsWith(root + nodePath.sep))
59
- throw new Error(`policy must be within the report's directory (${root}) — refusing to read \`${clip(policyPath)}\``);
60
- return fs.readFileSync(abs, "utf8");
73
+ if (!within(abs, root) || (WORKSPACE_ROOT && !within(abs, WORKSPACE_ROOT)))
74
+ throw new Error(`policy must be within the report's repo (${root}) — refusing to read \`${clip(policyPath)}\``);
75
+ try { return fs.readFileSync(abs, "utf8"); }
76
+ catch { throw new Error(`policy \`${clip(policyPath)}\` could not be read — NOT evaluated (a missing gate source must be loud, never a clean verdict)`); }
61
77
  }
62
78
  // The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
63
79
  function configPolicy(prefix) {
@@ -75,8 +91,32 @@ const reportArg = { report: { type: "string", description: "report prefix (optio
75
91
  // only shapes the MCP result for its token-sensitive transport). Small results are returned verbatim.
76
92
  const MCP_LIST_CAP = 50;
77
93
  function capImpact(r) {
78
- if (!Array.isArray(r.affected) || r.affected.length <= MCP_LIST_CAP) return r; // affectedCount is the full count
79
- return { ...r, affected: r.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true };
94
+ let out = r;
95
+ if (Array.isArray(r.affected) && r.affected.length > MCP_LIST_CAP) // affectedCount is the full count
96
+ out = { ...out, affected: r.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true };
97
+ if (Array.isArray(r.entryPoints) && r.entryPoints.length > MCP_LIST_CAP)
98
+ out = { ...out, entryPointCount: r.entryPoints.length, entryPoints: r.entryPoints.slice(0, MCP_LIST_CAP), entryPointsTruncated: true };
99
+ return out;
100
+ }
101
+ // The same token-amplification argument as capImpact, for the other unbounded lists: `where` on a
102
+ // pervasive effect (Log, Unknown) lists most of a large repo; a blindspot source's `affected` is a
103
+ // transitive-caller list. Counts stay exact; truncation is flagged.
104
+ function capWhere(r) {
105
+ const cap = (k) => Array.isArray(r[k]) && r[k].length > MCP_LIST_CAP;
106
+ if (!cap("directly") && !cap("inherited")) return r;
107
+ return {
108
+ effect: r.effect,
109
+ directlyCount: r.directly.length, directly: r.directly.slice(0, MCP_LIST_CAP),
110
+ inheritedCount: r.inherited.length, inherited: r.inherited.slice(0, MCP_LIST_CAP),
111
+ truncated: true,
112
+ };
113
+ }
114
+ function capBlindspots(r) {
115
+ const sources = (r.sources ?? []).map((s) =>
116
+ Array.isArray(s.affected) && s.affected.length > MCP_LIST_CAP
117
+ ? { ...s, affected: s.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true } // `reaches` is the full count
118
+ : s);
119
+ return { ...r, sources };
80
120
  }
81
121
  function capCallers(r) {
82
122
  const d = r.direct ?? [], t = r.transitive ?? [];
@@ -97,7 +137,7 @@ const TOOLS = {
97
137
  candor_where: {
98
138
  description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
99
139
  schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
100
- run: (a, p) => Q.where(Q.loadReport(p), a.effect),
140
+ run: (a, p) => capWhere(Q.where(Q.loadReport(p), a.effect)),
101
141
  },
102
142
  candor_reachable: {
103
143
  description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
@@ -128,14 +168,18 @@ const TOOLS = {
128
168
  description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
129
169
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
130
170
  run: (a, p) => {
131
- const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
171
+ // A GIVEN policy path is always read (confined, fail-closed) — the old `existsSync` guard made a
172
+ // typo'd/missing path silently evaluate with NO policy → `ok:true, violations:[]`, a false green
173
+ // on the agent-facing pre-edit gate (exactly what the CLI whatif exits 2 to prevent). The read's
174
+ // throw lands as the tool-level isError, mirroring the CLI's fail-closed posture.
175
+ const pol = a.policy ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
132
176
  const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
133
177
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
134
178
  return r;
135
179
  },
136
180
  },
137
181
  candor_gate: {
138
- description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report (an engine's own --gate-json run is the authoritative CI form).",
182
+ description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
139
183
  schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
140
184
  run: (a, p) => {
141
185
  let text;
@@ -157,7 +201,7 @@ const TOOLS = {
157
201
  candor_blindspots: {
158
202
  description: "The Unknown SOURCES — calls the engine genuinely could not resolve (reflection, wide dispatch, fn-pointers) — ranked by how many functions inherit Unknown through each. Turns a high-Unknown report into a short worklist.",
159
203
  schema: { type: "object", properties: { ...reportArg } },
160
- run: (_a, p) => Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p)),
204
+ run: (_a, p) => capBlindspots(Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p))),
161
205
  },
162
206
  candor_diff: {
163
207
  description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
@@ -211,13 +255,19 @@ function handle(msg) {
211
255
  if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications: no reply
212
256
  if (method === "ping") return result(id, {});
213
257
  if (method === "resources/list") {
214
- try { return result(id, { resources: DEFAULT_PREFIX && hasReport(DEFAULT_PREFIX) ? listResources(DEFAULT_PREFIX) : [] }); }
258
+ try { return result(id, { resources: DEFAULT_PREFIX && Q.hasReport(DEFAULT_PREFIX) ? listResources(DEFAULT_PREFIX) : [] }); }
215
259
  catch { return result(id, { resources: [] }); }
216
260
  }
217
261
  if (method === "resources/read") {
218
262
  try {
219
- const prefix = resolvePrefix({});
220
- const r = readResource(params?.uri || "", prefix);
263
+ // Honor the prefix ENCODED in the resource URI (resources/list mints `?prefix=…`) — it was
264
+ // decorative before, always resolving the default (review find). resolvePrefix keeps the
265
+ // existence + --root checks on whatever the client asked for.
266
+ const uri = params?.uri || "";
267
+ let encoded = null;
268
+ try { encoded = new URL(uri).searchParams.get("prefix"); } catch { /* not URL-shaped — default */ }
269
+ const prefix = resolvePrefix(encoded ? { report: encoded } : {});
270
+ const r = readResource(uri, prefix);
221
271
  return result(id, { contents: [{ uri: params?.uri, ...r }] });
222
272
  } catch (e) { return error(id, -32602, `candor: ${e.message}`); }
223
273
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.5",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
3
+ "version": "0.8.7",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -40,6 +40,7 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "files": [
43
+ "Cases.ts",
43
44
  "scan.mjs",
44
45
  "query.mjs",
45
46
  "policy.mjs",
package/policy.mjs CHANGED
@@ -120,7 +120,13 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
120
120
  for (const f of functions) {
121
121
  for (const r of pol.deny) {
122
122
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
123
- const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
123
+ // `pure` (empty forbidden set) forbids every EFFECT — not `Unknown`, which is the §4 trust
124
+ // marker, not an effect (AS-EFF-003's concern; `deny Unknown <scope>` is the explicit knob).
125
+ // The reference engine (candor-java) and the rust deep engine exclude it identically; candor-ts
126
+ // wrongly counted an Unknown-only fn as a `pure` violation until 2026-07-09.
127
+ const hits = r.effects.length === 0
128
+ ? f.inferred.filter((e) => e !== "Unknown")
129
+ : f.inferred.filter((e) => r.effects.includes(e));
124
130
  if (hits.length) push("AS-EFF-006", f.fn, hits, `\`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
125
131
  }
126
132
  for (const r of pol.allow) {
@@ -161,8 +167,11 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
161
167
 
162
168
  // ---- .candor/config discovery (spec §3.4) — shared by the MCP + LSP surfaces -----------------------
163
169
  // Walk UP from `fromDir` to the nearest .candor/config and return its `policy` entry resolved against
164
- // that config's repo root: { policyPath, repoRoot } — or null. Read-only + best-effort (a consumer
165
- // surface never gates a build; a broken config surfaces as the caller's error).
170
+ // that config's repo root: { policyPath, repoRoot } — or null. A RELATIVE `policy` value resolves
171
+ // against the repo the config belongs to (the parent of its `.candor/`), NEVER the process CWD — the
172
+ // family rule (scan.mjs configAnchor is the producer-side twin): a checked-in config means the same
173
+ // file wherever the consumer process was launched. Read-only + best-effort (a consumer surface never
174
+ // gates a build; a broken config surfaces as the caller's error).
166
175
  import fs from "node:fs";
167
176
  import nodePath from "node:path";
168
177
  export function discoverConfigPolicy(fromDir) {
package/query-core.mjs CHANGED
@@ -22,11 +22,27 @@ function siblings(prefix, predicate) {
22
22
  .map((f) => nodePath.join(dir, f));
23
23
  } catch { return []; }
24
24
  }
25
- // A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger, or a
26
- // calibrated-coverage sidecar). Exported so `hasReport` (the MCP existence check) uses the SAME predicate
27
- // as the loader — else a prefix whose only sibling is `.encountered-*`/`.calibrated.json` passes the
28
- // existence check but loads ZERO functions → an authoritative-empty result (silent under-report; review find).
29
- export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json");
25
+ // A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger, a
26
+ // calibrated-coverage sidecar, or a --gate-json verdict written beside the prefix). Exported so
27
+ // `hasReport` (the MCP existence check) uses the SAME predicate as the loader — else a prefix whose only
28
+ // sibling is `.encountered-*`/`.calibrated.json` passes the existence check but loads ZERO functions →
29
+ // an authoritative-empty result (silent under-report; review find). `.gate.json` has no functions array,
30
+ // so merging it "disclosed a malformed report" on every query over the recommended CI layout — noisy, excluded.
31
+ export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json") && !f.endsWith(".gate.json");
32
+
33
+ // A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
34
+ // `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so a
35
+ // consumer (MCP/LSP) serves a report from ANY engine. ONE copy here: the check was triplicated across
36
+ // mcp.mjs/lsp.mjs, and an earlier divergence from the loader predicate was itself a review find.
37
+ export function hasReport(p) {
38
+ if (!p) return false;
39
+ if (fs.existsSync(`${p}.json`)) return true;
40
+ const base = nodePath.basename(p);
41
+ try {
42
+ return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
43
+ f.startsWith(base + ".") && f.endsWith(".json") && isReport(f));
44
+ } catch { return false; }
45
+ }
30
46
 
31
47
  // Defend the queries against a partial/old-engine/hand-edited report: the §2 required fields are
32
48
  // defaulted, and a WRONG-TYPE field is coerced — a non-array `inferred` (e.g. the string "Net") must
@@ -120,7 +136,9 @@ export function matches(names, q) {
120
136
  return best === 0 ? [] : names.filter((n) => matchTier(n, q) >= best);
121
137
  }
122
138
 
123
- function reverseGraph(cg) {
139
+ // Exported for consumers that answer MANY caller-count questions over one loaded graph (the LSP
140
+ // codeLens): building the inversion once per request instead of once per `callers()` call.
141
+ export function reverseGraph(cg) {
124
142
  const rev = new Map();
125
143
  for (const [caller, callees] of Object.entries(cg))
126
144
  for (const c of callees) {
package/query.mjs CHANGED
@@ -29,8 +29,9 @@ import { printAgents } from "./contract.mjs";
29
29
  import { impact as coreImpact, path as corePath, gains as coreGains,
30
30
  show as coreShow, blindspots as coreBlindspots,
31
31
  callers as coreCallers, callersFrontier, loadHierarchy,
32
- containment as coreContainment,
33
- loadReport, loadCallgraph, matches , reportVersion } from "./query-core.mjs";
32
+ containment as coreContainment, diff as coreDiff,
33
+ where as coreWhere, map as coreMap, whatif as coreWhatif,
34
+ loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
34
35
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
35
36
 
36
37
  // ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
@@ -115,13 +116,11 @@ switch (cmd) {
115
116
  break;
116
117
  }
117
118
  case "where": {
119
+ // Shared query-core (like show/callers) — the CLI and MCP `candor_where` are ONE implementation.
120
+ // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
121
+ // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
118
122
  const [prefix, eff] = args;
119
- const fns = loadReport(prefix);
120
- emit({
121
- effect: eff,
122
- directly: fns.filter((e) => e.direct.includes(eff)).map((e) => e.fn).sort(),
123
- inherited: fns.filter((e) => e.inferred.includes(eff) && !e.direct.includes(eff)).map((e) => e.fn).sort(),
124
- });
123
+ emit(coreWhere(loadReport(prefix), eff));
125
124
  break;
126
125
  }
127
126
  case "callers": {
@@ -136,17 +135,9 @@ switch (cmd) {
136
135
  break;
137
136
  }
138
137
  case "map": {
138
+ // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
139
139
  const [prefix] = args;
140
- const fns = loadReport(prefix);
141
- const mods = {};
142
- for (const e of fns) {
143
- const mod = e.fn.includes(".") ? e.fn.split(".").slice(0, -1).join(".") : "(root)";
144
- const m = (mods[mod] ??= { effects: new Set(), functions: 0 });
145
- for (const x of e.inferred) m.effects.add(x);
146
- m.functions += 1;
147
- }
148
- emit(Object.fromEntries(Object.entries(mods).sort()
149
- .map(([k, v]) => [k, { effects: [...v.effects].sort(), functions: v.functions }])));
140
+ emit(coreMap(loadReport(prefix)));
150
141
  break;
151
142
  }
152
143
  case "containment": {
@@ -168,18 +159,13 @@ switch (cmd) {
168
159
  }
169
160
  case "diff": {
170
161
  // per-function effect delta vs a baseline: {changes: [{fn, gained, lost}]} — the envelope shape
171
- // the conformance suite pins (diff-vs-self must be {changes: []}).
162
+ // the conformance suite pins (diff-vs-self must be {changes: []}). Shared query-core: the CLI's
163
+ // former inline copy built `new Map(fns.map((e) => [e.fn, …]))` — the exact last-wins collapse
164
+ // core's effectsByFn was rewritten to avoid (merged multi-report siblings sharing a short fn name
165
+ // dropped one member's effects, so a gained Net could VANISH from diff and its exit-1 contract —
166
+ // a supply-chain miss, and the CLI disagreeing with MCP `candor_diff` on the same reports).
172
167
  const [curPrefix, basePrefix] = args;
173
- const cur = new Map(loadReport(curPrefix).map((e) => [e.fn, new Set(e.inferred)]));
174
- const base = new Map(loadReport(basePrefix).map((e) => [e.fn, new Set(e.inferred)]));
175
- const changes = [];
176
- for (const fn of new Set([...cur.keys(), ...base.keys()])) {
177
- const c = cur.get(fn) ?? new Set(), b = base.get(fn) ?? new Set();
178
- const gained = [...c].filter((e) => !b.has(e)).sort();
179
- const lost = [...b].filter((e) => !c.has(e)).sort();
180
- if (gained.length || lost.length) changes.push({ fn, gained, lost });
181
- }
182
- changes.sort((a, b) => a.fn.localeCompare(b.fn));
168
+ const { changes } = coreDiff(loadReport(curPrefix), loadReport(basePrefix));
183
169
  // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
184
170
  // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
185
171
  // provenance fields as the Rust candor-query (cross-engine parity, item 10).
@@ -239,26 +225,10 @@ switch (cmd) {
239
225
  }
240
226
  case "whatif": {
241
227
  const [prefix, target, eff, maybePolicy] = args;
242
- const cg = loadCallgraph(prefix);
243
- const names = Object.keys(cg);
244
- const targets = matches(names, target);
245
- if (targets.length === 0) {
246
- console.error(`candor: no function matching \`${target}\` in the call graph`);
247
- process.exit(2);
248
- }
249
- const rev = new Map();
250
- for (const [caller, callees] of Object.entries(cg))
251
- for (const c of callees) (rev.get(c) ?? rev.set(c, []).get(c)).push(caller);
252
- const affected = new Set(targets);
253
- const queue = [...targets];
254
- while (queue.length) {
255
- const n = queue.pop();
256
- for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
257
- }
258
- const violations = [];
259
228
  // A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
260
229
  // a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
261
230
  // which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
231
+ let pol = null;
262
232
  if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
263
233
  let text;
264
234
  try {
@@ -267,16 +237,17 @@ switch (cmd) {
267
237
  console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
268
238
  process.exit(2);
269
239
  }
270
- const pol = parsePolicy(text);
271
- for (const r of pol.deny) {
272
- if (r.effects.length && !r.effects.includes(eff)) continue; // pure ([]) forbids ANY effect
273
- for (const fn of affected)
274
- if (!r.scope || scopeMatches(fn, r.scope))
275
- violations.push({ fn, rule: `deny ${r.effects.join(" ") || "(pure)"} ${r.scope}`.trim() });
276
- }
240
+ pol = parsePolicy(text);
241
+ }
242
+ // Shared query-core — the CLI and MCP `candor_whatif` are one blast-radius + deny evaluation
243
+ // (the CLI keeps the I/O + exit codes; the core is pure — see `where` above for the drift class).
244
+ const r = coreWhatif(loadCallgraph(prefix), target, eff, pol, scopeMatches);
245
+ if (r === null) {
246
+ console.error(`candor: no function matching \`${target}\` in the call graph`);
247
+ process.exit(2);
277
248
  }
278
- emit({ of: targets, effect: eff, affected: [...affected].sort(), violations, ok: violations.length === 0 });
279
- process.exit(violations.length ? 1 : 0);
249
+ emit(r);
250
+ process.exit(r.violations.length ? 1 : 0);
280
251
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
281
252
  }
282
253
  default:
package/scan.mjs CHANGED
@@ -110,6 +110,16 @@ if (target === null) { console.error(usage); process.exit(2); }
110
110
  // implements `policy` + `deps` — the others are inert here (they drive other engines' gates), and a key
111
111
  // OUTSIDE the vocabulary warns (typo protection: a misspelt `policy` must not silently drop the gate).
112
112
  const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps"]);
113
+ // The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
114
+ // belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
115
+ // `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
116
+ // process CWD (family rule, matching policy.mjs discoverConfigPolicy's repoRoot): a checked-in config
117
+ // must mean the same file whether the scan is launched from the repo, from $HOME, or from a CI step's
118
+ // working-directory. Env/CLI values stay CWD-relative — they're per-invocation, not checked in.
119
+ function configAnchor(file) {
120
+ const dir = path.dirname(path.resolve(file));
121
+ return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
122
+ }
113
123
  function loadCandorConfig(targetPath) {
114
124
  let file = process.env.CANDOR_CONFIG ?? null;
115
125
  if (file !== null) {
@@ -146,6 +156,12 @@ function loadCandorConfig(targetPath) {
146
156
  }
147
157
  cfg[key] = val;
148
158
  }
159
+ // Resolve the PATH-valued keys against the config's anchor (see configAnchor). `deps` is a path
160
+ // LIST — each token resolves; an empty value stays empty (configured-with-empty fails loud below).
161
+ const anchor = configAnchor(file);
162
+ if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
163
+ if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
164
+ if (cfg.deps) cfg.deps = cfg.deps.split(/[\s:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
149
165
  return cfg;
150
166
  }
151
167
  const candorConfig = loadCandorConfig(target);
@@ -482,6 +498,27 @@ function moduleOf(sf) {
482
498
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
483
499
  return rel.split(path.sep).join(".");
484
500
  }
501
+ // Enclosing `namespace`/`module` blocks are NAME SEGMENTS (the family ruling: §6.2 scope segments
502
+ // split on the same boundaries as the §3.1 query name ladder, and a namespace is a segment — rust
503
+ // modules and swift enum-namespaces already qualify this way). A unit declared in
504
+ // `export namespace app { … }` is `mod.app.fn`, so a layer policy authored against namespace layers
505
+ // (`forbid app -> repo`, `deny Db app`) bites in TS instead of being silently inert. Returns the
506
+ // dotted prefix ("app." / "a.b.") or "". Dotted (`namespace a.b`) and nested forms both contribute
507
+ // each identifier segment; ambient string-named modules (`declare module "x"`) and `declare global`
508
+ // augmentations contribute nothing (not lexical layers of THIS module).
509
+ function namespacePrefixOf(node) {
510
+ const segs = [];
511
+ for (let p = node.parent; p && !ts.isSourceFile(p); p = p.parent) {
512
+ if (!ts.isModuleBlock(p)) continue;
513
+ // `namespace a.b { … }` nests ModuleDeclarations (a -> b -> block); walk the chain so every
514
+ // dotted segment lands, innermost-first up.
515
+ for (let d = p.parent; d && ts.isModuleDeclaration(d); d = ts.isModuleDeclaration(d.parent) ? d.parent : null) {
516
+ if (d.name && ts.isIdentifier(d.name) && !(d.flags & ts.NodeFlags.GlobalAugmentation))
517
+ segs.unshift(d.name.text);
518
+ }
519
+ }
520
+ return segs.length ? `${segs.join(".")}.` : "";
521
+ }
485
522
  // Is `node` (a function-expression / method-declaration / arrow) the `get` or `set` member of an
486
523
  // accessor DESCRIPTOR object passed to `Object.defineProperty(target, key, desc)` /
487
524
  // `Object.defineProperties(target, { key: desc, … })` / `Object.create(proto, { key: desc, … })`?
@@ -682,7 +719,7 @@ for (const sf of sources) {
682
719
  }
683
720
  }
684
721
  }
685
- const ctorQual = `${mod}.${node.name.text}.constructor`;
722
+ const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
686
723
  if (!fns.has(ctorQual)) {
687
724
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
688
725
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
@@ -703,7 +740,11 @@ for (const sf of sources) {
703
740
  // shared entry — FABRICATING them onto a pure caller. `nodeName` is keyed by NODE identity, so a
704
741
  // per-node-unique key keeps resolution exact; only TOP-LEVEL units need the stable bare name a
705
742
  // consumer's hash-join targets (a function-scoped local is never an export, so nothing joins to it).
706
- const qual = isFunctionScoped(node) ? `${mod}.${n}#${line + 1}:${character + 1}` : `${mod}.${n}`;
743
+ // Namespace segments go in the QUAL only; `local` (and so the §2 hash `pkg#local`) stays the
744
+ // bare name — a consumer's cross-package join resolves the callee's own name, never the
745
+ // producer's namespace nesting, so widening the hash would break report chaining.
746
+ const nsp = namespacePrefixOf(node);
747
+ const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
707
748
  fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
708
749
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
709
750
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
@@ -1470,7 +1511,7 @@ function visitCalls(node) {
1470
1511
  // dispatch-frontier (callers --include-unknown) can resolve overrides against the
1471
1512
  // hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
1472
1513
  const tn = decl.parent?.name
1473
- ? `${moduleOf(decl.parent.getSourceFile())}.${decl.parent.name.getText()}`
1514
+ ? `${moduleOf(decl.parent.getSourceFile())}.${namespacePrefixOf(decl.parent)}${decl.parent.name.getText()}`
1474
1515
  : "type";
1475
1516
  const mn = decl.name?.getText?.() ?? "member";
1476
1517
  rec.why.add(`dispatch:${tn}.${mn}`); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
@@ -2096,10 +2137,10 @@ for (const sf of sources) {
2096
2137
  let sym = checker.getSymbolAtLocation(t.expression);
2097
2138
  if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
2098
2139
  const d = (sym?.declarations ?? []).find((x) => ts.isClassDeclaration(x) || ts.isInterfaceDeclaration(x));
2099
- supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${d.name.getText()}` : t.expression.getText());
2140
+ supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : t.expression.getText());
2100
2141
  }
2101
2142
  }
2102
- if (supers.length) hierarchy[`${mod}.${node.name.getText()}`] = supers;
2143
+ if (supers.length) hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
2103
2144
  }
2104
2145
  ts.forEachChild(node, walk);
2105
2146
  })(sf);
@@ -2128,14 +2169,18 @@ if (unlistedSeen.size > 0) {
2128
2169
  }
2129
2170
 
2130
2171
  // ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
2172
+ // `!== null`, not truthiness: a CONFIGURED-but-EMPTY policy (a bare `policy` config line, a set-but-
2173
+ // empty CANDOR_POLICY) is "" — falsy, so a truthy check silently skipped the gate, the exact quiet
2174
+ // drop the config comment above promises fails loud. "" now reaches the read, which fails → exit 2
2175
+ // (the Rust engine's behavior on the same input).
2131
2176
  let gateViolations = [];
2132
- if (policyPath) {
2177
+ if (policyPath !== null) {
2133
2178
  let text;
2134
2179
  try {
2135
2180
  text = fs.readFileSync(policyPath, "utf8");
2136
2181
  } catch {
2137
2182
  // a set-but-unreadable policy must be LOUD — silently passing would let a violation ship
2138
- console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
2183
+ console.error(`candor-ts: policy ${policyPath === "" ? "(configured empty)" : policyPath} could not be read; gate NOT enforced`);
2139
2184
  process.exit(2);
2140
2185
  }
2141
2186
  // The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
@@ -2162,8 +2207,8 @@ if (gateJsonPath) {
2162
2207
  catch (e) { console.error(`candor-ts: could not write --gate-json ${gateJsonPath}: ${e.message}`); }
2163
2208
  }
2164
2209
  }
2165
- if (policyPath && gateViolations.length) {
2210
+ if (policyPath !== null && gateViolations.length) {
2166
2211
  console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
2167
2212
  process.exit(1);
2168
2213
  }
2169
- if (policyPath) console.error("candor-ts: policy ✓");
2214
+ if (policyPath !== null) console.error("candor-ts: policy ✓");
package/watch.mjs CHANGED
@@ -121,6 +121,14 @@ async function main() {
121
121
  // NO .unref() — the interval is the ONLY thing keeping the process alive; unref'ing it made Node exit
122
122
  // ~0.6s after the startup scan, so the watcher did ONE scan and died while printing "Watching…" (the
123
123
  // whole feature was silently broken, and test-watch.mjs only tests the helpers, never the live loop).
124
+
125
+ // GRACEFUL stop: the documented quit is Ctrl-C, but the default SIGINT/SIGTERM handler TERMINATES —
126
+ // exit hooks never run, the stop reads as a signal death (no exit code) to a supervisor, and a child
127
+ // instrumented with NODE_V8_COVERAGE discards its coverage (the TESTING.md §6 flush rule — this made
128
+ // the live loop measure 0% while actually exercised). Handle both: announce, exit 0.
129
+ for (const sig of ["SIGINT", "SIGTERM"]) {
130
+ process.on(sig, () => { console.error(`candor-ts-watch: ${sig} — stopping`); process.exit(0); });
131
+ }
124
132
  }
125
133
 
126
134
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) main();