candor-ts 0.17.0 → 0.19.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.17)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.19)."*
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.17" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.19" }, 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.17.x, speaking candor-spec 0.17: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.18.x, speaking candor-spec 0.19: 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.17.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.17)",
3
+ "version": "0.19.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.19)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/policy.mjs CHANGED
@@ -5,6 +5,23 @@
5
5
  */
6
6
 
7
7
  export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Llm"];
8
+
9
+ // Reason-scoped Unknown (REASON-SCOPED-UNKNOWN-DESIGN.md): the CLOSED, cross-engine reason-class set a
10
+ // `deny E Unknown[class…]` rule quantifies over. Must be IDENTICAL to candor-java's ReasonClass and
11
+ // candor-rust's — the mapping below mirrors java's prefix-based ReasonClass.classify(String).
12
+ export const REASON_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved", "setup"];
13
+ // `dynamic` = every GENUINE blind-spot class (excludes `setup`), incl. `unresolved` so it never under-gates.
14
+ const DYNAMIC_CLASSES = ["reflect", "dispatch", "indirect", "native", "unresolved"];
15
+ /** Map a raw `unknownWhy` token (e.g. `reflect:eval`, `callback:fetch`) to its normative reason class. */
16
+ export function reasonClass(why) {
17
+ const w = String(why).trim().toLowerCase();
18
+ if (w.startsWith("reflect") || w === "dynamicmemberlookup") return "reflect";
19
+ if (w.startsWith("native")) return "native";
20
+ if (w.startsWith("callback") || w.startsWith("closure") || w.startsWith("task-handoff")) return "indirect";
21
+ if (w.startsWith("dispatch") || w.startsWith("indy") || w.startsWith("ambiguous")) return "dispatch";
22
+ if (w.startsWith("missing-config") || w.startsWith("no-tsconfig") || w.startsWith("no-node_modules")) return "setup";
23
+ return "unresolved"; // conservative catch-all
24
+ }
8
25
  // The literal surfaces `allow` can restrict. `Llm` ⟨0.13⟩ rides Net's host literal (SPEC §1) —
9
26
  // `allow Llm <host…>` restricts which MODEL hosts a scope may reach, matched by hostname like Net.
10
27
  const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
@@ -14,7 +31,10 @@ const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db", "Llm"]);
14
31
  // (adversarial DSL review). A non-ASCII space stays part of its token → the rule is malformed, dropped.
15
32
  const ASCII_WS = /[ \t\n\v\f\r]+/;
16
33
  const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
