candor-ts 0.8.8 → 0.8.9

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.
Files changed (4) hide show
  1. package/AGENTS.md +5 -1
  2. package/README.md +8 -0
  3. package/lsp.mjs +125 -13
  4. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -107,7 +107,11 @@ And as an MCP server, so an agent pulls these as tools instead of shelling out:
107
107
  `CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
108
108
  plus `candor_gate`/`candor_whatif` — a given-but-unreadable `policy` is a loud tool error, never a
109
109
  clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the
110
- edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor.
110
+ edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor,
111
+ plus the pre-edit whatif as a code action (`candor: what if <fn> performed <E>?` → the
112
+ `candor.whatif` command: the query-core whatif's verdict + blast radius as a showMessage and a
113
+ transient diagnostic, cleared on the file's next open/save — plain LSP, so helix/neovim/VS
114
+ Code/JetBrains-via-LSP4IJ all get it without client code).
111
115
  CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
112
116
  `--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
113
117
  endpoint — internal state, not a report field), so treat a report-side green as advisory and the
package/README.md CHANGED
@@ -123,6 +123,14 @@ engine's report — and never scans. (Both report-computed gates are advisory: t
123
123
  `--gate-json` run additionally fails masked/incomplete literal surfaces and is the authoritative
124
124
  CI form.)
125
125
 
126
+ It also answers the pre-edit question in place: inside a function, a code action per boundary
127
+ effect the fn doesn't yet perform — `candor: what if handler performed Net?` — runs the same
128
+ whatif as `candor-ts-query whatif`/`candor_whatif` (blast radius + the policy rule that WOULD
129
+ fire) and shows the verdict as a message plus a transient diagnostic at the function (cleared on
130
+ the file's next open/save; with no policy discovered it says so and reports the radius alone).
131
+ Plain `textDocument/codeAction` + `workspace/executeCommand` (`candor.whatif`) — it works
132
+ unmodified in helix, neovim, VS Code, and JetBrains via LSP4IJ.
133
+
126
134
  **The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
127
135
  about the *current* code, not a stale snapshot:
128
136
 
package/lsp.mjs CHANGED
@@ -13,6 +13,18 @@
13
13
  * red in CI. The engine's --gate-json is the authoritative form (same caveat as MCP candor_gate).
14
14
  * • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
15
15
  * performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
16
+ * • CodeAction (pre-edit whatif): inside a function the report knows, one action per BOUNDARY effect
17
+ * the fn does NOT already perform — `candor: what if <fn> performed Net?`. Each resolves to the
18
+ * `candor.whatif` workspace/executeCommand, answered server-side with the SAME query-core whatif the
19
+ * CLI and MCP use (single-source): a window/showMessage one-liner (the policy rule that WOULD fire +
20
+ * the blast radius; no policy discovered → radius only, said so) and a transient Information
21
+ * diagnostic at the fn's line carrying the detail (rule + first callers), cleared on the next
22
+ * didOpen/didSave/didChange of that file or replaced by re-running the action. Plain LSP — works in
23
+ * helix/neovim/VS Code/JetBrains-via-LSP4IJ without client-side code.
24
+ *
25
+ * Perf (measured on the 5k-fn synthetic fixture in test-lsp.mjs — 50 files × 100 fns, one 5k-deep
26
+ * call chain, worst-case doc): codeLens ≈ 63ms, codeAction ≈ 5ms per request, INCLUDING the
27
+ * per-request report re-read. No caching layer — the freshness contract stays "re-read per request".
16
28
  *
17
29
  * The server is a pure CONSUMER of the spec report envelope + callgraph sidecar (any engine — JVM /
18
30
  * Rust / TS / Swift / agents; the same read layer as candor-mcp), and it never scans (the analyzer
@@ -33,7 +45,7 @@ import { createRequire } from "node:module";
33
45
  import nodePath from "node:path";
34
46
  import { fileURLToPath } from "node:url";
35
47
  import * as Q from "./query-core.mjs";
36
- import { discoverConfigPolicy, evaluatePolicy, parsePolicy } from "./policy.mjs";
48
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
37
49
 
38
50
  // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
39
51
  // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
@@ -110,17 +122,22 @@ function codeLenses(docPath) {
110
122
  });
111
123
  }
112
124
 
125
+ // The entry ENCLOSING a line: the report pins each fn at its declaration line, so the match is the
126
+ // greatest entry line ≤ the cursor (functions are sequential in a file — a sound approximation that
127
+ // needs no parser). Shared by hover and codeAction — one rule for "which function is the cursor in".
128
+ function enclosingEntry(docPath, line, fns = null) {
129
+ const found = entriesInDoc(docPath, fns);
130
+ if (!found || !found.length) return null;
131
+ return found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0] ?? null;
132
+ }
133
+
113
134
  // ---- Hover: effect provenance at the cursor ----------------------------------------------------------
