candor-ts 0.26.0 → 0.28.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
@@ -3,7 +3,16 @@
3
3
  You are working in a TypeScript project. **candor-ts** tells you, for every function, which side
4
4
  effects it can reach — network, filesystem, database, subprocess, env, clock — *including effects
5
5
  inherited transitively through any chain of calls across files*. Use it instead of tracing call
6
- chains by hand.
6
+ chains by hand. The language-agnostic consumption contract is
7
+ [candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is
8
+ the TypeScript-specific production + query surface.
9
+
10
+ > **If the repository is not TypeScript-only, start at the umbrella:**
11
+ > [candor/AGENTS.md](https://github.com/tombaldwin/candor/blob/main/AGENTS.md). `candor` is one
12
+ > command in front of every engine (TypeScript, JVM, Rust, Swift, agent fleets) — it picks the right
13
+ > one per target, `candor update` installs and upgrades them, and `candor doctor` checks that every
14
+ > installed engine agrees on a spec version. A polyglot repo scanned with this engine alone gets an
15
+ > answer about its TypeScript and nothing that says so.
7
16
 
8
17
  > **This document ships inside the package.** `npx -y candor-ts --agents` prints the contract for
9
18
  > the *installed* version — always prefer that over a vendored or fetched copy, which can describe
@@ -12,7 +21,7 @@ chains by hand.
12
21
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
22
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
23
  > 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.26)."*
24
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.28)."*
16
25
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
26
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
27
  >
@@ -73,6 +82,44 @@ pure functions are omitted** — a function present in the callgraph sidecar but
73
82
  `.functions[]` is pure (as far as the engine resolved). In *neither* file = never analyzed
74
83
  (a test file? an unexported arrow inside an object literal?) — conclude nothing.
75
84
 
85
+ **A report with `analyzed.count: 0` is a run that FAILED, not a clean codebase.** A scan that exits 2
86
+ leaves that fail-closed shape (`functions: []`, `analyzed.count: 0`, a non-empty `unanalyzed`) at the
87
+ `--out` report, and it DELETES that report's `.callgraph`/`.hierarchy`/`.locs` sidecars with it — so
88
+ `callers`/`whatif` answer from an absent call graph rather than from the last successful run. Read the
89
+ two together: **a sidecar whose report is one of these empties tells you nothing, whatever it says.**
90
+ Re-scan; do not conclude from either half.
91
+
92
+ ⟨0.28⟩ **And `callers`, `impact` and `path` SAY so in the machine channel over such a pair** — a document
93
+ carrying `"unanswerable": "<why>"` and NO answer keys, at **exit 2**, never an empty `direct` /
94
+ `affectedCount: 0` / `path: []` at exit 0. All three resolve their target over the call graph, and each of
95
+ those empties is the reassurance you asked the verb for: *nothing calls this*, *safe to change*, *it does
96
+ not reach that effect*. With no call graph the engine knows none of it — so do not read
97
+ `d.direct ?? []`, `d.affectedCount ?? 0` or `d.path ?? []` without checking `unanswerable` first.
98
+ Over a REAL graph the negatives stand: a function with genuinely no callers still answers `direct: []`,
99
+ one that affects nothing still answers `affectedCount: 0`, and one that does not reach an effect still
100
+ answers `path: []`, all at exit 0 — those are determined negatives, and they are correct.
101
+
102
+ ⟨0.28⟩ **A target NAME that does not resolve is a THIRD answer, and it is exit 2 as well** — `callers`,
103
+ `impact` and `path` print `no function matching '<target>'` on stderr instead of answering `direct: []` /
104
+ `affectedCount: 0` / `path: []`. So a query gets one of three, and a typo gets the third, not the second:
105
+ *there is no call graph* (an `unanswerable` document), *the graph says no* (a real empty answer, exit 0),
106
+ and *there is no such function* (stderr, and NO `unanswerable` key — a graph WAS read). If you generate
107
+ query targets, read exit 2 + `no function matching` as **fix the name**, never as a finding.
108
+
109
+ ⟨0.28⟩ **And the REPORT-ONLY verbs — `where`, `map`, `blindspots`, `reachable`, `containment`, `tour` —
110
+ carry the report's own ⟨0.21⟩ manifest instead**, because they have no call graph to be missing and their
111
+ empty answer is the whole of the harm: `{"directly":[],"inherited":[]}`, `{}`,
112
+ `{"sources":[],"totalUnknown":0}`, `{"entryPoints":0,"effects":{}}`, `{"contained":[],"ambient":{}}`,
113
+ `{"reaches":[]}`. Over a report declaring a non-empty `unanalyzed` **or** `analyzed.count: 0` they add
114
+ `"incomplete": true` (plus `unanalyzed` / `"judgedNothing": true`, naming which cause — the two want
115
+ different repairs) to the SAME document, and the human arm withdraws its ✓. **The exit code does NOT
116
+ move**: this rung is a caveat, not a refusal, so `if (!doc.incomplete)` is the guard and the exit is not.
117
+ Same keys on the MCP tools (`candor_where`, `candor_map`, `candor_blindspots`, `candor_reachable`,
118
+ `candor_containment`) and on the advisory verbs (`unverified`, `fix-gate`, `whatif`), which additionally
119
+ OMIT `ok`. Over a COMPLETE report the answer is byte-identical to a pre-⟨0.28⟩ one — no key, no note —
120
+ and `analyzed.count > 0` with `functions: []` is deliberately NOT hedged: that is a genuine all-pure
121
+ claim (§2 rule 3) you should believe.
122
+
76
123
  A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