17
- export function parsePolicy(text) {
34
+ // ⟨0.19⟩ `aliases` (a Map name→class-token[], from `.candor/config` `unknown-alias`) lets an `Unknown[<name>]`
35
+ // filter resolve a user-defined name (SPEC §6.2). A config alias never changes what bare `deny E Unknown`
36
+ // means (always `Unknown[*]`), so a rule's denied set stays legible from the policy alone.
37
+ export function parsePolicy(text, aliases = null) {
18
38
  const deny = [], allow = [], forbid = [];
19
39
  // Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
20
40
  // breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
@@ -28,14 +48,40 @@ export function parsePolicy(text) {
28
48
  if (t[0] === "deny") {
29
49
  const effects = [];
30
50
  let scope = "";
51
+ // Reason-class filter on an `Unknown` membership: empty ⇒ `Unknown[*]` (any reason — the bare
52
+ // form); non-empty ⇒ only those classes. `*` = all; `dynamic` = every genuine class.
53
+ const unknownClasses = new Set();
54
+ let unknownStar = false;
31
55
  for (const tok of t.slice(1)) {
32
- if (EFFECTS.includes(tok) || tok === "Unknown") effects.push(tok);
33
- else { scope = tok; break; }
56
+ const m = /^Unknown\[(.*)\]$/.exec(tok);
57
+ if (m) {
58
+ effects.push("Unknown");
59
+ for (let cn of m[1].split(",")) {
60
+ cn = cn.trim();
61
+ if (!cn) continue;
62
+ if (cn === "*") unknownStar = true;
63
+ else if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => unknownClasses.add(c));
64
+ else if (REASON_CLASSES.includes(cn)) unknownClasses.add(cn);
65
+ else if (aliases && aliases.has(cn)) aliases.get(cn).forEach((c) => unknownClasses.add(c)); // ⟨0.19⟩ config unknown-alias
66
+ else warn(`unknown reason-class/alias \`${cn}\` (known: ${REASON_CLASSES.join(",")}; aliases: dynamic,*, or a config \`unknown-alias\`)`);
67
+ }
68
+ continue;
69
+ }
70
+ if (EFFECTS.includes(tok) || tok === "Unknown") {
71
+ effects.push(tok);
72
+ if (tok === "Unknown") unknownStar = true; // bare Unknown ⇒ all classes
73
+ } else { scope = tok; break; }
34
74
  }
35
75
  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
76
+ // `*` (or bare Unknown) means all classes ⇒ empty filter (matches any Unknown).
77
+ let uc = unknownStar ? [] : [...unknownClasses].sort();
78
+ // A2 under-gating lint: a narrowed scope omitting `unresolved` (the catch-all for holes the engine
79
+ // couldn't classify) may silently tolerate exactly those — flag it (advisory, non-fatal).
80
+ if (uc.length && !uc.includes("unresolved"))
81
+ 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}`);
82
+ deny.push({ effects: [...new Set(effects)].sort(), scope, unknownClasses: uc, raw: line }); // dedup: a set, like rust/java
37
83
  } else if (t[0] === "pure") {
38
- deny.push({ effects: [], scope: t[1] ?? "", raw: line });
84
+ deny.push({ effects: [], scope: t[1] ?? "", unknownClasses: [], raw: line });
39
85
  } else if (t[0] === "allow") {
40
86
  if (t.length < 3) { warn("allow names no values"); continue; }
41
87
  if (!ALLOW_EFFECTS.has(t[1])) { warn("allow supports only Net hosts / Llm hosts / Exec commands / Fs paths / Db tables"); continue; }
@@ -121,7 +167,30 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
121
167
  const out = [];
122
168
  // `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
123
169
  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 });
170
+ // §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; omitted otherwise.
171
+ const push = (rule, fn, effects, detail, reasonClass) =>
172
+ out.push(reasonClass && reasonClass.length ? { rule, fn, effects, detail, reasonClass } : { rule, fn, effects, detail });
173
+ // Reason-scoped Unknown: the Unknown reason CLASS must travel the call graph the same way the Unknown
174
+ // EFFECT does (unknownWhy in the report is direct-only). Classify each fn's DIRECT reasons to class
175
+ // tokens, then propagate transitively over `callgraph` to a fixpoint — so `deny E Unknown[reflect]` at a
176
+ // caller inheriting Unknown from a reflect-caused callee still fires (matches java/rust reasonClassAcc).
177
+ const reasonAcc = new Map();
178
+ for (const f of functions) {
179
+ const cs = new Set((f.unknownWhy ?? []).map(reasonClass));
180
+ if (cs.size) reasonAcc.set(f.fn, cs);
181
+ }
182
+ for (let changed = true; changed; ) {
183
+ changed = false;
184
+ for (const [caller, callees] of Object.entries(callgraph)) {
185
+ for (const callee of callees) {
186
+ const cc = reasonAcc.get(callee);
187
+ if (!cc) continue;
188
+ let set = reasonAcc.get(caller);
189
+ if (!set) { set = new Set(); reasonAcc.set(caller, set); }
190
+ for (const c of cc) if (!set.has(c)) { set.add(c); changed = true; }
191
+ }
192
+ }
193
+ }
125
194
  for (const f of functions) {
126
195
  for (const r of pol.deny) {
127
196
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -132,7 +201,19 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
132
201
  const hits = r.effects.length === 0
133
202
  ? f.inferred.filter((e) => e !== "Unknown")
134
203
  : 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}\``);
