candor-ts 0.8.10 → 0.8.13

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.13",
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,37 @@ 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
+ // The SANDWICHED-layer check (/code-review): a hoist is CLEAN only if no forbidden fn sits ABOVE the
544
+ // frontier. If a denied fn calls into a hoist target, hoisting the effect there leaves that caller
545
+ // violating. Detected in the same climb that gathers `hoistHigher` (the allowed ancestors).
546
+ const hoistHigher = new Set();
547
+ let sandwiched = false;
548
+ const hseen = new Set(hoistTo);
549
+ const hq = [...hoistTo];
550
+ while (hq.length) {
551
+ const cur = hq.shift();
552
+ for (const caller of rev.get(cur) ?? []) {
553
+ const ce = byName.get(caller);
554
+ if (!ce || !(ce.inferred ?? []).includes(eff)) continue;
555
+ if (deniedLayer(caller, eff, policyParsed, scopeMatches) !== null) {
556
+ sandwiched = true;
557
+ } else if (!hseen.has(caller)) {
558
+ hseen.add(caller);
559
+ hoistHigher.add(caller);
560
+ hq.push(caller);
561
+ }
562
+ }
563
+ }
538
564
  return {
539
565
  fn: start, effect: eff, layer,
540
- cleanHoist: hoistTo.size > 0,
566
+ cleanHoist: hoistTo.size > 0 && !sandwiched,
541
567
  site: [...sites].sort(),
542
568
  deniedSpan: [...deniedSpan].sort(),
543
569
  hoistTo: [...hoistTo].sort(),
570
+ hoistHigher: [...hoistHigher].sort(),
544
571
  policyAlternative: layer ? `allow ${eff} ${layer}` : `allow ${eff}`,
545
572
  };
546
573
  }
@@ -548,10 +575,14 @@ function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMa
548
575
  // fix: the boundary remedy for ONE function (the remedial inverse of whatif). Returns null if the function
549
576
  // isn't in the graph; `{ crossing:false, reason }` if it performs the effect but no policy forbids it there
550
577
  // (or it doesn't perform it) — a no-op the caller reports plainly; else the full remedy (`crossing:true`).
578
+ // NOTE: `cg` (the callgraph sidecar) is REQUIRED — unlike candor-query/java/swift, a candor-ts report does not
579
+ // embed inline `calls`, so the sidecar is the only graph; the CLI/MCP callers fail loud when it's absent
580
+ // rather than compute a degenerate empty-graph remedy. (/code-review.)
551
581
  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);
582
+ // Resolve against REPORT function names only (not callgraph nodes, which include pure fns absent from the
583
+ // report) — so `fix <pure-fn>` is a uniform "no such fn" across engines, not a TS-only crossing:false.
584
+ // (/code-review — candor-query/java/swift all match report fns only.)
585
+ const m = matches(fns.map((e) => e.fn), target);
555
586
  if (m.length === 0) return null;
556
587
  const byName = indexFns(fns);
557
588
  // prefer a match that actually performs the effect, so a bare leaf resolves to the violating function
@@ -573,8 +604,10 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
573
604
  const byName = indexFns(fns);
574
605
  const rev = reverseGraph(cg);
575
606
  const plans = new Map();
576
- for (const e of fns) {
577
- for (const eff of (e.inferred ?? [])) {
607
+ // Iterate functions in sorted-name order so the first-writer-wins `fn` representative of a collapsed
608
+ // remedy is deterministic across engines (candor-query/java/swift all iterate a sorted key set).
609
+ for (const e of [...fns].sort((a, b) => (a.fn < b.fn ? -1 : a.fn > b.fn ? 1 : 0))) {
610
+ for (const eff of [...(e.inferred ?? [])].sort()) {
578
611
  const layer = deniedLayer(e.fn, eff, policyParsed, scopeMatches);
579
612
  if (layer !== null) {
580
613
  const p = computeRemedy(e.fn, eff, layer, cg, rev, byName, policyParsed, scopeMatches);
@@ -583,6 +616,7 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
583
616
  }
584
617
  }
585
618
  }
586
- const remedies = [...plans.values()];
619
+ // Emit remedies in dedup-key order (candor-query BTreeMap / java TreeMap / swift sorted-keys all do).
620
+ const remedies = [...plans.keys()].sort().map((k) => plans.get(k));
587
621
  return { ok: remedies.length === 0, remedies };
588
622
  }
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: