candor-ts 0.7.0 → 0.7.2

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
@@ -75,6 +75,7 @@ Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affe
75
75
  Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
76
76
  Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
77
77
  Q map $P 1 # {module: {effects, functions}}
78
+ Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
78
79
  Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
79
80
  Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
80
81
  Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/query-core.mjs CHANGED
@@ -229,6 +229,74 @@ export function map(fns) {
229
229
  .map(([k, v]) => [k, { effects: [...v.effects].sort(), functions: v.functions }]));
230
230
  }
231
231
 
232
+ // containment (SPEC §6.1) — how well each BOUNDARY effect stays in one layer (dispersion, NOT a count),
233
+ // with the AS-EFF-010 ratchet when a baseline is given. Mirrors candor-java Query.containment and
234
+ // candor-query cmd_containment: boundary effects are scored, ambient ones reported-not-scored; a layer is
235
+ // the segment AFTER the common dotted prefix ("(root)" when no package layer follows). Uses DIRECT effects.
236
+ export const CONTAINED = ["Db", "Net", "Exec", "Fs", "Ipc", "Clipboard"];
237
+ export const AMBIENT = ["Log", "Clock", "Rand", "Env"];
238
+ function commonPrefixLen(fns) {
239
+ let best = null;
240
+ for (const e of fns) {
241
+ const segs = e.fn.split(".");
242
+ if (best === null) { best = segs; continue; }
243
+ let i = 0; const n = Math.min(best.length, segs.length);
244
+ while (i < n && best[i] === segs[i]) i++;
245
+ best = best.slice(0, i);
246
+ }
247
+ return (best ?? []).length;
248
+ }
249
+ function layerOf(fn, prefixLen) {
250
+ // The layer = the first segment after the common prefix, the leaf excluded. candor-ts names functions
251
+ // with a FILE.fn (free fns) or FILE.Class.method tail — a SHALLOW 1-segment-minimum tail — so the rule is
252
+ // `prefixLen + 1 < length` (matching candor-rust's layer_of). candor-java uses `+2` because its names carry
253
+ // an extra Package.Class.method segment; copying that here collapsed every 2-segment free function to
254
+ // "(root)", killing the dispersion signal on real TS reports.
255
+ const segs = fn.split(".");
256
+ return prefixLen + 1 < segs.length ? segs[prefixLen] : "(root)";
257
+ }
258
+ export function containment(fns, baseFns) {
259
+ const pl = commonPrefixLen(fns);
260
+ const known = new Set([...CONTAINED, ...AMBIENT]);
261
+ const byEff = {}; // effect -> { layer -> count }, over DIRECT effects
262
+ for (const e of fns) for (const eff of (e.direct ?? [])) {
263
+ if (!known.has(eff)) continue;
264
+ const layer = layerOf(e.fn, pl);
265
+ (byEff[eff] ??= {})[layer] = (byEff[eff][layer] ?? 0) + 1;
266
+ }
267
+ // RATCHET: a baseline was given — flag any contained effect now in a layer it wasn't in (a leak), note removals.
268
+ if (baseFns) {
269
+ const bpl = commonPrefixLen(baseFns);
270
+ const baseLayers = {};
271
+ for (const e of baseFns) for (const eff of (e.direct ?? [])) {
272
+ if (!CONTAINED.includes(eff)) continue;
273
+ (baseLayers[eff] ??= new Set()).add(layerOf(e.fn, bpl));
274
+ }
275
+ const leaks = [], cleanups = [];
276
+ for (const eff of CONTAINED) {
277
+ const now = new Set(Object.keys(byEff[eff] ?? {}));
278
+ const was = baseLayers[eff] ?? new Set();
279
+ for (const l of now) if (!was.has(l)) leaks.push(`${eff} → ${l}`);
280
+ for (const l of was) if (!now.has(l)) cleanups.push(`${eff} ⊘ ${l}`);
281
+ }
282
+ return { leaks: leaks.sort(), cleanups: cleanups.sort() };
283
+ }
284
+ // REPORT: the containment diagnostic.
285
+ const contained = [];
286
+ for (const eff of CONTAINED) {
287
+ const layers = byEff[eff]; if (!layers) continue;
288
+ const entries = Object.entries(layers);
289
+ const tot = entries.reduce((a, [, n]) => a + n, 0);
290
+ const owner = entries.slice().sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
291
+ const placement = Object.fromEntries(entries.slice().sort((a, b) => a[0].localeCompare(b[0])));
292
+ contained.push({ effect: eff, containmentPct: Math.floor((100 * owner[1]) / tot),
293
+ layers: entries.length, owner: owner[0], placement });
294
+ }
295
+ const ambient = {};
296
+ for (const eff of AMBIENT) if (byEff[eff]) ambient[eff] = Object.keys(byEff[eff]).length;
297
+ return { contained, ambient };
298
+ }
299
+
232
300
  export function reachable(fns) {
233
301
  const roots = fns.filter((e) => e.entryPoint);
234
302
  const byEff = {};
package/query.mjs CHANGED
@@ -27,6 +27,7 @@ import { printAgents } from "./contract.mjs";
27
27
  import { impact as coreImpact, path as corePath, gains as coreGains,
28
28
  show as coreShow, blindspots as coreBlindspots,
29
29
  callers as coreCallers, callersFrontier, loadHierarchy,
30
+ containment as coreContainment,
30
31
  loadReport, loadCallgraph, matches } from "./query-core.mjs";
31
32
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
32
33
 
@@ -83,6 +84,23 @@ switch (cmd) {
83
84
  .map(([k, v]) => [k, { effects: [...v.effects].sort(), functions: v.functions }])));
84
85
  break;
85
86
  }
87
+ case "containment": {
88
+ // SPEC §6.1 boundary-effect dispersion; with a baseline prefix it's the AS-EFF-010 ratchet (exit 1 on a
89
+ // new leak), matching candor-java / candor-query. JSON-only, like every other candor-ts query command.
90
+ const [prefix, basePrefix] = args;
91
+ if (basePrefix) {
92
+ const baseFns = loadReport(basePrefix);
93
+ if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
94
+ console.error(`candor-ts: no report at baseline prefix '${basePrefix}' — check the path`);
95
+ process.exit(2);
96
+ }
97
+ const r = coreContainment(loadReport(prefix), baseFns);
98
+ emit(r);
99
+ process.exit(r.leaks.length ? 1 : 0);
100
+ }
101
+ emit(coreContainment(loadReport(prefix)));
102
+ break;
103
+ }
86
104
  case "diff": {
87
105
  // per-function effect delta vs a baseline: {changes: [{fn, gained, lost}]} — the envelope shape
88
106
  // the conformance suite pins (diff-vs-self must be {changes: []}).