204
+ // Reason-scoped Unknown: a `deny E Unknown[classes]` keeps its Unknown hit only for a fn whose
205
+ // TRANSITIVE reason classes include one of those; an Unknown with no recorded reason ⇒ `unresolved`.
206
+ let kept = hits;
207
+ if (hits.includes("Unknown") && (r.unknownClasses?.length)) {
208
+ const cs = reasonAcc.get(f.fn);
209
+ const fnClasses = cs && cs.size ? [...cs] : ["unresolved"];
210
+ if (!fnClasses.some((c) => r.unknownClasses.includes(c))) kept = hits.filter((e) => e !== "Unknown");
211
+ }
212
+ if (kept.length) {
213
+ // When Unknown is denied, report ALL reason classes on the fn (transitive) — every reason the gate bit.
214
+ const rc = kept.includes("Unknown") ? [...(reasonAcc.get(f.fn) ?? [])].sort() : undefined;
215
+ push("AS-EFF-006", f.fn, kept, `\`${f.fn}\` performs { ${kept.join(", ")} }, forbidden by policy: \`${r.raw}\``, rc);
216
+ }
136
217
  }
137
218
  for (const r of pol.allow) {
138
219
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
@@ -199,3 +280,50 @@ export function discoverConfigPolicy(fromDir) {
199
280
  dir = parent;
200
281
  }
201
282
  }
283
+
284
+ // ⟨0.19⟩ Discover `.candor/config` TEXT anchored at `fromDir`: $CANDOR_CONFIG if set + readable, else the
285
+ // nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
286
+ export function discoverConfigText(fromDir) {
287
+ const env = process.env.CANDOR_CONFIG;
288
+ if (env) { try { return fs.readFileSync(env, "utf8"); } catch { return null; } }
289
+ let dir = nodePath.resolve(fromDir);
290
+ for (;;) {
291
+ const cand = nodePath.join(dir, ".candor", "config");
292
+ if (fs.existsSync(cand)) { try { return fs.readFileSync(cand, "utf8"); } catch { return null; } }
293
+ const parent = nodePath.dirname(dir);
294
+ if (parent === dir) return null;
295
+ dir = parent;
296
+ }
297
+ }
298
+
299
+ // ⟨0.19⟩ Parse `unknown-alias <name> = <class,…>` lines (SPEC §6.2) into a Map name→class-token[]. A name
300
+ // that shadows a built-in (`*`/`dynamic`/a class token) is warned-and-skipped, as is a no-valid-class def.
301
+ // Byte-shape with the java `Config.addAlias` / rust `parse_unknown_aliases`.
302
+ export function parseUnknownAliases(configText) {
303
+ const out = new Map();
304
+ if (!configText) return out;
305
+ for (const raw of configText.split(/\r?\n/)) {
306
+ const line = raw.split("#", 1)[0].trim();
307
+ if (!line) continue;
308
+ const m = line.match(/^(\S+)\s+(.*)$/);
309
+ if (!m || m[1].toLowerCase() !== "unknown-alias") continue;
310
+ const eq = m[2].indexOf("=");
311
+ if (eq < 0) { console.error(`candor: ignoring \`unknown-alias\` (want \`unknown-alias <name> = <class,…>\`): ${m[2]}`); continue; }
312
+ const name = m[2].slice(0, eq).trim();
313
+ if (!name || name === "*" || name === "dynamic" || REASON_CLASSES.includes(name)) {
314
+ console.error(`candor: ignoring \`unknown-alias\` with reserved/empty name \`${name}\` (may not shadow \`*\`/\`dynamic\`/a class token)`);
315
+ continue;
316
+ }
317
+ const classes = new Set();
318
+ for (let cn of m[2].slice(eq + 1).split(",")) {
319
+ cn = cn.trim();
320
+ if (!cn) continue;
321
+ if (cn === "dynamic") DYNAMIC_CLASSES.forEach((c) => classes.add(c));
322
+ else if (REASON_CLASSES.includes(cn)) classes.add(cn);
323
+ else console.error(`candor: \`unknown-alias ${name}\` names unknown reason-class \`${cn}\` — skipped`);
324
+ }
325
+ if (classes.size === 0) console.error(`candor: ignoring \`unknown-alias ${name}\` — no valid reason-class`);
326
+ else out.set(name, [...classes]);
327
+ }
328
+ return out;
329
+ }
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";
@@ -44,6 +44,18 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
44
44
  // The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
