candor-ts 0.8.8 → 0.8.10
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 +10 -3
- package/README.md +8 -0
- package/lsp.mjs +202 -13
- package/mcp.mjs +18 -0
- package/package.json +1 -1
- package/query-core.mjs +100 -0
- package/query.mjs +28 -0
package/AGENTS.md
CHANGED
|
@@ -97,6 +97,8 @@ Q map $P 1 # {module: {effects, functions}}
|
|
|
97
97
|
Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
|
|
98
98
|
Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
|
|
99
99
|
Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
|
|
100
|
+
Q fix $P <fn> <Effect> <policy> # the boundary FIX: where the effect belongs + the hoist refactor
|
|
101
|
+
Q fix-gate $P <policy> # a fix for EVERY crossing — the loop's block-message remedy
|
|
100
102
|
Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
|
|
101
103
|
Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
|
|
102
104
|
Q reachable $P 1 # what the app DOES at runtime: effects over the entry points
|
|
@@ -105,9 +107,14 @@ Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate w
|
|
|
105
107
|
|
|
106
108
|
And as an MCP server, so an agent pulls these as tools instead of shelling out:
|
|
107
109
|
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
|
|
108
|
-
plus `candor_gate`/`candor_whatif` — a given-but-unreadable `policy` is a loud tool
|
|
109
|
-
clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and
|
|
110
|
-
edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP
|
|
110
|
+
plus `candor_gate`/`candor_whatif`/`candor_fix` — a given-but-unreadable `policy` is a loud tool
|
|
111
|
+
error, never a clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and
|
|
112
|
+
reports the edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP
|
|
113
|
+
editor, plus two code actions (plain LSP — helix/neovim/VS Code/JetBrains-via-LSP4IJ all get them
|
|
114
|
+
without client code): the pre-edit whatif (`candor: what if <fn> performed <E>?` → the `candor.whatif`
|
|
115
|
+
command) and, when the cursor sits in a function that actually violates the policy, the boundary FIX
|
|
116
|
+
(`candor fix: hoist <E> out of <fn>` → the `candor.fix` command: where the effect belongs + the hoist
|
|
117
|
+
refactor, as a showMessage and a transient diagnostic, cleared on the file's next open/save).
|
|
111
118
|
CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
|
|
112
119
|
`--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
|
|
113
120
|
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
|
-
//
|
|
115
|
-
//
|
|
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 (
|
|
121
|
-
const
|
|
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,165 @@ function publishDiagnostics(uri) {
|
|
|
188
205
|
let docPath;
|
|
189
206
|
try { docPath = fileURLToPath(uri); } catch { return; }
|
|
190
207
|
try {
|
|
191
|
-
|
|
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
|
+
const FIX_COMMAND = "candor.fix";
|
|
223
|
+
function codeActions(docPath, uri, range) {
|
|
224
|
+
const at = enclosingEntry(docPath, range?.start?.line ?? 0);
|
|
225
|
+
if (!at) return []; // a fn the report doesn't know → no actions, never an error
|
|
226
|
+
const have = new Set(at.entry.inferred || []);
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const eff of Q.CONTAINED) { // ≤6 boundary effects — the natural cap
|
|
229
|
+
if (have.has(eff)) continue;
|
|
230
|
+
out.push({
|
|
231
|
+
title: `candor: what if ${at.entry.fn} performed ${eff}?`,
|
|
232
|
+
command: {
|
|
233
|
+
title: `candor: what if ${at.entry.fn} performed ${eff}?`,
|
|
234
|
+
command: WHATIF_COMMAND,
|
|
235
|
+
arguments: [{ fn: at.entry.fn, effect: eff, uri, line: at.line }],
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// The REMEDIAL companion (integrations/FIX-SPEC.md): for each BOUNDARY effect the fn ALREADY performs that
|
|
240
|
+
// the active policy FORBIDS here, offer the FIX — where the effect belongs + the hoist. Only real crossings
|
|
241
|
+
// are offered (Q.fix returns `crossing:false` otherwise), so this is empty unless the cursor sits in a
|
|
242
|
+
// function that actually violates the boundary. Same policy source as the diagnostics + the whatif action.
|
|
243
|
+
const policyText = activePolicy();
|
|
244
|
+
if (policyText !== null && hasReport(reportPrefix)) {
|
|
245
|
+
const pol = parsePolicy(policyText);
|
|
246
|
+
const cg = Q.loadCallgraph(reportPrefix);
|
|
247
|
+
const fns = Q.loadReport(reportPrefix);
|
|
248
|
+
for (const eff of Q.CONTAINED) {
|
|
249
|
+
if (!have.has(eff)) continue;
|
|
250
|
+
const r = Q.fix(cg, fns, at.entry.fn, eff, pol, scopeMatches);
|
|
251
|
+
if (r && r.crossing) {
|
|
252
|
+
out.push({
|
|
253
|
+
title: `candor fix: hoist ${eff} out of ${at.entry.fn}`,
|
|
254
|
+
command: {
|
|
255
|
+
title: `candor fix: hoist ${eff} out of ${at.entry.fn}`,
|
|
256
|
+
command: FIX_COMMAND,
|
|
257
|
+
arguments: [{ fn: at.entry.fn, effect: eff, uri, line: at.line }],
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Transient whatif diagnostics (Information severity, appended to the gate diagnostics on publish):
|
|
267
|
+
// uri -> Diagnostic[]. Cleared on the next didOpen/didSave/didChange of that file; re-running the
|
|
268
|
+
// action replaces the previous answer (one live whatif overlay per file, not an accumulating pile).
|
|
269
|
+
const transient = new Map();
|
|
270
|
+
function clearTransient(uri) {
|
|
271
|
+
if (transient.delete(uri)) publishDiagnostics(uri); // republish without the overlay
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The candor.whatif command: the SAME query-core whatif the CLI (`query.mjs whatif`) and MCP
|
|
275
|
+
// (`candor_whatif`) run — blast radius over the callgraph + the deny rules that WOULD fire, against the
|
|
276
|
+
// live policy (CANDOR_POLICY / .candor/config discovery, same source as the diagnostics). Everything is
|
|
277
|
+
// re-read per call (the freshness contract). Malformed args → logMessage + null, never a throw.
|
|
278
|
+
function runWhatif(a) {
|
|
279
|
+
if (!a || typeof a !== "object" || typeof a.fn !== "string" || typeof a.effect !== "string") {
|
|
280
|
+
logMessage(`candor-lsp: ${WHATIF_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (!hasReport(reportPrefix)) {
|
|
284
|
+
showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const policyText = activePolicy();
|
|
288
|
+
const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
|
|
289
|
+
policyText === null ? null : parsePolicy(policyText), scopeMatches);
|
|
290
|
+
if (r === null) {
|
|
291
|
+
showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
|
|
295
|
+
const rules = [...new Set(r.violations.map((v) => v.rule))];
|
|
296
|
+
const verdict = policyText === null
|
|
297
|
+
? `candor: no policy discovered — blast radius only: ${callers.length} caller(s) would inherit ${a.effect}`
|
|
298
|
+
: rules.length
|
|
299
|
+
? `✗ ${rules[0]} would fire — ${callers.length} caller(s) inherit ${a.effect}`
|
|
300
|
+
: `✓ no policy rule fires — ${callers.length} caller(s) would inherit ${a.effect}`;
|
|
301
|
+
showMessage(rules.length ? 2 : 3, verdict); // warning when a rule fires, info otherwise
|
|
302
|
+
if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the detail, pinned at the fn's line
|
|
303
|
+
const head = callers.slice(0, 10);
|
|
304
|
+
const lines = [`what if ${r.of.join(", ")} performed ${a.effect}? ${verdict}`];
|
|
305
|
+
if (rules.length > 1) lines.push(`rules: ${rules.join("; ")}`);
|
|
306
|
+
lines.push(head.length
|
|
307
|
+
? `callers: ${head.join(", ")}${callers.length > head.length ? ` +${callers.length - head.length} more` : ""}`
|
|
308
|
+
: "no callers — the blast radius is the function itself");
|
|
309
|
+
transient.set(a.uri, [{
|
|
310
|
+
range: { start: { line: a.line, character: 0 }, end: { line: a.line, character: 200 } },
|
|
311
|
+
severity: 3, source: "candor", code: "whatif", message: lines.join("\n"),
|
|
312
|
+
}]);
|
|
313
|
+
publishDiagnostics(a.uri);
|
|
314
|
+
}
|
|
315
|
+
return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The candor.fix command: the SAME query-core `fix` the CLI (`query.mjs fix`) and MCP (`candor_fix`) run —
|
|
319
|
+
// the boundary remedy (where the effect belongs + the hoist refactor), against the live policy (same source
|
|
320
|
+
// as the diagnostics). Re-read per call (the freshness contract). Malformed args → logMessage + null.
|
|
321
|
+
function runFix(a) {
|
|
322
|
+
if (!a || typeof a !== "object" || typeof a.fn !== "string" || typeof a.effect !== "string") {
|
|
323
|
+
logMessage(`candor-lsp: ${FIX_COMMAND} called with malformed arguments (expected [{ fn, effect, uri?, line? }]) — ignored`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (!hasReport(reportPrefix)) {
|
|
327
|
+
showMessage(2, "candor: no report found — scan first (candor-ts <dir> --out .candor/report)");
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const policyText = activePolicy();
|
|
331
|
+
if (policyText === null) {
|
|
332
|
+
showMessage(2, "candor: no policy discovered — a fix is defined relative to a boundary; set CANDOR_POLICY or check one into .candor/config");
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const r = Q.fix(Q.loadCallgraph(reportPrefix), Q.loadReport(reportPrefix), a.fn, a.effect,
|
|
336
|
+
parsePolicy(policyText), scopeMatches);
|
|
337
|
+
if (r === null) {
|
|
338
|
+
showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (!r.crossing) {
|
|
342
|
+
showMessage(3, `candor: \`${a.fn}\` — ${a.effect} isn't forbidden here; no boundary fix needed`);
|
|
343
|
+
return r;
|
|
344
|
+
}
|
|
345
|
+
const verdict = r.cleanHoist
|
|
346
|
+
? `candor fix: hoist ${a.effect} to ${r.hoistTo.join(", ")} — the ${r.deniedSpan.length} ${r.layer || "(root)"} function(s) then stay pure (or relax the boundary: ${r.policyAlternative})`
|
|
347
|
+
: `candor fix: no clean hoist for ${a.effect} — introduce a port, or relax the boundary: ${r.policyAlternative}`;
|
|
348
|
+
showMessage(2, verdict);
|
|
349
|
+
if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the plan, pinned at the fn's line
|
|
350
|
+
const lines = [`candor fix — hoist ${a.effect} out of the ${r.layer || "(root)"} boundary`];
|
|
351
|
+
lines.push(`site: ${r.site.join(", ") || "(cross-module or Unknown source)"}`);
|
|
352
|
+
if (r.cleanHoist) {
|
|
353
|
+
lines.push(`hoist ${a.effect} to: ${r.hoistTo.join(", ")}`);
|
|
354
|
+
lines.push(`then pure (thread the value): ${r.deniedSpan.join(", ")}`);
|
|
355
|
+
} else {
|
|
356
|
+
lines.push("no clean hoist — introduce a port (inject the effect from an allowed layer), or relax the boundary");
|
|
357
|
+
}
|
|
358
|
+
lines.push(`policy alternative: ${r.policyAlternative}`);
|
|
359
|
+
transient.set(a.uri, [{
|
|
360
|
+
range: { start: { line: a.line, character: 0 }, end: { line: a.line, character: 200 } },
|
|
361
|
+
severity: 3, source: "candor", code: "fix", message: lines.join("\n"),
|
|
362
|
+
}]);
|
|
363
|
+
publishDiagnostics(a.uri);
|
|
364
|
+
}
|
|
365
|
+
return r; // the raw remedy rides back as the executeCommand result (a thick client can render it)
|
|
366
|
+
}
|
|
197
367
|
|
|
198
368
|
// ---- the LSP method surface ---------------------------------------------------------------------------
|
|
199
369
|
function handle(msg) {
|
|
@@ -211,14 +381,19 @@ function handle(msg) {
|
|
|
211
381
|
textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
|
|
212
382
|
codeLensProvider: { resolveProvider: false },
|
|
213
383
|
hoverProvider: true,
|
|
384
|
+
codeActionProvider: { resolveProvider: false }, // actions carry their command inline
|
|
385
|
+
executeCommandProvider: { commands: [WHATIF_COMMAND, FIX_COMMAND] },
|
|
214
386
|
},
|
|
215
387
|
serverInfo: { name: "candor-lsp", version: VERSION },
|
|
216
388
|
});
|
|
217
389
|
}
|
|
218
390
|
if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
391
|
+
// didOpen/didSave/didChange drop the file's transient whatif overlay — a fresh look at the file (or an
|
|
392
|
+
// edit) invalidates a hypothetical answered against the previous state. didChange is not negotiated
|
|
393
|
+
// (change: 0) but is handled defensively for clients that send it anyway.
|
|
394
|
+
if (method === "textDocument/didOpen") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
395
|
+
if (method === "textDocument/didSave") { transient.delete(params.textDocument.uri); return publishDiagnostics(params.textDocument.uri); }
|
|
396
|
+
if (method === "textDocument/didChange") return clearTransient(params.textDocument.uri);
|
|
222
397
|
if (method === "textDocument/didClose")
|
|
223
398
|
return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
|
|
224
399
|
if (method === "textDocument/hover") {
|
|
@@ -229,6 +404,20 @@ function handle(msg) {
|
|
|
229
404
|
try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
|
|
230
405
|
catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
|
|
231
406
|
}
|
|
407
|
+
if (method === "textDocument/codeAction") {
|
|
408
|
+
try { return result(id, codeActions(fileURLToPath(params.textDocument.uri), params.textDocument.uri, params.range)); }
|
|
409
|
+
catch { return result(id, []); } // unknown fn / non-file URI / unreadable report → no actions, never an error
|
|
410
|
+
}
|
|
411
|
+
if (method === "workspace/executeCommand") {
|
|
412
|
+
const handlers = { [WHATIF_COMMAND]: runWhatif, [FIX_COMMAND]: runFix };
|
|
413
|
+
const run = handlers[params?.command];
|
|
414
|
+
if (!run) {
|
|
415
|
+
logMessage(`candor-lsp: unknown command \`${params?.command}\` — this server provides ${WHATIF_COMMAND} and ${FIX_COMMAND}`);
|
|
416
|
+
return result(id, null);
|
|
417
|
+
}
|
|
418
|
+
try { return result(id, run(params?.arguments?.[0])); }
|
|
419
|
+
catch (e) { logMessage(`candor-lsp: ${params?.command} failed: ${e.message}`); return result(id, null); }
|
|
420
|
+
}
|
|
232
421
|
if (method === "shutdown") return result(id, null);
|
|
233
422
|
if (method === "exit") process.exit(0);
|
|
234
423
|
if (id !== undefined) error(id, -32601, `method not found: ${method}`);
|
package/mcp.mjs
CHANGED
|
@@ -178,6 +178,24 @@ const TOOLS = {
|
|
|
178
178
|
return r;
|
|
179
179
|
},
|
|
180
180
|
},
|
|
181
|
+
candor_fix: {
|
|
182
|
+
description: "THE BOUNDARY FIX: when `fn` performs `effect` in a layer the policy forbids (a violation candor_whatif/candor_gate reports), compute the architectural REMEDY — not just 'the domain can't do Net', but WHERE the effect belongs and the refactor to put it there: the direct call site to hoist, the forbidden-layer functions that become pure and thread the value as a parameter, and the nearest allowed-layer caller to perform the effect ({ crossing, site, deniedSpan, hoistTo, policyAlternative }). The remedial inverse of candor_whatif. Call this INSTEAD OF guessing a fix (adding `allow` to the domain, moving the I/O one call up, threading a handle the wrong way). Advisory: it names the structure, you write the code; the gate re-scan verifies. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4).",
|
|
183
|
+
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: ["fn", "effect"] },
|
|
184
|
+
run: (a, p) => {
|
|
185
|
+
// The fix is defined relative to a boundary — a policy is required. Given → confined fail-closed read;
|
|
186
|
+
// else the repo's checked-in policy (same resolution as candor_gate), so it works zero-config.
|
|
187
|
+
let text;
|
|
188
|
+
if (a.policy) text = confinedPolicyRead(a.policy, p);
|
|
189
|
+
else {
|
|
190
|
+
const cfg = configPolicy(p);
|
|
191
|
+
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4) — the fix is defined relative to the boundary it crosses");
|
|
192
|
+
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
193
|
+
}
|
|
194
|
+
const r = Q.fix(Q.loadCallgraph(p), Q.loadReport(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
|
|
195
|
+
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
196
|
+
return r;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
181
199
|
candor_gate: {
|
|
182
200
|
description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
|
|
183
201
|
schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
|
package/package.json
CHANGED
package/query-core.mjs
CHANGED
|
@@ -486,3 +486,103 @@ export function whatif(cg, target, eff, policyParsed, scopeMatches) {
|
|
|
486
486
|
}
|
|
487
487
|
return { of: targets, effect: eff, affected: [...affected].sort(), violations, ok: violations.length === 0 };
|
|
488
488
|
}
|
|
489
|
+
|
|
490
|
+
// deniedLayer: the deny/`pure` scope (the "layer") forbidding `eff` at `fn`, or null if allowed there.
|
|
491
|
+
// Mirrors the gate's AS-EFF-006 predicate (candor-java/candor-query): a `deny` fires when it names the
|
|
492
|
+
// effect; a `pure` rule (empty effects) forbids every real effect but not Unknown.
|
|
493
|
+
function deniedLayer(fn, eff, policyParsed, scopeMatches) {
|
|
494
|
+
for (const r of policyParsed.deny) {
|
|
495
|
+
const denies = r.effects.length === 0 ? eff !== "Unknown" : r.effects.includes(eff);
|
|
496
|
+
if (denies && (!r.scope || scopeMatches(fn, r.scope))) return r.scope ?? "";
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// The site-anchored cut (integrations/FIX-SPEC.md), shared by fix + fixGate — the byte-for-byte port of
|
|
502
|
+
// candor-query / candor-java's computeRemedy. Forward-BFS to the direct site(s), then climb UP through the
|
|
503
|
+
// denied layer so the pure span is the same whichever inheriting function triggered it (root-independent);
|
|
504
|
+
// the allowed-layer callers where the climb stops are the hoist frontier.
|
|
505
|
+
function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMatches) {
|
|
506
|
+
const sites = new Set();
|
|
507
|
+
const fseen = new Set([start]);
|
|
508
|
+
const fq = [start];
|
|
509
|
+
while (fq.length) {
|
|
510
|
+
const cur = fq.shift();
|
|
511
|
+
const fe = byName.get(cur);
|
|
512
|
+
if (fe && (fe.direct ?? []).includes(eff)) sites.add(cur);
|
|
513
|
+
for (const c of cg[cur] ?? []) {
|
|
514
|
+
const ce = byName.get(c);
|
|
515
|
+
if (ce && (ce.inferred ?? []).includes(eff) && !fseen.has(c)) { fseen.add(c); fq.push(c); }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const anchors = sites.size ? [...sites] : [start];
|
|
519
|
+
const deniedSpan = new Set();
|
|
520
|
+
const hoistTo = new Set();
|
|
521
|
+
const up = [];
|
|
522
|
+
for (const a of anchors) {
|
|
523
|
+
if (deniedLayer(a, eff, policyParsed, scopeMatches) !== null) deniedSpan.add(a);
|
|
524
|
+
up.push(a);
|
|
525
|
+
}
|
|
526
|
+
while (up.length) {
|
|
527
|
+
const cur = up.shift();
|
|
528
|
+
for (const caller of rev.get(cur) ?? []) {
|
|
529
|
+
const ce = byName.get(caller);
|
|
530
|
+
if (ce && !(ce.inferred ?? []).includes(eff)) continue; // doesn't route the effect
|
|
531
|
+
if (deniedLayer(caller, eff, policyParsed, scopeMatches) !== null) {
|
|
532
|
+
if (!deniedSpan.has(caller)) { deniedSpan.add(caller); up.push(caller); }
|
|
533
|
+
} else {
|
|
534
|
+
hoistTo.add(caller);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
fn: start, effect: eff, layer,
|
|
540
|
+
cleanHoist: hoistTo.size > 0,
|
|
541
|
+
site: [...sites].sort(),
|
|
542
|
+
deniedSpan: [...deniedSpan].sort(),
|
|
543
|
+
hoistTo: [...hoistTo].sort(),
|
|
544
|
+
policyAlternative: layer ? `allow ${eff} ${layer}` : `allow ${eff}`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// fix: the boundary remedy for ONE function (the remedial inverse of whatif). Returns null if the function
|
|
549
|
+
// isn't in the graph; `{ crossing:false, reason }` if it performs the effect but no policy forbids it there
|
|
550
|
+
// (or it doesn't perform it) — a no-op the caller reports plainly; else the full remedy (`crossing:true`).
|
|
551
|
+
export function fix(cg, fns, target, eff, policyParsed, scopeMatches) {
|
|
552
|
+
const names = new Set(Object.keys(cg));
|
|
553
|
+
for (const e of fns) names.add(e.fn);
|
|
554
|
+
const m = matches([...names], target);
|
|
555
|
+
if (m.length === 0) return null;
|
|
556
|
+
const byName = indexFns(fns);
|
|
557
|
+
// prefer a match that actually performs the effect, so a bare leaf resolves to the violating function
|
|
558
|
+
const start = m.find((n) => (byName.get(n)?.inferred ?? []).includes(eff)) ?? m[0];
|
|
559
|
+
const se = byName.get(start);
|
|
560
|
+
if (!se || !(se.inferred ?? []).includes(eff))
|
|
561
|
+
return { fn: start, effect: eff, crossing: false, reason: "does-not-perform" };
|
|
562
|
+
const layer = deniedLayer(start, eff, policyParsed, scopeMatches);
|
|
563
|
+
if (layer === null)
|
|
564
|
+
return { fn: start, effect: eff, crossing: false, reason: "not-forbidden" };
|
|
565
|
+
const rev = reverseGraph(cg);
|
|
566
|
+
return { crossing: true, ...computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMatches) };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// fixGate: a remedy for EVERY deny/`pure` (AS-EFF-006) crossing in the report, collapsing the inheritors of
|
|
570
|
+
// one root cause to a single plan (keyed by effect|layer|site|hoist). Returns { ok, remedies } — the shape
|
|
571
|
+
// the edit-time loop folds into its block message.
|
|
572
|
+
export function fixGate(cg, fns, policyParsed, scopeMatches) {
|
|
573
|
+
const byName = indexFns(fns);
|
|
574
|
+
const rev = reverseGraph(cg);
|
|
575
|
+
const plans = new Map();
|
|
576
|
+
for (const e of fns) {
|
|
577
|
+
for (const eff of (e.inferred ?? [])) {
|
|
578
|
+
const layer = deniedLayer(e.fn, eff, policyParsed, scopeMatches);
|
|
579
|
+
if (layer !== null) {
|
|
580
|
+
const p = computeRemedy(e.fn, eff, layer, cg, rev, byName, policyParsed, scopeMatches);
|
|
581
|
+
const key = `${p.effect}|${p.layer}|${p.site}|${p.hoistTo}`;
|
|
582
|
+
if (!plans.has(key)) plans.set(key, p);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const remedies = [...plans.values()];
|
|
587
|
+
return { ok: remedies.length === 0, remedies };
|
|
588
|
+
}
|
package/query.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
31
31
|
callers as coreCallers, callersFrontier, loadHierarchy,
|
|
32
32
|
containment as coreContainment, diff as coreDiff,
|
|
33
33
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
34
|
+
fix as coreFix, fixGate as coreFixGate,
|
|
34
35
|
loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
|
|
35
36
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
36
37
|
|
|
@@ -57,6 +58,8 @@ const SUBCOMMANDS = [
|
|
|
57
58
|
["gains", "<cur-prefix> <base-prefix>", "the supply-chain alarm: what the surface gained between two reports"],
|
|
58
59
|
["path", "<prefix> <fn> <Effect>", "a call path from a function to where an effect enters"],
|
|
59
60
|
["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
|
|
61
|
+
["fix", "<prefix> <fn> <Effect> <policy-file>", "the boundary fix: where the effect belongs + the hoist refactor"],
|
|
62
|
+
["fix-gate", "<prefix> <policy-file>", "a fix for EVERY boundary crossing — the loop's block-message remedy"],
|
|
60
63
|
["agents", "", "print the agent contract for this build (AGENTS.md)"],
|
|
61
64
|
];
|
|
62
65
|
|
|
@@ -250,6 +253,31 @@ switch (cmd) {
|
|
|
250
253
|
process.exit(r.violations.length ? 1 : 0);
|
|
251
254
|
break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
|
|
252
255
|
}
|
|
256
|
+
case "fix": {
|
|
257
|
+
// THE BOUNDARY FIX (integrations/FIX-SPEC.md): where a forbidden effect belongs + the hoist refactor.
|
|
258
|
+
// The remedial inverse of whatif. A policy is REQUIRED and must be readable (the fix is defined relative
|
|
259
|
+
// to the boundary the edit crossed) — a typo'd path fails LOUD, never a silently-empty "no crossing".
|
|
260
|
+
const [prefix, target, eff, policyFile] = args;
|
|
261
|
+
if (!target || !eff) { console.error("usage: candor-ts-query fix <prefix> <fn> <Effect> <policy-file>"); process.exit(2); }
|
|
262
|
+
if (!policyFile) { console.error("candor: fix requires a policy file — the fix is the refactor that restores the boundary the edit crossed"); process.exit(2); }
|
|
263
|
+
let ptext;
|
|
264
|
+
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
265
|
+
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
266
|
+
const r = coreFix(loadCallgraph(prefix), loadReport(prefix), target, eff, parsePolicy(ptext), scopeMatches);
|
|
267
|
+
if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
|
|
268
|
+
emit(r);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "fix-gate": {
|
|
272
|
+
// A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
|
|
273
|
+
const [prefix, policyFile] = args;
|
|
274
|
+
if (!policyFile) { console.error("candor: fix-gate requires a policy file"); process.exit(2); }
|
|
275
|
+
let ptext;
|
|
276
|
+
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
277
|
+
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
278
|
+
emit(coreFixGate(loadCallgraph(prefix), loadReport(prefix), parsePolicy(ptext), scopeMatches));
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
253
281
|
default:
|
|
254
282
|
// no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
|
|
255
283
|
if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
|