77
124
  `unitKind: "export"` (spec 0.8, informative); ordinary functions omit the field.
78
125
 
package/README.md CHANGED
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
192
192
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
193
193
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
194
194
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
195
- | `{ candor: { version, toolchain, spec: "0.26" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
195
+ | `{ candor: { version, toolchain, spec: "0.28" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
196
196
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
197
197
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
198
198
 
@@ -210,7 +210,7 @@ read the Rust source".
210
210
 
211
211
  ## Status
212
212
 
213
- 0.19.x, speaking candor-spec 0.26: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.19.x, speaking candor-spec 0.28: the analysis core, the gate (`--policy` / `--gate-json` /
214
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
215
215
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
216
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/contract.mjs CHANGED
@@ -8,6 +8,20 @@ import { fileURLToPath } from "node:url";
8
8
  export function printAgents() {
9
9
  const dir = path.dirname(fileURLToPath(import.meta.url)); // the package root (where AGENTS.md ships)
10
10
  const semver = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).version;
11
- console.log(`<!-- candor-ts ${semver} · the agent contract for this installed version -->`);
12
- process.stdout.write(fs.readFileSync(path.join(dir, "AGENTS.md"), "utf8"));
11
+ const out = `<!-- candor-ts ${semver} · the agent contract for this installed version -->\n`
12
+ + fs.readFileSync(path.join(dir, "AGENTS.md"), "utf8");
13
+ // fs.writeSync, NOT console.log/process.stdout.write. On a PIPE those are asynchronous, and scan.mjs
14
+ // calls `process.exit(0)` on the next line — which discards whatever is still buffered. The contract
15
+ // came out TRUNCATED AT 8170 OF 23121 CHARACTERS, cut mid-sentence, with exit 0 and nothing on stderr:
16
+ // an agent piping `candor-ts --agents` into its context silently read a third of its own instructions.
17
+ // Only on a pipe — a redirect to a file writes synchronously, which is why this survived a manual
18
+ // check and only the suite's execFileSync saw it.
19
+ //
20
+ // The comment above says one implementation cannot diverge, and it was still wrong: query.mjs `break`s
21
+ // and drains on the way out, scan.mjs exits. THE SIBLING ROUTE AGAIN — sharing the PRINTER does not
22
+ // share the EXIT, and the divergence lived in the caller the shared function was meant to protect.
23
+ // Fixing it here rather than in scan.mjs is deliberate: the next caller inherits the fix.
24
+ let off = 0;
25
+ const buf = Buffer.from(out, "utf8");
26
+ while (off < buf.length) off += fs.writeSync(1, buf, off, buf.length - off); // a short write is legal
13
27
  }
package/lsp.mjs CHANGED
@@ -45,7 +45,7 @@ import { createRequire } from "node:module";
45
45
  import nodePath from "node:path";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
47
47
  import * as Q from "./query-core.mjs";
48
- import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } from "./policy.mjs";
48
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, resolveReasonClasses, fatalPolicyErrors, policyZeroRules } from "./policy.mjs";
49
49
 
50
50
  // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
51
51
  // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
@@ -324,6 +324,18 @@ function activePolicyParsed(text) {
324
324
  warnOnce(`candor-lsp: ${policyErrorText(activePolicyPath ?? "(policy)", fatal)}\n No gate diagnostics are produced from it — their ABSENCE here is the refusal, not an all-clear.`);
325
325
  return null;
326
326
  }