45
45
  // with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
46
46
  const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
47
+ // Suggest the nearest known flag for a typo (longest shared prefix ≥3): `--polciy` → `--policy` (#2).
48
+ function didYouMeanFlag(unknown) {
49
+ const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown"];
50
+ const u = unknown.replace(/^-+/, "").toLowerCase();
51
+ let best = null, bestLen = 2;
52
+ for (const k of known) {
53
+ const kn = k.replace(/^-+/, "");
54
+ let s = 0; while (s < u.length && s < kn.length && u[s] === kn[s]) s++;
55
+ if (s >= 3 && s > bestLen) { bestLen = s; best = k; }
56
+ }
57
+ return best ? ` — did you mean \`${best}\`?` : "";
58
+ }
47
59
 
48
60
  // ---- #8 output mode: PROSE at a TTY, JSON when piped or `--json` — so interactive `candor where Db` reads
49
61
  // like candor-java/-rust instead of dumping raw JSON, while a pipe/redirect (never a TTY) still yields the
@@ -184,7 +196,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
184
196
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
185
197
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
186
198
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
187
- const SPEC_VERSION = "0.17";
199
+ const SPEC_VERSION = "0.19";
188
200
 
189
201
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
190
202
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -272,14 +284,21 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
272
284
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
273
285
  reportLocator = rawArgs[++i]; continue;
274
286
  }
275
- if (policy && a === "--policy") {
287
+ if (a === "--policy") { // consumed for EVERY verb (a valid candor flag); used only by policy verbs
276
288
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
277
- policyFile = rawArgs[++i]; continue;
289
+ const v = rawArgs[++i]; if (policy) policyFile = v; continue;
278
290
  }
279
291
  if (a === "--json" || a === "--text" || a === "--human") { continue; } // output-mode flags (#8) — consumed by
280
292
  // wantJsonOut(rawArgs), never a positional
281
- if (strict && a === "--strict") { wantStrict = true; continue; }
282
- if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
293
+ if (a === "--strict") { if (strict) wantStrict = true; continue; } // vocabulary — tolerated everywhere,
294
+ if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
295
+ if (a.startsWith("-") && a.length > 1) {
296
+ // An unrecognized flag is a TYPO, not a positional — reject it LOUD (exit 2), never silently swallow.
297
+ // A swallowed `--polciy` runs the query with NO policy and exits green: a CI author who typos --policy
298
+ // 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`);
300
+ process.exit(2);
301
+ }
283
302
  positionals.push(a);
284
303
  }
285
304
  // Deprecated trailing `0|1` JSON sentinel (Rust/TS legacy): if the LAST positional is a bare 0 or 1,
