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 +2 -2
- package/README.md +2 -2
- package/mcp.mjs +43 -17
- package/package.json +2 -2
- package/query-core.mjs +40 -7
- package/query.mjs +32 -6
- package/scan.mjs +1 -1
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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),
|
|
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(
|
|
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(
|
|
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(
|
|
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) =>
|
|
252
|
-
|
|
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) =>
|
|
258
|
-
|
|
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(
|
|
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)), ...
|
|
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.
|
|
4
|
-
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
53
|
-
//
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|