candor-ts 0.10.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 +10 -9
- package/README.md +10 -9
- package/mcp.mjs +43 -17
- package/package.json +3 -2
- package/query-core.mjs +117 -16
- package/query.mjs +190 -18
- package/scan.mjs +31 -4
- package/surface.mjs +233 -0
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,13 +86,14 @@ 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}
|
|
93
93
|
Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
|
|
94
94
|
Q callers $P <fn-query> --include-unknown 1 # + possibleViaUnknownDispatch: the unresolved-dispatch frontier
|
|
95
95
|
Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
|
|
96
|
+
Q tour [N] --report $P # the N (default 10) most surprising transitive reaches
|
|
96
97
|
Q map $P 1 # {module: {effects, functions}}
|
|
97
98
|
Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
|
|
98
99
|
Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
|
|
@@ -142,9 +143,9 @@ want-JSON flag.
|
|
|
142
143
|
"classifier" paragraph is the ONE current list (this file deliberately doesn't duplicate it — a
|
|
143
144
|
vendored copy here drifted a full generation once).
|
|
144
145
|
An unlisted package contributes nothing — an effect through it is invisible, not `Unknown`. The
|
|
145
|
-
scanner **names these per scan**: the receipt's
|
|
146
|
-
package the code demonstrably calls that
|
|
147
|
-
before concluding "no effect" through anything it names.
|
|
146
|
+
scanner **names these per scan**: the receipt's coverage-ledger line (marker: `classifier doesn't
|
|
147
|
+
cover`) lists every npm package the code demonstrably calls that candor's classifier neither
|
|
148
|
+
classifies nor has reviewed-pure — read it before concluding "no effect" through anything it names.
|
|
148
149
|
- **`process.env.X` reads are `Env`** (a property read, not a call); `Date.now()` is `Clock`.
|
|
149
150
|
- **DI-style code reads `Unknown` a lot, by design**: a function-typed parameter or field being
|
|
150
151
|
called is genuinely indeterminate (rimraf's injected-fs style yields many `Unknown`s — that's the
|
|
@@ -180,12 +181,12 @@ present — a callback value, an `any`-typed callee, resolution landing on a typ
|
|
|
180
181
|
body), the set may be incomplete: read the source for *that* function before relying on it. Never
|
|
181
182
|
conclude a function is pure while it is marked unresolved. The literal surfaces (`hosts`/`tables`/
|
|
182
183
|
`cmds`/`paths`) are the decidable subset only — absence is never a claim of absence. **And the
|
|
183
|
-
curated
|
|
184
|
-
NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name
|
|
185
|
-
|
|
184
|
+
curated-classifier caveat cuts the other way:** a call into an npm package the classifier doesn't
|
|
185
|
+
cover contributes NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name
|
|
186
|
+
(the coverage ledger, marker: `classifier doesn't cover`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
|
|
186
187
|
"no effect" through a package that line names (the documented weaker edge of the
|
|
187
188
|
never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
|
|
188
|
-
carries an `invisible` list — the
|
|
189
|
+
carries an `invisible` list — the uncovered packages it (transitively) reaches — so `inferred` is
|
|
189
190
|
never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
|
|
190
191
|
far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
|
|
191
192
|
dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
|
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ posthog-node, bull/bullmq), the database drivers (pg/mysql2/mongodb/redis/ioredi
|
|
|
88
88
|
better-sqlite3/knex) **and the ORM tier** (TypeORM — with `@Entity("…")` table extraction —
|
|
89
89
|
Prisma, Mongoose, Sequelize, drizzle-orm), plus execa/cross-spawn/shelljs/open, fs-extra/
|
|
90
90
|
graceful-fs/rimraf/glob/chokidar, dotenv, winston/pino/bunyan. An unlisted package contributes
|
|
91
|
-
nothing — candor never guesses an effect — but the scan **names it**: the receipt's
|
|
92
|
-
|
|
93
|
-
nor has reviewed-pure, and each function carries the
|
|
91
|
+
nothing — candor never guesses an effect — but the scan **names it**: the receipt's coverage-ledger
|
|
92
|
+
line (marker: `classifier doesn't cover`) lists every package the code demonstrably calls that
|
|
93
|
+
candor's classifier neither classifies nor has reviewed-pure, and each function carries the
|
|
94
|
+
`invisible` list it (transitively) reaches.
|
|
94
95
|
|
|
95
96
|
## MCP server — candor as agent ground truth
|
|
96
97
|
|
|
@@ -155,7 +156,7 @@ field being called, an `any`-typed callee, resolution landing on a type rather t
|
|
|
155
156
|
An **uncurated dependency** can opt out of `Unknown`/silent-pure by **declaring its effects** in its
|
|
156
157
|
`package.json` — `"candorEffects": ["Net"]` (spec §5.1, the effect manifest). candor-ts reads it as
|
|
157
158
|
the declared-not-verified tier: the package's calls classify to the declared set, and it stops being
|
|
158
|
-
a
|
|
159
|
+
a coverage-ledger blind spot. A name outside the §1 vocabulary voids the declaration loudly (a typo must not
|
|
159
160
|
silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
|
|
160
161
|
delta — the effects a surface *gained* between two reports.
|
|
161
162
|
Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
|
|
@@ -176,14 +177,14 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
176
177
|
| Piece | Spec source |
|
|
177
178
|
|---|---|
|
|
178
179
|
| Resolve every call via the compiler API (`getResolvedSignature`), never syntax | CLASSIFIER §1 |
|
|
179
|
-
|
|
|
180
|
+
| The classifier maps the resolved target's module (`node:fs`→Fs, `node:net`→Net, …) | CLASSIFIER §2, TS notes |
|
|
180
181
|
| `process.env` property read → Env; `Date.now` → Clock | SPEC §1 |
|
|
181
182
|
| Local edges (cross-file) + least-fixpoint propagation | SEMANTICS §5a |
|
|
182
183
|
| Closure bodies attribute to the nearest enclosing function | SEMANTICS §2 |
|
|
183
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
184
|
-
| Unmatched external calls contribute nothing (curated
|
|
185
|
+
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
185
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
186
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.12" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
187
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
188
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
189
190
|
|
|
@@ -201,7 +202,7 @@ read the Rust source".
|
|
|
201
202
|
|
|
202
203
|
## Status
|
|
203
204
|
|
|
204
|
-
0.
|
|
205
|
+
0.12.x, speaking candor-spec 0.12: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
205
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
206
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
207
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
|
@@ -228,5 +229,5 @@ node scan.mjs <dir | file.ts | tsconfig.json> --out .candor/report # scan a pr
|
|
|
228
229
|
```
|
|
229
230
|
|
|
230
231
|
The pure cores are factored into importable modules — `query-core.mjs` (the §3.1 queries),
|
|
231
|
-
`policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the
|
|
232
|
+
`policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the classifier + the SQL/
|
|
232
233
|
command/host extractors) — so they're unit-tested directly; the TS-compiler-driven walk stays in `scan.mjs`.
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"LICENSE-APACHE",
|
|
53
53
|
"query-core.mjs",
|
|
54
54
|
"scan-core.mjs",
|
|
55
|
+
"surface.mjs",
|
|
55
56
|
"mcp.mjs",
|
|
56
57
|
"watch.mjs",
|
|
57
58
|
"lsp.mjs"
|
package/query-core.mjs
CHANGED
|
@@ -86,22 +86,97 @@ export function reportVersion(prefix) {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** The report's §2 envelope `package` name — meaningful and locator-independent, so every engine and
|
|
90
|
+
* every --report form print the same crate in the `tour` header. null when absent/unreadable (the
|
|
91
|
+
* caller falls back to the prefix basename). Mirrors surface.rs/tour.rs::report_package. */
|
|
92
|
+
export function reportPackage(prefix) {
|
|
93
|
+
const files = fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport);
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
try {
|
|
96
|
+
const doc = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
97
|
+
const p = doc?.package;
|
|
98
|
+
if (typeof p === "string" && p) return p;
|
|
99
|
+
// The `packages` PLURAL envelope — the JVM shape (SPEC §2): one entry names it verbatim; several
|
|
100
|
+
// name their longest common dotted prefix (`com.a.x` + `com.a.y` → `com.a`); none shared → null.
|
|
101
|
+
if (Array.isArray(doc?.packages)) {
|
|
102
|
+
const label = packagesLabel(doc.packages.filter((x) => typeof x === "string" && x));
|
|
103
|
+
if (label) return label;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* unreadable sibling — keep looking */ }
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The longest common dot-separated prefix of a plural `packages` list — whole segments only (`com.ab` +
|
|
111
|
+
// `com.ac` share `com`, not `com.a`); null when nothing is shared. Mirrors Rust's packages_label (tour.rs).
|
|
112
|
+
function packagesLabel(pkgs) {
|
|
113
|
+
if (pkgs.length === 0) return null;
|
|
114
|
+
if (pkgs.length === 1) return pkgs[0];
|
|
115
|
+
const first = pkgs[0].split(".");
|
|
116
|
+
let n = first.length;
|
|
117
|
+
for (const p of pkgs.slice(1)) {
|
|
118
|
+
const segs = p.split(".");
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < Math.min(n, segs.length) && segs[i] === first[i]) i++;
|
|
121
|
+
n = i;
|
|
122
|
+
if (n === 0) return null; // nothing shared — the basename fallback is more honest
|
|
123
|
+
}
|
|
124
|
+
return first.slice(0, n).join(".");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
|
|
128
|
+
// yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
|
|
129
|
+
// doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
|
|
130
|
+
// tell "the report we found was corrupt" (never an all-clear) apart from a well-formed EMPTY report.
|
|
131
|
+
const tagHardFail = (fns, hardFail) => { Object.defineProperty(fns, "hardFail", { value: hardFail, enumerable: false }); return fns; };
|
|
132
|
+
|
|
133
|
+
// A well-formed report that legitimately lists ZERO functions — the ONLY empty result that is NOT a
|
|
134
|
+
// corruption (parity with the Rust engine, which returns Ok(empty) for a valid empty envelope). A §2
|
|
135
|
+
// envelope with `functions: []`, or a legacy bare `[]`. Anything else empty is malformed → hard fail.
|
|
136
|
+
const isCleanEmptyReport = (parsed) =>
|
|
137
|
+
(parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.functions) && parsed.functions.length === 0)
|
|
138
|
+
|| (Array.isArray(parsed) && parsed.length === 0);
|
|
139
|
+
|
|
140
|
+
// Load ONE report file → { entries, hardFail }. A read/parse throw, or an empty result over a doc that
|
|
141
|
+
// is NOT a clean-empty report, is a hard fail (the file was found but carries no trustworthy functions —
|
|
142
|
+
// letting it read as [] would be the §4 false all-clear). Discloses every failure mode on stderr.
|
|
143
|
+
function loadOneReport(file, label) {
|
|
144
|
+
let parsed;
|
|
145
|
+
try { parsed = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
146
|
+
catch { console.error(`candor-ts: report ${label} failed to parse — its functions are OMITTED from this query (corrupt or mid-write); re-run the scan`); return { entries: [], hardFail: true }; }
|
|
147
|
+
const entries = normFns(parsed, label);
|
|
148
|
+
// normFns already DISCLOSED any malformation (no functions array / dropped entries). If nothing usable
|
|
149
|
+
// survived AND the doc wasn't a clean-empty report, the report is corrupt — fail loud, never empty.
|
|
150
|
+
if (entries.length === 0 && !isCleanEmptyReport(parsed)) {
|
|
151
|
+
console.error(`candor-ts: report ${label} yielded no usable functions — OMITTED (malformed report); re-run the scan`);
|
|
152
|
+
return { entries, hardFail: true };
|
|
153
|
+
}
|
|
154
|
+
return { entries, hardFail: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
89
157
|
export function loadReport(prefix) {
|
|
90
158
|
if (fs.existsSync(`${prefix}.json`)) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
try { return normFns(JSON.parse(fs.readFileSync(`${prefix}.json`, "utf8")), `${prefix}.json`); }
|
|
94
|
-
catch { console.error(`candor-ts: report ${prefix}.json failed to parse — OMITTED (corrupt or mid-write); re-run the scan`); return []; }
|
|
159
|
+
const { entries, hardFail } = loadOneReport(`${prefix}.json`, `${prefix}.json`);
|
|
160
|
+
return tagHardFail(entries, hardFail);
|
|
95
161
|
}
|
|
96
162
|
// No exact <prefix>.json — merge the multi-report siblings (the Rust/workspace form).
|
|
97
163
|
const fns = [];
|
|
164
|
+
let hardFail = false;
|
|
98
165
|
for (const f of siblings(prefix, isReport)) {
|
|
99
166
|
// DISCLOSE a malformed sibling — never silently drop it (a vanished report reads as "no effect").
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
167
|
+
const r = loadOneReport(f, f);
|
|
168
|
+
fns.push(...r.entries);
|
|
169
|
+
if (r.hardFail) hardFail = true;
|
|
170
|
+
}
|
|
171
|
+
return tagHardFail(fns, hardFail);
|
|
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; };
|
|
105
180
|
export function loadCallgraph(prefix) {
|
|
106
181
|
// A `null`/non-object parse (a `null` callgraph, an array, a number) must NOT reach Object.entries —
|
|
107
182
|
// it throws "Cannot convert null to object". Coerce anything but a plain object to {} (an empty
|
|
@@ -112,16 +187,18 @@ export function loadCallgraph(prefix) {
|
|
|
112
187
|
if (fs.existsSync(`${prefix}.callgraph.json`)) {
|
|
113
188
|
// The PRIMARY callgraph parse must DISCLOSE-and-tolerate like the sibling path below and like
|
|
114
189
|
// loadReport — a bare JSON.parse here threw an uncaught stack trace on the CLI for a corrupt or
|
|
115
|
-
// `null` `<prefix>.callgraph.json` (asymmetric with siblings). Tolerate (empty graph) + disclose
|
|
116
|
-
|
|
117
|
-
|
|
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); }
|
|
118
194
|
}
|
|
119
195
|
const cg = {};
|
|
196
|
+
let partial = false;
|
|
120
197
|
for (const f of siblings(prefix, (x) => x.endsWith(".callgraph.json"))) {
|
|
121
198
|
try { Object.assign(cg, JSON.parse(fs.readFileSync(f, "utf8"))); }
|
|
122
|
-
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; }
|
|
123
200
|
}
|
|
124
|
-
return norm(cg);
|
|
201
|
+
return tagPartial(norm(cg), partial);
|
|
125
202
|
}
|
|
126
203
|
|
|
127
204
|
// ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
|
|
@@ -455,10 +532,34 @@ export function diff(curFns, baseFns) {
|
|
|
455
532
|
// gains: the package-level SUPPLY-CHAIN alarm (spec §5.1) — the UNION of effects the surface gained
|
|
456
533
|
// between two reports (base → cur), with per-function detail. A dependency that grows a Net/Exec reach
|
|
457
534
|
// between releases. Same shape as candor-query's `gains --json`. Built on diff so it can't drift.
|
|
458
|
-
|
|
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";
|
|
459
560
|
const gained = new Set(), byFunction = [];
|
|
460
561
|
for (const c of diff(curFns, baseFns).changes) {
|
|
461
|
-
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) }); }
|
|
462
563
|
}
|
|
463
564
|
return { gained: [...gained].sort(), byFunction };
|
|
464
565
|
}
|
package/query.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
import { parsePolicy, scopeMatches, discoverConfigPolicy } from "./policy.mjs";
|
|
27
27
|
import { hasReport } from "./query-core.mjs";
|
|
28
28
|
import { printAgents } from "./contract.mjs";
|
|
29
|
+
import { bestFinds } from "./surface.mjs";
|
|
30
|
+
import { isTestPath } from "./scan-core.mjs";
|
|
29
31
|
// ONE source of truth for loading + name-matching — query.mjs kept DRIFTED local copies that didn't
|
|
30
32
|
// merge sibling reports, didn't tolerate a corrupt report (bare JSON.parse → uncaught crash), and used
|
|
31
33
|
// a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
|
|
@@ -36,14 +38,63 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
36
38
|
containment as coreContainment, diff as coreDiff,
|
|
37
39
|
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
38
40
|
fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
|
|
39
|
-
|
|
41
|
+
matches as coreMatches,
|
|
42
|
+
loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
|
|
40
43
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
41
44
|
|
|
45
|
+
// Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
|
|
46
|
+
// Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
|
|
47
|
+
// UNTOUCHED (conformance PART 5 pins `{effect, fn, path:[{fn,loc,source}]}` four-way): this path is
|
|
48
|
+
// only taken when the caller did NOT pass --json, and it reads the SAME `path` array corePath computes.
|
|
49
|
+
// Prints to stdout and returns nothing (matches the JSON-only verbs' fire-and-forget style).
|
|
50
|
+
function renderPathHuman(fns, cg, fnQ, eff) {
|
|
51
|
+
// Resolve the start over the REPORT entries (as Rust does) — that's where `inferred` lives, and the
|
|
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.
|
|
58
|
+
const start = coreMatches(fns.map((e) => e.fn), fnQ)[0];
|
|
59
|
+
if (start === undefined) {
|
|
60
|
+
// No matching function at all — parity with Rust/Java's "no function matching" (stderr, exit 2).
|
|
61
|
+
console.error(`candor-query path: no function matching '${fnQ}'`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
const startEntry = fns.find((e) => e.fn === start);
|
|
65
|
+
const inferred = startEntry?.inferred ?? [];
|
|
66
|
+
if (!inferred.includes(eff)) {
|
|
67
|
+
// The effect is not even inferred — the honest "does not perform" answer (SPEC §3.1), NOT an error.
|
|
68
|
+
// `inferred` is printed in Rust's `{:?}` debug shape: each name quoted, ", "-joined, in `[...]`,
|
|
69
|
+
// in the report's original order (unsorted). An empty set prints `[]`.
|
|
70
|
+
const dbg = `[${inferred.map((e) => `"${e}"`).join(", ")}]`;
|
|
71
|
+
console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const r = corePath(fns, cg, start, eff);
|
|
75
|
+
if (r.path.length === 0) {
|
|
76
|
+
// Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
|
|
77
|
+
console.log(`${start} performs ${eff} but its source is not a local function `
|
|
78
|
+
+ `(cross-crate, or via Unknown) — not statically traceable.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(`candor path — how \`${start}\` comes to perform ${eff}:\n`);
|
|
82
|
+
r.path.forEach((step, i) => {
|
|
83
|
+
const indent = " ".repeat(i + 1);
|
|
84
|
+
const arrow = i === 0 ? "" : "→ ";
|
|
85
|
+
const isSource = i === r.path.length - 1;
|
|
86
|
+
const tag = isSource
|
|
87
|
+
? ` [${eff} source${step.loc ? ` @ ${step.loc}` : ""}]`
|
|
88
|
+
: "";
|
|
89
|
+
console.log(`${indent}${arrow}${step.fn}${tag}`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
42
93
|
// ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
|
|
43
94
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
44
95
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
45
96
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
46
|
-
const SPEC_VERSION = "0.
|
|
97
|
+
const SPEC_VERSION = "0.12";
|
|
47
98
|
|
|
48
99
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
49
100
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -98,6 +149,21 @@ function requireReport(prefix) {
|
|
|
98
149
|
return prefix;
|
|
99
150
|
}
|
|
100
151
|
|
|
152
|
+
// Load a report, but FAIL LOUD (exit 2) when a file was found yet nothing parsed — the disclose-and-
|
|
153
|
+
// tolerate loadReport returns [] there, which every verb would read as "no effects": `tour` prints
|
|
154
|
+
// "nothing hidden", a policy `map`/gate PASSES — the §4 cardinal-sin false all-clear over a corrupt
|
|
155
|
+
// report. A legitimately effect-free crate still writes a report that LISTS its functions, so empty +
|
|
156
|
+
// hardFail is always the corrupt case (mirrors candor-rust load_entries_loud; java/swift already die
|
|
157
|
+
// loud). One corrupt file among several still merges (non-empty → returned), staying tolerant.
|
|
158
|
+
function loadReportOrDie(prefix) {
|
|
159
|
+
const fns = loadReport(prefix);
|
|
160
|
+
if (fns.length === 0 && fns.hardFail) {
|
|
161
|
+
console.error(`candor-ts: every report found at prefix '${prefix}' failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan.`);
|
|
162
|
+
process.exit(2);
|
|
163
|
+
}
|
|
164
|
+
return fns;
|
|
165
|
+
}
|
|
166
|
+
|
|
101
167
|
// Parse the canonical flags out of a verb's args, leaving the POSITIONAL verb-args behind. Handles the
|
|
102
168
|
// deprecated `0|1` trailing sentinel (→ noted, dropped; JSON is the default here anyway) so the old
|
|
103
169
|
// grammar stays green. `flags` names the boolean flags this verb honours (`strict`/`includeUnknown`);
|
|
@@ -226,6 +292,7 @@ const SUBCOMMANDS = [
|
|
|
226
292
|
["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
|
|
227
293
|
["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
|
|
228
294
|
["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
|
|
295
|
+
["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
|
|
229
296
|
["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
|
|
230
297
|
["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
|
|
231
298
|
["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
|
|
@@ -287,7 +354,7 @@ switch (cmd) {
|
|
|
287
354
|
// written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
|
|
288
355
|
// the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
|
|
289
356
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
290
|
-
emit(coreShow(
|
|
357
|
+
emit(coreShow(loadReportOrDie(prefix), q));
|
|
291
358
|
break;
|
|
292
359
|
}
|
|
293
360
|
case "where": {
|
|
@@ -295,7 +362,7 @@ switch (cmd) {
|
|
|
295
362
|
// Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
|
|
296
363
|
// fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
|
|
297
364
|
const { prefix, args: [eff] } = resolveReportVerb(args, 1);
|
|
298
|
-
emit(coreWhere(
|
|
365
|
+
emit(coreWhere(loadReportOrDie(prefix), eff));
|
|
299
366
|
break;
|
|
300
367
|
}
|
|
301
368
|
case "callers": {
|
|
@@ -304,14 +371,14 @@ switch (cmd) {
|
|
|
304
371
|
// shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
|
|
305
372
|
const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
|
|
306
373
|
const cg = loadCallgraph(prefix);
|
|
307
|
-
if (includeUnknown) emit(callersFrontier(cg,
|
|
374
|
+
if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
|
|
308
375
|
else emit(coreCallers(cg, q));
|
|
309
376
|
break;
|
|
310
377
|
}
|
|
311
378
|
case "map": {
|
|
312
379
|
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
313
380
|
const { prefix } = resolveReportVerb(args, 0);
|
|
314
|
-
emit(coreMap(
|
|
381
|
+
emit(coreMap(loadReportOrDie(prefix)));
|
|
315
382
|
break;
|
|
316
383
|
}
|
|
317
384
|
case "containment": {
|
|
@@ -335,16 +402,16 @@ switch (cmd) {
|
|
|
335
402
|
}
|
|
336
403
|
prefix = requireReport(prefix);
|
|
337
404
|
if (basePrefix) {
|
|
338
|
-
const baseFns =
|
|
405
|
+
const baseFns = loadReportOrDie(basePrefix);
|
|
339
406
|
if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
|
|
340
407
|
console.error(`candor-ts: no report at baseline prefix '${basePrefix}' — check the path`);
|
|
341
408
|
process.exit(2);
|
|
342
409
|
}
|
|
343
|
-
const r = coreContainment(
|
|
410
|
+
const r = coreContainment(loadReportOrDie(prefix), baseFns);
|
|
344
411
|
emit(r);
|
|
345
412
|
process.exit(r.leaks.length ? 1 : 0);
|
|
346
413
|
}
|
|
347
|
-
emit(coreContainment(
|
|
414
|
+
emit(coreContainment(loadReportOrDie(prefix)));
|
|
348
415
|
break;
|
|
349
416
|
}
|
|
350
417
|
case "diff": {
|
|
@@ -358,8 +425,14 @@ switch (cmd) {
|
|
|
358
425
|
// each resolved by the shared locator rule (dir / .json path / prefix). --json is accepted (JSON is
|
|
359
426
|
// the only output). No leading-positional-report alias here: both positionals ARE the reports.
|
|
360
427
|
const { positionals } = parseCanonical(args, {});
|
|
428
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query diff <current> <baseline> [--json]"); process.exit(2); }
|
|
361
429
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
362
|
-
|
|
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); }
|
|
435
|
+
const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
|
|
363
436
|
// §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
|
|
364
437
|
// may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
|
|
365
438
|
// provenance fields as the Rust candor-query (cross-engine parity, item 10).
|
|
@@ -379,7 +452,7 @@ switch (cmd) {
|
|
|
379
452
|
// what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
|
|
380
453
|
// shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
|
|
381
454
|
const { prefix } = resolveReportVerb(args, 0);
|
|
382
|
-
const fns =
|
|
455
|
+
const fns = loadReportOrDie(prefix);
|
|
383
456
|
const roots = fns.filter((e) => e.entryPoint);
|
|
384
457
|
const byEff = {};
|
|
385
458
|
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
@@ -392,14 +465,90 @@ switch (cmd) {
|
|
|
392
465
|
// blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
|
|
393
466
|
// MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
|
|
394
467
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
395
|
-
emit(coreImpact(
|
|
468
|
+
emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
|
|
396
469
|
break;
|
|
397
470
|
}
|
|
398
471
|
case "blindspots": {
|
|
399
472
|
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
400
473
|
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
401
474
|
const { prefix } = resolveReportVerb(args, 0);
|
|
402
|
-
emit(coreBlindspots(
|
|
475
|
+
emit(coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)));
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "tour": {
|
|
479
|
+
// The ON-DEMAND, top-N cold-repo opener (SURFACE-BEST-FIND-DESIGN.md, P2): the N most SURPRISING
|
|
480
|
+
// transitive reaches in an existing report — NO re-scan. Delegates to the SHARED surface.mjs
|
|
481
|
+
// bestFinds (the same heuristic the scan-time note uses, so the ranking can't drift), reading the
|
|
482
|
+
// report + callgraph sidecar the scan already wrote. Port of candor-rust's candor-query tour verb —
|
|
483
|
+
// human + --json output byte-identical (a conformance PART pins it four-way).
|
|
484
|
+
// §3.3.1: `tour [<N>]`, report discovered / --report; the lone OPTIONAL positional is N (default 10).
|
|
485
|
+
// Unlike the JSON-only verbs, tour has BOTH a human default AND a --json form (like the Rust engine),
|
|
486
|
+
// so detect --json explicitly (parseCanonical otherwise silently swallows it).
|
|
487
|
+
const wantJson = args.includes("--json");
|
|
488
|
+
const { prefix, args: tourArgs } = resolveReportVerb(args, 1);
|
|
489
|
+
let n = 10;
|
|
490
|
+
if (tourArgs.length) {
|
|
491
|
+
// N MUST be a positive integer ≥ 1 that fits a safe integer — like the Rust engine, which rejects
|
|
492
|
+
// `tour 0` and a non-usize. `tour 0` printing "nothing hidden" over an effectful crate would be a
|
|
493
|
+
// false all-clear (the §4 cardinal sin), so a non-integer, zero, or out-of-range value → exit 2.
|
|
494
|
+
const parsed = /^\d+$/.test(tourArgs[0]) ? Number(tourArgs[0]) : NaN;
|
|
495
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
496
|
+
console.error("usage: candor-ts-query tour [<N>] [--report <locator>] [--json] (N is a positive integer ≥ 1)");
|
|
497
|
+
process.exit(2);
|
|
498
|
+
}
|
|
499
|
+
n = parsed;
|
|
500
|
+
}
|
|
501
|
+
const fns = loadReportOrDie(prefix);
|
|
502
|
+
const cg = loadCallgraph(prefix);
|
|
503
|
+
// Build the maps the heuristic wants from the report entries + the callgraph sidecar. `inferred`/
|
|
504
|
+
// `direct` come from the report; `loc` maps a function to its "file:line" for the source callout.
|
|
505
|
+
const inferred = new Map(), direct = new Map(), loc = new Map(), calls = new Map();
|
|
506
|
+
for (const e of fns) {
|
|
507
|
+
inferred.set(e.fn, new Set(e.inferred));
|
|
508
|
+
if (e.direct.length) direct.set(e.fn, new Set(e.direct));
|
|
509
|
+
if (e.loc) loc.set(e.fn, e.loc);
|
|
510
|
+
}
|
|
511
|
+
// `calls` prefers the FULL callgraph sidecar (every edge — the graph the scan held in memory). When
|
|
512
|
+
// the sidecar is absent/empty, FALL BACK to each entry's inline `.calls` (mirrors tour.rs:66-77:
|
|
513
|
+
// `if cg.is_empty() { use entry.calls } else { use cg }`). Without this fallback a report whose
|
|
514
|
+
// sidecar was deleted/never-written yields an empty graph, nearestSource finds nothing, and tour
|
|
515
|
+
// prints a FALSE "nothing hidden" at exit 0 — a silent under-report (the §4 cardinal sin). A corrupt
|
|
516
|
+
// sidecar is already disclosed on stderr by loadCallgraph, which then returns {} → we fall back here.
|
|
517
|
+
if (Object.keys(cg).length === 0) {
|
|
518
|
+
for (const e of fns) if (e.calls.length) calls.set(e.fn, e.calls);
|
|
519
|
+
} else {
|
|
520
|
+
for (const [k, v] of Object.entries(cg)) calls.set(k, v);
|
|
521
|
+
}
|
|
522
|
+
// Exclude test scaffolding — a qual is test code iff its recorded loc lies on a test path, the SAME
|
|
523
|
+
// isTestPath predicate the scan-note passes (scan.mjs's isTestQual). Without it `tour` surfaces test
|
|
524
|
+
// functions the scan-note (and every other engine) hides — an inconsistent, noisier reach list.
|
|
525
|
+
const isTestQual = (q) => { const l = loc.get(q); return l ? isTestPath(l) : false; };
|
|
526
|
+
const finds = bestFinds(inferred, direct, calls, loc, n, isTestQual);
|
|
527
|
+
// The header names the report's §2 envelope `package` — meaningful and locator-independent, so every
|
|
528
|
+
// engine and every --report form print the SAME crate. Falls back to the prefix basename.
|
|
529
|
+
const crateName = reportPackage(prefix) ?? path.basename(prefix);
|
|
530
|
+
if (wantJson) {
|
|
531
|
+
// Pure JSON to STDOUT: {"reaches":[{effect,fn,hops,loc,score,source}, …]} — ALPHABETICAL keys, the
|
|
532
|
+
// same order Rust+Swift emit (loc is the SOURCE's file:line, "" when absent).
|
|
533
|
+
const out = { reaches: finds.map((f) => ({
|
|
534
|
+
effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
|
|
535
|
+
})) };
|
|
536
|
+
console.log(JSON.stringify(out));
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (finds.length === 0) {
|
|
540
|
+
// Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
|
|
541
|
+
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
|
|
542
|
+
console.log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
|
|
546
|
+
finds.forEach((f, i) => {
|
|
547
|
+
const hopWord = f.hops === 1 ? "hop" : "hops";
|
|
548
|
+
const whereS = f.sourceLoc ? ` (${f.sourceLoc})` : "";
|
|
549
|
+
console.log(` ${i + 1}. \`${f.func}\` performs ${f.effect}, ${f.hops} ${hopWord} away via \`${f.source}\`${whereS}`);
|
|
550
|
+
console.log(` → candor path ${f.func} ${f.effect}`);
|
|
551
|
+
});
|
|
403
552
|
break;
|
|
404
553
|
}
|
|
405
554
|
case "gains": {
|
|
@@ -408,16 +557,39 @@ switch (cmd) {
|
|
|
408
557
|
// §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
|
|
409
558
|
// the shared locator rule; --json accepted.
|
|
410
559
|
const { positionals } = parseCanonical(args, {});
|
|
560
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json]"); process.exit(2); }
|
|
411
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); }
|
|
412
567
|
const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
|
|
413
568
|
if (gv && gbv && gv !== gbv)
|
|
414
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.`);
|
|
415
|
-
|
|
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)) });
|
|
416
575
|
break;
|
|
417
576
|
}
|
|
418
577
|
case "path": {
|
|
578
|
+
// BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
|
|
579
|
+
// `candor path <fn> <effect>`, so the DEFAULT is the readable indented chain; --json selects the
|
|
580
|
+
// pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
|
|
581
|
+
const wantJson = args.includes("--json");
|
|
419
582
|
const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
|
|
420
|
-
|
|
583
|
+
const fns = loadReportOrDie(prefix);
|
|
584
|
+
const cg = loadCallgraph(prefix);
|
|
585
|
+
if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
|
|
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
|
+
}
|
|
421
593
|
break;
|
|
422
594
|
}
|
|
423
595
|
case "whatif": {
|
|
@@ -466,7 +638,7 @@ switch (cmd) {
|
|
|
466
638
|
// The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
|
|
467
639
|
// it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
|
|
468
640
|
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); }
|
|
469
|
-
const r = coreFix(cg,
|
|
641
|
+
const r = coreFix(cg, loadReportOrDie(prefix), target, eff, parsePolicy(ptext), scopeMatches);
|
|
470
642
|
if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
|
|
471
643
|
emit(r);
|
|
472
644
|
break;
|
|
@@ -482,7 +654,7 @@ switch (cmd) {
|
|
|
482
654
|
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
483
655
|
const cg = loadCallgraph(prefix);
|
|
484
656
|
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); }
|
|
485
|
-
emit(coreFixGate(cg,
|
|
657
|
+
emit(coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches));
|
|
486
658
|
break;
|
|
487
659
|
}
|
|
488
660
|
case "unverified": {
|
|
@@ -495,7 +667,7 @@ switch (cmd) {
|
|
|
495
667
|
let ptext;
|
|
496
668
|
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
497
669
|
catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
|
|
498
|
-
const r = coreUnverified(
|
|
670
|
+
const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
|
|
499
671
|
emit(r);
|
|
500
672
|
process.exit(strict && !r.ok ? 1 : 0);
|
|
501
673
|
break; // unreachable
|
package/scan.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { parsePolicy, evaluatePolicy, scopeMatches } from "./policy.mjs";
|
|
|
30
30
|
import { unverifiedHoleRule, ruleUpgrade } from "./query-core.mjs";
|
|
31
31
|
import { printAgents } from "./contract.mjs";
|
|
32
32
|
import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql } from "./scan-core.mjs";
|
|
33
|
+
import { emitSurface } from "./surface.mjs";
|
|
33
34
|
|
|
34
35
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
35
36
|
|
|
@@ -39,7 +40,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
39
40
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
40
41
|
// Reused, never re-littered.
|
|
41
42
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
42
|
-
const SPEC_VERSION = "0.
|
|
43
|
+
const SPEC_VERSION = "0.12";
|
|
43
44
|
|
|
44
45
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
45
46
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -1733,7 +1734,7 @@ function visitCalls(node) {
|
|
|
1733
1734
|
}
|
|
1734
1735
|
// unmatched external = (OPAQUE): contributes nothing — the curated-κ caveat C1. The
|
|
1735
1736
|
// κ-coverage LEDGER makes the caveat per-scan evidence instead of a doc footnote: count
|
|
1736
|
-
// every npm package the code demonstrably calls that
|
|
1737
|
+
// every npm package the code demonstrably calls that the classifier doesn't cover ("classifier doesn't cover" marker) and no sibling
|
|
1737
1738
|
// report covers (the argon2 lesson — the blind spot landed on exactly the call a
|
|
1738
1739
|
// security review cared about). Builtins are excluded: κ's builtin coverage is the
|
|
1739
1740
|
// bounded frontier, and an unlisted builtin (path, util) is known-pure, not blind.
|
|
@@ -2111,6 +2112,11 @@ for (const [name, rec] of fns) {
|
|
|
2111
2112
|
overdeclared: [],
|
|
2112
2113
|
unresolved: inf.includes("Unknown"),
|
|
2113
2114
|
};
|
|
2115
|
+
// Inline call edges (§2 `calls`) — the SAME edges the callgraph sidecar carries, embedded per entry so a
|
|
2116
|
+
// consumer without the sidecar (deleted, never-written, an old workspace) can still reconstruct the graph.
|
|
2117
|
+
// `tour` falls back to these when the sidecar is empty (surface robustness — mirrors the Rust report, whose
|
|
2118
|
+
// entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
|
|
2119
|
+
if (rec.edges.size) entry.calls = [...rec.edges].sort();
|
|
2114
2120
|
if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
|
|
2115
2121
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
2116
2122
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
@@ -2187,8 +2193,29 @@ if (unlistedSeen.size > 0) {
|
|
|
2187
2193
|
const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
2188
2194
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|
|
2189
2195
|
const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
|
|
2190
|
-
console.error(`candor-ts:
|
|
2191
|
-
+ `effects
|
|
2196
|
+
console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
|
|
2197
|
+
+ `their effects are INVISIBLE to the scan (absent from the report, NOT a claim they're pure): ${shown}${more}`);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// ---- the cold-repo hook: surface the single most SURPRISING transitive reach (surface.mjs) ---------
|
|
2201
|
+
// One extra stderr line after the coverage ledger — the most benign-named function reaching a scary
|
|
2202
|
+
// effect a few hops away + a ready-to-run `candor path`. Deterministic; honest "nothing hidden"
|
|
2203
|
+
// fallback. Ported EXACTLY from candor-rust's surface.rs so every engine surfaces the SAME reach on a
|
|
2204
|
+
// shared fixture. Prefix is `candor:` (brand voice) and the command is `candor path …` — identical on
|
|
2205
|
+
// every engine. STDERR only, so the --json report on stdout stays clean.
|
|
2206
|
+
if (!wantJson) {
|
|
2207
|
+
const directMap = new Map();
|
|
2208
|
+
const callsMap = new Map();
|
|
2209
|
+
const locMap = new Map();
|
|
2210
|
+
for (const [name, rec] of fns) {
|
|
2211
|
+
directMap.set(name, rec.direct);
|
|
2212
|
+
callsMap.set(name, rec.edges);
|
|
2213
|
+
if (rec.loc) locMap.set(name, rec.loc);
|
|
2214
|
+
}
|
|
2215
|
+
// A qual is test code iff its recorded loc (file:line[:col]) lies on a test path — the same predicate
|
|
2216
|
+
// the scan already uses to keep test files out of the report.
|
|
2217
|
+
const isTestQual = (q) => { const l = locMap.get(q); return l ? isTestPath(l) : false; };
|
|
2218
|
+
emitSurface(inferred, directMap, callsMap, locMap, isTestQual);
|
|
2192
2219
|
}
|
|
2193
2220
|
|
|
2194
2221
|
// ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
|
package/surface.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Surface the single most SURPRISING transitive reach (the cold-repo hook).
|
|
2
|
+
//
|
|
3
|
+
// After the effect summary + coverage ledger, candor-ts emits ONE more stderr line: the most surprising
|
|
4
|
+
// transitive reach in the project + a ready-to-run `candor path` command. Port of candor-rust's
|
|
5
|
+
// crates/candor-scan/src/surface.rs — same behavior, idiomatic JS. See SURFACE-BEST-FIND-DESIGN.md.
|
|
6
|
+
//
|
|
7
|
+
// Fully deterministic — pure call-graph + name analysis, NO LLM. A CANDIDATE is a function `F` that
|
|
8
|
+
// INHERITS an effect `E` (E ∈ inferred[F] but E ∉ direct[F]); we BFS to the nearest local direct SOURCE
|
|
9
|
+
// `S` and score by how surprising the reach is (a benign-named function reaching a scary effect). The
|
|
10
|
+
// find is never *wrong*: `candor path` re-derives the chain and the gate is ground truth. When nothing
|
|
11
|
+
// clears the bar we emit an honest "nothing hidden" fallback — never a manufactured surprise.
|
|
12
|
+
|
|
13
|
+
// Name tokens that read as local / pure / config — a function whose leaf is named like this reaching a
|
|
14
|
+
// scary effect is the core surprise signal. Copied verbatim from surface.rs BENIGN.
|
|
15
|
+
const BENIGN = new Set([
|
|
16
|
+
"settings", "config", "conf", "options", "opts", "util", "utils", "helper", "helpers", "model",
|
|
17
|
+
"models", "dto", "entity", "format", "fmt", "parse", "get", "load", "new", "default", "validate",
|
|
18
|
+
"valid", "render", "view", "build", "builder", "item", "entry", "record", "state", "context",
|
|
19
|
+
"ctx", "info", "meta", "data", "value", "node", "field", "name", "key", "id", "path", "kind",
|
|
20
|
+
"type", "status", "check", "init", "setup",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// Name tokens that are effect-suggestive — a function in/near an effect-flavored context reaching that
|
|
24
|
+
// effect is EXPECTED, not surprising, so we EXCLUDE it. Copied verbatim from surface.rs EFFECTY.
|
|
25
|
+
const EFFECTY = new Set([
|
|
26
|
+
"fetch", "http", "https", "client", "api", "sync", "request", "req", "download", "upload", "query",
|
|
27
|
+
"sql", "store", "save", "persist", "connect", "conn", "socket", "send", "recv", "read", "write",
|
|
28
|
+
"open", "file", "fs", "io", "net", "tcp", "udp", "dns", "url", "host", "port", "cmd", "command",
|
|
29
|
+
"shell", "process", "proc", "exec", "spawn", "env", "clock", "time", "now", "rand", "random",
|
|
30
|
+
"log", "logger", "trace", "db",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// The qualified-name separator. Rust uses `::`; candor-ts quals are `mod.Class.member`.
|
|
34
|
+
const SEP = ".";
|
|
35
|
+
|
|
36
|
+
// Split a qualified name (or a leaf) into lowercase tokens on the separator, `_`, and camelCase
|
|
37
|
+
// boundaries. Mirrors surface.rs::tokenize (which splits on `_`, `:` and camelCase).
|
|
38
|
+
export function tokenize(name) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let cur = "";
|
|
41
|
+
let prevLower = false;
|
|
42
|
+
for (const ch of name) {
|
|
43
|
+
if (ch === "_" || ch === "." || ch === ":") {
|
|
44
|
+
if (cur) { out.push(cur); cur = ""; }
|
|
45
|
+
prevLower = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Unicode-aware uppercase (matches surface.rs's `ch.is_uppercase()`): a letter that differs from
|
|
49
|
+
// its lowercase form and equals its uppercase form. ASCII-only for the digit check (surface.rs uses
|
|
50
|
+
// `is_ascii_digit`), so a non-ASCII uppercase letter STILL starts a new token.
|
|
51
|
+
const lower = ch.toLowerCase();
|
|
52
|
+
const isUpper = ch !== lower && ch === ch.toUpperCase();
|
|
53
|
+
const isLower = ch !== ch.toUpperCase() && ch === lower;
|
|
54
|
+
// camelCase boundary: a lower/digit followed by an upper starts a new token.
|
|
55
|
+
if (isUpper && prevLower && cur) { out.push(cur); cur = ""; }
|
|
56
|
+
cur += lower;
|
|
57
|
+
prevLower = isLower || (ch >= "0" && ch <= "9");
|
|
58
|
+
}
|
|
59
|
+
if (cur) out.push(cur);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The leaf (final segment) of a qualified name.
|
|
64
|
+
function leaf(qual) {
|
|
65
|
+
const i = qual.lastIndexOf(SEP);
|
|
66
|
+
return i < 0 ? qual : qual.slice(i + SEP.length);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The module portion of a qualified name (everything before the leaf).
|
|
70
|
+
function moduleOf(qual) {
|
|
71
|
+
const i = qual.lastIndexOf(SEP);
|
|
72
|
+
return i < 0 ? "" : qual.slice(0, i);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The first token of `name` that appears in `lexicon`, or null.
|
|
76
|
+
function hasToken(name, lexicon) {
|
|
77
|
+
for (const t of tokenize(name)) if (lexicon.has(t)) return t;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Salience of an effect — the boundary/security-relevant effects a reviewer cares about score higher.
|
|
82
|
+
// Clock/Log/Rand are DELIBERATELY 0 (not surfaced): a mundane clock/log reach isn't "the most
|
|
83
|
+
// surprising reach", and a repo whose only reaches are mundane should honestly say "nothing hidden".
|
|
84
|
+
// Matches the Rust reference (candor-classify/src/surface.rs) + the java/swift ports.
|
|
85
|
+
function salience(effect) {
|
|
86
|
+
switch (effect) {
|
|
87
|
+
case "Net": case "Exec": case "Db": case "Ipc": return 5;
|
|
88
|
+
case "Fs": case "Env": return 3;
|
|
89
|
+
default: return 0; // Clock/Log/Rand/Unknown/everything-else — mundane, never surfaced
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hopsFactor(hops) {
|
|
94
|
+
if (hops === 1) return 2;
|
|
95
|
+
if (hops >= 2 && hops <= 4) return 3;
|
|
96
|
+
if (hops >= 5 && hops <= 6) return 2;
|
|
97
|
+
return 1; // ≥7 (hops is always ≥1 for an inherited reach)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// BFS from `func` over `calls` (follow callees, shortest hops) to the nearest function `S` with
|
|
101
|
+
// `effect` ∈ direct[S]. Returns { hops≥1, source } or null. Only traverses through callees that
|
|
102
|
+
// transitively carry the effect, so the frontier stays on-effect (matches `candor path`'s walk).
|
|
103
|
+
function nearestSource(func, effect, direct, inferred, calls) {
|
|
104
|
+
const seen = new Set([func]);
|
|
105
|
+
const q = [[func, 0]];
|
|
106
|
+
let head = 0;
|
|
107
|
+
while (head < q.length) {
|
|
108
|
+
const [cur, d] = q[head++];
|
|
109
|
+
// A direct source found at distance d≥1 is the nearest (BFS). The start `func` itself is an
|
|
110
|
+
// INHERITED reach (E ∉ direct[func]) so it never matches at d==0.
|
|
111
|
+
if (d >= 1 && direct.get(cur)?.has(effect)) return { hops: d, source: cur };
|
|
112
|
+
const cs = calls.get(cur);
|
|
113
|
+
if (cs) {
|
|
114
|
+
// Iterate callees in SORTED order — surface.rs/Java/Swift walk a BTreeSet<String> (sorted), so at
|
|
115
|
+
// an equal-distance tie the SAME source/score/`candor path` is chosen on every engine. Raw Map/JSON
|
|
116
|
+
// insertion order here would let a tie resolve differently (non-determinism vs the reference).
|
|
117
|
+
for (const c of [...cs].sort()) {
|
|
118
|
+
if (!seen.has(c) && inferred.get(c)?.has(effect)) {
|
|
119
|
+
seen.add(c);
|
|
120
|
+
q.push([c, d + 1]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Collect EVERY scored candidate reach (unranked), plus whether the project is effectful at all. The
|
|
129
|
+
// single source of the candidate pool for both bestFind (top-1) and bestFinds (top-N) — one heuristic,
|
|
130
|
+
// no drift. `loc` is a Map<qual, "file:line"> for the source callout ("" when absent). Returns
|
|
131
|
+
// { cands: <Find[]>, anyEffectful }.
|
|
132
|
+
function collectCandidates(inferred, direct, calls, loc, isTest) {
|
|
133
|
+
// Any function carrying a real (non-Unknown) effect makes the project "effectful" — governs
|
|
134
|
+
// whether the caller emits the fallback vs nothing.
|
|
135
|
+
let anyEffectful = false;
|
|
136
|
+
|
|
137
|
+
// Deterministic iteration: sort quals ascending so the tie-break (qual ascending) is stable and
|
|
138
|
+
// Map insertion order never leaks into the result.
|
|
139
|
+
const quals = [...inferred.keys()].sort();
|
|
140
|
+
|
|
141
|
+
const cands = [];
|
|
142
|
+
|
|
143
|
+
for (const f of quals) {
|
|
144
|
+
const inf = inferred.get(f);
|
|
145
|
+
for (const e of inf) if (e !== "Unknown") { anyEffectful = true; break; }
|
|
146
|
+
if (isTest(f)) continue;
|
|
147
|
+
const fLeaf = leaf(f);
|
|
148
|
+
const fMod = moduleOf(f);
|
|
149
|
+
// EXCLUDE the whole function if its leaf OR module reads effecty — its reach is obvious.
|
|
150
|
+
if (hasToken(fLeaf, EFFECTY) || hasToken(fMod, EFFECTY)) continue;
|
|
151
|
+
const dir = direct.get(f) ?? new Set();
|
|
152
|
+
// Candidate effects: inherited (in inferred, not direct), not Unknown; sorted ascending.
|
|
153
|
+
const effects = [...inf].filter((e) => e !== "Unknown" && !dir.has(e)).sort();
|
|
154
|
+
for (const e of effects) {
|
|
155
|
+
const sal = salience(e);
|
|
156
|
+
if (sal === 0) continue;
|
|
157
|
+
const ns = nearestSource(f, e, direct, inferred, calls);
|
|
158
|
+
if (!ns) continue; // no LOCAL direct source — nothing to show
|
|
159
|
+
const benign = hasToken(fLeaf, BENIGN);
|
|
160
|
+
const benignity = benign ? 3 : 1;
|
|
161
|
+
const crossing = moduleOf(ns.source) !== fMod ? 2 : 1;
|
|
162
|
+
const score = sal * benignity * hopsFactor(ns.hops) * crossing;
|
|
163
|
+
if (score === 0) continue;
|
|
164
|
+
cands.push({
|
|
165
|
+
func: f, effect: e, hops: ns.hops, source: ns.source,
|
|
166
|
+
sourceLoc: loc?.get(ns.source) ?? "", benignToken: benign ?? "", score,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { cands, anyEffectful };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Compute the top-`n` most surprising reaches, most-surprising first. DEDUPED by function — each
|
|
174
|
+
// function appears at most once (its single highest-scoring reach). The list is empty when nothing
|
|
175
|
+
// clears the bar. Each Find carries { func, effect, hops, source, sourceLoc, benignToken, score }.
|
|
176
|
+
//
|
|
177
|
+
// Ranking (the tie-break, applied to the whole candidate pool before the per-function dedup + take):
|
|
178
|
+
// score DESC → hops ASC → qualified name ASC. With `n === 1` the result is BYTE-IDENTICAL to the old
|
|
179
|
+
// bestFind's winner — the shared candidate pool + this same tie-break, one implementation. Port of
|
|
180
|
+
// surface.rs::best_finds. `loc` is a Map<qual, "file:line"> for the source callout (optional).
|
|
181
|
+
export function bestFinds(inferred, direct, calls, loc, n, isTest = () => false) {
|
|
182
|
+
const { cands } = collectCandidates(inferred, direct, calls, loc, isTest);
|
|
183
|
+
// Rank the whole pool: score DESC, hops ASC, qual ASC. Quals were iterated ascending and effects
|
|
184
|
+
// ascending, so on a full tie the first-pushed (smallest qual) candidate sorts first — matching the
|
|
185
|
+
// old bestFind's "keep the earliest winner on an exact tie" (a stable sort preserves push order).
|
|
186
|
+
cands.sort((a, b) => (b.score - a.score) || (a.hops - b.hops) || (a.func < b.func ? -1 : a.func > b.func ? 1 : 0));
|
|
187
|
+
// DEDUP by function — each appears at most once (its highest-scoring reach, first in ranked order).
|
|
188
|
+
// Then take up to `n` distinct functions.
|
|
189
|
+
const seenFns = new Set();
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const c of cands) {
|
|
192
|
+
if (out.length >= n) break;
|
|
193
|
+
if (!seenFns.has(c.func)) { seenFns.add(c.func); out.push(c); }
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Compute the single most surprising reach (the scan-time note).
|
|
199
|
+
// · returns null — ZERO effectful functions (caller emits nothing)
|
|
200
|
+
// · returns { winner: null } — effectful, but none cleared the bar (honest fallback)
|
|
201
|
+
// · returns { winner: <Find> } — the winning reach
|
|
202
|
+
//
|
|
203
|
+
// `inferred`/`direct` are Map<qual, Set<effect>>; `calls` is Map<qual, Iterable<qual>>; `isTest` is an
|
|
204
|
+
// optional (qual) => bool predicate (defaults to false — the caller supplies path-based test detection).
|
|
205
|
+
// ONE implementation with bestFinds — the winner is exactly bestFinds(…, 1)[0] (the scan-note output
|
|
206
|
+
// stays byte-identical, verified by the surface tests + conformance).
|
|
207
|
+
export function bestFind(inferred, direct, calls, isTest = () => false) {
|
|
208
|
+
const { anyEffectful } = collectCandidates(inferred, direct, calls, undefined, isTest);
|
|
209
|
+
if (!anyEffectful) return null;
|
|
210
|
+
const top = bestFinds(inferred, direct, calls, undefined, 1, isTest);
|
|
211
|
+
return { winner: top.length ? top[0] : null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Emit the surface note to STDERR. `loc` is a Map<qual, "file:line"> for the source callout; `log` is
|
|
215
|
+
// the sink (defaults to console.error). Mirrors surface.rs::emit exactly.
|
|
216
|
+
export function emitSurface(inferred, direct, calls, loc, isTest = () => false, log = console.error) {
|
|
217
|
+
const res = bestFind(inferred, direct, calls, isTest);
|
|
218
|
+
if (res === null) return; // zero effectful functions — emit nothing
|
|
219
|
+
if (res.winner === null) {
|
|
220
|
+
log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const f = res.winner;
|
|
224
|
+
const whereS = loc.get(f.source) ?? "?";
|
|
225
|
+
const hopWord = f.hops === 1 ? "hop" : "hops";
|
|
226
|
+
const benignNote = f.benignToken
|
|
227
|
+
? ` a "${f.benignToken}"-named function reaching ${f.effect}.\n`
|
|
228
|
+
: "";
|
|
229
|
+
log(
|
|
230
|
+
`candor: most surprising reach — \`${f.func}\` performs ${f.effect}, ${f.hops} ${hopWord} away via `
|
|
231
|
+
+ `\`${f.source}\` (${whereS}).\n${benignNote} → candor path ${f.func} ${f.effect}`,
|
|
232
|
+
);
|
|
233
|
+
}
|