candor-ts 0.11.0 → 0.12.0

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
@@ -12,7 +12,7 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
14
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.11)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.12)."*
16
16
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
@@ -86,7 +86,7 @@ downgraded to `Unknown` rather than silently trusted (spec §2.1). Caveat: a typ
86
86
  ## Query it (same names/shapes as the Rust and JVM engines — candor-spec §3.1)
87
87
 
88
88
  ```sh
89
- Q() { npx -y candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
89
+ Q() { npx -y -p candor-ts candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
90
90
  Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when visible)
91
91
  Q where $P <Effect> 1 # {effect, directly, inherited}
92
92
  Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
package/README.md CHANGED
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
184
184
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
185
185
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
186
186
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
187
- | `{ candor: { version, toolchain, spec: "0.11" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.12" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
188
188
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
189
189
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
190
190
 
@@ -202,7 +202,7 @@ read the Rust source".
202
202
 
203
203
  ## Status
204
204
 
205
- 0.11.x, speaking candor-spec 0.11: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.12.x, speaking candor-spec 0.12: the analysis core, the gate (`--policy` / `--gate-json` /
206
206
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
207
207
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
208
208
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
package/mcp.mjs CHANGED
@@ -51,6 +51,18 @@ function resolvePrefix(args) {
51
51
  // Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
52
52
  // verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
53
53
  const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
54
+ // Load a report but FAIL LOUD (a thrown tool-level error) when files were FOUND yet nothing parsed —
55
+ // Q.loadReport discloses-and-tolerates, returning [] with the non-enumerable `hardFail` tag there, and
56
+ // an empty SUCCESSFUL result ({gained:[],byFunction:[]}, [] show, {} map) reads as an all-clear over a
57
+ // corrupt report — the §4 cardinal sin, exactly what the CLI's loadReportOrDie exits 2 on. The throw
58
+ // surfaces as the same isError result shape every other tool failure uses. EVERY tool that loads a
59
+ // report (main prefix or baseline) goes through this — never bare Q.loadReport.
60
+ function loadReportLoud(p) {
61
+ const fns = Q.loadReport(p);
62
+ if (fns.length === 0 && fns.hardFail)
63
+ throw new Error(`every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`);
64
+ return fns;
65
+ }
54
66
  // The confinement root for a caller-supplied policy path: the repo the report belongs to — the
55
67
  // .candor/config-discovered repo root when there is one, else the parent of a `.candor/` report
56
68
  // directory, else the report's own directory. The old default (always dirname(prefix)) was the
@@ -132,22 +144,22 @@ const TOOLS = {
132
144
  candor_impact: {
133
145
  description: "Backward blast radius: every effectful function that transitively calls `fn`, and which runtime entry points are downstream. Answers 'if I change this, what surfaces at runtime?' — the cheapest possible alternative to tracing callers by hand.",
134
146
  schema: { type: "object", properties: { fn: { type: "string", description: "the function/unit to assess" }, ...reportArg }, required: ["fn"] },
135
- run: (a, p) => capImpact(Q.impact(Q.loadReport(p), Q.loadCallgraph(p), a.fn)),
147
+ run: (a, p) => capImpact(Q.impact(loadReportLoud(p), Q.loadCallgraph(p), a.fn)),
136
148
  },
137
149
  candor_where: {
138
150
  description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
139
151
  schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
140
- run: (a, p) => capWhere(Q.where(Q.loadReport(p), a.effect)),
152
+ run: (a, p) => capWhere(Q.where(loadReportLoud(p), a.effect)),
141
153
  },
142
154
  candor_reachable: {
143
155
  description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
144
156
  schema: { type: "object", properties: { ...reportArg } },
145
- run: (_a, p) => Q.reachable(Q.loadReport(p)),
157
+ run: (_a, p) => Q.reachable(loadReportLoud(p)),
146
158
  },
147
159
  candor_path: {
148
160
  description: "Forward provenance: the shortest call chain from `fn` to the nearest function that performs `effect` DIRECTLY — 'this reaches Net through WHAT?'.",
149
161
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
150
- run: (a, p) => Q.path(Q.loadReport(p), Q.loadCallgraph(p), a.fn, a.effect),
162
+ run: (a, p) => Q.path(loadReportLoud(p), Q.loadCallgraph(p), a.fn, a.effect),
151
163
  },
152
164
  candor_callers: {
153
165
  description: "Who calls `fn` — direct (one hop) and transitive callers over the effect-relevant call graph.",
@@ -157,12 +169,12 @@ const TOOLS = {
157
169
  candor_show: {
158
170
  description: "A function's effects (inferred = transitive, direct = own body) plus its literal surfaces (hosts/cmds/paths/tables) when present.",
159
171
  schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
160
- run: (a, p) => Q.show(Q.loadReport(p), a.fn),
172
+ run: (a, p) => Q.show(loadReportLoud(p), a.fn),
161
173
  },
162
174
  candor_map: {
163
175
  description: "Per-module effect overview: each module's union of effects and function count. The architecture-at-a-glance.",
164
176
  schema: { type: "object", properties: { ...reportArg } },
165
- run: (_a, p) => Q.map(Q.loadReport(p)),
177
+ run: (_a, p) => Q.map(loadReportLoud(p)),
166
178
  },
167
179
  candor_whatif: {
168
180
  description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
@@ -195,7 +207,7 @@ const TOOLS = {
195
207
  // The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
196
208
  // never a degenerate empty-graph remedy. (/code-review.)
197
209
  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);
210
+ const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
199
211
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
200
212
  return r;
201
213
  },
@@ -211,7 +223,7 @@ const TOOLS = {
211
223
  if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
212
224
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
213
225
  }
214
- const v = evaluatePolicy(parsePolicy(text), Q.loadReport(p), Q.loadCallgraph(p));
226
+ const v = evaluatePolicy(parsePolicy(text), loadReportLoud(p), Q.loadCallgraph(p));
215
227
  return { ok: v.length === 0, violations: v };
216
228
  },
217
229
  },
@@ -232,30 +244,44 @@ const TOOLS = {
232
244
  if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
233
245
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
234
246
  }
235
- return Q.unverified(Q.loadReport(p), parsePolicy(text), scopeMatches);
247
+ return Q.unverified(loadReportLoud(p), parsePolicy(text), scopeMatches);
236
248
  },
237
249
  },
238
250
  candor_containment: {
239
251
  description: "Per boundary effect (Db/Net/Exec/Fs/Ipc/Clipboard): how contained it is in one architectural layer — the dispersion diagnostic (spec §6.1). Not a score; per-effect facts.",
240
252
  schema: { type: "object", properties: { ...reportArg } },
241
- run: (_a, p) => Q.containment(Q.loadReport(p)),
253
+ run: (_a, p) => Q.containment(loadReportLoud(p)),
242
254
  },
243
255
  candor_blindspots: {
244
256
  description: "The Unknown SOURCES — calls the engine genuinely could not resolve (reflection, wide dispatch, fn-pointers) — ranked by how many functions inherit Unknown through each. Turns a high-Unknown report into a short worklist.",
245
257
  schema: { type: "object", properties: { ...reportArg } },
246
- run: (_a, p) => capBlindspots(Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p))),
258
+ run: (_a, p) => capBlindspots(Q.blindspots(loadReportLoud(p), Q.loadCallgraph(p))),
247
259
  },
248
260
  candor_diff: {
249
261
  description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
250
262
  schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
251
- run: (a, p) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
252
- ...Q.diff(Q.loadReport(p), Q.loadReport(a.baseline)) }),
263
+ run: (a, p) => {
264
+ // The BASELINE locator gets the SAME existence + --root confinement checks as the main report
265
+ // (resolvePrefix) — a typo'd baseline loaded [] with hardFail=false and diffed as an
266
+ // authoritative empty {changes:[]} (the CLI now exits 2 on the same miss).
267
+ const b = resolvePrefix({ report: a.baseline });
268
+ return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
269
+ ...Q.diff(loadReportLoud(p), loadReportLoud(b)) };
270
+ },
253
271
  },