327
+ // ⟨0.28⟩ SPEC §2/§6.2 — DID THIS CONFIGURED POLICY ASK ANYTHING AT ALL? Every rule vector, never a
328
+ // subset: keying on `deny` alone would call an ordinary allow-only or forbid-only gate empty.
329
+ const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
330
+ // The editor has no exit code and no JSON document, so both of this rung's channels collapse onto the
331
+ // one it does have. Same `warnOnce` shape (and same reasoning) as the judged-nothing warning below: there
332
+ // is no line to pin it to, and a per-keystroke popup is how an advisory gets turned off.
333
+ const zeroRulePolicyWarn = (what) =>
334
+ warnOnce(`candor-lsp: ${policyZeroRules(activePolicyPath ?? "(policy)").why} — every line was ignored, the `
335
+ + `file is empty, or it holds only comments. ${what} A policy with no rules ASKS NOTHING, so the silence `
336
+ + `here is NOT an all-clear: \`gate\` REFUSES over this policy outright (exit 2, SPEC §6.2). If you did `
337
+ + `not mean to gate, remove the policy configuration rather than pointing it at a file with no rules.`);
338
+
327
339
  function diagnosticsFor(docPath) {
328
340
  const text = activePolicy();
329
341
  if (text === null || !hasReport(reportPrefix)) return [];
@@ -346,6 +358,21 @@ function diagnosticsFor(docPath) {
346
358
  // producer's project, in both the fabricating and the fail-open direction (see reportNetClasses).
347
359
  const dpol = activePolicyParsed(text);
348
360
  if (dpol === null) return [];
361
+ // ⟨0.28⟩ …and a CONFIGURED policy that parsed to ZERO RULES produces no squiggles either, which in an
362
+ // editor is indistinguishable from a gate that ran and found nothing — §6.2's harm on the surface where
363
+ // it is least visible, since the live gate's entire vocabulary IS the absence or presence of squiggles.
364
+ if (policyAskedNothing(dpol))
365
+ zeroRulePolicyWarn("No gate diagnostics can come from it.");
366
+ // ⟨0.28⟩ SPEC §6.2 — …AND THE LINES THE PARSE DROPPED, which is the same clause one fraction down: the
367
+ // zero-rule warning above fires only at ZERO survivors, so a policy where three of four lines were
368
+ // dropped produced the surviving rule's squiggles and nothing else. In an editor that reads as the
369
+ // whole gate, because squiggles ARE this surface's entire vocabulary. The CLI routes carry `ignored`
370
+ // on the verdict document; here there is no document, so the log channel carries it — once, with the
371
+ // line numbers, so the operator can go to them.
372
+ else if (dpol.ignored?.length)
373
+ warnOnce(`candor-lsp: ${dpol.ignored.length} line(s) of the configured policy were DROPPED by the `
374
+ + `parse, so the gate you are seeing is SMALLER than the gate that was written (SPEC §6.2 ⟨0.28⟩):\n`
375
+ + dpol.ignored.map((g) => ` line ${g.line}: ${g.text}`).join("\n"));
349
376
  // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
350
377
  // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
351
378
  // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
@@ -487,12 +514,24 @@ function runWhatif(a) {
487
514
  return null;
488
515
  }
489
516
  const policyText = activePolicy();
490
- const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
491
- policyText === null ? null : activePolicyParsed(policyText), scopeMatches);
517
+ const wpol = policyText === null ? null : activePolicyParsed(policyText);
518
+ const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect, wpol, scopeMatches);
492
519
  if (r === null) {
493
520
  showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
494
521
  return null;
495
522
  }
523
+ // ⟨0.28⟩ SPEC §2 — a CONFIGURED policy that yielded zero rules asked nothing, and `✓ no policy rule
524
+ // fires` IS the prose spelling of `ok: true`. The pre-edit verdict is withheld on both this channel and
525
+ // the executeCommand RESULT (a thick client renders that); the blast radius is not a policy claim, so it
526
+ // is still counted in the message the operator gets. A policy that is NOT configured keeps its own
527
+ // "no policy discovered" wording below — that is the honest way to say "I am not gating".
528
+ if (policyAskedNothing(wpol)) {
529
+ zeroRulePolicyWarn("No pre-edit verdict can come from it.");
530
+ const zcallers = r.affected.filter((f) => !r.of.includes(f));
531
+ showMessage(2, `candor: the configured policy has NO RULES — no pre-edit verdict (blast radius only: `
532
+ + `${zcallers.length} caller(s) would inherit ${a.effect})`);
533
+ return { unevaluated: policyZeroRules(activePolicyPath ?? "(policy)").unevaluated };
534
+ }
496
535
  const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
497
536
  const rules = [...new Set(r.violations.map((v) => v.rule))];
498
537
  // ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
@@ -549,6 +588,15 @@ function runFix(a) {
549
588
  }
550
589
  const fpol = activePolicyParsed(policyText);
551
590
  if (fpol === null) return null;
591
+ // ⟨0.28⟩ SPEC §2 — the same caveat, before any `crossing` reading: `${a.effect} isn't forbidden here`
592
+ // from a policy that forbids nothing is vacuously true, and this surface is the one that ASKED for a
593
+ // fix. No `crossing` key on the result either — present exactly when the verb answered.
594
+ if (policyAskedNothing(fpol)) {
595
+ zeroRulePolicyWarn("No boundary fix can be computed from it.");
596
+ showMessage(2, `candor: \`${a.fn}\` — no fix computed: the configured policy has NO RULES, so there is `
597
+ + `no boundary to have crossed. The absence of a plan here is the caveat, not an all-clear.`);
598
+ return { unevaluated: policyZeroRules(activePolicyPath ?? "(policy)").unevaluated };
599
+ }
552
600
  const r = Q.fix(Q.loadCallgraph(reportPrefix), Q.loadReport(reportPrefix), a.fn, a.effect,
553
601
  fpol, scopeMatches);
