candor-ts 0.33.1 → 0.34.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 +1 -1
- package/README.md +2 -2
- package/contract.mjs +54 -0
- package/lsp.mjs +188 -10
- package/mcp.mjs +64 -13
- package/package.json +2 -2
- package/query-core.mjs +175 -7
- package/query.mjs +63 -71
- package/scan.mjs +473 -93
package/AGENTS.md
CHANGED
|
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
|
|
|
21
21
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
22
22
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
23
23
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
24
|
-
> 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.34)."*
|
|
25
25
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
26
26
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
27
27
|
>
|
package/README.md
CHANGED
|
@@ -198,7 +198,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
198
198
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
199
199
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
200
200
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
201
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
201
|
+
| `{ candor: { version, toolchain, spec: "0.34" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
202
202
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
203
203
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
204
204
|
|
|
@@ -216,7 +216,7 @@ read the Rust source".
|
|
|
216
216
|
|
|
217
217
|
## Status
|
|
218
218
|
|
|
219
|
-
0.30.0, speaking candor-spec 0.
|
|
219
|
+
0.30.0, speaking candor-spec 0.34: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
220
220
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
221
221
|
`--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
|
|
222
222
|
report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
|
package/contract.mjs
CHANGED
|
@@ -113,3 +113,57 @@ export function writeStdoutSync(out, what = "output", fd = 1, budgetMs = 5000) {
|
|
|
113
113
|
}
|
|
114
114
|
return true;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
// ── SPEC §3.3.1 ⟨0.28⟩ THE SINK WRITER, AND THE ONE SHAPE THAT IS NEVER A SINK ────────────────────
|
|
118
|
+
//
|
|
119
|
+
// ONE IMPLEMENTATION, for the same reason `printAgents` and `writeStdoutSync` live here: scan.mjs and
|
|
120
|
+
// query.mjs are two entry points that must not import from each other, and until 2026-08-30 each
|
|
121
|
+
// carried its own byte-identical copy of these two functions plus its own spelling of the
|
|
122
|
+
// `.candor/config` shape check. That duplication is not theoretical drift — it is the measured
|
|
123
|
+
// SIBLING-ROUTE pattern: three guard-deletion findings were closed on query.mjs's copies on
|
|
124
|
+
// 2026-08-30, and scan.mjs's copies were still unprotected hours later (neutering all three left the
|
|
125
|
+
// full 1702-row battery green, while three hand repros destroyed a `.candor/config`, severed a symlink
|
|
126
|
+
// and stranded a hard-linked sink). Both routes now have watched-RED rows; keeping ONE implementation
|
|
127
|
+
// is what stops the third route from starting out with none.
|
|
128
|
+
//
|
|
129
|
+
// RESOLVE THE SINK TO ITS FINAL ARTIFACT BEFORE WRITING, and preserve the operator's layout.
|
|
130
|
+
// `renameSync` REPLACES a symlink rather than following it, so an `artifacts/verdict.json` linked into a
|
|
131
|
+
// shared directory kept a previous run's `{"ok": true}` while this run's document landed on the link — a
|
|
132
|
+
// stale green with a single `--gate-json` and no operator mistake. And rename gives the destination a NEW
|
|
133
|
+
// inode, so a multiply-linked target strands its other name with the previous document; there the write
|
|
134
|
+
// goes in place, trading the atomicity window for not publishing a stale verdict at a name the operator
|
|
135
|
+
// wired up. SPEC §3.3.1 states identity about ARTIFACTS; this family had it in the comparison only.
|
|
136
|
+
export function resolveSinkArtifact(p) {
|
|
137
|
+
let cur = p;
|
|
138
|
+
for (let i = 0; i < 32; i++) {
|
|
139
|
+
let st;
|
|
140
|
+
try { st = fs.lstatSync(cur); } catch { return cur; }
|
|
141
|
+
if (!st.isSymbolicLink()) return cur;
|
|
142
|
+
let t;
|
|
143
|
+
try { t = fs.readlinkSync(cur); } catch { return cur; }
|
|
144
|
+
cur = path.isAbsolute(t) ? t : path.join(path.dirname(cur), t);
|
|
145
|
+
}
|
|
146
|
+
return cur;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function writeSinkAtomic(p, text) {
|
|
150
|
+
const target = resolveSinkArtifact(p);
|
|
151
|
+
try {
|
|
152
|
+
if (fs.statSync(target).nlink > 1) { fs.writeFileSync(target, text); return; }
|
|
153
|
+
} catch { /* not there yet — the ordinary temp+rename path is right */ }
|
|
154
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
155
|
+
fs.writeFileSync(tmp, text);
|
|
156
|
+
fs.renameSync(tmp, target);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `.candor/config` is never a verdict sink, wherever it is — the SHAPE, not a discovered path.
|
|
160
|
+
* The per-input collision checks can only name inputs a run was TOLD about; the config is DISCOVERED
|
|
161
|
+
* by walking up from the target, so by the time its path is known the arming has already destroyed it.
|
|
162
|
+
* A check on the shape needs no discovery, so it runs before the first write and covers a config found
|
|
163
|
+
* anywhere up the tree. The PREDICATE is shared; each route keeps its own diagnostic and its own
|
|
164
|
+
* refusal plumbing (scan.mjs also feeds the ⟨0.28⟩ report stream through `refuseEarly`). */
|
|
165
|
+
export function isCandorConfigSink(p) {
|
|
166
|
+
if (!p || p === "-") return false;
|
|
167
|
+
const abs = path.resolve(p);
|
|
168
|
+
return path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor";
|
|
169
|
+
}
|
package/lsp.mjs
CHANGED
|
@@ -209,6 +209,103 @@ function entriesInDoc(docPath, fns = null) {
|
|
|
209
209
|
return out;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A report FILE FOUND but UNREADABLE (corrupt, mid-write, or a permissions error) — `Q.loadReport` tags
|
|
214
|
+
* its result with the non-enumerable `hardFail` the CLI's `loadReportOrDie` and the MCP `loadReportLoud`
|
|
215
|
+
* both consult, but until now nothing in this file read it: `diagnosticsFor`, `hoverAt` and `codeLenses`
|
|
216
|
+
* called `Q.loadReport` bare, so a corrupt report degraded to an EMPTY `functions` array and every one of
|
|
217
|
+
* the three read that as "no effects" — the live gate published `[]` (clean), hover answered `null`
|
|
218
|
+
* (nothing to show), codeLens answered `[]` (nothing effectful) — on the ONE surface a developer watches
|
|
219
|
+
* continuously (`integrations/vscode` and the JetBrains plugin both bundle this exact server). The CLI
|
|
220
|
+
* exits 2 over exactly this ("refusing to report an empty (all-clear) answer over a corrupt report"); the
|
|
221
|
+
* MCP tools throw. This server has neither an exit code nor a safe throw-and-crash option — killing the
|
|
222
|
+
* connection over one bad report would cost the developer every OTHER file and language feature the
|
|
223
|
+
* editor provides, for a fault a re-scan clears in seconds. So: never silent, never fatal — the invariant
|
|
224
|
+
* this closes is "the engine declined to answer must never be presentable as the engine answered clean",
|
|
225
|
+
* not "an unreadable report must end the session".
|
|
226
|
+
*
|
|
227
|
+
* FULL vs PARTIAL, the CLI/MCP distinction carried here because the repair text and the right amount of
|
|
228
|
+
* caution differ: `fns.length === 0` is every report file at the prefix failing (a single corrupt
|
|
229
|
+
* `<prefix>.json`, or a multi-report prefix whose every sibling is unreadable) — nothing loaded, not even
|
|
230
|
+
* a partial claim. `fns.length > 0` with `hardFail` still set is the multi-report (Rust/workspace) form
|
|
231
|
+
* with ONE corrupt sibling among healthy ones — MEASURED to be the quieter failure: `reportJudgedNothing`
|
|
232
|
+
* ANDs `analyzed.count<=0` across every sibling, so ONE healthy sibling with real entries makes it return
|
|
233
|
+
* FALSE and the corrupt sibling's absence gets no disclosure of ANY kind (repro: a `.good.scan.json`
|
|
234
|
+
* sibling with one pure fn beside a `.bad.scan.json` garbage sibling — diagnostics/log/show all silent).
|
|
235
|
+
*
|
|
236
|
+
* `full` decides what each caller does with it: `diagnosticsFor` (the live GATE — squiggles ARE this
|
|
237
|
+
* surface's whole verdict, per `discloseIncompleteness`'s own framing) refuses to draw ANY ordinary
|
|
238
|
+
* squiggle on either kind of hardFail, mirroring `candor_gate`, which takes the strict bar at its OWN
|
|
239
|
+
* `g.hardFail` throw off `Q.loadGateReport` — the same reader `gate --report` uses. A partial signature
|
|
240
|
+
* makes a green verdict meaningless FOR THE SAME REASON on all three routes: the effects that failed to
|
|
241
|
+
* load are exactly the ones a violation would come from. `hoverAt`/`codeLenses` are read-only
|
|
242
|
+
* per-function/per-line surfaces, the LSP analogue of `Q.show`/`Q.map` — MCP keeps THOSE tolerant of a
|
|
243
|
+
* partial load (plain `loadReportLoud`, whose bar is "NOTHING parsed") because a partial answer is a
|
|
244
|
+
* smaller claim than a green gate, and the same argument holds here:
|
|
245
|
+
*
|
|
246
|
+
* NAMED FOR A MECHANISM THAT EXISTS, since 2026-08-30 (panel finding 22). This paragraph, the one at
|
|
247
|
+
* `diagnosticsFor` and two lines in test-lsp.mjs — one of them an assertion NAME — all cited a
|
|
248
|
+
* `partialIsFatal:true` argument to `loadReportLoud`. NO CALLER EVER PASSED IT; it was a dead knob, and
|
|
249
|
+
* `ee82d38` deleted it hours after those comments were written, leaving five citations of a mechanism
|
|
250
|
+
* with no code. The BEHAVIOUR they describe was and is real — `candor_gate` does take the strict bar —
|
|
251
|
+
* so this is not a defect, it is the failure mode where an explanation goes on reading as considered
|
|
252
|
+
* after the thing it explains is gone, and the next reader checks the wrong function for the guarantee.
|
|
253
|
+
* only a FULL failure gets the visible marker in place of the answer; a partial one still returns
|
|
254
|
+
* whatever loaded, `warnLoudOnce` having already said the signature may be incomplete.
|
|
255
|
+
*/
|
|
256
|
+
/** ⟨0.32⟩ SPEC §3.3.1 — THE REFUSAL MARKER, ON THE EDITOR ROUTE. The reader and the sentence come from
|
|
257
|
+
* query-core (`Q.refusalMarkerFor` / `Q.refusalSentence`), the same two the CLI's `requireReport` and the
|
|
258
|
+
* MCP `resolvePrefix` use, so all three routes make the SAME claim about the same bytes. It reached only
|
|
259
|
+
* the CLI until 2026-08-30 (panel finding 3): over one directory with unchanged bytes, `gate --report`
|
|
260
|
+
* exited 2 naming the refusal while this server drew its ordinary squiggles and `candor_gate` returned
|
|
261
|
+
* `{"ok":true,"violations":[]}`. This surface is where that costs the most — a developer reads the ABSENCE
|
|
262
|
+
* of a squiggle as "clean" continuously, and a refused scan means nobody checked.
|
|
263
|
+
*
|
|
264
|
+
* It is folded into `hardFailDetail` rather than added beside it because a refusal and an unreadable
|
|
265
|
+
* report are the same instruction to this file — "do not present this as an answer" — and its three
|
|
266
|
+
* callers (diagnostics, hover, lenses) already implement exactly that for `full`. A fourth predicate
|
|
267
|
+
* would have been a fourth thing to remember at each call site, which is the shape of the finding above. */
|
|
268
|
+
function refusalDetail(prefix) {
|
|
269
|
+
const m = Q.refusalMarkerFor(prefix);
|
|
270
|
+
if (!m) return null;
|
|
271
|
+
return {
|
|
272
|
+
full: true,
|
|
273
|
+
log: `candor-lsp: ${Q.refusalSentence(m)} No diagnostics, hovers or lenses are drawn from these `
|
|
274
|
+
+ `reports — their ABSENCE here is the refusal, not an all-clear.`,
|
|
275
|
+
brief: `candor: the last scan REFUSED — these reports are stale and UNVERIFIED (see the candor log)`,
|
|
276
|
+
doc: `candor: the last scan over this project REFUSED (${m.reason}) — the effects shown here are from `
|
|
277
|
+
+ `an earlier run and are UNVERIFIED, not clean. Re-scan.`,
|
|
278
|
+
// The marker a refusal shows is NOT the unreadable-report one: these bytes parse perfectly, and an
|
|
279
|
+
// operator told "report unreadable" goes looking for a corrupt file that isn't there. A refusal's own
|
|
280
|
+
// words, and its own diagnostic `code`, so a client filtering or counting them can tell the two apart.
|
|
281
|
+
lens: "⚠ candor: the last scan REFUSED — these effects are stale, re-scan",
|
|
282
|
+
code: "report-refused",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function hardFailDetail(fns, prefix) {
|
|
287
|
+
const refused = refusalDetail(prefix);
|
|
288
|
+
if (refused) return refused;
|
|
289
|
+
if (!fns.hardFail) return null;
|
|
290
|
+
const full = fns.length === 0;
|
|
291
|
+
const log = full
|
|
292
|
+
? `candor-lsp: every report found at \`${prefix}\` failed to load (corrupt or mid-write) — refusing `
|
|
293
|
+
+ `to treat the empty result as an all-clear. Every file's effects are UNVERIFIED, not clean — `
|
|
294
|
+
+ `re-run the scan (candor-ts <src> --out ${prefix}).`
|
|
295
|
+
: `candor-lsp: a report found at \`${prefix}\` failed to load alongside sibling report(s) that did — `
|
|
296
|
+
+ `a partial signature makes any verdict meaningless (the effects that failed to load are exactly `
|
|
297
|
+
+ `the ones a violation would come from). Diagnostics/hover/lenses drawn from the reports that DID `
|
|
298
|
+
+ `load may be missing functions that live in the one that didn't — re-run the scan `
|
|
299
|
+
+ `(candor-ts <src> --out ${prefix}).`;
|
|
300
|
+
const brief = full
|
|
301
|
+
? `candor: the report could not be read — effects are UNVERIFIED, not clean (see the candor log)`
|
|
302
|
+
: `candor: part of the report could not be read — some effects may be missing (see the candor log)`;
|
|
303
|
+
const doc = full
|
|
304
|
+
? "candor: the report could not be read (corrupt or mid-write) — effects here are UNVERIFIED, not clean. Re-run the scan."
|
|
305
|
+
: "candor: part of the report could not be read (corrupt or mid-write) — some effects across this project may be missing. Re-run the scan.";
|
|
306
|
+
return { full, log, brief, doc, lens: "⚠ candor: report unreadable — re-run the scan", code: "report-unreadable" };
|
|
307
|
+
}
|
|
308
|
+
|
|
212
309
|
// The transitive-caller COUNT for an exact fn name over an already-inverted graph. The lenses used
|
|
213
310
|
// Q.callers per entry, and every callers() call rebuilt reverseGraph from scratch — a 50-fn document
|
|
214
311
|
// over a JVM-scale callgraph did 50 full graph inversions PER codeLens request (review find). One
|
|
@@ -225,7 +322,18 @@ function transitiveCallerCount(rev, fn) {
|
|
|
225
322
|
|
|
226
323
|
// ---- CodeLens ---------------------------------------------------------------------------------------
|
|
227
324
|
function codeLenses(docPath) {
|
|
228
|
-
|
|
325
|
+
if (!hasReport(reportPrefix)) return [];
|
|
326
|
+
const fns = Q.loadReport(reportPrefix); // ONE load per request — see hardFailDetail
|
|
327
|
+
const hf = hardFailDetail(fns, reportPrefix);
|
|
328
|
+
if (hf) {
|
|
329
|
+
warnLoudOnce(hf.log, hf.brief);
|
|
330
|
+
if (hf.full) return [{
|
|
331
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
332
|
+
command: { title: hf.lens, command: "" },
|
|
333
|
+
}];
|
|
334
|
+
// partial: fall through and lens whatever DID load, per hardFailDetail's read-only tolerance
|
|
335
|
+
}
|
|
336
|
+
const found = entriesInDoc(docPath, fns);
|
|
229
337
|
if (found === null) return [];
|
|
230
338
|
let rev = null;
|
|
231
339
|
try { rev = Q.reverseGraph(Q.loadCallgraph(reportPrefix)); } catch { /* no callgraph — effects-only lens */ }
|
|
@@ -254,6 +362,15 @@ function enclosingEntry(docPath, line, fns = null) {
|
|
|
254
362
|
function hoverAt(docPath, line) {
|
|
255
363
|
if (!hasReport(reportPrefix)) return null;
|
|
256
364
|
const fns = Q.loadReport(reportPrefix); // ONE load per request (enclosingEntry reuses it)
|
|
365
|
+
const hf = hardFailDetail(fns, reportPrefix);
|
|
366
|
+
if (hf) {
|
|
367
|
+
warnLoudOnce(hf.log, hf.brief);
|
|
368
|
+
if (hf.full) return {
|
|
369
|
+
contents: { kind: "markdown", value: `**${hf.doc}**` },
|
|
370
|
+
range: { start: { line, character: 0 }, end: { line, character: 200 } },
|
|
371
|
+
};
|
|
372
|
+
// partial: fall through and hover whatever DID load, per hardFailDetail's read-only tolerance
|
|
373
|
+
}
|
|
257
374
|
const at = enclosingEntry(docPath, line, fns);
|
|
258
375
|
if (!at) return null;
|
|
259
376
|
const { entry } = at;
|
|
@@ -409,7 +526,7 @@ const zeroRulePolicyWarn = (what) =>
|
|
|
409
526
|
// 2 (INCOMPLETE)" / "CI exits 2 over these bytes" is a wrong, checkable claim in exactly that case. Two
|
|
410
527
|
// fixtures with the identical unread-file cause differ only in whether a violation coexists, and only the
|
|
411
528
|
// violation-free one made this text true.
|
|
412
|
-
function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, certainViolation) {
|
|
529
|
+
function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, unaskedRulesPredates033, certainViolation) {
|
|
413
530
|
const causes = [];
|
|
414
531
|
// The CLI's order (`unanalyzed` → `outOfScope` → `unread` → `unaskedRules`), so a report tripping two
|
|
415
532
|
// of them reads the same way here as it does in CI. The repairs genuinely differ — a parse to fix, a
|
|
@@ -433,12 +550,28 @@ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, ce
|
|
|
433
550
|
// policy's own. Distinct from `unread` above: that is "nothing looked", this is "something looked, for
|
|
434
551
|
// a narrower question than the one this editor is asking now" — the peek is bounded to the PRODUCER's
|
|
435
552
|
// denied effects (⟨0.29⟩), so an empty finding there answers nothing about a rule it was never put.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
553
|
+
//
|
|
554
|
+
// ⟨0.34⟩ TWO SENTENCES, chosen by `unaskedRulesPredates033` (SAME wire shape either way — this function
|
|
555
|
+
// only ever writes to the log/showMessage channel, never a document). The ⟨0.33⟩ text is misleading of a
|
|
556
|
+
// report that predates ⟨0.33⟩ entirely: such a producer never had a `scannedUnder` key to hold ANY deny
|
|
557
|
+
// set in, so "does not cover" reads as "chose a different policy" where the truth is "could not yet
|
|
558
|
+
// record one". See `specPredates`'s doc in query-core.mjs for the full argument; the `else` arm here is
|
|
559
|
+
// character-for-character the pre-⟨0.34⟩ text.
|
|
560
|
+
if (unaskedRules?.length) {
|
|
561
|
+
if (unaskedRulesPredates033)
|
|
562
|
+
causes.push(`this report was produced before ⟨0.33⟩, when a producing scan did not yet record the `
|
|
563
|
+
+ `deny set its peek ran under (\`scannedUnder\`), so it cannot say whether ${unaskedRules.length} `
|
|
564
|
+
+ `rule(s) of this policy were ever asked: ${unaskedRules.join(", ")}. There is no way to tell from `
|
|
565
|
+
+ `a report this old what the excluded files it reports as read were searched for, so nothing in `
|
|
566
|
+
+ `them can be squiggled here — re-scan with a 0.33+ engine under THE SAME policy this editor is `
|
|
567
|
+
+ `applying (candor-ts <dir> --policy <file>), not merely under a policy.`);
|
|
568
|
+
else
|
|
569
|
+
causes.push(`this report's peek was bounded by the deny set its producing scan held, and that set `
|
|
570
|
+
+ `does not cover ${unaskedRules.length} rule(s) of this policy: ${unaskedRules.join(", ")}. The `
|
|
571
|
+
+ `excluded files it reports as read were searched for OTHER effects, so nothing in them can be `
|
|
572
|
+
+ `squiggled here — re-run the producing scan under THE SAME policy this editor is applying `
|
|
573
|
+
+ `(candor-ts <dir> --policy <file>), not merely under a policy.`);
|
|
574
|
+
}
|
|
442
575
|
if (!causes.length) return;
|
|
443
576
|
// ⟨lsp-precedence⟩ the exit-code claim is conditional on whether a certain violation ALSO fires: if one
|
|
444
577
|
// does, Lemma 2 makes it dominate (exit 1), and the incompleteness below is true but not what CI is red
|
|
@@ -468,6 +601,23 @@ function diagnosticsFor(docPath) {
|
|
|
468
601
|
const text = activePolicy();
|
|
469
602
|
if (text === null || !hasReport(reportPrefix)) return [];
|
|
470
603
|
const fns = Q.loadReport(reportPrefix);
|
|
604
|
+
// A report FOUND but UNREADABLE (hardFail — see hardFailDetail): this is the live GATE, so it takes
|
|
605
|
+
// the STRICT `candor_gate` bar (its `g.hardFail` throw off `Q.loadGateReport`) on EITHER kind, full or partial — a
|
|
606
|
+
// partial signature makes a green verdict meaningless for the identical reason on both routes (the
|
|
607
|
+
// effects that failed to load are exactly the ones a violation would come from). Refuses to draw ANY
|
|
608
|
+
// ordinary squiggle from fns that may be missing exactly the function a rule would have fired on; the
|
|
609
|
+
// marker diagnostic below is drawn INSTEAD, on every open document, because the corruption is a fact
|
|
610
|
+
// about the REPORT and not about any one file — there is no "innocent file" to misattribute it to the
|
|
611
|
+
// way the ⟨0.30⟩/⟨0.32⟩ scope causes below have one, since a report that cannot be read says nothing
|
|
612
|
+
// about ANY function in ANY document, this one included.
|
|
613
|
+
const hf = hardFailDetail(fns, reportPrefix);
|
|
614
|
+
if (hf) {
|
|
615
|
+
warnLoudOnce(hf.log, hf.brief);
|
|
616
|
+
return [{
|
|
617
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
618
|
+
severity: 2, source: "candor", code: hf.code, message: hf.doc,
|
|
619
|
+
}];
|
|
620
|
+
}
|
|
471
621
|
// ⟨0.24⟩ A report that JUDGED NOTHING is not a clean bill of health, and this surface is where that is
|
|
472
622
|
// hardest to notice: the live gate's whole vocabulary is squiggles, and a report with `analyzed.count: 0`
|
|
473
623
|
// has no entries, so it produces none — an empty editor reads as "the gate is green" when the truth is
|
|
@@ -523,7 +673,8 @@ function diagnosticsFor(docPath) {
|
|
|
523
673
|
const certainViolation = evaluatePolicy(dwp0.answerable, fns, Q.loadCallgraph(reportPrefix),
|
|
524
674
|
new Map(), new Set(), dnet0, dwithhold0, dunits0).length > 0;
|
|
525
675
|
discloseIncompleteness(dcomp.unanalyzed ?? [], dcomp.outOfScope ?? [],
|
|
526
|
-
dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? [],
|
|
676
|
+
dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? [],
|
|
677
|
+
dcomp.unaskedRulesPredates033 ?? false, certainViolation);
|
|
527
678
|
// ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
|
|
528
679
|
// `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
|
|
529
680
|
// editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
|
|
@@ -673,6 +824,19 @@ function runWhatif(a) {
|
|
|
673
824
|
logMessage(`candor-lsp: ${WHATIF_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
|
|
674
825
|
return null;
|
|
675
826
|
}
|
|
827
|
+
// ⟨0.32⟩ DID THE LAST SCAN REFUSE? The three passive surfaces get this through `hardFailDetail`;
|
|
828
|
+
// these two COMMAND endpoints resolve the report themselves and gate only on `hasReport`, so the rule
|
|
829
|
+
// has to be said here too or the executeCommand route is the one that still certifies refused bytes —
|
|
830
|
+
// which is the finding this closes, one route over. `showMessage(2, …)` is this surface's refusal
|
|
831
|
+
// envelope (the CLI's exit 2, the MCP tool's isError), and the sentence is query-core's.
|
|
832
|
+
{
|
|
833
|
+
const rm = Q.refusalMarkerFor(reportPrefix);
|
|
834
|
+
if (rm) {
|
|
835
|
+
warnLoudOnce(`candor-lsp: ${Q.refusalSentence(rm)}`, `candor: the last scan REFUSED — no pre-edit verdict over stale, UNVERIFIED reports`);
|
|
836
|
+
showMessage(2, `candor: the last scan over this project REFUSED (${rm.reason}) — no pre-edit verdict is computed from reports it would not certify. Re-scan.`);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
676
840
|
if (!hasReport(reportPrefix)) {
|
|
677
841
|
showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
|
|
678
842
|
return null;
|
|
@@ -707,7 +871,8 @@ function runWhatif(a) {
|
|
|
707
871
|
const wcomp = Q.reportCompleteness(reportPrefix, wpol?.deny ?? []);
|
|
708
872
|
const wUnread = wpol?.deny?.length ? (wcomp.unread ?? []) : [];
|
|
709
873
|
const wUnasked = wcomp.unaskedRules ?? [];
|
|
710
|
-
discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked
|
|
874
|
+
discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked,
|
|
875
|
+
wcomp.unaskedRulesPredates033 ?? false);
|
|
711
876
|
const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
|
|
712
877
|
const rules = [...new Set(r.violations.map((v) => v.rule))];
|
|
713
878
|
// ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
|
|
@@ -759,6 +924,19 @@ function runFix(a) {
|
|
|
759
924
|
logMessage(`candor-lsp: ${FIX_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
|
|
760
925
|
return null;
|
|
761
926
|
}
|
|
927
|
+
// ⟨0.32⟩ DID THE LAST SCAN REFUSE? The three passive surfaces get this through `hardFailDetail`;
|
|
928
|
+
// these two COMMAND endpoints resolve the report themselves and gate only on `hasReport`, so the rule
|
|
929
|
+
// has to be said here too or the executeCommand route is the one that still certifies refused bytes —
|
|
930
|
+
// which is the finding this closes, one route over. `showMessage(2, …)` is this surface's refusal
|
|
931
|
+
// envelope (the CLI's exit 2, the MCP tool's isError), and the sentence is query-core's.
|
|
932
|
+
{
|
|
933
|
+
const rm = Q.refusalMarkerFor(reportPrefix);
|
|
934
|
+
if (rm) {
|
|
935
|
+
warnLoudOnce(`candor-lsp: ${Q.refusalSentence(rm)}`, `candor: the last scan REFUSED — no boundary fix over stale, UNVERIFIED reports`);
|
|
936
|
+
showMessage(2, `candor: the last scan over this project REFUSED (${rm.reason}) — no boundary fix is computed from reports it would not certify. Re-scan.`);
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
762
940
|
if (!hasReport(reportPrefix)) {
|
|
763
941
|
showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
|
|
764
942
|
return null;
|
package/mcp.mjs
CHANGED
|
@@ -48,6 +48,26 @@ function resolvePrefix(args) {
|
|
|
48
48
|
if (!p) throw new Error("no report prefix: pass `report`, set $CANDOR_REPORT, or give one as the CLI arg");
|
|
49
49
|
if (WORKSPACE_ROOT && !within(nodePath.resolve(p), WORKSPACE_ROOT))
|
|
50
50
|
throw new Error(`report prefix \`${clip(p)}\` is outside the served workspace (--root ${WORKSPACE_ROOT}) — refusing`);
|
|
51
|
+
// ⟨0.32⟩ SPEC §3.3.1 — DID THE MOST RECENT SCAN OVER THESE BYTES REFUSE? This is the CLI's
|
|
52
|
+
// `requireReport` check on the agent route, reading the SAME marker through the SAME query-core helper
|
|
53
|
+
// and saying the SAME sentence. It was CLI-only until 2026-08-30 (panel finding 3): over one directory
|
|
54
|
+
// and unchanged bytes, `gate --report`/`where` exited 2 naming the refusal while `candor_gate` returned
|
|
55
|
+
// `{"ok":true,"violations":[]}`, `candor_unverified` `{"ok":true,"unverified":[]}` and `candor_where` a
|
|
56
|
+
// clean effect surface — the CI route refusing and the route an agent actually asks certifying, which is
|
|
57
|
+
// the divergence §3.1 exists to forbid, in the silent direction, on the surface with no exit code to read.
|
|
58
|
+
//
|
|
59
|
+
// IT SITS AT THE PREFIX, NOT IN A VERB: every report-reading tool resolves through here (and so does
|
|
60
|
+
// `resources/read`), so this cannot be the route that has the rule while its sibling does not — the
|
|
61
|
+
// failure mode this very finding is. The ENVELOPE is this surface's own: a thrown error becomes the
|
|
62
|
+
// `isError` tool result every other refusal here uses, the agent-transport spelling of the CLI's exit 2.
|
|
63
|
+
//
|
|
64
|
+
// BEFORE the existence check, which is the order `requireReport` uses and the order MATTERS: a project
|
|
65
|
+
// whose FIRST scan refused has the marker and NO reports, and existence-first answered `no report at
|
|
66
|
+
// \`…\` — run a candor scan first` — advice to do the thing the operator just did, with the recorded
|
|
67
|
+
// cause of the refusal sitting unread one file away. Neither order is a false all-clear; only one tells
|
|
68
|
+
// the truth about which of the two situations this is.
|
|
69
|
+
const m = Q.refusalMarkerFor(p);
|
|
70
|
+
if (m) throw new Error(Q.refusalSentence(m));
|
|
51
71
|
if (!Q.hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
52
72
|
return p;
|
|
53
73
|
}
|
|
@@ -71,19 +91,25 @@ const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n
|
|
|
71
91
|
// corrupt report — the §4 cardinal sin, exactly what the CLI's loadReportOrDie exits 2 on. The throw
|
|
72
92
|
// surfaces as the same isError result shape every other tool failure uses. EVERY tool that loads a
|
|
73
93
|
// report (main prefix or baseline) goes through this — never bare Q.loadReport.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
94
|
+
// THE BAR HERE IS "NOTHING PARSED", deliberately: these are the read-only tools, and they return what
|
|
95
|
+
// they found rather than asserting a clean bill of health, so a partial answer is a smaller claim than a
|
|
96
|
+
// green gate (`warnLoudOnce`/Q.loadReport has already disclosed the dropped file).
|
|
97
|
+
//
|
|
98
|
+
// THE STRICTER BAR — "not EVERYTHING parsed" — BELONGS TO `candor_gate`, WHICH EMITS A VERDICT: `{ok:
|
|
99
|
+
// true, violations: []}` over a multi-report prefix with one clean sibling and one truncated one is a
|
|
100
|
+
// green document over a package half of whose signature never loaded, and Q.loadReport's disclosure goes
|
|
101
|
+
// to the SERVER's stderr, a channel the calling agent never reads. That rule lives at `candor_gate`'s own
|
|
102
|
+
// `g.hardFail` throw, off `Q.loadGateReport` — the SAME reader `gate --report` uses, which is what keeps
|
|
103
|
+
// the two routes from drifting. It is NOT reached through this helper.
|
|
104
|
+
//
|
|
105
|
+
// This function used to carry a `partialIsFatal` option whose comment said "`candor_gate` passes it".
|
|
106
|
+
// No caller ever passed it — a dead knob documented as load-bearing, found by the guard-deletion sweep
|
|
107
|
+
// (2026-08-30): neutering it left the suite green AND left `candor_gate`'s behaviour byte-identical,
|
|
108
|
+
// because the tool had never routed through it. Removed rather than left reading as coverage.
|
|
109
|
+
function loadReportLoud(p) {
|
|
82
110
|
const fns = Q.loadReport(p);
|
|
83
|
-
if (fns.hardFail &&
|
|
84
|
-
throw new Error(
|
|
85
|
-
? `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`
|
|
86
|
-
: `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`);
|
|
111
|
+
if (fns.hardFail && fns.length === 0)
|
|
112
|
+
throw new Error(`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`);
|
|
87
113
|
return fns;
|
|
88
114
|
}
|
|
89
115
|
// The confinement root for a caller-supplied policy path: the repo the report belongs to — the
|
|
@@ -105,8 +131,17 @@ function policyRoot(prefix) {
|
|
|
105
131
|
// (the gateless-green shape the CLI's whatif exits 2 on).
|
|
106
132
|
function confinedPolicyRead(policyPath, prefix, root = policyRoot(prefix)) {
|
|
107
133
|
const abs = nodePath.resolve(policyPath);
|
|
108
|
-
if (!within(abs, root)
|
|
134
|
+
if (!within(abs, root))
|
|
109
135
|
throw new Error(`policy must be within the report's repo (${root}) — refusing to read \`${clip(policyPath)}\``);
|
|
136
|
+
// …AND WITHIN THE SERVED WORKSPACE — a SEPARATE arm with its own sentence, because the repo root above
|
|
137
|
+
// is DERIVED FROM THE CLIENT-CHOSEN REPORT (a `.candor/config` discovered ABOVE `--root` widens it past
|
|
138
|
+
// the workspace) and is therefore exactly the case this arm exists for. Sharing one sentence named a
|
|
139
|
+
// directory the path IS inside — a refusal whose stated reason its own evidence contradicts, which
|
|
140
|
+
// reads as a broken checker rather than as the confinement working. GUARD-DELETION SWEEP, 2026-08-30.
|
|
141
|
+
if (WORKSPACE_ROOT && !within(abs, WORKSPACE_ROOT))
|
|
142
|
+
throw new Error(`policy \`${clip(policyPath)}\` is outside the served workspace (--root ${WORKSPACE_ROOT}) `
|
|
143
|
+
+ `— refusing to read it. The report's repo root (${root}) is WIDER than the served workspace (it is `
|
|
144
|
+
+ `discovered from the report, which the client names), so the workspace bound is the one that holds here.`);
|
|
110
145
|
try { return fs.readFileSync(abs, "utf8"); }
|
|
111
146
|
catch { throw new Error(`policy \`${clip(policyPath)}\` could not be read — NOT evaluated (a missing gate source must be loud, never a clean verdict)`); }
|
|
112
147
|
}
|
|
@@ -839,6 +874,22 @@ function handle(msg) {
|
|
|
839
874
|
const missing = (t.schema.required || []).filter((k) => args[k] === undefined || args[k] === "");
|
|
840
875
|
if (missing.length)
|
|
841
876
|
return result(id, { content: [{ type: "text", text: `candor: missing required argument(s): ${missing.join(", ")}` }], isError: true });
|
|
877
|
+
// ⟨0.34⟩ BACKLOG "`--policy` accept-and-drop is THREE engines, not one" — the CLI half of this fix
|
|
878
|
+
// (query.mjs, DESCRIPTIVE_NO_POLICY) closes a grammar-level promise: `--policy <file>` is a token
|
|
879
|
+
// the CLI parser accepts for EVERY verb, so silently dropping it on a descriptive one breaks that
|
|
880
|
+
// promise. This tool surface makes no such blanket promise (each tool's OWN schema is what a caller
|
|
881
|
+
// reads to learn its inputs) — but `candor_where`/`candor_show`/… never DECLARED a `policy`
|
|
882
|
+
// property and their `run` never reads `args.policy`, so a caller who reasons "the sibling tools
|
|
883
|
+
// (`candor_whatif`/`candor_fix`/`candor_gate`/`candor_unverified`) take `policy`, this one probably
|
|
884
|
+
// does too" gets the identical hazard: a policy is passed, an answer comes back computed WITHOUT
|
|
885
|
+
// it, and nothing discloses the difference. MEASURED 2026-08-28: `candor_where`/`candor_show` (this
|
|
886
|
+
// fix's CLI siblings) AND `candor_gains` (which the CLI already protects — `37c9b10`'s pattern —
|
|
887
|
+
// but this surface never did) all returned BYTE-IDENTICAL results with and without a `policy`
|
|
888
|
+
// argument. Derived from the SCHEMA rather than a maintained name list, so a future tool needs no
|
|
889
|
+
// entry here: if it wants `policy` to do something, it lists `policy` among its own properties and
|
|
890
|
+
// reads `args.policy`, and this check gets out of its way for free.
|
|
891
|
+
if (args.policy !== undefined && !("policy" in (t.schema.properties || {})))
|
|
892
|
+
return result(id, { content: [{ type: "text", text: `candor: ${params.name} has no policy-relative verdict — its schema declares no \`policy\` property, and it never reads one; apply a policy to an existing report with \`candor_gate\`, or use \`candor_whatif\`/\`candor_fix\`/\`candor_unverified\` for a policy-relative pre-edit check.` }], isError: true });
|
|
842
893
|
// A log-only tool (candor_activity) needs no report — resolving one would wrongly demand a
|
|
843
894
|
// scan before the gate's own activity can be read.
|
|
844
895
|
const prefix = t.noReport ? null : resolvePrefix(args);
|
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.34.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.34)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|