114
- // The entry ENCLOSING the hovered line: the report pins each fn at its declaration line, so the match is
115
- // the greatest entry line ≤ the cursor (functions are sequential in a file — a sound approximation that
116
- // needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
117
- // chain to the direct source. unknownWhy rides along when the fn introduces opacity.
135
+ // For each inferred effect: direct → "performed here"; inherited → the §3.1 `path` chain to the direct
136
+ // source. unknownWhy rides along when the fn introduces opacity.
118
137
  function hoverAt(docPath, line) {
119
138
  if (!hasReport(reportPrefix)) return null;
120
- const fns = Q.loadReport(reportPrefix); // ONE load per request (entriesInDoc reuses it)
121
- const found = entriesInDoc(docPath, fns);
122
- if (!found || !found.length) return null;
123
- const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
139
+ const fns = Q.loadReport(reportPrefix); // ONE load per request (enclosingEntry reuses it)
140
+ const at = enclosingEntry(docPath, line, fns);
124
141
  if (!at) return null;
125
142
  const { entry } = at;
126
143
  const cg = Q.loadCallgraph(reportPrefix);
@@ -188,12 +205,90 @@ function publishDiagnostics(uri) {
188
205
  let docPath;
189
206
  try { docPath = fileURLToPath(uri); } catch { return; }
190
207
  try {
191
- send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diagnosticsFor(docPath) } });
208
+ const diags = diagnosticsFor(docPath).concat(transient.get(uri) ?? []);
209
+ send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diags } });
192
210
  } catch (e) {
193
211
  logMessage(`candor-lsp: diagnostics failed for ${uri}: ${e.message}`);
194
212
  }
195
213
  }
196
214
  function logMessage(message) { send({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 2, message } }); }