@@ -384,11 +403,11 @@ const SUBCOMMANDS = [
384
403
  ["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
385
404
  ["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
386
405
  ["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
387
- ["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
406
+ ["gains", "<current> <baseline> [--json] [--strict]", "the supply-chain alarm: what the surface gained between two reports (--strict: exit 1 on ANY gain)"],
388
407
  ["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
389
408
  ["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
390
409
  ["fix", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the boundary fix: where the effect belongs + the hoist refactor"],
391
- ["fix-gate", `[--policy <file>] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — the loop's block-message remedy"],
410
+ ["fix-gate", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — advisory (--strict: exit 1 while any remains)"],
392
411
  ["unverified", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
393
412
  ["agents", "", "print the agent contract for this build (AGENTS.md)"],
394
413
  ];
@@ -447,7 +466,9 @@ OPTIONS (uniform across every engine)
447
466
  --json machine-readable JSON (the default when output is piped/redirected)
448
467
  --text, --human human-readable prose (the default at a terminal)
449
468
  --include-unknown callers: also list the unresolved-dispatch frontier
450
- --strict unverified: exit 1 on an unverified hole (advisory otherwise)
469
+ --strict make an advisory verb a CI gate — exit 1 while a finding remains:
470
+ unverified (an unverified-purity hole), fix-gate (a boundary
471
+ crossing), gains (ANY gained effect). Advisory (exit 0) otherwise.
451
472
  -V, --version print the installed version + upgrade line (offline)
452
473
  -h, --help show this help
453
474
 
@@ -480,7 +501,10 @@ switch (cmd) {
480
501
  console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
481
502
  process.exit(2);
482
503
  }
483
- emit(parsePolicy(text));
504
+ // ⟨0.19⟩ config-aware: resolve `Unknown[<alias>]` via a checked-in `unknown-alias`, anchored to the
505
+ // policy file (or CANDOR_CONFIG) — the dump reflects real gate resolution + pins the four-way expansion.
506
+ const aliases = parseUnknownAliases(discoverConfigText(path.dirname(path.resolve(args[0]))));
507
+ emit(parsePolicy(text, aliases));
484
508
  break;
485
509
  }
486
510
  case "show": {
@@ -693,13 +717,33 @@ switch (cmd) {
693
717
  const out = { reaches: finds.map((f) => ({
694
718
  effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
695
719
  })) };
720
+ // The MACHINE half of the mostly-Unknown disclosure (Fable-review finding E): a JSON consumer (the
721
+ // agent loop) got a bare `{"reaches":[]}` and read it as clean — the same false all-clear the text
722
+ // branch qualifies. ADDITIVE + present only when the ≥⅓-Unknown threshold trips (byte-identical
723
+ // otherwise). Keys sorted after `reaches` (reaches < unknown) to match Rust's serde output.
724
+ const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
725
+ const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
726
+ if (teff > 0 && tunk * 3 >= teff) out.unknown = { count: tunk, total: teff };
696
727
  console.log(JSON.stringify(out));
697
728
  break;
698
729
  }
699
730
  if (finds.length === 0) {
700
731
  // Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
701
- // answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
702
- console.log("candor: nothing hidden — every effect sits where its name says it should.");
732
+ // answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
733
+ // reassure "nothing hidden" over a meaningfully-Unknown graph (unresolved calls — missing tsconfig /
734
+ // imports): those Unknowns ARE the hidden part, their transitive effects unanalyzed (re-audit cardinal
735
+ // sin). Same ≥⅓-effectful-Unknown gate as the scan opener (surface.mjs emitSurface).
736
+ const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
737
+ const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
738
+ if (teff > 0 && tunk * 3 >= teff) {
739
+ console.log(
740
+ `candor: no surprising reaches — but ${tunk} of ${teff} function(s) are Unknown `
741
+ + `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
742
+ + `a missing tsconfig.json or unresolvable imports are the usual cause.`,
743
+ );
744
+ } else {
745
+ console.log("candor: nothing hidden — every effect sits where its name says it should.");
746
+ }
703
747
  break;
704
748
  }
705
749
  console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
@@ -716,8 +760,13 @@ switch (cmd) {
716
760
  // surface gained between two reports (base → cur), the cross-engine machine-readable form.
717
761
  // §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
718
762
  // the shared locator rule; --json accepted.
719
- const { positionals } = parseCanonical(args, {});
720
- if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json]"); process.exit(2); }
763
+ // gains has no `--policy` of its own: parseCanonical consumes `--policy` for every verb (a valid flag),
764
+ // which for gains would SILENTLY drop it and exit 0 — a CI author who reaches for `--policy` to gate a
765
+ // supply-chain diff ships a gate that never fires. Reject it loud and point at the real gate. `--strict`
766
+ // (below) fails on ANY gained effect; the effect-SPECIFIC gate is a `deny <E> gained` scan policy.
767
+ if (args.includes("--policy")) { console.error("candor-ts-query gains: unknown flag '--policy' — gains is a diff view; to FAIL CI on a newly-gained effect gate at scan time with a `deny <E> gained` policy (AS-EFF-005), or use `--strict` to fail on ANY gain\n known flags: --json, --strict"); process.exit(2); }
768
+ const { positionals, strict } = parseCanonical(args, { strict: true });
769
+ if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json] [--strict]"); process.exit(2); }
721
770
  const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
722
771
  // BOTH locators must name real report files (the Rust engine's no-files check, named per side):
723
772
  // a typo'd prefix loaded [] with hardFail=false and emitted an authoritative EMPTY
@@ -736,10 +785,13 @@ switch (cmd) {
736
785
  // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
737
786
  // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
738
787
  // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
788
+ const gainsResult = coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix));
739
789
  put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
740
- ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
741
- ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
742
- break;
790
+ ...gainsResult, ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
791
+ // Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
792
+ // supply-chain CI job can require a bump introduce no new capability (mirrors `unverified --strict`).
793
+ process.exit(strict && (gainsResult.gained?.length ?? 0) > 0 ? 1 : 0);
794
+ break; // unreachable
743
795
  }
744
796
  case "path": {
745
797
  // BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
@@ -818,15 +870,19 @@ switch (cmd) {
818
870
  // A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
819
871
  // §3.3.1: `fix-gate [--policy <file>]`, report discovered / --report. DEPRECATED alias: the old
820
872
  // `fix-gate <prefix> <policy-file>` (leading report + positional policy).
821
- const { prefix, policyFile } = resolveGateVerb(args);
873
+ // Advisory by default (exit 0 — the agent fix-loop reads the remedy and edits); `--strict` makes the
874
+ // exit follow `ok`, so CI can REQUIRE zero outstanding crossings (mirrors `unverified --strict`).
875
+ const { prefix, policyFile, strict } = resolveGateVerb(args, { strict: true });
822
876
  if (!policyFile) { console.error("candor: fix-gate requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
823
877
  let ptext;
824
878
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
825
879
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
826
880
  const cg = loadCallgraph(prefix);
827
881
  if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
828
- emit(coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches));
829
- break;
882
+ const fgr = coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
883
+ emit(fgr);
884
+ process.exit(strict && !fgr.ok ? 1 : 0);
885
+ break; // unreachable
830
886
  }
831
887
  case "unverified": {
832
888
  // PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
package/scan.mjs CHANGED
@@ -26,7 +26,7 @@ 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, 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,
@@ -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.17";
44
+ const SPEC_VERSION = "0.19";
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 {
@@ -2620,6 +2673,30 @@ if (!wantJson) {
2620
2673
  console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
2621
2674
  }
2622
2675
  }
2676
+ {
2677
+ // ⟨0.19⟩ SETUP diagnostic (SPEC §6.2 §3, the setup/genuine split): functions that read Unknown ONLY
2678
+ // because the scan isn't configured (a declared dep not installed → `no-node_modules:<pkg>`, reason class
2679
+ // `setup`) are a FIXABLE mis-configuration, not a genuine dynamic blind spot. Surface them LOUDLY with the
2680
+ // fix and separate from real dynamism — so a team runs `npm install` instead of disabling a strict gate on
2681
+ // unconfigured analysis (the referee's week-two-uninstall). `Unknown[dynamic]` EXCLUDES `setup`, so a
2682
+ // strict gate can bite genuine dynamism while tolerating these until the config is fixed.
2683
+ const setupPkgs = new Set();
2684
+ let setupFns = 0;
2685
+ for (const e of functions) {
2686
+ const why = e.unknownWhy ?? [];
2687
+ if (!why.some((w) => reasonClass(w) === "setup")) continue;
2688
+ setupFns++;
2689
+ for (const w of why) { const m = /^no-node_modules:(.+)$/.exec(w); if (m) setupPkgs.add(m[1]); }
2690
+ }
2691
+ if (setupFns > 0) {
2692
+ const pkgs = [...setupPkgs].sort();
2693
+ const shown = pkgs.slice(0, 6).join(", ") + (pkgs.length > 6 ? `, +${pkgs.length - 6} more` : "");
2694
+ console.error(`candor-ts: SETUP — ${setupFns} function(s) read Unknown ONLY because ${pkgs.length} declared `
2695
+ + `package(s) aren't installed (${shown}); run \`npm install\`, then re-scan. These are unconfigured `
2696
+ + `analysis, NOT real blind spots — a strict gate can still bite genuine dynamism with `
2697
+ + `\`deny E Unknown[dynamic]\` (which tolerates \`setup\`), then shrink to zero once installed.`);
2698
+ }
2699
+ }
2623
2700
  if (unlistedSeen.size > 0) {
2624
2701
  const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
2625
2702
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
@@ -2796,11 +2873,13 @@ if (policyPath !== null) {
2796
2873
  // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
2797
2874
  const incompleteMap = new Map();
2798
2875
  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));
2876
+ // ⟨0.19⟩ reason-class aliases (SPEC §6.2) from `.candor/config`, so `Unknown[<alias>]` resolves at the gate.
2877
+ const unknownAliases = parseUnknownAliases(discoverConfigText(target));
2878
+ gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text, unknownAliases), functions, cg, incompleteMap));
2800
2879
  // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
2801
2880
  // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
2802
2881
  // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
2803
- const disclosePolicy = parsePolicy(text);
2882
+ const disclosePolicy = parsePolicy(text, unknownAliases);
2804
2883
  const purityHoles = [];
2805
2884
  for (const f of functions) {
2806
2885
  // Same predicate + upgrade as `candor-ts-query unverified` (query-core.mjs) — one source of truth.
package/surface.mjs CHANGED
@@ -215,8 +215,26 @@ export function bestFind(inferred, direct, calls, isTest = () => false) {
215
215
  // the sink (defaults to console.error). Mirrors surface.rs::emit exactly.
216
216
  export function emitSurface(inferred, direct, calls, loc, isTest = () => false, log = console.error) {
217
217
  const res = bestFind(inferred, direct, calls, isTest);
218
- if (res === null) return; // zero effectful functions — emit nothing
219
- if (res.winner === null) {
218
+ // A real SURPRISING reach is a genuine finding — show it (below), even amid Unknowns.
219
+ if (res !== null && res.winner !== null) { /* fall through to the surprising-reach message */ }
220
+ else {
221
+ // No surprising reach. But do NOT reassure "nothing hidden" over a meaningfully-UNKNOWN graph: those
222
+ // Unknowns (unresolved calls — e.g. a missing tsconfig.json, unresolvable imports) ARE the hidden part,
223
+ // and their transitive effects are unanalyzed. "nothing hidden" there is a false all-clear — the
224
+ // cardinal sin for a tool that sells transitive-reach detection (corpus re-audit). Qualify + point at
225
+ // blindspots. `bestFind` returns null for BOTH "no effectful fns" and "effectful-but-nothing-surprising
226
+ // (incl. all-Unknown)", so measure the Unknown fraction from `inferred` directly, not from `res`.
227
+ const total = [...inferred.values()].filter((s) => s.size > 0).length; // EFFECTFUL fns (pure units excluded)
228
+ const unknown = [...inferred.values()].filter((s) => s.has("Unknown")).length;
229
+ if (total > 0 && unknown * 3 >= total) { // ≥ ~1/3 of effectful functions Unknown → meaningfully unresolved
230
+ log(
231
+ `candor: no surprising reaches — but ${unknown} of ${total} function(s) are Unknown `
232
+ + `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
233
+ + `a missing tsconfig.json or unresolvable imports are the usual cause.`,
234
+ );
235
+ return;
236
+ }
237
+ if (res === null) return; // genuinely nothing effectful/surprising and few Unknowns — emit nothing
220
238
  log("candor: nothing hidden — every effect sits where its name says it should.");
221
239
  return;
222
240
  }