254
272
  candor_gains: {
255
273
  description: "The supply-chain alarm: effects the surface GAINED versus a baseline (package-level + per-function) — 'did this dependency bump add Net/Exec somewhere?'.",
256
274
  schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
257
- run: (a, p) => ({ baseline_version: Q.reportVersion(a.baseline) ?? "", engine_version: Q.reportVersion(p) ?? "",
258
- ...Q.gains(Q.loadReport(p), Q.loadReport(a.baseline)) }),
275
+ run: (a, p) => {
276
+ // Same baseline existence + --root confinement as candor_diff — an empty {gained:[]} over a
277
+ // typo'd baseline is a silent all-clear on the supply-chain ALARM tool.
278
+ const b = resolvePrefix({ report: a.baseline });
279
+ // ⟨spec 0.12 staged⟩ baseline callgraph → byFunction[].origin, same as the CLI (parity). The
280
+ // loader's non-enumerable `partial` tag rides along: a corrupt baseline sidecar (edges dropped,
281
+ // disclosed) downgrades origin to "unknown", never a fabricated "new" over a truncated graph.
282
+ return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
283
+ ...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)) };
284
+ },
259
285
  },
260
286
  };
261
287
 
@@ -270,7 +296,7 @@ function listResources(prefix) {
270
296
  return res;
271
297
  }