554
602
  if (r === null) {
package/mcp.mjs CHANGED
@@ -21,7 +21,7 @@ import nodePath from "node:path";
21
21
  import * as Q from "./query-core.mjs";
22
22
  import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses,
23
23
  parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText,
24
- unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } from "./policy.mjs";
24
+ unanswerableScoped, resolveReasonClasses, fatalPolicyErrors, policyZeroRules } from "./policy.mjs";
25
25
 
26
26
  const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
27
27
 
@@ -136,6 +136,22 @@ function policyOrThrow(text, policyPath) {
136
136
  if (fatal.length) throw new Error(policyErrorText(policyPath ?? "(policy)", fatal));
137
137
  return pol;
138
138
  }
139
+ // ⟨0.28⟩ SPEC §2 — AN ADVISORY TOOL OVER A CONFIGURED ZERO-RULE POLICY ANSWERS WITH THE CAVEAT DOCUMENT,
140
+ // RESULT KEYS WITHHELD. The CLI half is `emitZeroRuleCaveat` in query.mjs and this is the SAME document
141
+ // through the same builder (`policyZeroRules`), because the agent surface is a channel these verbs answer
142
+ // on and a caveat that exists on one of them is a caveat the other silently drops. MEASURED on the CLI
143
+ // twins before this: `{"ok": true, "unverified": []}` / `{"crossing": false, "reason": "not-forbidden"}`
144
+ // over `# no rules yet` — an all-clear produced by deleting the question, handed to a consumer that
145
+ // cannot ask a follow-up. `fix` emits NO `crossing` key: that key is present exactly when the verb
146
+ // answered. §6.2's gate REFUSES over the same policy (exit 2); these are advisory, so they disclose.
147
+ const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
148
+ // `policyZeroRules` also returns a `why` — the HUMAN sentence the gate's refusal puts in `reason`. The
149
+ // caveat document carries only `unevaluated`, so it is not spread here rather than minted as a wire key.
150
+ const zeroRuleCaveat = (policyPath, prefix) => {
151
+ const { unevaluated } = policyZeroRules(policyPath ?? "(policy)");
152
+ return { unevaluated, ...Q.completenessFields(Q.reportCompleteness(prefix)) };
153
+ };
154
+
139
155
  // The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
140
156
  function configPolicy(prefix) {
141
157
  return discoverConfigPolicy(nodePath.dirname(nodePath.resolve(prefix)) || ".");
@@ -189,41 +205,109 @@ function capCallers(r) {
189
205
  truncated: true,
190
206
  };
191
207
  }
