candor-ts 0.33.0 → 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 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.33)."*
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.33" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
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.33: the analysis core, the gate (`--policy` / `--gate-json` /
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
- const found = entriesInDoc(docPath);
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;
@@ -402,7 +519,14 @@ const zeroRulePolicyWarn = (what) =>
402
519
  * same reason ("there is no line to pin it to"); the activity overlay's line-0 diagnostic is not a
403
520
  * counter-example, because its record NAMES the edited file and this one names no file in the workspace.
404
521
  */
405
- function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules) {
522
+ // `certainViolation`: SPEC §3.1's precedence (`query.mjs`'s `gate --report`: violation (1) > refusal (2) >
523
+ // incomplete (2)) means a policy violation ELSEWHERE in this same report makes the real exit 1, not the 2
524
+ // this function used to assert unconditionally. Measured: a report with an unread file AND a certain `Fs`
525
+ // violation exits 1 over `gate --report` — the incompleteness is still real and still unjudged, but "exits
526
+ // 2 (INCOMPLETE)" / "CI exits 2 over these bytes" is a wrong, checkable claim in exactly that case. Two
527
+ // fixtures with the identical unread-file cause differ only in whether a violation coexists, and only the
528
+ // violation-free one made this text true.
529
+ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, unaskedRulesPredates033, certainViolation) {
406
530
  const causes = [];
407
531
  // The CLI's order (`unanalyzed` → `outOfScope` → `unread` → `unaskedRules`), so a report tripping two
408
532
  // of them reads the same way here as it does in CI. The repairs genuinely differ — a parse to fix, a
@@ -426,28 +550,74 @@ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules) {
426
550
  // policy's own. Distinct from `unread` above: that is "nothing looked", this is "something looked, for
427
551
  // a narrower question than the one this editor is asking now" — the peek is bounded to the PRODUCER's
428
552
  // denied effects (⟨0.29⟩), so an empty finding there answers nothing about a rule it was never put.
429
- if (unaskedRules?.length)
430
- causes.push(`this report's peek was bounded by the deny set its producing scan held, and that set `
431
- + `does not cover ${unaskedRules.length} rule(s) of this policy: ${unaskedRules.join(", ")}. The `
432
- + `excluded files it reports as read were searched for OTHER effects, so nothing in them can be `
433
- + `squiggled here — re-run the producing scan under THE SAME policy this editor is applying `
434
- + `(candor-ts <dir> --policy <file>), not merely under a policy.`);
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
+ }
435
575
  if (!causes.length) return;
576
+ // ⟨lsp-precedence⟩ the exit-code claim is conditional on whether a certain violation ALSO fires: if one
577
+ // does, Lemma 2 makes it dominate (exit 1), and the incompleteness below is true but not what CI is red
578
+ // over. See `certainViolation`'s definition above.
579
+ const exitClaim = certainViolation
580
+ ? `a certain policy violation ELSEWHERE in this report makes \`gate --report\` over the same bytes exit `
581
+ + `1 — SPEC §3.1's precedence has a firing rule dominate a refusal, so CI is red on THAT, not on this. `
582
+ + `The incompleteness below is still real and still unjudged; it is just not what the exit code names`
583
+ : `\`gate --report\` over the same bytes exits 2 (INCOMPLETE), so the squiggles in this editor are NOT `
584
+ + `the whole verdict`;
585
+ const briefClaim = certainViolation
586
+ ? `candor gate: a certain violation elsewhere in this report already makes CI exit 1 over these bytes — `
587
+ + `but this report ALSO has unjudged code (see the candor log); fixing the violation alone will not `
588
+ + `make it complete.`
589
+ : `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
590
+ + `See the candor log for what went unjudged and how to fix it.`;
436
591
  warnLoudOnce(
437
- `candor-lsp: this report cannot support a GREEN gate — \`gate --report\` over the same bytes exits 2 `
438
- + `(INCOMPLETE), so the squiggles in this editor are NOT the whole verdict:\n`
592
+ `candor-lsp: this report cannot support a GREEN gate — ${exitClaim}:\n`
439
593
  + causes.map((c) => ` · ${c}`).join("\n")
440
594
  + `\n NO diagnostic can be drawn for any of the above: the code it names is not in the report, so it `
441
595
  + `has no line in this editor to sit on. Its ABSENCE from the squiggles is the incompleteness itself, `
442
596
  + `not an all-clear.`,
