candor-ts 0.8.9 → 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 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,13 +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 error, never a
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,
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).
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).
115
118
  CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
116
119
  `--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
117
120
  endpoint — internal state, not a report field), so treat a report-side green as advisory and the
package/lsp.mjs CHANGED
@@ -219,6 +219,7 @@ function showMessage(type, message) { send({ jsonrpc: "2.0", method: "window/sho
219
219
  // BOUNDARY effect (Q.CONTAINED — ambient effects gate nothing) the fn does not already carry. The action
220
220
  // carries a plain `command` (no client-side resolve, no edit) so it works in any LSP client verbatim.
221
221
  const WHATIF_COMMAND = "candor.whatif";
222
+ const FIX_COMMAND = "candor.fix";
222
223
  function codeActions(docPath, uri, range) {
223
224
  const at = enclosingEntry(docPath, range?.start?.line ?? 0);
224
225
  if (!at) return []; // a fn the report doesn't know → no actions, never an error
@@ -235,6 +236,30 @@ function codeActions(docPath, uri, range) {
235
236
  },
236
237
  });
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
+ }
238
263
  return out;
239
264
  }
240
265
 
@@ -290,6 +315,56 @@ function runWhatif(a) {
290
315
  return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
291
316
  }
292
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
+ }
367
+
293
368
  // ---- the LSP method surface ---------------------------------------------------------------------------
294
369
  function handle(msg) {
295
370
  const { id, method, params } = msg;
@@ -307,7 +382,7 @@ function handle(msg) {
307
382
  codeLensProvider: { resolveProvider: false },
308
383
  hoverProvider: true,
309
384
  codeActionProvider: { resolveProvider: false }, // actions carry their command inline
310
- executeCommandProvider: { commands: [WHATIF_COMMAND] },
385
+ executeCommandProvider: { commands: [WHATIF_COMMAND, FIX_COMMAND] },
311
386
  },
312
387
  serverInfo: { name: "candor-lsp", version: VERSION },
313
388
  });
@@ -334,12 +409,14 @@ function handle(msg) {
334
409
  catch { return result(id, []); } // unknown fn / non-file URI / unreadable report → no actions, never an error
335
410
  }
336
411
  if (method === "workspace/executeCommand") {
337
- if (params?.command !== WHATIF_COMMAND) {
338
- logMessage(`candor-lsp: unknown command \`${params?.command}\` — this server provides only ${WHATIF_COMMAND}`);
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}`);
339
416
  return result(id, null);
340
417
  }
341
- try { return result(id, runWhatif(params?.arguments?.[0])); }
342
- catch (e) { logMessage(`candor-lsp: ${WHATIF_COMMAND} failed: ${e.message}`); return result(id, null); }
418
+ try { return result(id, run(params?.arguments?.[0])); }
419
+ catch (e) { logMessage(`candor-lsp: ${params?.command} failed: ${e.message}`); return result(id, null); }
343
420
  }
344
421
  if (method === "shutdown") return result(id, null);
345
422
  if (method === "exit") process.exit(0);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
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": {
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}'`);