candor-ts 0.8.10 → 0.8.12

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
@@ -352,6 +352,9 @@ function runFix(a) {
352
352
  if (r.cleanHoist) {
353
353
  lines.push(`hoist ${a.effect} to: ${r.hoistTo.join(", ")}`);
354
354
  lines.push(`then pure (thread the value): ${r.deniedSpan.join(", ")}`);
355
+ if (r.hoistHigher?.length) {
356
+ lines.push(`or hoist higher (up to ${r.hoistHigher.slice(0, 4).join(", ")}): keeps the frontier pure too, threads through more signatures`);
357
+ }
355
358
  } else {
356
359
  lines.push("no clean hoist — introduce a port (inject the effect from an allowed layer), or relax the boundary");
357
360
  }
package/mcp.mjs CHANGED
@@ -191,7 +191,11 @@ const TOOLS = {
191
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
192
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
193
193
  }
194
- const r = Q.fix(Q.loadCallgraph(p), Q.loadReport(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
194
+ const cg = Q.loadCallgraph(p);
195
+ // The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
196
+ // never a degenerate empty-graph remedy. (/code-review.)
197
+ if (!cg || Object.keys(cg).length === 0) throw new Error(`no call-graph sidecar for the report — fix needs it (re-scan with --out)`);
198
+ const r = Q.fix(cg, Q.loadReport(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
195
199
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
196
200
  return r;
197
201
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
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
@@ -527,7 +527,9 @@ function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMa
527
527
  const cur = up.shift();
528
528
  for (const caller of rev.get(cur) ?? []) {
529
529
  const ce = byName.get(caller);
530
- if (ce && !(ce.inferred ?? []).includes(eff)) continue; // doesn't route the effect
530
+ // skip a caller that doesn't route the effect — INCLUDING one absent from the report (a pure
531
+ // callgraph-only node never carries the effect). Matches candor-swift. (/code-review — was `ce && !…`.)
532
+ if (!ce || !(ce.inferred ?? []).includes(eff)) continue;
531
533
  if (deniedLayer(caller, eff, policyParsed, scopeMatches) !== null) {
532
534
  if (!deniedSpan.has(caller)) { deniedSpan.add(caller); up.push(caller); }
533
535
  } else {
@@ -535,12 +537,30 @@ function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMa
535
537
  }
536
538
  }
537
539
  }
540
+ // higher hoist options: allowed-layer transitive callers of the minimal frontier that also route the
541
+ // effect — hoisting higher keeps the frontier pure too, at the cost of threading through more signatures
542
+ // (FIX-SPEC: the trade-off, disclosed not hidden).
543
+ const hoistHigher = new Set();
544
+ const hseen = new Set(hoistTo);
545
+ const hq = [...hoistTo];
546
+ while (hq.length) {
547
+ const cur = hq.shift();
548
+ for (const caller of rev.get(cur) ?? []) {
549
+ const ce = byName.get(caller);
550
+ if (ce && (ce.inferred ?? []).includes(eff) && deniedLayer(caller, eff, policyParsed, scopeMatches) === null && !hseen.has(caller)) {
551
+ hseen.add(caller);
552
+ hoistHigher.add(caller);
553
+ hq.push(caller);
554
+ }
555
+ }
556
+ }
538
557
  return {
539
558
  fn: start, effect: eff, layer,
540
559
  cleanHoist: hoistTo.size > 0,
541
560
  site: [...sites].sort(),
542
561
  deniedSpan: [...deniedSpan].sort(),
543
562
  hoistTo: [...hoistTo].sort(),
563
+ hoistHigher: [...hoistHigher].sort(),
544
564
  policyAlternative: layer ? `allow ${eff} ${layer}` : `allow ${eff}`,
545
565
  };
546
566
  }
@@ -548,10 +568,14 @@ function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMa
548
568
  // fix: the boundary remedy for ONE function (the remedial inverse of whatif). Returns null if the function
549
569
  // isn't in the graph; `{ crossing:false, reason }` if it performs the effect but no policy forbids it there
550
570
  // (or it doesn't perform it) — a no-op the caller reports plainly; else the full remedy (`crossing:true`).
571
+ // NOTE: `cg` (the callgraph sidecar) is REQUIRED — unlike candor-query/java/swift, a candor-ts report does not
572
+ // embed inline `calls`, so the sidecar is the only graph; the CLI/MCP callers fail loud when it's absent
573
+ // rather than compute a degenerate empty-graph remedy. (/code-review.)
551
574
  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);
575
+ // Resolve against REPORT function names only (not callgraph nodes, which include pure fns absent from the
576
+ // report) — so `fix <pure-fn>` is a uniform "no such fn" across engines, not a TS-only crossing:false.
577
+ // (/code-review — candor-query/java/swift all match report fns only.)
578
+ const m = matches(fns.map((e) => e.fn), target);
555
579
  if (m.length === 0) return null;
556
580
  const byName = indexFns(fns);
557
581
  // prefer a match that actually performs the effect, so a bare leaf resolves to the violating function
@@ -573,8 +597,10 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
573
597
  const byName = indexFns(fns);
574
598
  const rev = reverseGraph(cg);
575
599
  const plans = new Map();
576
- for (const e of fns) {
577
- for (const eff of (e.inferred ?? [])) {
600
+ // Iterate functions in sorted-name order so the first-writer-wins `fn` representative of a collapsed
601
+ // remedy is deterministic across engines (candor-query/java/swift all iterate a sorted key set).
602
+ for (const e of [...fns].sort((a, b) => (a.fn < b.fn ? -1 : a.fn > b.fn ? 1 : 0))) {
603
+ for (const eff of [...(e.inferred ?? [])].sort()) {
578
604
  const layer = deniedLayer(e.fn, eff, policyParsed, scopeMatches);
579
605
  if (layer !== null) {
580
606
  const p = computeRemedy(e.fn, eff, layer, cg, rev, byName, policyParsed, scopeMatches);
@@ -583,6 +609,7 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
583
609
  }
584
610
  }
585
611
  }
586
- const remedies = [...plans.values()];
612
+ // Emit remedies in dedup-key order (candor-query BTreeMap / java TreeMap / swift sorted-keys all do).
613
+ const remedies = [...plans.keys()].sort().map((k) => plans.get(k));
587
614
  return { ok: remedies.length === 0, remedies };
588
615
  }
package/query.mjs CHANGED
@@ -263,7 +263,11 @@ switch (cmd) {
263
263
  let ptext;
264
264
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
265
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);
266
+ const cg = loadCallgraph(prefix);
267
+ // The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
268
+ // it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
269
+ if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
270
+ const r = coreFix(cg, loadReport(prefix), target, eff, parsePolicy(ptext), scopeMatches);
267
271
  if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
268
272
  emit(r);
269
273
  break;
@@ -275,7 +279,9 @@ switch (cmd) {
275
279
  let ptext;
276
280
  try { ptext = fs.readFileSync(policyFile, "utf8"); }
277
281
  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));
282
+ const cg = loadCallgraph(prefix);
283
+ if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
284
+ emit(coreFixGate(cg, loadReport(prefix), parsePolicy(ptext), scopeMatches));
279
285
  break;
280
286
  }
281
287
  default: