candor-ts 0.8.3 → 0.8.4

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 (2) hide show
  1. package/lsp.mjs +48 -1
  2. package/package.json +1 -1
package/lsp.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  * how many functions transitively call it (who is affected if it changes).
8
8
  * • Diagnostics: the repo's architecture-policy verdict (the §6.2 gate, resolved from CANDOR_POLICY
9
9
  * or the checked-in .candor/config — spec §3.4) as squiggles at each violating function's line.
10
+ * • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
11
+ * performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
10
12
  *
11
13
  * The server is a pure CONSUMER of the spec report envelope + callgraph sidecar (any engine — JVM /
12
14
  * Rust / TS / Swift / agents; the same read layer as candor-mcp), and it never scans (the analyzer
@@ -29,7 +31,10 @@ import { fileURLToPath } from "node:url";
29
31
  import * as Q from "./query-core.mjs";
30
32
  import { discoverConfigPolicy, evaluatePolicy, parsePolicy } from "./policy.mjs";
31
33
 
32
- const VERSION = createRequire(import.meta.url)("./package.json").version;
34
+ // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
35
+ // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
36
+ let VERSION = "bundled";
37
+ try { VERSION = createRequire(import.meta.url)("./package.json").version; } catch { /* bundled */ }
33
38
 
34
39
  // ---- state (set at initialize) ---------------------------------------------------------------------
35
40
  let rootPath = null;
@@ -96,6 +101,43 @@ function codeLenses(docPath) {
96
101
  });
97
102
  }
98
103
 
104
+ // ---- Hover: effect provenance at the cursor ----------------------------------------------------------
105
+ // The entry ENCLOSING the hovered line: the report pins each fn at its declaration line, so the match is
106
+ // the greatest entry line ≤ the cursor (functions are sequential in a file — a sound approximation that
107
+ // needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
108
+ // chain to the direct source. unknownWhy rides along when the fn introduces opacity.
109
+ function hoverAt(docPath, line) {
110
+ const found = entriesInDoc(docPath);
111
+ if (!found || !found.length) return null;
112
+ const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
113
+ if (!at) return null;
114
+ const { entry } = at;
115
+ const fns = Q.loadReport(reportPrefix);
116
+ const cg = Q.loadCallgraph(reportPrefix);
117
+ const lines = [`**${entry.fn}** — ⚡ { ${(entry.inferred || []).join(", ") || "pure"} }`];
118
+ for (const eff of entry.inferred || []) {
119
+ if (eff === "Unknown") continue; // covered by unknownWhy below
120
+ if ((entry.direct || []).includes(eff)) {
121
+ lines.push(`- **${eff}** — performed directly here`);
122
+ continue;
123
+ }
124
+ try {
125
+ const hops = (Q.path(fns, cg, entry.fn, eff)?.path || []).map((h) => h.fn.split(/[.:]+/).pop() + (h.source ? " (source)" : ""));
126
+ lines.push(hops.length > 1 ? `- **${eff}** — via ${hops.slice(1).join(" → ")}` : `- **${eff}** — inherited (source is cross-boundary or framework-synthesised)`);
127
+ } catch { lines.push(`- **${eff}** — inherited`); }
128
+ }
129
+ if (entry.unknownWhy?.length) lines.push(`- **Unknown** — ${entry.unknownWhy.join(", ")}`);
130
+ if (entry.invisible?.length) lines.push(`- _invisible_: ${entry.invisible.join(", ")} (unmodeled — the effect set is a lower bound)`);
131
+ try {
132
+ const c = Q.callers(cg, entry.fn);
133
+ lines.push(`\nBlast radius: **${(c?.transitive || []).length}** transitive caller(s)`);
134
+ } catch { /* no callgraph */ }
135
+ return {
136
+ contents: { kind: "markdown", value: lines.join("\n") },
137
+ range: { start: { line: at.line, character: 0 }, end: { line: at.line, character: 200 } },
138
+ };
139
+ }
140
+
99
141
  // ---- Diagnostics (the live gate) ---------------------------------------------------------------------
100
142
  function activePolicy() {
101
143
  const env = process.env.CANDOR_POLICY;
@@ -151,6 +193,7 @@ function handle(msg) {
151
193
  capabilities: {
152
194
  textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
153
195
  codeLensProvider: { resolveProvider: false },
196
+ hoverProvider: true,
154
197
  },
155
198
  serverInfo: { name: "candor-lsp", version: VERSION },
156
199
  });
@@ -161,6 +204,10 @@ function handle(msg) {
161
204
  if (method === "textDocument/didChange") return; // see textDocumentSync: report-backed
162
205
  if (method === "textDocument/didClose")
163
206
  return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
207
+ if (method === "textDocument/hover") {
208
+ try { return result(id, hoverAt(fileURLToPath(params.textDocument.uri), params.position?.line ?? 0)); }
209
+ catch { return result(id, null); } // hover is best-effort — null, never a crash
210
+ }
164
211
  if (method === "textDocument/codeLens") {
165
212
  try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
166
213
  catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
5
5
  "type": "module",
6
6
  "dependencies": {