443
- `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
444
- + `See the candor log for what went unjudged and how to fix it.`);
597
+ briefClaim);
445
598
  }
446
599
 
447
600
  function diagnosticsFor(docPath) {
448
601
  const text = activePolicy();
449
602
  if (text === null || !hasReport(reportPrefix)) return [];
450
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
+ }
451
621
  // ⟨0.24⟩ A report that JUDGED NOTHING is not a clean bill of health, and this surface is where that is
452
622
  // hardest to notice: the live gate's whole vocabulary is squiggles, and a report with `analyzed.count: 0`
453
623
  // has no entries, so it produces none — an empty editor reads as "the gate is green" when the truth is
@@ -490,8 +660,21 @@ function diagnosticsFor(docPath) {
490
660
  // condition is applied HERE, to the value, for the reason the CLI states at its own call site: one
491
661
  // list, one condition, so two consumers of it cannot disagree about a run.
492
662
  const dcomp = Q.reportCompleteness(reportPrefix, dpol.deny);
663
+ // Cheap, side-effect-free pre-check for `discloseIncompleteness`'s exit-code wording (see its own
664
+ // comment): does THIS report already carry a certain violation, over the WHOLE report the way
665
+ // `gate --report` reads it, not just this one document? A redundant pass over an already-loaded report
666
+ // — the real one runs again below, where its `dunevaluated`/`violations` are also needed for the
667
+ // diagnostics themselves and for OTHER `warnOnce` lines whose relative order this must not disturb.
668
+ const dwp0 = wholePolicyUnanswerable(dpol, "the editor's report route");
669
+ const dunits0 = reportUnits(fns);
670
+ const dnet0 = reportNetClasses(fns, { authoritative: true, units: dunits0 });
671
+ const { withhold: dwithhold0 } = unanswerableScoped(dpol, fns,
672
+ resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix), dunits0), dnet0, dunits0);
673
+ const certainViolation = evaluatePolicy(dwp0.answerable, fns, Q.loadCallgraph(reportPrefix),
674
+ new Map(), new Set(), dnet0, dwithhold0, dunits0).length > 0;
493
675
  discloseIncompleteness(dcomp.unanalyzed ?? [], dcomp.outOfScope ?? [],
494
- dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? []);
676
+ dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? [],
677
+ dcomp.unaskedRulesPredates033 ?? false, certainViolation);
495
678
  // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
496
679
  // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
497
680
  // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
@@ -641,6 +824,19 @@ function runWhatif(a) {
641
824
  logMessage(`candor-lsp: ${WHATIF_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
642
825
  return null;
643
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
+ }
644
840
  if (!hasReport(reportPrefix)) {
645
841
  showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
646
842
  return null;
@@ -675,7 +871,8 @@ function runWhatif(a) {
675
871
  const wcomp = Q.reportCompleteness(reportPrefix, wpol?.deny ?? []);
676
872
  const wUnread = wpol?.deny?.length ? (wcomp.unread ?? []) : [];
677
873
  const wUnasked = wcomp.unaskedRules ?? [];
678
- discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked);
874
+ discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked,
875
+ wcomp.unaskedRulesPredates033 ?? false);
679
876
  const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
680
877
  const rules = [...new Set(r.violations.map((v) => v.rule))];
681
878
  // ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
@@ -727,6 +924,19 @@ function runFix(a) {
727
924
  logMessage(`candor-lsp: ${FIX_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
728
925
  return null;
729
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
+ }
730
940
  if (!hasReport(reportPrefix)) {
731
941
  showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
732
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
- // `partialIsFatal` raises the bar from "NOTHING parsed" to "not EVERYTHING parsed", and `candor_gate`
75
- // passes it because that tool emits a VERDICT: `{ok: true, violations: []}` over a multi-report prefix
76
- // with one clean sibling and one truncated one is a green document over a package half of whose signature
77
- // never loaded, and the disclosure Q.loadReport writes goes to the SERVER's stderr — a channel the calling
78
- // agent never reads. Same rule, same argument as the CLI `gate --report` (see query.mjs). The read-only
79
- // tools keep the looser bar deliberately: they return what they found rather than asserting a clean bill
80
- // of health, so the partial answer is a smaller claim than a green gate.
81
- function loadReportLoud(p, { partialIsFatal = false } = {}) {
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 && (partialIsFatal || fns.length === 0))
84
- throw new Error(fns.length === 0
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) || (WORKSPACE_ROOT && !within(abs, WORKSPACE_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.33.0",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.33)",
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",