candor-ts 0.27.0 → 0.28.1
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 +49 -2
- package/README.md +2 -2
- package/contract.mjs +34 -2
- package/lsp.mjs +51 -3
- package/mcp.mjs +181 -21
- package/package.json +2 -2
- package/policy.mjs +43 -2
- package/query-core.mjs +335 -12
- package/query.mjs +741 -64
- package/scan.mjs +868 -27
- package/surface.mjs +15 -2
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.
|
|
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.
|
|
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.
|
|
213
|
+
0.28.0, 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,38 @@ 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
|
-
|
|
12
|
-
|
|
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
|
+
//
|
|
25
|
+
// Two failure modes the first version of this did not handle, both specific to a PIPE, which is the
|
|
26
|
+
// case it exists for:
|
|
27
|
+
// EPIPE — the reader stopped early (`| head -n5`, `| grep -m1`, a consumer that closed). The old
|
|
28
|
+
// console.log path exited 0 silently; a bare writeSync raises, and a print-and-exit mode
|
|
29
|
+
// answering a contract request with a Node stack trace and exit 1 is a worse regression
|
|
30
|
+
// than the truncation this replaced. Swallowed — the reader left, that is not our error.
|
|
31
|
+
// EAGAIN — once stdout has been initialised, libuv puts the pipe in non-blocking mode and writeSync
|
|
32
|
+
// THROWS rather than short-writing as soon as the payload exceeds the 64 KiB pipe buffer.
|
|
33
|
+
// The contract is 24 KiB today, so this is latent, not live — and it would come back as
|
|
34
|
+
// exactly the truncation-plus-noise this function was written to remove. Retry with a
|
|
35
|
+
// small backoff (Atomics.wait is the only synchronous sleep available here).
|
|
36
|
+
let off = 0;
|
|
37
|
+
const buf = Buffer.from(out, "utf8");
|
|
38
|
+
const idle = new Int32Array(new SharedArrayBuffer(4));
|
|
39
|
+
try {
|
|
40
|
+
while (off < buf.length) {
|
|
41
|
+
try { off += fs.writeSync(1, buf, off, buf.length - off); } // a short write is legal
|
|
42
|
+
catch (e) { if (e.code !== "EAGAIN") throw e; Atomics.wait(idle, 0, 0, 1); }
|
|
43
|
+
}
|
|
44
|
+
} catch (e) { if (e.code !== "EPIPE") throw e; }
|
|
13
45
|
}
|
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
|
|
491
|
-
|
|
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(
|
|
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) =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.28.1",
|
|
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",
|