candor-ts 0.23.1 → 0.25.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 +54 -6
- package/README.md +12 -3
- package/lsp.mjs +103 -8
- package/mcp.mjs +141 -15
- package/package.json +8 -5
- package/policy.mjs +600 -52
- package/query-core.mjs +748 -52
- package/query.mjs +454 -20
- package/scan.mjs +1897 -141
- package/transitive-recall.mjs +385 -0
- package/verify-core.mjs +32 -9
- package/verify-emit.mjs +88 -3
- package/verify.mjs +8 -2
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.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.25)."*
|
|
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
|
>
|
|
@@ -80,8 +80,31 @@ A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) ca
|
|
|
80
80
|
(a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
|
|
81
81
|
report inherits that function's recorded transitive effects and literal surfaces, joined by the
|
|
82
82
|
report's `hash` (`package#LocalName`). A report produced by a different candor-ts version is
|
|
83
|
-
downgraded to `Unknown` rather than silently trusted
|
|
84
|
-
|
|
83
|
+
downgraded to `Unknown` rather than silently trusted, and grants that package **no coverage** — so a key it
|
|
84
|
+
does not answer falls back to the κ ledger's `invisible` hedge rather than reading pure (spec §2.1). **A report
|
|
85
|
+
that declares itself INCOMPLETE** — a non-empty ⟨0.21⟩ `unanalyzed` — grants **no coverage** either, for the
|
|
86
|
+
same reason read one step earlier: spec §2 rule 3 makes a report's silence a purity claim, and this one has
|
|
87
|
+
just said it never read some of its own source. Its entries are kept unchanged (they were derived from source
|
|
88
|
+
it *did* read); only its silence hedges, and an `import` backed only by such a report discloses
|
|
89
|
+
`Unknown[incomplete-dep:<pkg>]`. Chaining an incomplete report is therefore never worse than not chaining it.
|
|
90
|
+
**And a report that JUDGED NOTHING** — ⟨0.24⟩ `analyzed.count: 0`, or no manifest and no entries, or a manifest
|
|
91
|
+
too garbled to read — grants **no coverage** either: rule 3 turns a report's silence into a purity claim, and a
|
|
92
|
+
report that judged nothing is all silence, so its package stays in the κ ledger exactly as if it were never
|
|
93
|
+
chained. Keyed on the COUNT and never on `functions` being empty — `count: n > 0` with `functions: []` is a
|
|
94
|
+
legitimate all-pure claim rule 3 says to believe, and it is untouched. The same reading binds every route a
|
|
95
|
+
report arrives by (`gate --report`, MCP `candor_gate`, the LSP gate), each of which discloses the count-0 case
|
|
96
|
+
rather than answering "no violations" flat; the verdict itself does not move, because the report gives no
|
|
97
|
+
evidence of an effect and asserting one would be fabrication.
|
|
98
|
+
Caveat: a type-only boundary (`import type` …, the tRPC style) has no runtime calls to inherit through —
|
|
99
|
+
nothing to join.
|
|
100
|
+
|
|
101
|
+
**Changing a report KEY is a wire change: bump `package.json`'s version in the same commit.** `candor.version`
|
|
102
|
+
is the only discriminator §2.1 has, so two builds that disagree about a key while sharing a version string
|
|
103
|
+
disarm it — and note what the bump does and does not buy. It downgrades the entries a report *carries*; it
|
|
104
|
+
cannot conjure a key the report *lacks*, so an already-installed consumer that looks up a key this build no
|
|
105
|
+
longer emits simply misses, whatever the version says. That direction is unfixable from the producing side
|
|
106
|
+
(the consumer's code is frozen), which is why a stale report is denied coverage here: it is what stops the
|
|
107
|
+
NEXT key change reading as a purity claim.
|
|
85
108
|
|
|
86
109
|
## Query it (same names/shapes as the Rust and JVM engines — candor-spec §3.1)
|
|
87
110
|
|
|
@@ -101,6 +124,21 @@ Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it woul
|
|
|
101
124
|
Q fix $P <fn> <Effect> <policy> # the boundary FIX: where the effect belongs + the hoist refactor
|
|
102
125
|
Q unverified $P <policy> [--strict] # pure/deny layers that PASS but are Unknown (not PROVABLY clean)
|
|
103
126
|
Q fix-gate $P <policy> # a fix for EVERY crossing — the loop's block-message remedy
|
|
127
|
+
# ⟨0.24⟩ BOTH, and `fix`: an advisory verb may be LESS certain than
|
|
128
|
+
# the gate, never MORE (SPEC §3.2). Where `gate --report` REFUSES a
|
|
129
|
+
# narrowed rule for want of evidence, `unverified` NAMES the function
|
|
130
|
+
# with the MISSING EVIDENCE as its reason (never a derived class),
|
|
131
|
+
# `fix-gate`/`fix` offer NO remedy premised on it, both carry the
|
|
132
|
+
# gate's `unevaluated:[{rule,why}]`, `ok` is OMITTED, `--strict` → 2.
|
|
133
|
+
Q gate --report $P --policy <file> [--json] [--gate-json <f>]
|
|
134
|
+
# ⟨0.24⟩ apply a policy to an EXISTING report, NO scan (exit 0/1/2).
|
|
135
|
+
# The supply-chain verb: gate a DEPENDENCY's published report. Reads
|
|
136
|
+
# the report and nothing else — no callgraph sidecar, no chained dep,
|
|
137
|
+
# no re-classification; an ABSENT entry stays absent (the ⟨0.21⟩
|
|
138
|
+
# purity claim). Its --gate-json is BYTE-EQUAL to `scan --policy`'s.
|
|
139
|
+
# `forbid`, `allow`, and a class-scoped `deny` whose scoping datum
|
|
140
|
+
# the report does not carry are REFUSED (exit 2), never evaluated on
|
|
141
|
+
# partial evidence — gate those at scan time instead.
|
|
104
142
|
Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
|
|
105
143
|
Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
|
|
106
144
|
Q reachable $P 1 # what the app DOES at runtime: effects over the entry points
|
|
@@ -159,9 +197,19 @@ want-JSON flag.
|
|
|
159
197
|
`class PgStore implements Store`) resolves to the local implementors when the dispatch is narrow
|
|
160
198
|
(≤12 classes) — the layered-DI pattern carries its real effects; only an interface with no
|
|
161
199
|
visible implementor still reads `Unknown` (`dispatch:<Type>`).
|
|
162
|
-
- **`unknownWhy` names each direct Unknown's origin**
|
|
163
|
-
|
|
164
|
-
|
|
200
|
+
- **`unknownWhy` names each direct Unknown's origin** as `kind:detail` — triage starts at the named
|
|
201
|
+
site. Inheritors carry `Unknown` with no why; follow the callgraph down to the root. The `kind` is
|
|
202
|
+
drawn from §4's closed **five**-kind vocabulary — `reflect:` / `native:` / `dispatch:` /
|
|
203
|
+
`callback:` / `ambiguous:` — of which candor-ts's language model produces three:
|
|
204
|
+
`reflect:eval`, `reflect:defineProperty:dynamic-key` (TypeScript folds its native boundary into
|
|
205
|
+
`reflect:`, so no bare `native:`), `callback:param#0`, and `dispatch:<pkg>.<Type>.<member>` —
|
|
206
|
+
the one **normative** detail shape, and what the `callers --include-unknown` frontier resolves
|
|
207
|
+
overrides against. It also carries `no-node_modules:<pkg>` for the *setup* class (a declared dep
|
|
208
|
+
that isn't installed — a mis-configuration, not a blind spot). A kind candor-ts never emits can
|
|
209
|
+
still appear in its output: a chained dependency's reasons are relayed verbatim, and every query
|
|
210
|
+
verb reads other engines' reports — `ambiguous:` (name resolution was ambiguous, so no owner type
|
|
211
|
+
was formed at all) is candor-rust's and arrives that way. Any kind outside the five round-trips
|
|
212
|
+
untouched and classifies through the conservative catch-all (§2 forward-compatibility).
|
|
165
213
|
- **`entryPoint: true` marks runtime-invoked roots** (Nest `@Get/@Post/…` handler methods, Next
|
|
166
214
|
`route.ts` HTTP exports and `middleware`) — their effects are never orphaned; `reachable` unions
|
|
167
215
|
over them. Pure entry points stay visible in the report.
|
package/README.md
CHANGED
|
@@ -34,6 +34,14 @@ node query.mjs blindspots .candor/report # the Unknown SOURCES, ranked
|
|
|
34
34
|
node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdict (exit 1)
|
|
35
35
|
node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain;
|
|
36
36
|
# a baseline from a DIFFERENT build ⇒ disclosed ⚠ + exit 0)
|
|
37
|
+
node query.mjs gate --report dep/.candor/report.json --policy arch.policy --gate-json -
|
|
38
|
+
# ⟨0.24⟩ apply a policy to an EXISTING report, NO scan
|
|
39
|
+
# (exit 0/1/2) — the supply-chain verb: gate a
|
|
40
|
+
# DEPENDENCY's published report without its source.
|
|
41
|
+
# Reads that report and nothing else; a rule the wire
|
|
42
|
+
# cannot answer (`forbid`, `allow`, a class-scoped
|
|
43
|
+
# `deny` with the scoping field absent) is REFUSED
|
|
44
|
+
# with exit 2, never evaluated on partial evidence.
|
|
37
45
|
```
|
|
38
46
|
|
|
39
47
|
A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
|
|
@@ -184,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
192
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
193
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
194
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
195
|
+
| `{ candor: { version, toolchain, spec: "0.25" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
196
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
197
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
198
|
|
|
@@ -202,9 +210,10 @@ read the Rust source".
|
|
|
202
210
|
|
|
203
211
|
## Status
|
|
204
212
|
|
|
205
|
-
0.19.x, speaking candor-spec 0.
|
|
213
|
+
0.19.x, speaking candor-spec 0.25: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
214
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
|
-
`--include-unknown` dispatch frontier
|
|
215
|
+
`--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
|
|
216
|
+
report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
|
|
208
217
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
|
209
218
|
with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
|
|
210
219
|
call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
|
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 } from "./policy.mjs";
|
|
48
|
+
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } 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.
|
|
@@ -280,24 +280,91 @@ function hoverAt(docPath, line) {
|
|
|
280
280
|
// ---- Diagnostics (the live gate) ---------------------------------------------------------------------
|
|
281
281
|
const warned = new Set();
|
|
282
282
|
function warnOnce(message) { if (!warned.has(message)) { warned.add(message); logMessage(message); } }
|
|
283
|
+
// ⟨0.24⟩ the file `activePolicy()` last read, so the vocabulary anchor can be the POLICY FILE's dir.
|
|
284
|
+
let activePolicyPath = null;
|
|
283
285
|
function activePolicy() {
|
|
284
286
|
const env = process.env.CANDOR_POLICY;
|
|
285
287
|
if (env) {
|
|
286
|
-
if (fs.existsSync(env)) return fs.readFileSync(env, "utf8");
|
|
288
|
+
if (fs.existsSync(env)) { activePolicyPath = env; return fs.readFileSync(env, "utf8"); }
|
|
287
289
|
// Set-but-missing must be LOUD (the family's configured-but-unusable posture — scan exits 2 here).
|
|
288
290
|
// This is an advisory surface, so: disclose the policy-source swap, then fall through to discovery.
|
|
289
291
|
warnOnce(`candor-lsp: CANDOR_POLICY is set but ${env} does not exist — falling back to .candor/config discovery (diagnostics may reflect a different policy than you configured)`);
|
|
290
292
|
}
|
|
291
293
|
const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
|
|
292
294
|
const cfg = from ? discoverConfigPolicy(from) : null;
|
|
293
|
-
if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
|
|
295
|
+
if (cfg && fs.existsSync(cfg.policyPath)) { activePolicyPath = cfg.policyPath; return fs.readFileSync(cfg.policyPath, "utf8"); }
|
|
296
|
+
activePolicyPath = null;
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* ⟨0.24⟩ ONE policy PARSE for this surface — the LSP twin of query.mjs's `loadPolicyOrDie` and mcp.mjs's
|
|
302
|
+
* `policyOrThrow`, and it closes the same drift. Every call site here parsed with NO alias map, so a rule
|
|
303
|
+
* written against a checked-in `unknown-alias` was silently WIDENED to the bare effect: the editor drew
|
|
304
|
+
* squiggles for a class the operator had deliberately excluded, and drew none for the one they had named.
|
|
305
|
+
* The vocabulary anchors at the POLICY FILE, as it does on both CLI routes, so the same policy means the
|
|
306
|
+
* same thing in the editor and in the gate that judges the edit.
|
|
307
|
+
*
|
|
308
|
+
* On a policy this engine CANNOT HONOUR AS WRITTEN (§6.2 `be0b9a9`), it returns null and says so ONCE.
|
|
309
|
+
* This surface has no exit code, so the enforcement posture cannot be borrowed wholesale — but evaluating
|
|
310
|
+
* a policy the engine has silently rewritten is the fail-open the ruling exists to close, and it is worse
|
|
311
|
+
* here than elsewhere because a squiggle that does not appear is invisible. What the disclosure buys is
|
|
312
|
+
* that the empty editor is EXPLAINED rather than read as a clean bill of health, which is the same
|
|
313
|
+
* argument this file already makes about a report that judged nothing.
|
|
314
|
+
*/
|
|
315
|
+
function activePolicyParsed(text) {
|
|
316
|
+
const errs = [];
|
|
317
|
+
const aliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(activePolicyPath, rootPath || process.cwd())), errs);
|
|
318
|
+
const pol = parsePolicy(text, aliases);
|
|
319
|
+
errs.push(...pol.errors);
|
|
320
|
+
// ⟨0.24⟩ FATAL only: `errors` also carries every LINE the parser dropped whole (SPEC §3.1
|
|
321
|
+
// `195d45a`) — additive to the `parsepolicy` witness, deliberately silent about the gate.
|
|
322
|
+
const fatal = fatalPolicyErrors(errs);
|
|
323
|
+
if (!fatal.length) return pol;
|
|
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.`);
|
|
294
325
|
return null;
|
|
295
326
|
}
|
|
296
327
|
function diagnosticsFor(docPath) {
|
|
297
328
|
const text = activePolicy();
|
|
298
329
|
if (text === null || !hasReport(reportPrefix)) return [];
|
|
299
330
|
const fns = Q.loadReport(reportPrefix);
|
|
300
|
-
|
|
331
|
+
// ⟨0.24⟩ A report that JUDGED NOTHING is not a clean bill of health, and this surface is where that is
|
|
332
|
+
// hardest to notice: the live gate's whole vocabulary is squiggles, and a report with `analyzed.count: 0`
|
|
333
|
+
// has no entries, so it produces none — an empty editor reads as "the gate is green" when the truth is
|
|
334
|
+
// that nothing was ever judged. SPEC §2's three-row table, bound here by §3.1 ⟨0.24⟩ ("the obligation is
|
|
335
|
+
// on the reading, not on the route by which the report arrived"); this route takes a `report` locator,
|
|
336
|
+
// so a FOREIGN report arrives by it. The channel is the LOG and `warnOnce` rather than a diagnostic on
|
|
337
|
+
// every publish, because there is no line to pin it to and a per-keystroke popup is how an advisory gets
|
|
338
|
+
// turned off. The verdict is untouched: no effect is asserted that the report does not carry.
|
|
339
|
+
if (Q.reportJudgedNothing(reportPrefix))
|
|
340
|
+
warnOnce(`candor-lsp: the report at ${reportPrefix} judged NOTHING (⟨0.24⟩ \`analyzed.count\` is 0, absent with no `
|
|
341
|
+
+ `entries, or unreadable) — no gate diagnostics can come from it, and their absence is NOT an all-clear: `
|
|
342
|
+
+ `absence from \`functions\` licenses no purity claim about any unit. Re-scan the sources you meant to gate `
|
|
343
|
+
+ `(candor-ts <src> --out ${reportPrefix}).`);
|
|
344
|
+
// ⟨0.24⟩ `netClass` VERBATIM off the wire — the same report-route rule as the MCP `candor_gate` tool and
|
|
345
|
+
// `gate --report`; re-deriving it here would answer with the CONSUMER's `net-partner` evidence about the
|
|
346
|
+
// producer's project, in both the fabricating and the fail-open direction (see reportNetClasses).
|
|
347
|
+
const dpol = activePolicyParsed(text);
|
|
348
|
+
if (dpol === null) return [];
|
|
349
|
+
// ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
|
|
350
|
+
// `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
|
|
351
|
+
// editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
|
|
352
|
+
// where `gate --report` exits 2 (a squiggle that does not appear is the least visible false all-clear this
|
|
353
|
+
// project has), and `deny Net[unknown-host]` drew one whose message ASSERTS a destination class the report
|
|
354
|
+
// never carried. `authoritative: true` for the same reason the MCP gate takes it: an entry without
|
|
355
|
+
// `netClass` gets the EMPTY set rather than a derivation from `hosts`, so the report is the only source of
|
|
356
|
+
// the class and nothing is re-classified with this machine's evidence about the producer's project.
|
|
357
|
+
const dnet = reportNetClasses(fns, { authoritative: true });
|
|
358
|
+
const { unevaluated: dunevaluated, withhold: dwithhold } =
|
|
359
|
+
unanswerableScoped(dpol, fns, resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix)), dnet);
|
|
360
|
+
// The surface has no exit code, so the refusal is carried the way this file already carries its other two
|
|
361
|
+
// (the unhonourable policy, the judged-nothing report): ONE log line naming the rules, so the missing
|
|
362
|
+
// squiggle is EXPLAINED rather than read as a clean bill of health. Per rule, not per keystroke — warnOnce
|
|
363
|
+
// keys on the message, and the message is a function of the policy and the report, not of the edit.
|
|
364
|
+
for (const u of dunevaluated)
|
|
365
|
+
warnOnce(`candor-lsp: ${u.why}\n NO diagnostics are drawn for that rule — their ABSENCE here is the refusal, not an all-clear.`);
|
|
366
|
+
const violations = evaluatePolicy(dpol, fns, Q.loadCallgraph(reportPrefix),
|
|
367
|
+
new Map(), new Set(), dnet, dwithhold);
|
|
301
368
|
const locByFn = new Map(fns.filter((e) => e.loc).map((e) => [e.fn, locParts(e.loc)]));
|
|
302
369
|
const out = [];
|
|
303
370
|
for (const v of violations) {
|
|
@@ -360,7 +427,8 @@ function codeActions(docPath, uri, range) {
|
|
|
360
427
|
// function that actually violates the boundary. Same policy source as the diagnostics + the whatif action.
|
|
361
428
|
const policyText = activePolicy();
|
|
362
429
|
if (policyText !== null && hasReport(reportPrefix)) {
|
|
363
|
-
const pol =
|
|
430
|
+
const pol = activePolicyParsed(policyText);
|
|
431
|
+
if (pol === null) return out;
|
|
364
432
|
const cg = Q.loadCallgraph(reportPrefix);
|
|
365
433
|
const fns = Q.loadReport(reportPrefix);
|
|
366
434
|
for (const eff of Q.CONTAINED) {
|
|
@@ -420,23 +488,36 @@ function runWhatif(a) {
|
|
|
420
488
|
}
|
|
421
489
|
const policyText = activePolicy();
|
|
422
490
|
const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
|
|
423
|
-
policyText === null ? null :
|
|
491
|
+
policyText === null ? null : activePolicyParsed(policyText), scopeMatches);
|
|
424
492
|
if (r === null) {
|
|
425
493
|
showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
|
|
426
494
|
return null;
|
|
427
495
|
}
|
|
428
496
|
const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
|
|
429
497
|
const rules = [...new Set(r.violations.map((v) => v.rule))];
|
|
498
|
+
// ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
|
|
499
|
+
// surface becomes the one place the defect still lives — and it is the sharpest place for it, because
|
|
500
|
+
// `rules[0]` is now the operator's RAW line. Without the condition the squiggle would read
|
|
501
|
+
// `✗ deny Net[unknown-host] app would fire`: a narrowed rule beside an unconditional verdict, which SPEC
|
|
502
|
+
// §3.1 calls WORSE than printing the rule stripped of its filter, since it reads as a narrowing candor
|
|
503
|
+
// evaluated and did not.
|
|
504
|
+
const condOf = new Map(r.violations.filter((v) => v.conditional).map((v) => [v.rule, v.conditional]));
|
|
430
505
|
const verdict = policyText === null
|
|
431
506
|
? `candor: no policy discovered — blast radius only: ${callers.length} caller(s) would inherit ${a.effect}`
|
|
432
507
|
: rules.length
|
|
433
|
-
? `✗ ${rules[0]} would fire — ${callers.length} caller(s) inherit ${a.effect}`
|
|
508
|
+
? `✗ ${rules[0]} would fire${condOf.has(rules[0]) ? ` IF ${condOf.get(rules[0])}` : ""} — ${callers.length} caller(s) inherit ${a.effect}`
|
|
434
509
|
: `✓ no policy rule fires — ${callers.length} caller(s) would inherit ${a.effect}`;
|
|
435
510
|
showMessage(rules.length ? 2 : 3, verdict); // warning when a rule fires, info otherwise
|
|
436
511
|
if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the detail, pinned at the fn's line
|
|
437
512
|
const head = callers.slice(0, 10);
|
|
438
513
|
const lines = [`what if ${r.of.join(", ")} performed ${a.effect}? ${verdict}`];
|
|
439
514
|
if (rules.length > 1) lines.push(`rules: ${rules.join("; ")}`);
|
|
515
|
+
// The one-liner has room for the condition but not for WHY there is one; the pinned detail has room for
|
|
516
|
+
// both, and the reason is the half that stops an operator reading a fail-closed hedge as a false alarm.
|
|
517
|
+
for (const [rule, c] of condOf)
|
|
518
|
+
lines.push(`conditional — \`${rule}\` fires IF ${c}. That rule NARROWS, and the effect you have not `
|
|
519
|
+
+ "written yet has no class to match, so candor charges it fail-closed rather than guessing "
|
|
520
|
+
+ "which class your edit would land in.");
|
|
440
521
|
lines.push(head.length
|
|
441
522
|
? `callers: ${head.join(", ")}${callers.length > head.length ? ` +${callers.length - head.length} more` : ""}`
|
|
442
523
|
: "no callers — the blast radius is the function itself");
|
|
@@ -466,12 +547,26 @@ function runFix(a) {
|
|
|
466
547
|
showMessage(2, "candor: no policy discovered — a fix is defined relative to a boundary; set CANDOR_POLICY or check one into .candor/config");
|
|
467
548
|
return null;
|
|
468
549
|
}
|
|
550
|
+
const fpol = activePolicyParsed(policyText);
|
|
551
|
+
if (fpol === null) return null;
|
|
469
552
|
const r = Q.fix(Q.loadCallgraph(reportPrefix), Q.loadReport(reportPrefix), a.fn, a.effect,
|
|
470
|
-
|
|
553
|
+
fpol, scopeMatches);
|
|
471
554
|
if (r === null) {
|
|
472
555
|
showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
|
|
473
556
|
return null;
|
|
474
557
|
}
|
|
558
|
+
// ⟨0.24⟩ SPEC §3.2 `4fd140c` — THE REFUSAL, BEFORE the `crossing` test and not folded into it. `Q.fix`
|
|
559
|
+
// now returns `{refused:true, unevaluated}` with NO `crossing` key where the gate could not adjudicate the
|
|
560
|
+
// boundary, and a falsy-`crossing` reading of that lands on "isn't forbidden here; no boundary fix
|
|
561
|
+
// needed" — the derived second opinion, delivered as a clean bill of health, on the surface that ASKED
|
|
562
|
+
// for a fix. Same disclosure the diagnostics path already makes (dunevaluated), repeated here because a
|
|
563
|
+
// code action can be invoked on a file whose diagnostics were never published.
|
|
564
|
+
if (r.refused) {
|
|
565
|
+
showMessage(2, `candor: \`${a.fn}\` — no fix computed: the gate could NOT judge ${a.effect} here (this report does not carry the evidence the policy narrows on), and a hoist plan for a boundary the gate could not adjudicate would rest on a guess`);
|
|
566
|
+
for (const u of r.unevaluated ?? [])
|
|
567
|
+
logMessage(`candor-lsp: ${u.why}\n NO fix is offered for that rule — the ABSENCE of a remedy here is the refusal, not an all-clear.`);
|
|
568
|
+
return r;
|
|
569
|
+
}
|
|
475
570
|
if (!r.crossing) {
|
|
476
571
|
showMessage(3, `candor: \`${a.fn}\` — ${a.effect} isn't forbidden here; no boundary fix needed`);
|
|
477
572
|
return r;
|
package/mcp.mjs
CHANGED
|
@@ -19,7 +19,9 @@ import fs from "node:fs";
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import nodePath from "node:path";
|
|
21
21
|
import * as Q from "./query-core.mjs";
|
|
22
|
-
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches
|
|
22
|
+
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses,
|
|
23
|
+
parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText,
|
|
24
|
+
unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } from "./policy.mjs";
|
|
23
25
|
|
|
24
26
|
const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
|
|
25
27
|
|
|
@@ -68,10 +70,19 @@ const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n
|
|
|
68
70
|
// corrupt report — the §4 cardinal sin, exactly what the CLI's loadReportOrDie exits 2 on. The throw
|
|
69
71
|
// surfaces as the same isError result shape every other tool failure uses. EVERY tool that loads a
|
|
70
72
|
// report (main prefix or baseline) goes through this — never bare Q.loadReport.
|
|
71
|
-
|
|
73
|
+
// `partialIsFatal` raises the bar from "NOTHING parsed" to "not EVERYTHING parsed", and `candor_gate`
|
|
74
|
+
// passes it because that tool emits a VERDICT: `{ok: true, violations: []}` over a multi-report prefix
|
|
75
|
+
// with one clean sibling and one truncated one is a green document over a package half of whose signature
|
|
76
|
+
// never loaded, and the disclosure Q.loadReport writes goes to the SERVER's stderr — a channel the calling
|
|
77
|
+
// agent never reads. Same rule, same argument as the CLI `gate --report` (see query.mjs). The read-only
|
|
78
|
+
// tools keep the looser bar deliberately: they return what they found rather than asserting a clean bill
|
|
79
|
+
// of health, so the partial answer is a smaller claim than a green gate.
|
|
80
|
+
function loadReportLoud(p, { partialIsFatal = false } = {}) {
|
|
72
81
|
const fns = Q.loadReport(p);
|
|
73
|
-
if (fns.
|
|
74
|
-
throw new Error(
|
|
82
|
+
if (fns.hardFail && (partialIsFatal || fns.length === 0))
|
|
83
|
+
throw new Error(fns.length === 0
|
|
84
|
+
? `every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`
|
|
85
|
+
: `a report found at prefix \`${clip(p)}\` failed to load — refusing to gate over a report that did not load cleanly; a partial signature makes a green verdict meaningless (the effects of the report that did not load are exactly the ones a violation would come from). Re-run the scan`);
|
|
75
86
|
return fns;
|
|
76
87
|
}
|
|
77
88
|
// The confinement root for a caller-supplied policy path: the repo the report belongs to — the
|
|
@@ -98,6 +109,33 @@ function confinedPolicyRead(policyPath, prefix, root = policyRoot(prefix)) {
|
|
|
98
109
|
try { return fs.readFileSync(abs, "utf8"); }
|
|
99
110
|
catch { throw new Error(`policy \`${clip(policyPath)}\` could not be read — NOT evaluated (a missing gate source must be loud, never a clean verdict)`); }
|
|
100
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* ⟨0.24⟩ ONE policy PARSE for every tool here that consults one — the MCP twin of query.mjs's
|
|
114
|
+
* `loadPolicyOrDie`, and it closes the same two defects on the agent-facing surface.
|
|
115
|
+
*
|
|
116
|
+
* These tools called `parsePolicy(text)` with NO alias map, so a rule written against a checked-in
|
|
117
|
+
* `unknown-alias` was silently REWRITTEN — widened to the bare effect — in the very tools an agent
|
|
118
|
+
* consults before and after an edit, while the CLI gate honoured it. And with ⟨0.24⟩ making an
|
|
119
|
+
* unrecognised value token a POLICY ERROR (§6.2 `382a7e0`/`be0b9a9`), `candor_gate` would otherwise
|
|
120
|
+
* have kept enforcing a policy it cannot honour as written — the narrowing case stops gating what the
|
|
121
|
+
* operator spelled while the gate still looks armed, which is the fail-open the ruling exists to
|
|
122
|
+
* close, arriving here through the surface an agent trusts most.
|
|
123
|
+
*
|
|
124
|
+
* The vocabulary anchors at the POLICY FILE (§3.1 `99eb4e9`), as it does on both CLI routes, so the
|
|
125
|
+
* same policy means the same thing however it is reached. A policy error throws, which the tool layer
|
|
126
|
+
* renders as `isError` — the MCP shape of the CLI's exit 2.
|
|
127
|
+
*/
|
|
128
|
+
function policyOrThrow(text, policyPath) {
|
|
129
|
+
const errs = [];
|
|
130
|
+
const aliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, process.cwd())), errs);
|
|
131
|
+
const pol = parsePolicy(text, aliases);
|
|
132
|
+
errs.push(...pol.errors);
|
|
133
|
+
// ⟨0.24⟩ FATAL only: `errors` also carries every LINE the parser dropped whole (SPEC §3.1
|
|
134
|
+
// `195d45a`) — additive to the `parsepolicy` witness, deliberately silent about the gate.
|
|
135
|
+
const fatal = fatalPolicyErrors(errs);
|
|
136
|
+
if (fatal.length) throw new Error(policyErrorText(policyPath ?? "(policy)", fatal));
|
|
137
|
+
return pol;
|
|
138
|
+
}
|
|
101
139
|
// The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
|
|
102
140
|
function configPolicy(prefix) {
|
|
103
141
|
return discoverConfigPolicy(nodePath.dirname(nodePath.resolve(prefix)) || ".");
|
|
@@ -195,22 +233,23 @@ const TOOLS = {
|
|
|
195
233
|
// typo'd/missing path silently evaluate with NO policy → `ok:true, violations:[]`, a false green
|
|
196
234
|
// on the agent-facing pre-edit gate (exactly what the CLI whatif exits 2 to prevent). The read's
|
|
197
235
|
// throw lands as the tool-level isError, mirroring the CLI's fail-closed posture.
|
|
198
|
-
const pol = a.policy ?
|
|
236
|
+
const pol = a.policy ? policyOrThrow(confinedPolicyRead(a.policy, p), a.policy) : null;
|
|
199
237
|
const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
|
|
200
238
|
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
201
239
|
return r;
|
|
202
240
|
},
|
|
203
241
|
},
|
|
204
242
|
candor_fix: {
|
|
205
|
-
description: "THE BOUNDARY FIX: when `fn` performs `effect` in a layer the policy forbids (a violation candor_whatif/candor_gate reports), compute the architectural REMEDY — not just 'the domain can't do Net', but WHERE the effect belongs and the refactor to put it there: the direct call site to hoist, the forbidden-layer functions that become pure and thread the value as a parameter, and the nearest allowed-layer caller to perform the effect ({ crossing, site, deniedSpan, hoistTo, policyAlternative }). The remedial inverse of candor_whatif. Call this INSTEAD OF guessing a fix (adding `allow` to the domain, moving the I/O one call up, threading a handle the wrong way). Advisory: it names the structure, you write the code; the gate re-scan verifies. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4).",
|
|
243
|
+
description: "THE BOUNDARY FIX: when `fn` performs `effect` in a layer the policy forbids (a violation candor_whatif/candor_gate reports), compute the architectural REMEDY — not just 'the domain can't do Net', but WHERE the effect belongs and the refactor to put it there: the direct call site to hoist, the forbidden-layer functions that become pure and thread the value as a parameter, and the nearest allowed-layer caller to perform the effect ({ crossing, site, deniedSpan, hoistTo, policyAlternative }). The remedial inverse of candor_whatif. Call this INSTEAD OF guessing a fix (adding `allow` to the domain, moving the I/O one call up, threading a handle the wrong way). Advisory: it names the structure, you write the code; the gate re-scan verifies. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `refused` BEFORE `crossing`: where the policy narrows on evidence this report does not carry, candor_gate REFUSES and no remedy is computed — the result is `{ fn, effect, refused:true, unevaluated:[{rule, why}] }` WITH NO `crossing` KEY, an absent key rather than `crossing:false`, because 'no boundary fix needed' is a claim and that is the one thing the tool cannot claim here (spec §3.2: an advisory verb may be LESS certain than the gate, never MORE). Re-scan so the report carries the narrowing evidence, or widen the rule; do not read the missing plan as an all-clear.",
|
|
206
244
|
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: ["fn", "effect"] },
|
|
207
245
|
run: (a, p) => {
|
|
208
246
|
// The fix is defined relative to a boundary — a policy is required. Given → confined fail-closed read;
|
|
209
247
|
// else the repo's checked-in policy (same resolution as candor_gate), so it works zero-config.
|
|
210
|
-
let text;
|
|
248
|
+
let text, polPath = a.policy ?? null;
|
|
211
249
|
if (a.policy) text = confinedPolicyRead(a.policy, p);
|
|
212
250
|
else {
|
|
213
251
|
const cfg = configPolicy(p);
|
|
252
|
+
polPath = cfg?.policyPath ?? null;
|
|
214
253
|
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4) — the fix is defined relative to the boundary it crosses");
|
|
215
254
|
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
216
255
|
}
|
|
@@ -218,24 +257,94 @@ const TOOLS = {
|
|
|
218
257
|
// The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
|
|
219
258
|
// never a degenerate empty-graph remedy. (/code-review.)
|
|
220
259
|
if (!cg || Object.keys(cg).length === 0) throw new Error(`no call-graph sidecar for the report — fix needs it (re-scan with --out)`);
|
|
221
|
-
const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect,
|
|
260
|
+
const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, policyOrThrow(text, polPath), scopeMatches);
|
|
222
261
|
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
223
262
|
return r;
|
|
224
263
|
},
|
|
225
264
|
},
|
|
226
265
|
candor_gate: {
|
|
227
|
-
description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
|
|
266
|
+
description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `{ incomplete:true, unanalyzed }` means the report declares code candor could not analyze, so the gate cannot be green. Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
|
|
228
267
|
schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
|
|
229
268
|
run: (a, p) => {
|
|
230
|
-
let text;
|
|
269
|
+
let text, polPath = a.policy ?? null;
|
|
231
270
|
if (a.policy) text = confinedPolicyRead(a.policy, p);
|
|
232
271
|
else {
|
|
233
272
|
const cfg = configPolicy(p);
|
|
273
|
+
polPath = cfg?.policyPath ?? null;
|
|
234
274
|
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
|
|
235
275
|
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
236
276
|
}
|
|
237
|
-
|
|
238
|
-
|
|
277
|
+
// ⟨0.24⟩ THE SAME THREE-PIECE GATE THE CLI RUNS, because this tool is a REPORT route exactly as
|
|
278
|
+
// `gate --report` is, and the two pieces it was missing were BOTH live harms on the surface an
|
|
279
|
+
// agent trusts and no human reads. Measured, same report, same policy, against the CLI:
|
|
280
|
+
//
|
|
281
|
+
// deny Unknown[reflect] app -> CLI exit 2 (refused); here {"ok":true,"violations":[]}
|
|
282
|
+
// deny Net[unknown-host] app -> CLI exit 2 (refused); here FIRES, asserting
|
|
283
|
+
// "netClass":["unknown-host"] the report never carried
|
|
284
|
+
//
|
|
285
|
+
// (1) `authoritative` netClass. The DEFAULT mode maps only entries that CARRY the field and lets the
|
|
286
|
+
// rest fall back to `netClassesOf`, which floors an empty surface at `unknown-host`. That was chosen
|
|
287
|
+
// as a hedge because "neither surface can refuse a question" — but a hedge that ASSERTS a destination
|
|
288
|
+
// class in the violation record is not a hedge, it is the re-derivation §3.1 ⟨0.24⟩ forbids, in a
|
|
289
|
+
// field a consumer reads as the PRODUCER's judgment. Now that the tool discloses (3), it can refuse,
|
|
290
|
+
// so the authoritative mode is the correct one and the two routes read one report identically.
|
|
291
|
+
// (2) `withhold` (unanswerableScoped). A scoped `deny` whose narrowing evidence the wire does not
|
|
292
|
+
// carry is NOT EVALUATED, per (rule, function, effect) — never scored as a filter that succeeded for
|
|
293
|
+
// lack of evidence, and never fired on `reasonClassesMatch`'s empty-set floor, which is right for a
|
|
294
|
+
// MATCHER and wrong for a FIRING.
|
|
295
|
+
// (3) the disclosure, which is what makes (1) and (2) safe here: an unevaluated rule rides the tool
|
|
296
|
+
// result, so the agent sees WHICH part of its policy went unenforced rather than an empty list.
|
|
297
|
+
//
|
|
298
|
+
// ONE PASS over the report files (`loadGateReport`), the same reader `gate --report` uses: it yields
|
|
299
|
+
// the entries AND the ⟨0.21⟩/⟨0.15⟩ envelope out of the same bytes. Three separate reads (functions,
|
|
300
|
+
// judged-nothing, and — newly — `unanalyzed`) could disagree with each other on a file another
|
|
301
|
+
// process rewrites between them.
|
|
302
|
+
const pol = policyOrThrow(text, polPath);
|
|
303
|
+
const g = Q.loadGateReport(p);
|
|
304
|
+
if (g.hardFail)
|
|
305
|
+
throw new Error((g.corrupt.length
|
|
306
|
+
? `the report at prefix \`${clip(p)}\` has ${g.corrupt.length} present-but-unparseable §2 key(s) — a key that is THERE but of the wrong shape is corrupt input, not an empty one (SPEC §2 ⟨0.24⟩); coercing it to its empty value would turn corruption into a purity claim: ${g.corrupt.join("; ")}. `
|
|
307
|
+
: "")
|
|
308
|
+
+ (g.functions.length === 0
|
|
309
|
+
? `every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`
|
|
310
|
+
: `a report found at prefix \`${clip(p)}\` failed to load — refusing to gate over a report that did not load cleanly; a partial signature makes a green verdict meaningless (the effects of the report that did not load are exactly the ones a violation would come from). Re-run the scan`));
|
|
311
|
+
const gfns = g.functions;
|
|
312
|
+
const cg = Q.loadCallgraph(p);
|
|
313
|
+
const gnet = reportNetClasses(gfns, { authoritative: true });
|
|
314
|
+
const { unevaluated, withhold } = unanswerableScoped(pol, gfns, resolveReasonClasses(gfns, cg), gnet);
|
|
315
|
+
const v = evaluatePolicy(pol, gfns, cg, new Map(), new Set(), gnet, withhold);
|
|
316
|
+
// ⟨0.21⟩ COMPLETENESS MANIFEST — this tool implemented no incompleteness rule at all: it answered
|
|
317
|
+
// `{ok:true, violations:[]}` over a report DECLARING `unanalyzed`, where the CLI exits 2. A gate
|
|
318
|
+
// cannot be green over code candor never analyzed, and the manifest travels ON the report, so the
|
|
319
|
+
// same verdict follows from it here. Additive to the pinned `{ok, violations}` shape.
|
|
320
|
+
const incomplete = g.unanalyzed.length > 0;
|
|
321
|
+
// ⟨0.24⟩ …and a report that JUDGED NOTHING is not an all-clear (SPEC §2's three-row table, bound to
|
|
322
|
+
// every report-reading route by §3.1: "the obligation is on the reading, not on the route by which
|
|
323
|
+
// the report arrived"). This tool is exactly such a route — it gates whatever `report` points at,
|
|
324
|
+
// which is how a FOREIGN report arrives here — and `{ok: true, violations: []}` over a report with
|
|
325
|
+
// `analyzed.count: 0` tells an agent the code is clean when nothing in it was ever judged. The
|
|
326
|
+
// caveat is ADDITIVE (the two existing keys keep their shape and meaning, and the field is absent
|
|
327
|
+
// on every ordinary report) because the verdict itself must not move: the report asserts no effect,
|
|
328
|
+
// 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." } : {};
|
|
333
|
+
const inc = incomplete ? { incomplete: true, unanalyzed: g.unanalyzed } : {};
|
|
334
|
+
// ⟨0.24⟩ PRECEDENCE (SPEC §3.1 `7271c69`/`4c79958`): violation (1) > refusal (2) > incomplete (2), and
|
|
335
|
+
// the REFUSAL SHAPE is the one the CLI writes — `ok:false`, `refused:true`, and NO `violations` KEY AT
|
|
336
|
+
// ALL, because the gate is making no claim about violations and `[]` is precisely the claim it cannot
|
|
337
|
+
// make. THE TOOL-RESULT SHAPE DECISION, stated: a refusal is returned as this structured document and
|
|
338
|
+
// NOT as `isError`. `isError` flattens to a text blob, and the machine-actionable half of a refusal is
|
|
339
|
+
// `unevaluated` — WHICH rules went unenforced and why — which is exactly what an agent needs to fix it.
|
|
340
|
+
// The document is still fail-closed to the naivest possible reader (`ok` is false), and it is the same
|
|
341
|
+
// 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 };
|
|
343
|
+
if (unevaluated.length)
|
|
344
|
+
return { ok: false, refused: true,
|
|
345
|
+
reason: `${unevaluated.length} policy rule(s) could not be evaluated against this report`,
|
|
346
|
+
unevaluated, ...inc, ...judged };
|
|
347
|
+
return { ok: !incomplete, violations: v, ...inc, ...judged };
|
|
239
348
|
},
|
|
240
349
|
},
|
|
241
350
|
candor_unverified: {
|
|
@@ -245,17 +354,34 @@ const TOOLS = {
|
|
|
245
354
|
+ "case is a fn/closure-injected 'port' — the domain reads as Unknown, so `deny Net domain`/`pure "
|
|
246
355
|
+ "domain` clear it though it may reach Net at runtime. Returns each such function + the `deny <E> "
|
|
247
356
|
+ "Unknown <scope>` upgrade that makes the layer PROVABLY clean. Uses `policy` if given, else the "
|
|
248
|
-
+ "repo's checked-in .candor/config policy."
|
|
357
|
+
+ "repo's checked-in .candor/config policy. ALWAYS CHECK `ok`, never the length of `unverified`: "
|
|
358
|
+
+ "over a report declaring code candor could NOT analyze, `ok` IS ABSENT and `{incomplete:true, "
|
|
359
|
+
+ "unanalyzed}` takes its place — a function in an unanalyzed file is missing from the report "
|
|
360
|
+
+ "entirely, so it cannot be enumerated as an unverified pass, and an empty array there is not "
|
|
361
|
+
+ "an all-clear (spec §3.2). `ok` is ABSENT for a second reason too, and the entries that come "
|
|
362
|
+
+ "with it are the sharpest ones: where the policy narrows on evidence this report does not carry, "
|
|
363
|
+
+ "candor_gate REFUSES — and this verb then NAMES each function the gate could not judge, as "
|
|
364
|
+
+ "`{fn, rule, why}` where `why` is THE MISSING EVIDENCE and never a derived class, plus the gate's "
|
|
365
|
+
+ "own `unevaluated:[{rule, why}]`. Those entries carry no `upgrade`: whether they pass at all is "
|
|
366
|
+
+ "the open question, so there is nothing to upgrade yet — re-scan so the report carries the "
|
|
367
|
+
+ "evidence, or widen the rule (spec §3.2: an advisory verb may be LESS certain than the gate, "
|
|
368
|
+
+ "never MORE).",
|
|
249
369
|
schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
|
|
250
370
|
run: (a, p) => {
|
|
251
|
-
let text;
|
|
371
|
+
let text, polPath = a.policy ?? null;
|
|
252
372
|
if (a.policy) text = confinedPolicyRead(a.policy, p);
|
|
253
373
|
else {
|
|
254
374
|
const cfg = configPolicy(p);
|
|
375
|
+
polPath = cfg?.policyPath ?? null;
|
|
255
376
|
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
|
|
256
377
|
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
257
378
|
}
|
|
258
|
-
|
|
379
|
+
// ⟨0.24⟩ SPEC §3.2 — the agent surface is a CHANNEL this verb answers on, and the rule binds every
|
|
380
|
+
// one of them. `candor_gate` already refuses to read green over `unanalyzed`; this returned
|
|
381
|
+
// `ok:true` with an empty array over the identical bytes, on the surface an agent trusts and no
|
|
382
|
+
// 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));
|
|
259
385
|
},
|
|
260
386
|
},
|
|
261
387
|
candor_containment: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.25)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
"candor-mcp": "./mcp.mjs",
|
|
16
16
|
"candor-lsp": "./lsp.mjs",
|
|
17
17
|
"candor-ts-verify": "./verify.mjs",
|
|
18
|
-
"candor-ts-sensitivity": "./sensitivity.mjs"
|
|
18
|
+
"candor-ts-sensitivity": "./sensitivity.mjs",
|
|
19
|
+
"candor-ts-transitive-recall": "./transitive-recall.mjs"
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
21
22
|
"lint": "eslint *.mjs",
|
|
22
23
|
"test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
|
|
23
24
|
"test:unit": "node --test test-unit.mjs",
|
|
24
25
|
"test:probe": "node fabrication_probe.mjs",
|
|
25
|
-
"test:fuzz": "node fuzz.mjs"
|
|
26
|
+
"test:fuzz": "node fuzz.mjs",
|
|
27
|
+
"test:transitive-recall": "node transitive-recall.mjs"
|
|
26
28
|
},
|
|
27
29
|
"license": "(MIT OR Apache-2.0)",
|
|
28
30
|
"repository": {
|
|
@@ -64,7 +66,8 @@
|
|
|
64
66
|
"verify-emit.mjs",
|
|
65
67
|
"verify-loader.mjs",
|
|
66
68
|
"verify-syscall.mjs",
|
|
67
|
-
"sensitivity.mjs"
|
|
69
|
+
"sensitivity.mjs",
|
|
70
|
+
"transitive-recall.mjs"
|
|
68
71
|
],
|
|
69
72
|
"devDependencies": {
|
|
70
73
|
"@eslint/js": "^9.39.4",
|