208
+ // ⟨0.28⟩ SPEC §2 — THE COMPLETENESS CAVEAT, ON THE AGENT-FACING CHANNEL. The same reader and the same key
209
+ // set as the CLI (`query-core`), never a second mechanism: the CLI and this server are one implementation,
210
+ // and a caveat that exists on one of them is a caveat the other silently drops. MEASURED here, over the
211
+ // standard post-⟨0.28⟩ artifact (`analyzed.count: 0` + a non-empty `unanalyzed`), before this wrapper:
212
+ // `candor_where` → `{"effect":"Fs","directly":[],"inherited":[]}`, `candor_map` → `{}`,
213
+ // `candor_blindspots` → `{"sources":[],"totalUnknown":0}`, `candor_reachable` →
214
+ // `{"entryPoints":0,"effects":{}}`, `candor_containment` → `{"contained":[],"ambient":{}}` — five flat
215
+ // all-clears with no hedge. `candor_show`/`candor_impact` already fail closed on the fn-existence guard.
216
+ //
217
+ // THE AGENT IS THE CONSUMER THAT CANNOT ASK A FOLLOW-UP QUESTION. A human running the CLI at least sees an
218
+ // oddly bare answer; a loop reading `blindspots.sources.length === 0` records "no blind spots" and moves on.
219
+ // Additive and a no-op on a complete report (`completenessFields` returns `{}`), so every pinned tool shape
220
+ // is unchanged on an ordinary one.
221
+ // The collision loop this used to carry is gone with ⟨0.28⟩ Rung A: `candor_map` was its only possible
222
+ // trigger and now takes `caveatInstead`, where nothing is displaced. Every remaining caller has a fixed
223
+ // key set, so the condition is not constructible — and a guard whose condition cannot arise reads as
224
+ // coverage.
225
+ const withCompleteness = (p, doc) => ({ ...doc, ...Q.completenessFields(Q.reportCompleteness(p)) });
226
+ // ⟨0.28⟩ RUNG A, ON THE AGENT CHANNEL — SPEC §2: a verb whose pinned shape cannot carry the caveat emits
227
+ // the CAVEAT DOCUMENT INSTEAD of its result document. Two tools qualify and both are handled here,
228
+ // because THE MCP HALF HAS BEEN THE MISSED ROUTE TWICE IN THIS REPO — and it is the worse one: an agent
229
+ // reading `Object.keys(map).length === 0` records "this codebase performs no effects" and moves on, with
230
+ // no follow-up question available to it.
231
+ //
232
+ // candor_show the CLI's `show` is pinned to an ARRAY; this tool returns `Q.show(...)`, the same array,
233
+ // and had no completeness reader at all.
234
+ // candor_map keyed by the operator's own MODULE names. The merged shape it used to take displaced a
235
+ // real module row to make space for the hedge, and the `@`-prefix escape is unavailable
236
+ // for the reason the ruling names candor-ts for: `@scope/name` is a key a module owns.
237
+ //
238
+ // A no-op on a complete report, so both pinned tool shapes are unchanged on an ordinary one.
239
+ // ⟨0.28⟩ THE DOC ARRIVES AS A THUNK, and that is the whole fix rather than a style preference.
240
+ // This took `doc` by value, so JavaScript evaluated `Q.show(loadReportLoud(p), a.fn)` BEFORE
241
+ // `caveatInstead` was ever called — and `Q.show`'s fn-existence guard throws. Over a judged-nothing
242
+ // report `candor_show <anything>` therefore answered "no function matching …" to an AGENT: a
243
+ // determined negative about the code, produced by a report that examined none of it, on the surface
244
+ // where a wrong answer is acted on rather than read. The CLI was already correct (measured: it emits
245
+ // the caveat document), so this was the MCP half of a rung the CLI had shipped — the third time today
246
+ // that half was the one nobody checked.
247
+ //
248
+ // Hedging is now decided BEFORE the answer is computed, so no guard inside the verb can pre-empt it.
249
+ // AND CORRUPTION IS NOT A HEDGE — the deferral made that distinction load-bearing where it had been
250
+ // free. `mustHedge` is true on the `unreadable` arm too, so simply deferring turned `candor_map` over a
251
+ // CORRUPT report from a loud tool error into `{"incomplete": true}`: a disclosure where the contract
252
+ // says refuse (§2 ⟨0.24⟩ — a signature key that cannot be read impeaches the document, it does not
253
+ // qualify it). Caught by the existing row, which is the second time today that fixing a false negative
254
+ // introduced a wrong downgrade in the same edit.
255
+ //
256
+ // So the loud causes fall THROUGH to the thunk, whose loader throws; only the qualifying causes
257
+ // substitute a caveat. Corruption anywhere in the set wins over a hedge elsewhere in it.
258
+ const caveatInstead = (p, doc) => {
259
+ const comp = Q.reportCompleteness(p);
260
+ const call = () => (typeof doc === "function" ? doc() : doc);
261
+ if (comp?.unreadable?.length) return call(); // refuse loudly, via the loader
262
+ if (Q.mustHedge(comp)) return Q.completenessFields(comp);
263
+ return call();
264
+ };
265
+ // ⟨0.28⟩ The graph the three graph verbs answer over: the §2.2 sidecar when there is one, else the
266
+ // report's own embedded `calls` edges (Q.reportCallsGraph) — the same fallback the CLI and rust/java
267
+ // run, so this surface cannot refuse a report the CLI answers. The fn-existence guard below already
268
+ // unions the report's names, so a sidecar-less locator passed the guard and then computed over an
269
+ // EMPTY graph: `candor_callers` returned `{of:[],direct:[],transitive:[]}` — "nobody calls this,
270
+ // safe to edit" — to an agent, over a pair whose graph was present one key over. An ARMED pair (no
271
+ // sidecar, report judged nothing) still fails closed: both sets are empty and the guard refuses.
272
+ const graphOrReportEdges = (p, fns) => {
273
+ const cg = Q.loadCallgraph(p);
274
+ return Object.keys(cg).length ? cg : Q.reportCallsGraph(fns);
275
+ };
192
276
  const TOOLS = {
193
277
  candor_impact: {
194
278
  description: "Backward blast radius: every effectful function that transitively calls `fn`, and which runtime entry points are downstream. Answers 'if I change this, what surfaces at runtime?' — the cheapest possible alternative to tracing callers by hand.",
195
279
  schema: { type: "object", properties: { fn: { type: "string", description: "the function/unit to assess" }, ...reportArg }, required: ["fn"] },
196
- run: (a, p) => capImpact(Q.impact(loadReportLoud(p), Q.loadCallgraph(p), a.fn)),
280
+ run: (a, p) => { const fns = loadReportLoud(p); return capImpact(Q.impact(fns, graphOrReportEdges(p, fns), a.fn)); },
197
281
  },
198
282
  candor_where: {
199
283
  description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
200
284
  schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
201
- run: (a, p) => capWhere(Q.where(loadReportLoud(p), a.effect)),
285
+ run: (a, p) => withCompleteness(p, capWhere(Q.where(loadReportLoud(p), a.effect))),
202
286
  },
203
287
  candor_reachable: {
204
288
  description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
205
289
  schema: { type: "object", properties: { ...reportArg } },
206
- run: (_a, p) => Q.reachable(loadReportLoud(p)),
290
+ run: (_a, p) => withCompleteness(p, Q.reachable(loadReportLoud(p))),
207
291
  },
208
292
  candor_path: {
209
293
  description: "Forward provenance: the shortest call chain from `fn` to the nearest function that performs `effect` DIRECTLY — 'this reaches Net through WHAT?'.",
210
294
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
211
- run: (a, p) => Q.path(loadReportLoud(p), Q.loadCallgraph(p), a.fn, a.effect),
295
+ run: (a, p) => { const fns = loadReportLoud(p); return Q.path(fns, graphOrReportEdges(p, fns), a.fn, a.effect); },
212
296
  },
213
297
  candor_callers: {
214
298
  description: "Who calls `fn` — direct (one hop) and transitive callers over the effect-relevant call graph.",
215
299
  schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
216
- run: (a, p) => capCallers(Q.callers(Q.loadCallgraph(p), a.fn)),
300
+ run: (a, p) => capCallers(Q.callers(graphOrReportEdges(p, loadReportLoud(p)), a.fn)),
217
301
  },
218
302
  candor_show: {
219
303
  description: "A function's effects (inferred = transitive, direct = own body) plus its literal surfaces (hosts/cmds/paths/tables) when present.",
220
304
  schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
221
- run: (a, p) => Q.show(loadReportLoud(p), a.fn),
305
+ run: (a, p) => caveatInstead(p, () => Q.show(loadReportLoud(p), a.fn)),
222
306
  },
223
307
  candor_map: {
224
308
  description: "Per-module effect overview: each module's union of effects and function count. The architecture-at-a-glance.",
225
309
  schema: { type: "object", properties: { ...reportArg } },
226
- run: (_a, p) => Q.map(loadReportLoud(p)),
310
+ run: (_a, p) => caveatInstead(p, () => Q.map(loadReportLoud(p))),
227
311
  },
228
312
  candor_whatif: {
229
313
  description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
@@ -236,6 +320,10 @@ const TOOLS = {
236
320
  const pol = a.policy ? policyOrThrow(confinedPolicyRead(a.policy, p), a.policy) : null;
237
321
  const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
238
322
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
323
+ // ⟨0.28⟩ a CONFIGURED policy that parsed to zero rules asked nothing — the pre-edit verdict and the
324
+ // blast radius it qualifies are withheld for the caveat document (see `zeroRuleCaveat`). A policy
325
+ // that is NOT configured stays untouched: that is the honest way to say "I am not gating".
326
+ if (policyAskedNothing(pol)) return zeroRuleCaveat(a.policy, p);
239
327
  return r;
240
328
  },
241
329
  },
@@ -257,8 +345,13 @@ const TOOLS = {
257
345
  // The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
258
346
  // never a degenerate empty-graph remedy. (/code-review.)
259
347
  if (!cg || Object.keys(cg).length === 0) throw new Error(`no call-graph sidecar for the report — fix needs it (re-scan with --out)`);
260
- const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, policyOrThrow(text, polPath), scopeMatches);
348
+ const fpol = policyOrThrow(text, polPath);
349
+ const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, fpol, scopeMatches);
261
350
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
351
+ // ⟨0.28⟩ …and NO `crossing` key over a zero-rule policy: this tool's own description tells an agent
352
+ // to read `crossing` as the answer, and `crossing: false` from a policy that forbids nothing is
353
+ // vacuously true. Present exactly when the verb answered.
354
+ if (policyAskedNothing(fpol)) return zeroRuleCaveat(polPath, p);
262
355
  return r;