272
298
  function readResource(uri, prefix) {
273
- if (uri.startsWith("candor://report")) return { mimeType: "application/json", text: JSON.stringify(Q.loadReport(prefix)) };
299
+ if (uri.startsWith("candor://report")) return { mimeType: "application/json", text: JSON.stringify(loadReportLoud(prefix)) };
274
300
  if (uri.startsWith("candor://policy")) {
275
301
  const cfg = configPolicy(prefix);
276
302
  if (!cfg) throw new Error("no checked-in policy (no .candor/config with a `policy` key)");
@@ -332,7 +358,7 @@ function handle(msg) {
332
358
  // A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
333
359
  // an agent must distinguish "no such function" from "found, nothing calls it".
334
360
  if (args.fn !== undefined) {
335
- const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...Q.loadReport(prefix).map((e) => e.fn)])];
361
+ const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...loadReportLoud(prefix).map((e) => e.fn)])];
336
362
  if (Q.matches(names, args.fn).length === 0)
337
363
  return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
338
364
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.11.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.11)",
3
+ "version": "0.12.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.12)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -170,6 +170,13 @@ export function loadReport(prefix) {
170
170
  }
171
171
  return tagHardFail(fns, hardFail);
172
172
  }
173
+ // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
174
+ // true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
175
+ // stderr above), so the graph is an UNDER-approximation. An ABSENT sidecar is NOT partial: nothing
176
+ // matched, and the empty graph is the whole (disclosable) truth. gains' origin ladder needs the
177
+ // distinction: over a partial baseline graph, absence from the surviving edges proves nothing, so
178
+ // labeling a dropped file's fns "new" would downgrade the attack signal — fall back to "unknown".
179
+ const tagPartial = (cg, partial) => { Object.defineProperty(cg, "partial", { value: partial, enumerable: false }); return cg; };
173
180
  export function loadCallgraph(prefix) {
174
181
  // A `null`/non-object parse (a `null` callgraph, an array, a number) must NOT reach Object.entries —
175
182
  // it throws "Cannot convert null to object". Coerce anything but a plain object to {} (an empty
@@ -180,16 +187,18 @@ export function loadCallgraph(prefix) {
180
187
  if (fs.existsSync(`${prefix}.callgraph.json`)) {
181
188
  // The PRIMARY callgraph parse must DISCLOSE-and-tolerate like the sibling path below and like
182
189
  // loadReport — a bare JSON.parse here threw an uncaught stack trace on the CLI for a corrupt or
183
- // `null` `<prefix>.callgraph.json` (asymmetric with siblings). Tolerate (empty graph) + disclose.
184
- try { return norm(JSON.parse(fs.readFileSync(`${prefix}.callgraph.json`, "utf8"))); }
185
- catch { console.error(`candor-ts: callgraph ${prefix}.callgraph.json failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); return {}; }
190
+ // `null` `<prefix>.callgraph.json` (asymmetric with siblings). Tolerate (empty graph) + disclose,
191
+ // and TAG the drop (`partial`) so a consumer never mistakes the truncated graph for the whole one.
192
+ try { return tagPartial(norm(JSON.parse(fs.readFileSync(`${prefix}.callgraph.json`, "utf8"))), false); }
193
+ catch { console.error(`candor-ts: callgraph ${prefix}.callgraph.json failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); return tagPartial({}, true); }
186
194
  }
187
195
  const cg = {};
196
+ let partial = false;
188
197
  for (const f of siblings(prefix, (x) => x.endsWith(".callgraph.json"))) {
189
198
  try { Object.assign(cg, JSON.parse(fs.readFileSync(f, "utf8"))); }
190
- catch { console.error(`candor-ts: callgraph ${f} failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); }
199
+ catch { console.error(`candor-ts: callgraph ${f} failed to parse — its edges are OMITTED from this query (corrupt or mid-write); re-run the scan`); partial = true; }
191
200
  }
192
- return norm(cg);
201
+ return tagPartial(norm(cg), partial);
193
202
  }
194
203
 
195
204
  // ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
@@ -523,10 +532,34 @@ export function diff(curFns, baseFns) {
523
532
  // gains: the package-level SUPPLY-CHAIN alarm (spec §5.1) — the UNION of effects the surface gained
524
533
  // between two reports (base → cur), with per-function detail. A dependency that grows a Net/Exec reach
525
534
  // between releases. Same shape as candor-query's `gains --json`. Built on diff so it can't drift.
526
- export function gains(curFns, baseFns) {
535
+ //
536
+ // ⟨spec 0.12 staged⟩ each byFunction entry carries `origin` — the candor-gains prototype's key finding
537
+ // promoted into the open query. A gain on a fn that EXISTED at the baseline (shipped pure, now does
538
+ // Net — the supply-chain attack signal) is a different alarm from a NEW fn that does Net (a feature).
539
+ // Reports OMIT pure functions (§2), so existence is keyed on the baseline CALLGRAPH (a baseline-pure
540
+ // fn is a graph node with no report entry):
541
+ // "existing" — in the baseline report, or a baseline-callgraph node (caller key or callee);
542
+ // "new" — a COMPLETE baseline callgraph was loaded and the fn is in neither (did not exist);
543
+ // "unknown" — absent from the baseline report AND the graph cannot decide: no baseline callgraph
544
+ // found (empty graph) OR the graph is PARTIAL (loadCallgraph's non-enumerable `partial`
545
+ // tag — a matched sidecar failed to load, its edges were dropped-and-disclosed, so
546
+ // absence from the survivors proves nothing). Undecidable is DISCLOSED, never guessed
547
+ // (§4) — a partial graph must not downgrade the attack signal from a dropped file's
548
+ // fns to a benign-looking "new".
549
+ // `baseCg` defaults to {} (no callgraph → "unknown") so core-only callers keep working unchanged.
550
+ export function gains(curFns, baseFns, baseCg = {}) {
551
+ const baseSet = new Set(baseFns.map((e) => e.fn));
552
+ const cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...vs]));
553
+ // The ladder: report hit → existing; graph node → existing (a surviving node is real even in a
554
+ // partial graph — the drop loses nodes, never invents them); else "new" only when a COMPLETE
555
+ // non-empty graph can vouch for non-existence; else "unknown".
556
+ const graphDecides = cgNodes.size > 0 && baseCg.partial !== true;
557
+ const originOf = (fn) => baseSet.has(fn) ? "existing"
558
+ : cgNodes.has(fn) ? "existing"
559
+ : graphDecides ? "new" : "unknown";
527
560
  const gained = new Set(), byFunction = [];
528
561
  for (const c of diff(curFns, baseFns).changes) {
529
- for (const e of c.gained) { gained.add(e); byFunction.push({ fn: c.fn, effect: e }); }
562
+ for (const e of c.gained) { gained.add(e); byFunction.push({ effect: e, fn: c.fn, origin: originOf(c.fn) }); }
530
563
  }
531
564
  return { gained: [...gained].sort(), byFunction };
532
565
  }
package/query.mjs CHANGED
@@ -49,8 +49,12 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
49
49
  // Prints to stdout and returns nothing (matches the JSON-only verbs' fire-and-forget style).
50
50
  function renderPathHuman(fns, cg, fnQ, eff) {
51
51
  // Resolve the start over the REPORT entries (as Rust does) — that's where `inferred` lives, and the
52
- // no-effect wording quotes it. corePath resolves over the callgraph keys for the chain; the two agree
53
- // on any fn that has an entry, which every graphed fn does.
52
+ // no-effect wording quotes it. The RESOLVED name (not the raw query) is then handed to corePath,
53
+ // which re-resolves over the CALLGRAPH keys — a DIFFERENT name set: a raw partial query could pick
54
+ // a different fn there (report `app.db.save`, graph `app.cache.save` for the query "save"), so the
55
+ // header described one function and the chain/verdict another (a misleading "not statically
56
+ // traceable" over a traceable fn). An exact name resolves identically in both sets (match tier 3,
57
+ // exact, beats every partial tier and only its own name can equal it), so they cannot disagree.
54
58
  const start = coreMatches(fns.map((e) => e.fn), fnQ)[0];
55
59
  if (start === undefined) {
56
60
  // No matching function at all — parity with Rust/Java's "no function matching" (stderr, exit 2).
@@ -67,7 +71,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
67
71
  console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
68
72
  return;
69
73
  }
70
- const r = corePath(fns, cg, fnQ, eff);
74
+ const r = corePath(fns, cg, start, eff);
71
75
  if (r.path.length === 0) {
72
76
  // Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
73
77
  console.log(`${start} performs ${eff} but its source is not a local function `
@@ -90,7 +94,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
90
94
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
91
95
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
92
96
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
93
- const SPEC_VERSION = "0.11";
97
+ const SPEC_VERSION = "0.12";
94
98
 
95
99
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
96
100
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -421,7 +425,13 @@ switch (cmd) {
421
425
  // each resolved by the shared locator rule (dir / .json path / prefix). --json is accepted (JSON is
422
426
  // the only output). No leading-positional-report alias here: both positionals ARE the reports.
423
427
  const { positionals } = parseCanonical(args, {});
428
+ if (positionals.length < 2) { console.error("usage: candor-ts-query diff <current> <baseline> [--json]"); process.exit(2); }
424
429
  const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
430
+ // BOTH locators must name real report files (the Rust engine's no-files check, named per side so
431
+ // the user knows which path to fix): a typo'd prefix loaded [] with hardFail=false and emitted an
432
+ // authoritative EMPTY {changes:[]} at exit 0 — the §4 false all-clear on the ratchet verb.
433
+ if (!hasReport(curPrefix)) { console.error(`candor-ts: no report files at current prefix '${curPrefix}' — check the path.`); process.exit(2); }
434
+ if (!hasReport(basePrefix)) { console.error(`candor-ts: no report files at baseline prefix '${basePrefix}' — check the path.`); process.exit(2); }
425
435
  const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
426
436
  // §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
427
437
  // may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
@@ -547,11 +557,21 @@ switch (cmd) {
547
557
  // §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
548
558
  // the shared locator rule; --json accepted.
549
559
  const { positionals } = parseCanonical(args, {});
560
+ if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json]"); process.exit(2); }
550
561
  const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
562
+ // BOTH locators must name real report files (the Rust engine's no-files check, named per side):
563
+ // a typo'd prefix loaded [] with hardFail=false and emitted an authoritative EMPTY
564
+ // {gained:[],byFunction:[]} at exit 0 — a silent all-clear on the supply-chain ALARM verb.
565
+ if (!hasReport(curPrefix)) { console.error(`candor-ts: no report files at current prefix '${curPrefix}' — check the path.`); process.exit(2); }
566
+ if (!hasReport(basePrefix)) { console.error(`candor-ts: no report files at baseline prefix '${basePrefix}' — check the path.`); process.exit(2); }
551
567
  const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
552
568
  if (gv && gbv && gv !== gbv)
553
569
  console.error(`candor-ts: ⚠ baseline @${gbv} ≠ engine @${gv} — a "gained capability" may be the engine reclassifying, not the dependency changing. Regenerate both reports with one build to compare releases.`);
554
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix)) });
570
+ // ⟨spec 0.12 staged⟩ the BASELINE callgraph feeds byFunction[].origin (existing/new/unknown) —
571
+ // a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
572
+ // with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
573
+ // to "unknown" — the JSON itself discloses, never guessing "new" over a truncated graph.
574
+ emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)) });
555
575
  break;
556
576
  }
557
577
  case "path": {
@@ -563,7 +583,13 @@ switch (cmd) {
563
583
  const fns = loadReportOrDie(prefix);
564
584
  const cg = loadCallgraph(prefix);
565
585
  if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
566
- else renderPathHuman(fns, cg, fn, eff);
586
+ else {
587
+ // The accepted 0.11 default change (the human chain replaced JSON as the no-flag output) gets a
588
+ // ONE-line stderr breadcrumb, so a pre-0.11 pipeline that broke on the new default is pointed at
589
+ // --json rather than left guessing. stderr only — stdout stays the human chain; --json untouched.
590
+ console.error("candor-ts-query: tip — `--json` selects the machine-readable path shape (the default before 0.11)");
591
+ renderPathHuman(fns, cg, fn, eff);
592
+ }
567
593
  break;
568
594
  }
569
595
  case "whatif": {
package/scan.mjs CHANGED
@@ -40,7 +40,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
40
40
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
41
41
  // Reused, never re-littered.
42
42
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
43
- const SPEC_VERSION = "0.11";
43
+ const SPEC_VERSION = "0.12";
44
44
 
45
45
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
46
46
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed