candor-ts 0.8.3 → 0.8.5

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/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/mcp.mjs CHANGED
@@ -162,12 +162,14 @@ const TOOLS = {
162
162
  candor_diff: {
163
163
  description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
164
164
  schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
165
- run: (a, p) => Q.diff(Q.loadReport(p), Q.loadReport(a.baseline)),
165
+ run: (a, p) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
166
+ ...Q.diff(Q.loadReport(p), Q.loadReport(a.baseline)) }),
166
167
  },
167
168
  candor_gains: {
168
169
  description: "The supply-chain alarm: effects the surface GAINED versus a baseline (package-level + per-function) — 'did this dependency bump add Net/Exec somewhere?'.",
169
170
  schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
170
- run: (a, p) => Q.gains(Q.loadReport(p), Q.loadReport(a.baseline)),
171
+ run: (a, p) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
172
+ ...Q.gains(Q.loadReport(p), Q.loadReport(a.baseline)) }),
171
173
  },
172
174
  };
173
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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": {
package/query-core.mjs CHANGED
@@ -55,6 +55,21 @@ function normFns(parsed, source) {
55
55
  return out;
56
56
  }
57
57
 
58
+ /** The producing engine build of the report(s) at a prefix (the §2.1 envelope `candor.version`) — null
59
+ * when unreadable/absent (a legacy bare array has no provenance). Baselines are comparable only to
60
+ * their own producing version (§2.1): diff/gains consumers disclose a mismatch (an engine swap makes
61
+ * "gained" effects ambiguous — unmasking vs regression — the baseline-invalidation rule, AGENTS §2a). */
62
+ export function reportVersion(prefix) {
63
+ const files = fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport);
64
+ for (const f of files) {
65
+ try {
66
+ const v = JSON.parse(fs.readFileSync(f, "utf8"))?.candor?.version;
67
+ if (v) return String(v);
68
+ } catch { /* unreadable sibling — keep looking */ }
69
+ }
70
+ return null;
71
+ }
72
+
58
73
  export function loadReport(prefix) {
59
74
  if (fs.existsSync(`${prefix}.json`)) {
60
75
  // The PRIMARY report parse must DISCLOSE-and-tolerate like the sibling path — a bare JSON.parse here
package/query.mjs CHANGED
@@ -30,7 +30,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
30
30
  show as coreShow, blindspots as coreBlindspots,
31
31
  callers as coreCallers, callersFrontier, loadHierarchy,
32
32
  containment as coreContainment,
33
- loadReport, loadCallgraph, matches } from "./query-core.mjs";
33
+ loadReport, loadCallgraph, matches , reportVersion } from "./query-core.mjs";
34
34
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
35
35
 
36
36
  // ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
@@ -180,8 +180,19 @@ switch (cmd) {
180
180
  if (gained.length || lost.length) changes.push({ fn, gained, lost });
181
181
  }
182
182
  changes.sort((a, b) => a.fn.localeCompare(b.fn));
183
- emit({ changes });
184
- process.exit(changes.some((c) => c.gained.length) ? 1 : 0);
183
+ // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
184
+ // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
185
+ // provenance fields as the Rust candor-query (cross-engine parity, item 10).
186
+ const engineV = reportVersion(curPrefix), baseV = reportVersion(basePrefix);
187
+ const versionMismatch = engineV && baseV && engineV !== baseV;
188
+ if (versionMismatch)
189
+ console.error(`candor-ts: ⚠ baseline @${baseV} ≠ engine @${engineV} — some changes may be the engine reclassifying, not your code. Treat an engine swap as baseline-invalidating: review, then regenerate the baseline.`);
190
+ emit({ baseline_version: baseV ?? "", engine_version: engineV ?? "", changes });
191
+ // diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
192
+ // same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
193
+ // so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
194
+ // closed, queries disclose).
195
+ process.exit(!versionMismatch && changes.some((c) => c.gained.length) ? 1 : 0);
185
196
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
186
197
  }
187
198
  case "reachable": {
@@ -215,7 +226,10 @@ switch (cmd) {
215
226
  // the supply-chain alarm (SPEC §5.1): {gained:[Effect], byFunction:[{fn,effect}]} — what the
216
227
  // surface gained between two reports (base → cur), the cross-engine machine-readable form.
217
228
  const [curPrefix, basePrefix] = args;
218
- emit(coreGains(loadReport(curPrefix), loadReport(basePrefix)));
229
+ const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
230
+ if (gv && gbv && gv !== gbv)
231
+ console.error(`candor-ts: ⚠ baseline @${gbv} ≠ engine @${gv} — a "gained capability" may be the engine reclassifying, not the dependency changing. Regenerate both reports with one build to compare releases.`);
232
+ emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReport(curPrefix), loadReport(basePrefix)) });
219
233
  break;
220
234
  }
221
235
  case "path": {