263
356
  },
264
357
  },
@@ -326,10 +419,47 @@ const TOOLS = {
326
419
  // caveat is ADDITIVE (the two existing keys keep their shape and meaning, and the field is absent
327
420
  // on every ordinary report) because the verdict itself must not move: the report asserts no effect,
328
421
  // so asserting one here would be the fabrication mirror of the silence being disclosed.
329
- const judged = g.judgedNothing ? { judgedNothing: true,
330
- caveat: "⟨0.24⟩ this report judged NOTHING (`analyzed.count` is 0, absent with no entries, or unreadable) — "
331
- + "a green verdict here certifies nothing: absence from `functions` licenses no purity claim about any "
332
- + "unit. Re-scan the sources you meant to gate, or point `report` at the package that has them." } : {};
422
+ //
423
+ // `judgedNothing` IS THE ARRAY HERE TOO, and it used to be a boolean. The old comment defended the
424
+ // boolean on one premise — "this tool's document is ONE gate verdict about ONE locator" — and that
425
+ // premise is contradicted in this very file: `report` is a PREFIX (DEFAULT_PREFIX, and
426
+ // `loadReportLoud`, whose own message reads "every report found at prefix … failed to load" and
427
+ // whose header says "over a multi-report prefix"). ONE VERDICT IS NOT ONE REPORT. SPEC §2 gives
428
+ // precisely that reason for the array: "a verb reading a prefix answers over many sibling reports,
429
+ // and WHICH of them judged nothing is the whole of the actionable content".
430
+ //
431
+ // The boolean did not merely lose "which". `loadGateReport` computes it as an AND over the siblings
432
+ // (`let judgedNothing = true`, cleared by the first report with content), so the PARTIAL case — the
433
+ // common one — emitted `false` and therefore NO CAVEAT AT ALL. MEASURED 2026-08-13 on a two-report
434
+ // prefix, one judged-nothing and one carrying a real function: the boolean said `false` while
435
+ // `reportCompleteness` named `r.empty.json`. A green gate with NO disclosure over a surface half of
436
+ // which was never judged — on the one channel whose consumer cannot ask a follow-up question, which
437
+ // is the argument this file already makes immediately below about `ignored`.
438
+ //
439
+ // ⟨0.28⟩ `noManifest` (SPEC §2 row 3) rides with it, for the reason that row exists: a report with
440
+ // no `analyzed` key declares nothing, and listing it under `judgedNothing` would be the false
441
+ // disclosure the rung split out. The `unverified` route in this same file already emitted both;
442
+ // this route is its sibling and was never brought along.
443
+ //
444
+ // ADDITIVE STILL: `ok` does not consult either key. ⟨0.24⟩'s carve-out keeps the gate verdict's
445
+ // `ok`, and the report asserts no effect — asserting one here would be the fabrication mirror of
446
+ // the silence being disclosed.
447
+ // ⟨0.28⟩ SPEC §6.2 `ignored` — THE LINES THE PARSE DROPPED, on the surface where their absence is
448
+ // worst. MEASURED here 2026-08-12 over a policy whose 3 of 4 lines were dropped: this tool returned
449
+ // `{"ok":true,"violations":[]}` while the per-line warnings went to the SERVER's stderr, a channel
450
+ // the calling agent never reads — so the one consumer that cannot ask a follow-up question was
451
+ // handed a green verdict from a gate three-quarters of which was never asked. Same shape and same
452
+ // builder as both CLI routes; omitted when nothing was dropped, and `ok` does not consult it.
453
+ const ignored = pol.ignored?.length ? { ignored: pol.ignored } : {};
454
+ const gcomp = Q.reportCompleteness(p);
455
+ const judged = (gcomp.judgedNothing?.length || gcomp.noManifest?.length)
456
+ ? { ...(gcomp.judgedNothing?.length ? { judgedNothing: gcomp.judgedNothing } : {}),
457
+ ...(gcomp.noManifest?.length ? { noManifest: gcomp.noManifest } : {}),
458
+ caveat: "⟨0.24⟩ report(s) under this locator judged NOTHING (`analyzed.count` is 0, or absent with no "
459
+ + "entries) — a green verdict does not certify them: absence from `functions` licenses no purity "
460
+ + "claim about any unit they contain. The named report(s) are the gap; the verdict above covers "
461
+ + "only the siblings that DID judge. Re-scan those sources, or point `report` at the package that "
462
+ + "has them." } : {};
333
463
  const inc = incomplete ? { incomplete: true, unanalyzed: g.unanalyzed } : {};
334
464
  // ⟨0.24⟩ PRECEDENCE (SPEC §3.1 `7271c69`/`4c79958`): violation (1) > refusal (2) > incomplete (2), and
335
465
  // the REFUSAL SHAPE is the one the CLI writes — `ok:false`, `refused:true`, and NO `violations` KEY AT
@@ -339,12 +469,12 @@ const TOOLS = {
339
469
  // `unevaluated` — WHICH rules went unenforced and why — which is exactly what an agent needs to fix it.
340
470
  // The document is still fail-closed to the naivest possible reader (`ok` is false), and it is the same
341
471
  // shape the agent would get from `--gate-json`, so one consumer parses both routes.
342
- if (v.length) return { ok: false, violations: v, ...(unevaluated.length ? { unevaluated } : {}), ...inc, ...judged };
472
+ if (v.length) return { ok: false, violations: v, ...(unevaluated.length ? { unevaluated } : {}), ...ignored, ...inc, ...judged };
343
473
  if (unevaluated.length)
344
474
  return { ok: false, refused: true,
345
475
  reason: `${unevaluated.length} policy rule(s) could not be evaluated against this report`,
346
476
  unevaluated, ...inc, ...judged };
347
- return { ok: !incomplete, violations: v, ...inc, ...judged };
477
+ return { ok: !incomplete, violations: v, ...ignored, ...inc, ...judged };
348
478
  },
349
479
  },
