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 +48 -1
- package/mcp.mjs +4 -2
- package/package.json +1 -1
- package/query-core.mjs +15 -0
- package/query.mjs +18 -4
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
|
-
|
|
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.
|
|
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.
|
|
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
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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": {
|