215
+ function showMessage(type, message) { send({ jsonrpc: "2.0", method: "window/showMessage", params: { type, message } }); }
216
+
217
+ // ---- CodeAction: the pre-edit whatif (spec §3.1 whatif, rendered as an editor action) -----------------
218
+ // From a position inside a function the report knows, offer "what if <fn> performed <E>?" for each
219
+ // BOUNDARY effect (Q.CONTAINED — ambient effects gate nothing) the fn does not already carry. The action
220
+ // carries a plain `command` (no client-side resolve, no edit) so it works in any LSP client verbatim.
221
+ const WHATIF_COMMAND = "candor.whatif";
222
+ function codeActions(docPath, uri, range) {
223
+ const at = enclosingEntry(docPath, range?.start?.line ?? 0);
224
+ if (!at) return []; // a fn the report doesn't know → no actions, never an error
225
+ const have = new Set(at.entry.inferred || []);
226
+ const out = [];
227
+ for (const eff of Q.CONTAINED) { // ≤6 boundary effects — the natural cap
228
+ if (have.has(eff)) continue;
229
+ out.push({
230
+ title: `candor: what if ${at.entry.fn} performed ${eff}?`,
231
+ command: {
232
+ title: `candor: what if ${at.entry.fn} performed ${eff}?`,
233
+ command: WHATIF_COMMAND,
234
+ arguments: [{ fn: at.entry.fn, effect: eff, uri, line: at.line }],
235
+ },
236
+ });
237
+ }
238
+ return out;
239
+ }
240
+
241
+ // Transient whatif diagnostics (Information severity, appended to the gate diagnostics on publish):
242
+ // uri -> Diagnostic[]. Cleared on the next didOpen/didSave/didChange of that file; re-running the
243
+ // action replaces the previous answer (one live whatif overlay per file, not an accumulating pile).
244
+ const transient = new Map();
245
+ function clearTransient(uri) {
246
+ if (transient.delete(uri)) publishDiagnostics(uri); // republish without the overlay
247
+ }
248
+
249
+ // The candor.whatif command: the SAME query-core whatif the CLI (`query.mjs whatif`) and MCP
250
+ // (`candor_whatif`) run — blast radius over the callgraph + the deny rules that WOULD fire, against the
251
+ // live policy (CANDOR_POLICY / .candor/config discovery, same source as the diagnostics). Everything is
252
+ // re-read per call (the freshness contract). Malformed args → logMessage + null, never a throw.
253
+ function runWhatif(a) {
254
+ if (!a || typeof a !== "object" || typeof a.fn !== "string" || typeof a.effect !== "string") {
255
+ logMessage(`candor-lsp: ${WHATIF_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
256
+ return null;
257
+ }
258
+ if (!hasReport(reportPrefix)) {
259
+ showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
260
+ return null;
261
+ }
262
+ const policyText = activePolicy();
263
+ const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
264
+ policyText === null ? null : parsePolicy(policyText), scopeMatches);
265
+ if (r === null) {
266
+ showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
267
+ return null;
268
+ }
269
+ const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
270
+ const rules = [...new Set(r.violations.map((v) => v.rule))];
271
+ const verdict = policyText === null
272
+ ? `candor: no policy discovered — blast radius only: ${callers.length} caller(s) would inherit ${a.effect}`
273
+ : rules.length
274
+ ? `✗ ${rules[0]} would fire — ${callers.length} caller(s) inherit ${a.effect}`
275
+ : `✓ no policy rule fires — ${callers.length} caller(s) would inherit ${a.effect}`;
276
+ showMessage(rules.length ? 2 : 3, verdict); // warning when a rule fires, info otherwise
277
+ if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the detail, pinned at the fn's line
278
+ const head = callers.slice(0, 10);
279
+ const lines = [`what if ${r.of.join(", ")} performed ${a.effect}? ${verdict}`];
280
+ if (rules.length > 1) lines.push(`rules: ${rules.join("; ")}`);
281
+ lines.push(head.length
282
+ ? `callers: ${head.join(", ")}${callers.length > head.length ? ` +${callers.length - head.length} more` : ""}`
283
+ : "no callers — the blast radius is the function itself");
284
+ transient.set(a.uri, [{
285
+ range: { start: { line: a.line, character: 0 }, end: { line: a.line, character: 200 } },
286
+ severity: 3, source: "candor", code: "whatif", message: lines.join("\n"),
287
+ }]);
288
+ publishDiagnostics(a.uri);
289
+ }
290
+ return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
291
+ }
197
292
 
198
293
  // ---- the LSP method surface ---------------------------------------------------------------------------
199
294
  function handle(msg) {
@@ -211,14 +306,19 @@ function handle(msg) {
211
306
  textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
212
307
  codeLensProvider: { resolveProvider: false },
213
308
  hoverProvider: true,
309
+ codeActionProvider: { resolveProvider: false }, // actions carry their command inline
310
+ executeCommandProvider: { commands: [WHATIF_COMMAND] },
214
311
  },
215
312
  serverInfo: { name: "candor-lsp", version: VERSION },
216
313
  });
217
314
  }
218
315
  if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
219
- if (method === "textDocument/didOpen") return publishDiagnostics(params.textDocument.uri);
220
- if (method === "textDocument/didSave") return publishDiagnostics(params.textDocument.uri);
221
- if (method === "textDocument/didChange") return; // see textDocumentSync: report-backed
316
+ // didOpen/didSave/didChange drop the file's transient whatif overlay — a fresh look at the file (or an
317
+ // edit) invalidates a hypothetical answered against the previous state. didChange is not negotiated
318
+ // (change: 0) but is handled defensively for clients that send it anyway.
319
+ if (method === "textDocument/didOpen") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
320
+ if (method === "textDocument/didSave") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
321
+ if (method === "textDocument/didChange") return clearTransient(params.textDocument.uri);
222
322
  if (method === "textDocument/didClose")
223
323
  return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
224
324
  if (method === "textDocument/hover") {
@@ -229,6 +329,18 @@ function handle(msg) {
229
329
  try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
230
330
  catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
231
331
  }
332
+ if (method === "textDocument/codeAction") {
333
+ try { return result(id, codeActions(fileURLToPath(params.textDocument.uri), params.textDocument.uri, params.range)); }
334
+ catch { return result(id, []); } // unknown fn / non-file URI / unreadable report → no actions, never an error
335
+ }
336
+ if (method === "workspace/executeCommand") {
337
+ if (params?.command !== WHATIF_COMMAND) {
338
+ logMessage(`candor-lsp: unknown command \`${params?.command}\` — this server provides only ${WHATIF_COMMAND}`);
339
+ return result(id, null);
340
+ }
341
+ try { return result(id, runWhatif(params?.arguments?.[0])); }
342
+ catch (e) { logMessage(`candor-lsp: ${WHATIF_COMMAND} failed: ${e.message}`); return result(id, null); }
343
+ }
232
344
  if (method === "shutdown") return result(id, null);
233
345
  if (method === "exit") process.exit(0);
234
346
  if (id !== undefined) error(id, -32601, `method not found: ${method}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
5
5
  "type": "module",
6
6
  "dependencies": {