350
480
  candor_unverified: {
@@ -380,19 +510,30 @@ const TOOLS = {
380
510
  // one of them. `candor_gate` already refuses to read green over `unanalyzed`; this returned
381
511
  // `ok:true` with an empty array over the identical bytes, on the surface an agent trusts and no
382
512
  // human reads. Same `advisoryAnswer` the CLI applies, so the two cannot drift.
383
- return Q.advisoryAnswer(Q.unverified(loadReportLoud(p), policyOrThrow(text, polPath), scopeMatches),
384
- Q.reportUnanalyzed(p));
513
+ // ⟨0.28⟩ …and the `analyzed.count: 0` cause on the same terms (SPEC §2), read through the SAME
514
+ // `reportCompleteness` the CLI and the descriptive tools use — one reader, so the two channels
515
+ // cannot disagree about which reports judged nothing.
516
+ const ucomp = Q.reportCompleteness(p);
517
+ const upol = policyOrThrow(text, polPath);
518
+ // ⟨0.28⟩ the sharpest of the three: the verb whose job is "your green gate is not provably green"
519
+ // answered `{ok: true, unverified: []}` over a policy that asked nothing. The empty list is withheld
520
+ // for ⟨0.27⟩'s reason — a document that made no evaluation must not carry the finding key.
521
+ if (policyAskedNothing(upol)) return zeroRuleCaveat(polPath, p);
522
+ // ⟨0.28⟩ `noManifest` (SPEC §2 row 3) rides here too — a report with no `analyzed` key declares
523
+ // nothing, and listing it under `judgedNothing` would be the false disclosure the rung split out.
524
+ return Q.advisoryAnswer(Q.unverified(loadReportLoud(p), upol, scopeMatches),
525
+ ucomp.unanalyzed, ucomp.judgedNothing, ucomp.unreadable, ucomp.noManifest);
385
526
  },
386
527
  },
