candor-ts 0.18.0 → 0.20.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.18)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.20)."*
16
16
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
package/README.md CHANGED
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
184
184
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
185
185
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
186
186
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
187
- | `{ candor: { version, toolchain, spec: "0.18" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.20" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
188
188
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
189
189
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
190
190
 
@@ -202,7 +202,7 @@ read the Rust source".
202
202
 
203
203
  ## Status
204
204
 
205
- 0.18.x, speaking candor-spec 0.18: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.19.x, speaking candor-spec 0.20: the analysis core, the gate (`--policy` / `--gate-json` /
206
206
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
207
207
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
208
208
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.18.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.18)",
3
+ "version": "0.20.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.20)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/policy.mjs CHANGED
@@ -4,7 +4,26 @@
4
4
  * engines follow (candor-classify::policy), so the TS gate can never disagree with its own whatif.
5
5
  */
6
6
 
7
+ import { NET_DEST_CLASSES, netClassesOf } from "./scan-core.mjs";
8
+
7
9
  export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"];
10
+
11
+ // Reason-scoped Unknown (REASON-SCOPED-UNKNOWN-DESIGN.md): the CLOSED, cross-engine reason-class set a
12
+ // `deny E Unknown[class…]` rule quantifies over. Must be IDENTICAL to candor-java's ReasonClass and
13
+ // candor-rust's — the mapping below mirrors java's prefix-based ReasonClass.classify(String).
14
+ export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
15
+ // `dynamic` = every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` so it never under-gates.
16
+ const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
17
+ /** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
18
+ export function reasonClass(why) {
19
+ const w = String(why).trim().toLowerCase();
20
+ if (w.startsWith("reflect") || w === "dynamicmemberlookup") return "reflect";
21
+ if (w.startsWith("native")) return "native";
22
+ if (w.startsWith("callback") || w.startsWith("closure") || w.startsWith("task-handoff")) return "indirect";
23
+ if (w.startsWith("dispatch") || w.startsWith("indy") || w.startsWith("ambiguous")) return "dispatch";
24
+ if (w.startsWith("missing-config") || w.startsWith("no-tsconfig") || w.startsWith("no-node_modules")) return "setup";
25
+ return "unresolved"; // conservative catch-all
26
+ }
8
27
  // The literal surfaces `allow` can restrict. `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) —
9
28
  // `allow Llm <host…>` restricts which MODEL hosts a scope may reach, matched by hostname like Net.
10
29
  const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
@@ -14,7 +33,10 @@ const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
14
33
  // (adversarial DSL review). A non-ASCII space stays part of its token → the rule is malformed, dropped.
15
34
  const ASCII_WS = /[ \t\n\v\f\r]+/;
16
35
  const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
17
- export function parsePolicy(text) {
36
+ // ⟨0.19⟩ `aliases` (a Map name→class-token[], from `.candor/config` `unknown-alias`) lets an `Unknown[<name>]`
37
+ // filter resolve a user-defined name (SPEC §6.2). A config alias never changes what bare `deny E Unknown`
38
+ // means (always `Unknown[*]`), so a rule's denied set stays legible from the policy alone.
39
+ export function parsePolicy(text, aliases = null) {
18
40
  const deny = [], allow = [], forbid = [];
19
41
  // Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
20
42
  // breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
@@ -28,14 +50,59 @@ export function parsePolicy(text) {
28
50
  if (t[0] === "deny") {
29
51
  const effects = [];
30
52
  let scope = "";
53
+ // Reason-class filter on an `Unknown` membership: empty ⇒ `Unknown[*]` (any reason — the bare
54
+ // form); non-empty ⇒ only those classes. `*` = all; `dynamic` = every genuine class.
55
+ const unknownClasses = new Set();
56
+ let unknownStar = false;
57
+ // Destination-class filter on a `Net` membership (NET-DESTINATION-CLASS-DESIGN.md): empty ⇒ `Net[*]`
58
+ // (any destination — the bare form); non-empty ⇒ only those classes. `*` = all.
59
+ const netClasses = new Set();
60
+ let netStar = false;
31
61
  for (const tok of t.slice(1)) {
32
- if (EFFECTS.includes(tok) || tok === "Unknown") effects.push(tok);
33
- else { scope = tok; break; }
62
+ const nm = /^Net\[(.*)\]$/.exec(tok);
63
+ if (nm) {
64
+ effects.push("Net");
65
+ for (let cn of nm[1].split(",")) {
66
+ cn = cn.trim();
67
+ if (!cn) continue;
68
+ if (cn === "*") netStar = true;
69
+ else if (NET_DEST_CLASSES.includes(cn)) netClasses.add(cn);
70
+ else warn(`unknown Net destination-class \`${cn}\` (known: ${NET_DEST_CLASSES.join(",")}, or *)`);
71
+ }
72
+ continue;
73
+ }
74
+ const m = /^Unknown\[(.*)\]$/.exec(tok);
75
+ if (m) {
76
+ effects.push("Unknown");
77
+ for (let cn of m[1].split(",")) {
78
+ cn = cn.trim();
79
+ if (!cn) continue;
80
+ if (cn === "*") unknownStar = true;
81
+ else if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => unknownClasses.add(c));
82
+ else if (REASON_CLASSES.includes(cn)) unknownClasses.add(cn);
83
+ else if (aliases && aliases.has(cn)) aliases.get(cn).forEach((c) => unknownClasses.add(c)); // ⟨0.19⟩ config unknown-alias
84
+ else warn(`unknown reason-class/alias \`${cn}\` (known: ${REASON_CLASSES.join(",")}; aliases: dynamic,*, or a config \`unknown-alias\`)`);
85
+ }
86
+ continue;
87
+ }
88
+ if (EFFECTS.includes(tok) || tok === "Unknown") {
89
+ effects.push(tok);
90
+ if (tok === "Unknown") unknownStar = true; // bare Unknown ⇒ all classes
91
+ if (tok === "Net") netStar = true; // bare Net ⇒ all destinations
92
+ } else { scope = tok; break; }
34
93
  }
35
94
  if (effects.length === 0) { warn("deny names no known effect"); continue; }
36
- deny.push({ effects: [...new Set(effects)].sort(), scope, raw: line }); // dedup: a set, like rust/java
95
+ // `*` (or bare Unknown) means all classes ⇒ empty filter (matches any Unknown).
96
+ let uc = unknownStar ? [] : [...unknownClasses].sort();
97
+ // `*` (or bare Net) means all destinations ⇒ empty filter (matches any Net).
98
+ const nc = netStar ? [] : [...netClasses].sort();
99
+ // A2 under-gating lint: a narrowed scope omitting `unresolved` (the catch-all for holes the engine
100
+ // couldn't classify) may silently tolerate exactly those — flag it (advisory, non-fatal).
101
+ if (uc.length && !uc.includes("unresolved"))
102
+ console.error(`candor: policy rule narrows \`Unknown[…]\` but omits \`unresolved\` — may UNDER-gate on holes the engine couldn't classify; add \`unresolved\` (or use \`dynamic\`): ${line}`);
103
+ deny.push({ effects: [...new Set(effects)].sort(), scope, unknownClasses: uc, netClasses: nc, raw: line }); // dedup: a set, like rust/java
37
104
  } else if (t[0] === "pure") {
38
- deny.push({ effects: [], scope: t[1] ?? "", raw: line });
105
+ deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], netClasses: [], raw: line });
39
106
  } else if (t[0] === "allow") {
40
107
  if (t.length < 3) { warn("allow names no values"); continue; }
41
108
  if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
@@ -117,11 +184,39 @@ export function literalAllowed(effect, reached, values) {
117
184
  // is the specific denied/allowed effect set the violation concerns ([] for the 009 layer-flow, which has
118
185
  // no single effect); `detail` is the message BODY (no `[AS-EFF-00x]` prefix — the rule carries the code).
119
186
  // The console gate renders `[${rule}] ${detail}`; --gate-json emits the records verbatim.
120
- export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
187
+ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set()) {
121
188
  const out = [];
122
189
  // `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
123
190
  const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
124
- const push = (rule, fn, effects, detail) => out.push({ rule, fn, effects, detail });
191
+ // §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
192
+ // (all destination classes on the fn) rides a Net violation. Both omitted when empty (byte-identical verdict).
193
+ const push = (rule, fn, effects, detail, reasonClass, netClass) => {
194
+ const rec = { rule, fn, effects, detail };
195
+ if (reasonClass && reasonClass.length) rec.reasonClass = reasonClass;
196
+ if (netClass && netClass.length) rec.netClass = netClass;
197
+ out.push(rec);
198
+ };
199
+ // Reason-scoped Unknown: the Unknown reason CLASS must travel the call graph the same way the Unknown
200
+ // EFFECT does (unknownWhy in the report is direct-only). Classify each fn's DIRECT reasons to class
201
+ // tokens, then propagate transitively over `callgraph` to a fixpoint — so `deny E Unknown[reflect]` at a
202
+ // caller inheriting Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
203
+ const reasonAcc = new Map();
204
+ for (const f of functions) {
205
+ const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
206
+ if (cs.size) reasonAcc.set(f.fn, cs);
207
+ }
208
+ for (let changed = true; changed; ) {
209
+ changed = false;
210
+ for (const [caller, callees] of Object.entries(callgraph)) {
211
+ for (const callee of callees) {
212
+ const cc = reasonAcc.get(callee);
213
+ if (!cc) continue;
214
+ let set = reasonAcc.get(caller);
215
+ if (!set) { set = new Set(); reasonAcc.set(caller, set); }
216
+ for (const c of cc) if (!set.has(c)) { set.add(c); changed = true; }
217
+ }
218
+ }
219
+ }
125
220
  for (const f of functions) {
126
221
  for (const r of pol.deny) {
127
222
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -132,7 +227,31 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
132
227
  const hits = r.effects.length === 0
133
228
  ? f.inferred.filter((e) => e !== "Unknown")
134
229
  : f.inferred.filter((e) => r.effects.includes(e));
135
- if (hits.length) push("AS-EFF-006", f.fn, hits, `\`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
230
+ // Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
231
+ // TRANSITIVE reason classes include one of those; an Unknown with no recorded reason ⇒ `unresolved`.
232
+ let kept = hits;
233
+ if (hits.includes("Unknown") && (r.unknownClasses?.length)) {
234
+ const cs = reasonAcc.get(f.fn);
235
+ const fnClasses = cs && cs.size ? [...cs] : ["unresolved"];
236
+ if (!fnClasses.some((c) => r.unknownClasses.includes(c))) kept = hits.filter((e) => e !== "Unknown");
237
+ }
238
+ // Net destination-class: a `deny Net[dest…]` keeps its Net hit only for a fn reaching one of those
239
+ // destination classes; else tolerate (only asserted-safe destinations). Fail-closed: a masked surface /
240
+ // a Net with no visible host is unknown-host (netClassesOf). The class travels the call graph via
241
+ // f.hosts + f.incomplete, both propagated transitively before the gate (scan.mjs).
242
+ if (kept.includes("Net") && (r.netClasses?.length)) {
243
+ const fnNet = netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners);
244
+ if (!fnNet.some((c) => r.netClasses.includes(c))) kept = kept.filter((e) => e !== "Net");
245
+ }
246
+ if (kept.length) {
247
+ // When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
248
+ const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
249
+ // ⟨0.20⟩ when Net is denied, report ALL of the fn's destination classes (transitive).
250
+ const ncv = kept.includes("Net")
251
+ ? netClassesOf(f.hosts ?? [], incomplete.get(f.fn)?.has("Net") ?? false, partners)
252
+ : undefined;
253
+ push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc, ncv);
254
+ }
136
255
  }
137
256
  for (const r of pol.allow) {
138
257
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -199,3 +318,68 @@ export function discoverConfigPolicy(fromDir) {
199
318
  dir = parent;
200
319
  }
201
320
  }
321
+
322
+ // ⟨0.19⟩ Discover `.candor/config` TEXT anchored at `fromDir`: $CANDOR_CONFIG if set + readable, else the
323
+ // nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
324
+ export function discoverConfigText(fromDir) {
325
+ const env = process.env.CANDOR_CONFIG;
326
+ if (env) { try { return fs.readFileSync(env, "utf8"); } catch { return null; } }
327
+ let dir = nodePath.resolve(fromDir);
328
+ for (;;) {
329
+ const cand = nodePath.join(dir, ".candor", "config");
330
+ if (fs.existsSync(cand)) { try { return fs.readFileSync(cand, "utf8"); } catch { return null; } }
331
+ const parent = nodePath.dirname(dir);
332
+ if (parent === dir) return null;
333
+ dir = parent;
334
+ }
335
+ }
336
+
337
+ // ⟨0.19⟩ Parse `unknown-alias <name> = <class,…>` lines (SPEC §6.2) into a Map name→class-token[]. A name
338
+ // that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped, as is a no-valid-class def.
339
+ // Byte-shape with the java `Config.addAlias` / rust `parse_unknown_aliases`.
340
+ export function parseUnknownAliases(configText) {
341
+ const out = new Map();
342
+ if (!configText) return out;
343
+ for (const raw of configText.split(/\r?\n/)) {
344
+ const line = raw.split("#", 1)[0].trim();
345
+ if (!line) continue;
346
+ const m = line.match(/^(\S+)\s+(.*)$/);
347
+ if (!m || m[1].toLowerCase() !== "unknown-alias") continue;
348
+ const eq = m[2].indexOf("=");
349
+ if (eq < 0) { console.error(`candor: ignoring \`unknown-alias\` (want \`unknown-alias <name> = <class,…>\`): ${m[2]}`); continue; }
350
+ const name = m[2].slice(0, eq).trim();
351
+ if (!name || name === "*" || name === "dynamic" || REASON_CLASSES.includes(name)) {
352
+ console.error(`candor: ignoring \`unknown-alias\` with reserved/empty name \`${name}\` (may not shadow \`*\`/\`dynamic\`/a class token)`);
353
+ continue;
354
+ }
355
+ const classes = new Set();
356
+ for (let cn of m[2].slice(eq + 1).split(",")) {
357
+ cn = cn.trim();
358
+ if (!cn) continue;
359
+ if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => classes.add(c));
360
+ else if (REASON_CLASSES.includes(cn)) classes.add(cn);
361
+ else console.error(`candor: \`unknown-alias ${name}\` names unknown reason-class \`${cn}\` — skipped`);
362
+ }
363
+ if (classes.size === 0) console.error(`candor: ignoring \`unknown-alias ${name}\` — no valid reason-class`);
364
+ else out.set(name, [...classes]);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // ⟨0.20⟩ Parse `net-partner <host>` lines (NET-DESTINATION-CLASS-DESIGN.md) into a Set of host-normalized
370
+ // partner hosts — the per-project `known-partner` set for the Net destination-class classifier. Multi-value
371
+ // (repeatable key); the value's `:port` is stripped + lowercased like MODEL_HOSTS. Case-insensitive key,
372
+ // mirroring parseUnknownAliases + the java/rust config loaders. A partner is per-project — never universal.
373
+ export function parseNetPartners(configText) {
374
+ const out = new Set();
375
+ if (!configText) return out;
376
+ for (const raw of configText.split(/\r?\n/)) {
377
+ const line = raw.split("#", 1)[0].trim();
378
+ if (!line) continue;
379
+ const m = line.match(/^(\S+)\s+(.*)$/);
380
+ if (!m || m[1].toLowerCase() !== "net-partner") continue;
381
+ const val = m[2].trim();
382
+ if (val) out.add(hostPart(val).toLowerCase());
383
+ }
384
+ return out;
385
+ }
package/query-core.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import fs from "node:fs";
12
12
  import nodePath from "node:path";
13
+ import { reasonClass } from "./policy.mjs";
13
14
 
14
15
  // Sibling report/callgraph files of a multi-report prefix (candor-scan writes <prefix>.<crate>.scan.json,
15
16
  // one per workspace member) — so the loaders read ANY engine's output, not just candor-ts's <prefix>.json.
@@ -498,13 +499,32 @@ export function impact(fns, cg, q) {
498
499
  // Unknown through it). The actionable inverse of a widely-propagated Unknown: a report can read mostly
499
500
  // Unknown from a handful of root causes — this names them, ranked, to declare/resolve/accept. Matches
500
501
  // candor-java/candor-query: { sources:[{fn,why,reaches,affected}], totalUnknown }.
501
- export function blindspots(fns, cg) {
502
+ // ⟨0.20⟩ `--class <c,…>` filter: the six reason classes, `dynamic` (every genuine class), or `*` (all).
503
+ // null spec ⇒ no filter; an unknown token warns; an all-unknown spec ⇒ an empty set that matches nothing.
504
+ const ALL_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
505
+ export function parseClassFilter(spec) {
506
+ if (!spec) return null;
507
+ const out = new Set();
508
+ for (let t of spec.split(",")) {
509
+ t = t.trim();
510
+ if (!t) continue;
511
+ if (t === "*") return new Set(ALL_CLASSES);
512
+ if (t === "dynamic") { for (const c of ALL_CLASSES) if (c !== "setup") out.add(c); continue; }
513
+ if (ALL_CLASSES.includes(t)) out.add(t);
514
+ else console.error(`candor-ts: --class ignores unknown reason-class \`${t}\` (known: ${ALL_CLASSES.join(",")}; aliases: dynamic,*)`);
515
+ }
516
+ return out;
517
+ }
518
+ const classMatches = (cf, why) => cf === null || (why ?? []).some((w) => cf.has(reasonClass(w)));
519
+
520
+ export function blindspots(fns, cg, classSpec = null) {
521
+ const cf = parseClassFilter(classSpec);
502
522
  const rev = reverseGraph(cg);
503
523
  const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
504
524
  const sources = [];
505
525
  for (const e of fns) {
506
526
  const why = e.unknownWhy ?? [];
507
- if (why.length === 0) continue; // a SOURCE carries its own unknownWhy; a purely-transitive Unknown does not
527
+ if (why.length === 0 || !classMatches(cf, why)) continue; // a SOURCE of a matching reason class
508
528
  const reached = new Set();
509
529
  const queue = [e.fn];
510
530
  const seen = new Set([e.fn]);
@@ -519,6 +539,26 @@ export function blindspots(fns, cg) {
519
539
  return { sources, totalUnknown };
520
540
  }
521
541
 
542
+ // `blindspots --stats` (SPEC §3.1 ⟨0.20⟩): the reason-class DISTRIBUTION over the Unknown SOURCES — how
543
+ // much Unknown, by class {reflect,dispatch,indirect,native,unresolved,setup} — so a team can SIZE the
544
+ // blind-spot cost (and separate genuine dynamism from `setup` mis-config) BEFORE `deny E Unknown`. Counts
545
+ // SOURCE functions per class (a multi-reason fn counts in each class it has). Matches candor-java/rust/swift.
546
+ export function blindspotsStats(fns, classSpec = null) {
547
+ const cf = parseClassFilter(classSpec);
548
+ const ORDER = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
549
+ const byClass = Object.fromEntries(ORDER.map((c) => [c, 0]));
550
+ const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
551
+ let sources = 0;
552
+ for (const e of fns) {
553
+ const why = e.unknownWhy ?? [];
554
+ if (why.length === 0 || !classMatches(cf, why)) continue;
555
+ sources++;
556
+ const classes = new Set(why.map(reasonClass));
557
+ for (const c of classes) byClass[c]++;
558
+ }
559
+ return { byClass, sources, totalUnknown };
560
+ }
561
+
522
562
  // path: the FORWARD provenance — a shortest BFS over the calls graph from `fn` to the nearest unit
523
563
  // that performs `eff` DIRECTLY (the source). Matches candor-query's {effect, fn, path:[{fn,loc,source}]}.
524
564
  export function path(fns, cg, fnQ, eff) {
@@ -806,12 +846,13 @@ export function unverifiedHoleRule(fn, inferred, policyParsed, scopeMatches) {
806
846
  return null;
807
847
  }
808
848
 
809
- export function unverified(fns, policyParsed, scopeMatches) {
849
+ export function unverified(fns, policyParsed, scopeMatches, classSpec = null) {
850
+ const cf = parseClassFilter(classSpec); // ⟨0.20⟩ --class: keep only holes of a matching reason class
810
851
  const holes = [];
811
852
  for (const e of fns) {
812
853
  // Same predicate + upgrade as the gate note (scan.mjs) — one source of truth for a hole.
813
854
  const r = unverifiedHoleRule(e.fn, e.inferred, policyParsed, scopeMatches);
814
- if (!r) continue;
855
+ if (!r || !classMatches(cf, e.unknownWhy)) continue;
815
856
  const [rule, upgrade] = ruleUpgrade(r);
816
857
  holes.push({ fn: e.fn, rule, unknownWhy: e.unknownWhy ?? [], upgrade });
817
858
  }
package/query.mjs CHANGED
@@ -23,7 +23,7 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
 
26
- import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
26
+ import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText } from "./policy.mjs";
27
27
  import { hasReport } from "./query-core.mjs";
28
28
  import { printAgents } from "./contract.mjs";
29
29
  import { bestFinds } from "./surface.mjs";
@@ -33,7 +33,7 @@ import { isTestPath } from "./scan-core.mjs";
33
33
  // a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
34
34
  // JVM `Type#method` report). Importing the shared functions removes all three divergences (review find).
35
35
  import { impact as coreImpact, path as corePath, gains as coreGains,
36
- show as coreShow, blindspots as coreBlindspots,
36
+ show as coreShow, blindspots as coreBlindspots, blindspotsStats as coreBlindspotsStats,
37
37
  callers as coreCallers, callersFrontier, loadHierarchy,
38
38
  containment as coreContainment, diff as coreDiff,
39
39
  where as coreWhere, map as coreMap, whatif as coreWhatif,
@@ -46,7 +46,7 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
46
46
  const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
47
47
  // Suggest the nearest known flag for a typo (longest shared prefix ≥3): `--polciy` → `--policy` (#2).
48
48
  function didYouMeanFlag(unknown) {
49
- const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown"];
49
+ const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown", "--stats", "--class"];
50
50
  const u = unknown.replace(/^-+/, "").toLowerCase();
51
51
  let best = null, bestLen = 2;
52
52
  for (const k of known) {
@@ -132,6 +132,12 @@ const P = {
132
132
  console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
133
133
  for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
134
134
  },
135
+ blindspotsStats: (d) => {
136
+ if (!d.sources) { console.log("candor blindspots --stats — no Unknown sources (nothing to classify). ✓"); return; }
137
+ console.log(`candor blindspots --stats — ${d.sources} Unknown source(s) by reason class (of ${d.totalUnknown} function(s) carrying Unknown) — size the blind-spot cost before \`deny E Unknown[…]\`:`);
138
+ Object.entries(d.byClass).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
139
+ .forEach(([k, v]) => console.log(` ${k.padEnd(12)} ${String(v).padStart(4)}${k === "setup" ? " ← fixable: the scan isn't configured, not a real blind spot" : ""}`));
140
+ },
135
141
  gains: (d) => {
136
142
  if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
137
143
  console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
@@ -196,7 +202,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
196
202
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
197
203
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
198
204
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
199
- const SPEC_VERSION = "0.18";
205
+ const SPEC_VERSION = "0.20";
200
206
 
201
207
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
202
208
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -292,11 +298,16 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
292
298
  // wantJsonOut(rawArgs), never a positional
293
299
  if (a === "--strict") { if (strict) wantStrict = true; continue; } // vocabulary — tolerated everywhere,
294
300
  if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
301
+ if (a === "--stats") { continue; } // ⟨0.20⟩ tolerated everywhere; read by the `blindspots` case via args.includes
302
+ if (a === "--class") { // ⟨0.20⟩ value flag; the value is read by the `blindspots` case
303
+ if (i + 1 >= rawArgs.length) { console.error("candor-ts: --class requires a <class,…> value (reflect,dispatch,indirect,native,unresolved,setup; aliases: dynamic,*)"); process.exit(2); }
304
+ i++; continue;
305
+ }
295
306
  if (a.startsWith("-") && a.length > 1) {
296
307
  // An unrecognized flag is a TYPO, not a positional — reject it LOUD (exit 2), never silently swallow.
297
308
  // A swallowed `--polciy` runs the query with NO policy and exits green: a CI author who typos --policy
298
309
  // ships a gate that never fires (corpus re-audit cardinal sin — a loud error, never a silent guess).
299
- console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown`);
310
+ console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown, --stats`);
300
311
  process.exit(2);
301
312
  }
302
313
  positionals.push(a);
@@ -401,7 +412,7 @@ const SUBCOMMANDS = [
401
412
  ["diff", "<current> <baseline> [--json]", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
402
413
  ["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
403
414
  ["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
404
- ["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
415
+ ["blindspots", `${REPORT_TAIL} [--stats] [--class <c,…>]`, "the Unknown sources ranked by blast radius; --stats: reason-class distribution; --class: drill down"],
405
416
  ["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
406
417
  ["gains", "<current> <baseline> [--json] [--strict]", "the supply-chain alarm: what the surface gained between two reports (--strict: exit 1 on ANY gain)"],
407
418
  ["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
@@ -501,7 +512,10 @@ switch (cmd) {
501
512
  console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
502
513
  process.exit(2);
503
514
  }
504
- emit(parsePolicy(text));
515
+ // ⟨0.19⟩ config-aware: resolve `Unknown[<alias>]` via a checked-in `unknown-alias`, anchored to the
516
+ // policy file (or CANDOR_CONFIG) — the dump reflects real gate resolution + pins the four-way expansion.
517
+ const aliases = parseUnknownAliases(discoverConfigText(path.dirname(path.resolve(args[0]))));
518
+ emit(parsePolicy(text, aliases));
505
519
  break;
506
520
  }
507
521
  case "show": {
@@ -653,7 +667,13 @@ switch (cmd) {
653
667
  // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
654
668
  // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
655
669
  const { prefix } = resolveReportVerb(args, 0);
656
- put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)), P.blindspots);
670
+ const ci = args.indexOf("--class");
671
+ const classFilter = ci >= 0 ? args[ci + 1] : null; // ⟨0.20⟩ drill-down by reason class
672
+ if (args.includes("--stats")) { // ⟨0.20⟩ the reason-class distribution, not the source list
673
+ put(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats);
674
+ } else {
675
+ put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots);
676
+ }
657
677
  break;
658
678
  }
659
679
  case "tour": {
@@ -891,7 +911,8 @@ switch (cmd) {
891
911
  let ptext;
892
912
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
893
913
  catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
894
- const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
914
+ const uci = args.indexOf("--class"); // ⟨0.20⟩ drill-down by reason class
915
+ const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches, uci >= 0 ? args[uci + 1] : null);
895
916
  emit(r);
896
917
  process.exit(strict && !r.ok ? 1 : 0);
897
918
  break; // unreachable
package/scan-core.mjs CHANGED
@@ -293,6 +293,65 @@ export function isModelHost(hostLiteral) {
293
293
  export function modelHostEffects(hostLiteral) {
294
294
  return isModelHost(hostLiteral) ? ["Llm"] : [];
295
295
  }
296
+ // ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
297
+ // (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with the sibling engines (java Literals.TELEMETRY_HOSTS
298
+ // / rust TELEMETRY_HOSTS), like MODEL_HOSTS. A benign observability endpoint. Matched by host,
299
+ // case-insensitive; a SUBDOMAIN of a listed host counts. Tight, high-precision STARTER set — mis-including
300
+ // an exfil-capable host would under-gate `deny Net[unknown-host]`.
301
+ export const TELEMETRY_HOSTS = new Set([
302
+ "sentry.io",
303
+ "bugsnag.com",
304
+ "rollbar.com",
305
+ "segment.io", "segment.com",
306
+ "mixpanel.com",
307
+ "amplitude.com",
308
+ "google-analytics.com", "analytics.google.com",
309
+ "datadoghq.com", "datadoghq.eu",
310
+ "newrelic.com", "nr-data.net",
311
+ "honeycomb.io",
312
+ "logtail.com",
313
+ ]);
314
+ // Normalize a `host[:port]` literal to a bare lowercase hostname (the MODEL_HOSTS stripping), for the
315
+ // destination-class membership tests.
316
+ function normHost(hostLiteral) {
317
+ if (hostLiteral == null) return "";
318
+ let host = hostLiteral;
319
+ if (host.startsWith("[")) { const e = host.indexOf("]"); if (e >= 0) host = host.slice(1, e); }
320
+ else if ((host.match(/:/g) ?? []).length === 1) host = host.split(":")[0];
321
+ return host.toLowerCase();
322
+ }
323
+ // Subdomain-aware membership of `hostLiteral` in a host `set` (mirrors java Literals.hostInSet).
324
+ function hostInSet(hostLiteral, set) {
325
+ const host = normHost(hostLiteral);
326
+ if (set.has(host)) return true;
327
+ for (const e of set) if (host.endsWith("." + e)) return true;
328
+ return false;
329
+ }
330
+ export function isTelemetryHost(hostLiteral) { return hostInSet(hostLiteral, TELEMETRY_HOSTS); }
331
+ // ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
332
+ // (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
333
+ // `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). `partners` is a
334
+ // per-project Set (config-declared). Never fabricated: a null/unresolved host is unknown-host. Mirrors java
335
+ // Literals.netDestClass.
336
+ export function netDestClass(hostLiteral, partners) {
337
+ if (isTelemetryHost(hostLiteral)) return "known-telemetry";
338
+ const host = normHost(hostLiteral);
339
+ const partnerMatch = partners && (partners.has(host) || [...partners].some((p) => host.endsWith("." + p)));
340
+ if (partnerMatch || isModelHost(hostLiteral)) return "known-partner";
341
+ return "unknown-host";
342
+ }
343
+ // ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
344
+ export const NET_DEST_CLASSES = ["known-telemetry", "known-partner", "unknown-host"];
345
+ // ⟨0.20⟩ The `Net` destination classes an fn reaches — the SINGLE derivation shared by the report's
346
+ // `netClass` field (scan.mjs) and the gate (policy.mjs), so they can never drift: an exact host-literal
347
+ // match (netDestClass) for the visible (transitive) hosts, plus the fail-closed `unknown-host` when the Net
348
+ // surface is masked (`netIncomplete`) OR carries no visible host (a runtime endpoint). `hostsArr` is an
349
+ // array; call only for an fn with Net. Returns sorted. Mirrors java Policy.netClassesOf / rust net_classes_of.
350
+ export function netClassesOf(hostsArr, netIncomplete, partners) {
351
+ const classes = new Set(hostsArr.map((h) => netDestClass(h, partners)));
352
+ if (netIncomplete || hostsArr.length === 0) classes.add("unknown-host");
353
+ return [...classes].sort();
354
+ }
296
355
  // Table-position identifiers in a SQL string literal (SPEC §2 `tables`). Mirrors the Rust
297
356
  // tables_in_sql exactly: must open with a statement keyword; FROM/JOIN/INTO anywhere,
298
357
  // statement-leading UPDATE/TRUNCATE, TABLE (skipping ONLY/IF NOT EXISTS); a FOR UPDATE locking
package/scan.mjs CHANGED
@@ -26,11 +26,11 @@ import fs from "node:fs";
26
26
  import path from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { createRequire } from "node:module";
29
- import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
29
+ import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText, reasonClass } 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,
33
- modelHostEffects, isModelHost, isModelSdkPackage } from "./scan-core.mjs";
33
+ modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
34
34
  import { emitSurface } from "./surface.mjs";
35
35
 
36
36
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
41
41
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
42
42
  // Reused, never re-littered.
43
43
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
44
- const SPEC_VERSION = "0.18";
44
+ const SPEC_VERSION = "0.20";
45
45
 
46
46
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
47
47
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -294,6 +294,13 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
294
294
  // A target with declared dependencies but no node_modules resolves almost nothing — the scan
295
295
  // would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
296
296
  // Warn LOUDLY; the report is still written (it is sound), but the cause must be visible.
297
+ // ⟨0.19⟩ Also compute `declaredButUninstalled` (SPEC §6.2 §3, the setup/genuine split): a declared dep
298
+ // whose `node_modules/<dep>` is absent. An `Unknown` caused by a call into one of these is a SETUP hole
299
+ // (`no-node_modules:<pkg>` → reason class `setup`), NOT a genuine dynamic blind spot — the fix is
300
+ // `npm install`, not a policy decision. Tagging them separates the fatigue-vector (the referee's
301
+ // week-two-uninstall) from real dynamism, so a team can `Unknown[dynamic]` a strict gate AND be told
302
+ // exactly what to configure to shrink the rest.
303
+ const declaredButUninstalled = new Set();
297
304
  {
298
305
  // Find the nearest package.json AT OR ABOVE the scan root: scanning a `src/` subdirectory must still see
299
306
  // the project manifest one level up, else the warning stays silent and a deps-less scan reads as a
@@ -303,14 +310,25 @@ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive:
303
310
  if (fs.existsSync(path.join(d, "package.json"))) { projDir = d; break; }
304
311
  if (path.dirname(d) === d) break; // filesystem root
305
312
  }
306
- if (projDir && !fs.existsSync(path.join(projDir, "node_modules"))) {
313
+ if (projDir) {
307
314
  try {
308
315
  // BOTH dependency kinds: `npm install` installs devDependencies too, and a project can import a
309
316
  // dev/vendored package in its source (zx imports `chalk` as a devDependency) — a `dependencies`-only
310
317
  // check left exactly that case unwarned.
311
318
  const pj = JSON.parse(fs.readFileSync(path.join(projDir, "package.json"), "utf8"));
312
319
  const deps = { ...(pj.dependencies ?? {}), ...(pj.devDependencies ?? {}) };
313
- if (Object.keys(deps).length > 0)
320
+ // "Installed?" follows node resolution: node_modules is searched at projDir AND every ANCESTOR, so a
321
+ // dep HOISTED to a monorepo/workspace root counts as installed. Checking only projDir wrongly named a
322
+ // hoisted-but-resolvable package in the SETUP diagnostic (review-found; cosmetic — the gate was already
323
+ // safe via the resolve-first ordering, but the message must not cry wolf).
324
+ const installed = (dep) => {
325
+ for (let d = projDir; ; d = path.dirname(d)) {
326
+ if (fs.existsSync(path.join(d, "node_modules", dep))) return true;
327
+ if (path.dirname(d) === d) return false;
328
+ }
329
+ };
330
+ for (const dep of Object.keys(deps)) if (!installed(dep)) declaredButUninstalled.add(dep);
331
+ if (Object.keys(deps).length > 0 && !fs.existsSync(path.join(projDir, "node_modules")))
314
332
  console.error("candor-ts: WARNING — the project declares dependencies but has no node_modules; " +
315
333
  "imports won't resolve, so calls into those packages can't be analyzed (they read " +
316
334
  "`Unknown`, and their types don't resolve). Run `npm install` in the project first.");
@@ -407,6 +425,33 @@ function declModule(decl) {
407
425
  return f;
408
426
  }
409
427
 
428
+ // ⟨0.19⟩ The bare-package ROOT of an import specifier: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`,
429
+ // a relative/absolute path → null (not a package). Used to match an import against `declaredButUninstalled`.
430
+ function pkgRoot(spec) {
431
+ if (!spec || spec.startsWith(".") || spec.startsWith("/")) return null;
432
+ const seg = spec.split("/");
433
+ return spec.startsWith("@") ? seg.slice(0, 2).join("/") : seg[0];
434
+ }
435
+
436
+ // ⟨0.19⟩ The import module a call's HEAD identifier binds to (`winston.info()` → head `winston`; `chalk()` →
437
+ // `chalk`) via its import declaration — resolvable even when the package ISN'T installed, because the import
438
+ // statement is syntactically present in the local file. Mirrors the specifier extraction at the κ seam.
439
+ // Returns the bare-package root, or null when the head isn't an imported binding.
440
+ function importPkgOfHead(expr) {
441
+ let head = expr;
442
+ while (head && ts.isPropertyAccessExpression(head)) head = head.expression;
443
+ if (!head || !ts.isIdentifier(head)) return null;
444
+ const sym = checker.getSymbolAtLocation(head);
445
+ for (const d of sym?.declarations ?? []) {
446
+ let spec = null;
447
+ if (ts.isNamespaceImport(d)) spec = d.parent?.parent?.moduleSpecifier;
448
+ else if (ts.isImportClause(d)) spec = d.parent?.moduleSpecifier; // default import
449
+ else if (ts.isImportSpecifier(d)) spec = d.parent?.parent?.parent?.moduleSpecifier; // named import
450
+ if (spec && ts.isStringLiteralLike(spec)) return pkgRoot(spec.text);
451
+ }
452
+ return null;
453
+ }
454
+
410
455
  // SPEC §5.1 — the effect manifest. An uncurated package MAY declare its effect surface in its
411
456
  // package.json (`"candorEffects": ["Net"]`), read as the declared-not-verified tier: it kills the
412
457
  // silent pure/blind-spot the package would otherwise carry, exactly like a cap type (and unlike
@@ -1662,8 +1707,16 @@ function visitCalls(node) {
1662
1707
  // happens in the global/builtin arm below, which fires for the same node.
1663
1708
  } else {
1664
1709
  rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
1665
- const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
1666
- rec.why.add(`callback:${callee}`); // an `any`-typed/indeterminate callee (a function VALUE) — canonical `callback:`
1710
+ // ⟨0.19⟩ SETUP split (SPEC §6.2 §3): if the callee binds to a DECLARED-but-UNINSTALLED package,
1711
+ // this Unknown is a mis-configuration (the pkg isn't `npm install`ed), not a genuine dynamic hole
1712
+ // — tag `no-node_modules:<pkg>` (reason class `setup`) so it's SEPARABLE + `npm install`-fixable.
1713
+ const setupPkg = declaredButUninstalled.size ? importPkgOfHead(node.expression) : null;
1714
+ if (setupPkg && declaredButUninstalled.has(setupPkg)) {
1715
+ rec.why.add(`no-node_modules:${setupPkg}`);
1716
+ } else {
1717
+ const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
1718
+ rec.why.add(`callback:${callee}`); // an `any`-typed/indeterminate callee (a function VALUE) — canonical `callback:`
1719
+ }
1667
1720
  }
1668
1721
  }
1669
1722
  } else {
@@ -2510,6 +2563,9 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
2510
2563
  }
2511
2564
 
2512
2565
  // ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
2566
+ // ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
2567
+ // `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
2568
+ const netPartners = parseNetPartners(discoverConfigText(target));
2513
2569
  const functions = [];
2514
2570
  for (const [name, rec] of fns) {
2515
2571
  const inf = [...inferred.get(name)].sort();
@@ -2533,6 +2589,10 @@ for (const [name, rec] of fns) {
2533
2589
  // entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
2534
2590
  if (rec.edges.size) entry.calls = [...rec.edges].sort();
2535
2591
  if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
2592
+ // ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
2593
+ // transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
2594
+ // .incomplete has Net) OR a Net with no visible host. The class travels the call graph like the effect.
2595
+ if (inf.includes("Net")) entry.netClass = netClassesOf([...rec.hosts], rec.incomplete.has("Net"), netPartners);
2536
2596
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
2537
2597
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
2538
2598
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
@@ -2620,6 +2680,30 @@ if (!wantJson) {
2620
2680
  console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
2621
2681
  }
2622
2682
  }
2683
+ {
2684
+ // ⟨0.19⟩ SETUP diagnostic (SPEC §6.2 §3, the setup/genuine split): functions that read Unknown ONLY
2685
+ // because the scan isn't configured (a declared dep not installed → `no-node_modules:<pkg>`, reason class
2686
+ // `setup`) are a FIXABLE mis-configuration, not a genuine dynamic blind spot. Surface them LOUDLY with the
2687
+ // fix and separate from real dynamism — so a team runs `npm install` instead of disabling a strict gate on
2688
+ // unconfigured analysis (the referee's week-two-uninstall). `Unknown[dynamic]` EXCLUDES `setup`, so a
2689
+ // strict gate can bite genuine dynamism while tolerating these until the config is fixed.
2690
+ const setupPkgs = new Set();
2691
+ let setupFns = 0;
2692
+ for (const e of functions) {
2693
+ const why = e.unknownWhy ?? [];
2694
+ if (!why.some((w) => reasonClass(w) === "setup")) continue;
2695
+ setupFns++;
2696
+ for (const w of why) { const m = /^no-node_modules:(.+)$/.exec(w); if (m) setupPkgs.add(m[1]); }
2697
+ }
2698
+ if (setupFns > 0) {
2699
+ const pkgs = [...setupPkgs].sort();
2700
+ const shown = pkgs.slice(0, 6).join(", ") + (pkgs.length > 6 ? `, +${pkgs.length - 6} more` : "");
2701
+ console.error(`candor-ts: SETUP — ${setupFns} function(s) read Unknown ONLY because ${pkgs.length} declared `
2702
+ + `package(s) aren't installed (${shown}); run \`npm install\`, then re-scan. These are unconfigured `
2703
+ + `analysis, NOT real blind spots — a strict gate can still bite genuine dynamism with `
2704
+ + `\`deny E Unknown[dynamic]\` (which tolerates \`setup\`), then shrink to zero once installed.`);
2705
+ }
2706
+ }
2623
2707
  if (unlistedSeen.size > 0) {
2624
2708
  const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
2625
2709
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
@@ -2796,11 +2880,13 @@ if (policyPath !== null) {
2796
2880
  // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
2797
2881
  const incompleteMap = new Map();
2798
2882
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2799
- gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap));
2883
+ // ⟨0.19⟩ reason-class aliases (SPEC §6.2) from `.candor/config`, so `Unknown[<alias>]` resolves at the gate.
2884
+ const unknownAliases = parseUnknownAliases(discoverConfigText(target));
2885
+ gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text, unknownAliases), functions, cg, incompleteMap, netPartners));
2800
2886
  // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
2801
2887
  // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
2802
2888
  // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
2803
- const disclosePolicy = parsePolicy(text);
2889
+ const disclosePolicy = parsePolicy(text, unknownAliases);
2804
2890
  const purityHoles = [];
2805
2891
  for (const f of functions) {
2806
2892
  // Same predicate + upgrade as `candor-ts-query unverified` (query-core.mjs) — one source of truth.