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 +3 -0
- package/mcp.mjs +5 -1
- package/package.json +1 -1
- package/query-core.mjs +42 -8
- package/query.mjs +8 -2
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
|
|
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
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|