387
528
  candor_containment: {
388
529
  description: "Per boundary effect (Db/Net/Exec/Fs/Ipc/Clipboard): how contained it is in one architectural layer — the dispersion diagnostic (spec §6.1). Not a score; per-effect facts.",
389
530
  schema: { type: "object", properties: { ...reportArg } },
390
- run: (_a, p) => Q.containment(loadReportLoud(p)),
531
+ run: (_a, p) => withCompleteness(p, Q.containment(loadReportLoud(p))),
391
532
  },
392
533
  candor_blindspots: {
393
534
  description: "The Unknown SOURCES — calls the engine genuinely could not resolve (reflection, wide dispatch, fn-pointers) — ranked by how many functions inherit Unknown through each. Turns a high-Unknown report into a short worklist.",
394
535
  schema: { type: "object", properties: { ...reportArg } },
395
- run: (_a, p) => capBlindspots(Q.blindspots(loadReportLoud(p), Q.loadCallgraph(p))),
536
+ run: (_a, p) => withCompleteness(p, capBlindspots(Q.blindspots(loadReportLoud(p), Q.loadCallgraph(p)))),
396
537
  },
397
538
  candor_diff: {
398
539
  description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
@@ -420,9 +561,13 @@ const TOOLS = {
420
561
  // ⟨0.15 staged⟩ coverage disclosure — the SAME gainsCoverage the CLI verb spreads (the parity
421
562
  // rule): optional `coverage` (current envelope's ledger) + `coverageDelta` (baseline names
422
563
  // differ), both omitted when nothing applies — no other field of the tool result changes.
564
+ // ⟨0.28⟩ …and the ⟨0.21⟩ manifest on the same terms, BOTH SIDES separately (`gainsCompleteness`,
565
+ // the same one the CLI verb spreads). SPEC §2 puts the obligation on the READING, not the route the
566
+ // report arrived by, and this is the route an agent takes: an "it gained nothing" over a report that
567
+ // judged nothing is the false all-clear, and no human sees this channel.
423
568
  return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
424
569
  ...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)),
425
- ...Q.gainsCoverage(p, b) };
570
+ ...Q.gainsCoverage(p, b), ...Q.gainsCompleteness(p, b) };
426
571
  },
427
572
  },
428
573
  candor_activity: {
@@ -579,7 +724,22 @@ function handle(msg) {
579
724
  const prefix = t.noReport ? null : resolvePrefix(args);
580
725
  // A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
581
726
  // an agent must distinguish "no such function" from "found, nothing calls it".
582
- if (args.fn !== undefined) {
727
+ //
728
+ // ⟨0.28⟩ …BUT THE HEDGE OUTRANKS THE EXISTENCE GUARD, and this guard sits in the DISPATCHER, so it
729
+ // pre-empted the caveat for EVERY fn-taking tool rather than one. Over a judged-nothing report
730
+ // `candor_show`/`candor_impact`/`candor_callers`/`candor_path` answered "no function matching …"
731
+ // — a determined negative about the code, asserted by a report that examined none of it, to an
732
+ // agent that acts on it. "Found, nothing calls it" and "no such function" are indeed different
733
+ // answers and the guard is right to separate them; what it cannot do is choose between them from
734
+ // a report that judged nothing. Skipping it here hands the case to each tool's own completeness
735
+ // reader, which emits the caveat document (Rung A).
736
+ //
737
+ // CORRUPTION IS NOT A HEDGE and must stay loud: `unreadable` falls through to the guard, whose
738
+ // `loadReportLoud` throws — §2 ⟨0.24⟩ impeaches a document whose signature keys cannot be read
739
+ // rather than qualifying it.
740
+ const fnComp = t.noReport ? null : Q.reportCompleteness(prefix);
741
+ const fnHedges = !!fnComp && !fnComp.unreadable?.length && Q.mustHedge(fnComp);
742
+ if (args.fn !== undefined && !fnHedges) {
583
743
  const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...loadReportLoud(prefix).map((e) => e.fn)])];
584
744
  if (Q.matches(names, args.fn).length === 0)
585
745
  return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.26.0",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.26)",
3
+ "version": "0.28.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.28)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",