candor-ts 0.10.0 → 0.11.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 +9 -8
- package/README.md +10 -9
- package/package.json +3 -2
- package/query-core.mjs +75 -7
- package/query.mjs +164 -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.11)."*
|
|
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
|
>
|
|
@@ -93,6 +93,7 @@ Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affe
|
|
|
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.11" }, 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.11.x, speaking candor-spec 0.11: 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/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.11.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.11)",
|
|
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,21 +86,89 @@ 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
|
-
|
|
167
|
+
const r = loadOneReport(f, f);
|
|
168
|
+
fns.push(...r.entries);
|
|
169
|
+
if (r.hardFail) hardFail = true;
|
|
102
170
|
}
|
|
103
|
-
return fns;
|
|
171
|
+
return tagHardFail(fns, hardFail);
|
|
104
172
|
}
|
|
105
173
|
export function loadCallgraph(prefix) {
|
|
106
174
|
// A `null`/non-object parse (a `null` callgraph, an array, a number) must NOT reach Object.entries —
|
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,59 @@ 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. corePath resolves over the callgraph keys for the chain; the two agree
|
|
53
|
+
// on any fn that has an entry, which every graphed fn does.
|
|
54
|
+
const start = coreMatches(fns.map((e) => e.fn), fnQ)[0];
|
|
55
|
+
if (start === undefined) {
|
|
56
|
+
// No matching function at all — parity with Rust/Java's "no function matching" (stderr, exit 2).
|
|
57
|
+
console.error(`candor-query path: no function matching '${fnQ}'`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
const startEntry = fns.find((e) => e.fn === start);
|
|
61
|
+
const inferred = startEntry?.inferred ?? [];
|
|
62
|
+
if (!inferred.includes(eff)) {
|
|
63
|
+
// The effect is not even inferred — the honest "does not perform" answer (SPEC §3.1), NOT an error.
|
|
64
|
+
// `inferred` is printed in Rust's `{:?}` debug shape: each name quoted, ", "-joined, in `[...]`,
|
|
65
|
+
// in the report's original order (unsorted). An empty set prints `[]`.
|
|
66
|
+
const dbg = `[${inferred.map((e) => `"${e}"`).join(", ")}]`;
|
|
67
|
+
console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const r = corePath(fns, cg, fnQ, eff);
|
|
71
|
+
if (r.path.length === 0) {
|
|
72
|
+
// Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
|
|
73
|
+
console.log(`${start} performs ${eff} but its source is not a local function `
|
|
74
|
+
+ `(cross-crate, or via Unknown) — not statically traceable.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(`candor path — how \`${start}\` comes to perform ${eff}:\n`);
|
|
78
|
+
r.path.forEach((step, i) => {
|
|
79
|
+
const indent = " ".repeat(i + 1);
|
|
80
|
+
const arrow = i === 0 ? "" : "→ ";
|
|
81
|
+
const isSource = i === r.path.length - 1;
|
|
82
|
+
const tag = isSource
|
|
83
|
+
? ` [${eff} source${step.loc ? ` @ ${step.loc}` : ""}]`
|
|
84
|
+
: "";
|
|
85
|
+
console.log(`${indent}${arrow}${step.fn}${tag}`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
42
89
|
// ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
|
|
43
90
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
44
91
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
45
92
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
46
|
-
const SPEC_VERSION = "0.
|
|
93
|
+
const SPEC_VERSION = "0.11";
|
|
47
94
|
|
|
48
95
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
49
96
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -98,6 +145,21 @@ function requireReport(prefix) {
|
|
|
98
145
|
return prefix;
|
|
99
146
|
}
|
|
100
147
|
|
|
148
|
+
// Load a report, but FAIL LOUD (exit 2) when a file was found yet nothing parsed — the disclose-and-
|
|
149
|
+
// tolerate loadReport returns [] there, which every verb would read as "no effects": `tour` prints
|
|
150
|
+
// "nothing hidden", a policy `map`/gate PASSES — the §4 cardinal-sin false all-clear over a corrupt
|
|
151
|
+
// report. A legitimately effect-free crate still writes a report that LISTS its functions, so empty +
|
|
152
|
+
// hardFail is always the corrupt case (mirrors candor-rust load_entries_loud; java/swift already die
|
|
153
|
+
// loud). One corrupt file among several still merges (non-empty → returned), staying tolerant.
|
|
154
|
+
function loadReportOrDie(prefix) {
|
|
155
|
+
const fns = loadReport(prefix);
|
|
156
|
+
if (fns.length === 0 && fns.hardFail) {
|
|
157
|
+
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.`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
return fns;
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
// Parse the canonical flags out of a verb's args, leaving the POSITIONAL verb-args behind. Handles the
|
|
102
164
|
// deprecated `0|1` trailing sentinel (→ noted, dropped; JSON is the default here anyway) so the old
|
|
103
165
|
// grammar stays green. `flags` names the boolean flags this verb honours (`strict`/`includeUnknown`);
|
|
@@ -226,6 +288,7 @@ const SUBCOMMANDS = [
|
|
|
226
288
|
["reachable", REPORT_TAIL, "effects unioned over the entry points: what the app DOES at runtime"],
|
|
227
289
|
["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
|
|
228
290
|
["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
|
|
291
|
+
["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
|
|
229
292
|
["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
|
|
230
293
|
["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
|
|
231
294
|
["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 +350,7 @@ switch (cmd) {
|
|
|
287
350
|
// written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
|
|
288
351
|
// the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
|
|
289
352
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
290
|
-
emit(coreShow(
|
|
353
|
+
emit(coreShow(loadReportOrDie(prefix), q));
|
|
291
354
|
break;
|
|
292
355
|
}
|
|
293
356
|
case "where": {
|
|
@@ -295,7 +358,7 @@ switch (cmd) {
|
|
|
295
358
|
// Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
|
|
296
359
|
// fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
|
|
297
360
|
const { prefix, args: [eff] } = resolveReportVerb(args, 1);
|
|
298
|
-
emit(coreWhere(
|
|
361
|
+
emit(coreWhere(loadReportOrDie(prefix), eff));
|
|
299
362
|
break;
|
|
300
363
|
}
|
|
301
364
|
case "callers": {
|
|
@@ -304,14 +367,14 @@ switch (cmd) {
|
|
|
304
367
|
// shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
|
|
305
368
|
const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
|
|
306
369
|
const cg = loadCallgraph(prefix);
|
|
307
|
-
if (includeUnknown) emit(callersFrontier(cg,
|
|
370
|
+
if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
|
|
308
371
|
else emit(coreCallers(cg, q));
|
|
309
372
|
break;
|
|
310
373
|
}
|
|
311
374
|
case "map": {
|
|
312
375
|
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
313
376
|
const { prefix } = resolveReportVerb(args, 0);
|
|
314
|
-
emit(coreMap(
|
|
377
|
+
emit(coreMap(loadReportOrDie(prefix)));
|
|
315
378
|
break;
|
|
316
379
|
}
|
|
317
380
|
case "containment": {
|
|
@@ -335,16 +398,16 @@ switch (cmd) {
|
|
|
335
398
|
}
|
|
336
399
|
prefix = requireReport(prefix);
|
|
337
400
|
if (basePrefix) {
|
|
338
|
-
const baseFns =
|
|
401
|
+
const baseFns = loadReportOrDie(basePrefix);
|
|
339
402
|
if (baseFns.length === 0) { // fail CLOSED (exit 2), not a wall of bogus "everything leaked" (exit 1)
|
|
340
403
|
console.error(`candor-ts: no report at baseline prefix '${basePrefix}' — check the path`);
|
|
341
404
|
process.exit(2);
|
|
342
405
|
}
|
|
343
|
-
const r = coreContainment(
|
|
406
|
+
const r = coreContainment(loadReportOrDie(prefix), baseFns);
|
|
344
407
|
emit(r);
|
|
345
408
|
process.exit(r.leaks.length ? 1 : 0);
|
|
346
409
|
}
|
|
347
|
-
emit(coreContainment(
|
|
410
|
+
emit(coreContainment(loadReportOrDie(prefix)));
|
|
348
411
|
break;
|
|
349
412
|
}
|
|
350
413
|
case "diff": {
|
|
@@ -359,7 +422,7 @@ switch (cmd) {
|
|
|
359
422
|
// the only output). No leading-positional-report alias here: both positionals ARE the reports.
|
|
360
423
|
const { positionals } = parseCanonical(args, {});
|
|
361
424
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
362
|
-
const { changes } = coreDiff(
|
|
425
|
+
const { changes } = coreDiff(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix));
|
|
363
426
|
// §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
|
|
364
427
|
// may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
|
|
365
428
|
// provenance fields as the Rust candor-query (cross-engine parity, item 10).
|
|
@@ -379,7 +442,7 @@ switch (cmd) {
|
|
|
379
442
|
// what the app DOES at runtime: effects unioned over the entry points (SPEC §3.1; same JSON
|
|
380
443
|
// shape as the Rust engine: {entryPoints, effects: {Eff: {count, via}}}).
|
|
381
444
|
const { prefix } = resolveReportVerb(args, 0);
|
|
382
|
-
const fns =
|
|
445
|
+
const fns = loadReportOrDie(prefix);
|
|
383
446
|
const roots = fns.filter((e) => e.entryPoint);
|
|
384
447
|
const byEff = {};
|
|
385
448
|
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
@@ -392,14 +455,90 @@ switch (cmd) {
|
|
|
392
455
|
// blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
|
|
393
456
|
// MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
|
|
394
457
|
const { prefix, args: [q] } = resolveReportVerb(args, 1);
|
|
395
|
-
emit(coreImpact(
|
|
458
|
+
emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
|
|
396
459
|
break;
|
|
397
460
|
}
|
|
398
461
|
case "blindspots": {
|
|
399
462
|
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
400
463
|
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
401
464
|
const { prefix } = resolveReportVerb(args, 0);
|
|
402
|
-
emit(coreBlindspots(
|
|
465
|
+
emit(coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)));
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
case "tour": {
|
|
469
|
+
// The ON-DEMAND, top-N cold-repo opener (SURFACE-BEST-FIND-DESIGN.md, P2): the N most SURPRISING
|
|
470
|
+
// transitive reaches in an existing report — NO re-scan. Delegates to the SHARED surface.mjs
|
|
471
|
+
// bestFinds (the same heuristic the scan-time note uses, so the ranking can't drift), reading the
|
|
472
|
+
// report + callgraph sidecar the scan already wrote. Port of candor-rust's candor-query tour verb —
|
|
473
|
+
// human + --json output byte-identical (a conformance PART pins it four-way).
|
|
474
|
+
// §3.3.1: `tour [<N>]`, report discovered / --report; the lone OPTIONAL positional is N (default 10).
|
|
475
|
+
// Unlike the JSON-only verbs, tour has BOTH a human default AND a --json form (like the Rust engine),
|
|
476
|
+
// so detect --json explicitly (parseCanonical otherwise silently swallows it).
|
|
477
|
+
const wantJson = args.includes("--json");
|
|
478
|
+
const { prefix, args: tourArgs } = resolveReportVerb(args, 1);
|
|
479
|
+
let n = 10;
|
|
480
|
+
if (tourArgs.length) {
|
|
481
|
+
// N MUST be a positive integer ≥ 1 that fits a safe integer — like the Rust engine, which rejects
|
|
482
|
+
// `tour 0` and a non-usize. `tour 0` printing "nothing hidden" over an effectful crate would be a
|
|
483
|
+
// false all-clear (the §4 cardinal sin), so a non-integer, zero, or out-of-range value → exit 2.
|
|
484
|
+
const parsed = /^\d+$/.test(tourArgs[0]) ? Number(tourArgs[0]) : NaN;
|
|
485
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
486
|
+
console.error("usage: candor-ts-query tour [<N>] [--report <locator>] [--json] (N is a positive integer ≥ 1)");
|
|
487
|
+
process.exit(2);
|
|
488
|
+
}
|
|
489
|
+
n = parsed;
|
|
490
|
+
}
|
|
491
|
+
const fns = loadReportOrDie(prefix);
|
|
492
|
+
const cg = loadCallgraph(prefix);
|
|
493
|
+
// Build the maps the heuristic wants from the report entries + the callgraph sidecar. `inferred`/
|
|
494
|
+
// `direct` come from the report; `loc` maps a function to its "file:line" for the source callout.
|
|
495
|
+
const inferred = new Map(), direct = new Map(), loc = new Map(), calls = new Map();
|
|
496
|
+
for (const e of fns) {
|
|
497
|
+
inferred.set(e.fn, new Set(e.inferred));
|
|
498
|
+
if (e.direct.length) direct.set(e.fn, new Set(e.direct));
|
|
499
|
+
if (e.loc) loc.set(e.fn, e.loc);
|
|
500
|
+
}
|
|
501
|
+
// `calls` prefers the FULL callgraph sidecar (every edge — the graph the scan held in memory). When
|
|
502
|
+
// the sidecar is absent/empty, FALL BACK to each entry's inline `.calls` (mirrors tour.rs:66-77:
|
|
503
|
+
// `if cg.is_empty() { use entry.calls } else { use cg }`). Without this fallback a report whose
|
|
504
|
+
// sidecar was deleted/never-written yields an empty graph, nearestSource finds nothing, and tour
|
|
505
|
+
// prints a FALSE "nothing hidden" at exit 0 — a silent under-report (the §4 cardinal sin). A corrupt
|
|
506
|
+
// sidecar is already disclosed on stderr by loadCallgraph, which then returns {} → we fall back here.
|
|
507
|
+
if (Object.keys(cg).length === 0) {
|
|
508
|
+
for (const e of fns) if (e.calls.length) calls.set(e.fn, e.calls);
|
|
509
|
+
} else {
|
|
510
|
+
for (const [k, v] of Object.entries(cg)) calls.set(k, v);
|
|
511
|
+
}
|
|
512
|
+
// Exclude test scaffolding — a qual is test code iff its recorded loc lies on a test path, the SAME
|
|
513
|
+
// isTestPath predicate the scan-note passes (scan.mjs's isTestQual). Without it `tour` surfaces test
|
|
514
|
+
// functions the scan-note (and every other engine) hides — an inconsistent, noisier reach list.
|
|
515
|
+
const isTestQual = (q) => { const l = loc.get(q); return l ? isTestPath(l) : false; };
|
|
516
|
+
const finds = bestFinds(inferred, direct, calls, loc, n, isTestQual);
|
|
517
|
+
// The header names the report's §2 envelope `package` — meaningful and locator-independent, so every
|
|
518
|
+
// engine and every --report form print the SAME crate. Falls back to the prefix basename.
|
|
519
|
+
const crateName = reportPackage(prefix) ?? path.basename(prefix);
|
|
520
|
+
if (wantJson) {
|
|
521
|
+
// Pure JSON to STDOUT: {"reaches":[{effect,fn,hops,loc,score,source}, …]} — ALPHABETICAL keys, the
|
|
522
|
+
// same order Rust+Swift emit (loc is the SOURCE's file:line, "" when absent).
|
|
523
|
+
const out = { reaches: finds.map((f) => ({
|
|
524
|
+
effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
|
|
525
|
+
})) };
|
|
526
|
+
console.log(JSON.stringify(out));
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
if (finds.length === 0) {
|
|
530
|
+
// Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
|
|
531
|
+
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
|
|
532
|
+
console.log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
|
|
536
|
+
finds.forEach((f, i) => {
|
|
537
|
+
const hopWord = f.hops === 1 ? "hop" : "hops";
|
|
538
|
+
const whereS = f.sourceLoc ? ` (${f.sourceLoc})` : "";
|
|
539
|
+
console.log(` ${i + 1}. \`${f.func}\` performs ${f.effect}, ${f.hops} ${hopWord} away via \`${f.source}\`${whereS}`);
|
|
540
|
+
console.log(` → candor path ${f.func} ${f.effect}`);
|
|
541
|
+
});
|
|
403
542
|
break;
|
|
404
543
|
}
|
|
405
544
|
case "gains": {
|
|
@@ -412,12 +551,19 @@ switch (cmd) {
|
|
|
412
551
|
const gv = reportVersion(curPrefix), gbv = reportVersion(basePrefix);
|
|
413
552
|
if (gv && gbv && gv !== gbv)
|
|
414
553
|
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
|
-
emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(
|
|
554
|
+
emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix)) });
|
|
416
555
|
break;
|
|
417
556
|
}
|
|
418
557
|
case "path": {
|
|
558
|
+
// BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
|
|
559
|
+
// `candor path <fn> <effect>`, so the DEFAULT is the readable indented chain; --json selects the
|
|
560
|
+
// pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
|
|
561
|
+
const wantJson = args.includes("--json");
|
|
419
562
|
const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
|
|
420
|
-
|
|
563
|
+
const fns = loadReportOrDie(prefix);
|
|
564
|
+
const cg = loadCallgraph(prefix);
|
|
565
|
+
if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
|
|
566
|
+
else renderPathHuman(fns, cg, fn, eff);
|
|
421
567
|
break;
|
|
422
568
|
}
|
|
423
569
|
case "whatif": {
|
|
@@ -466,7 +612,7 @@ switch (cmd) {
|
|
|
466
612
|
// The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
|
|
467
613
|
// it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
|
|
468
614
|
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,
|
|
615
|
+
const r = coreFix(cg, loadReportOrDie(prefix), target, eff, parsePolicy(ptext), scopeMatches);
|
|
470
616
|
if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
|
|
471
617
|
emit(r);
|
|
472
618
|
break;
|
|
@@ -482,7 +628,7 @@ switch (cmd) {
|
|
|
482
628
|
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
483
629
|
const cg = loadCallgraph(prefix);
|
|
484
630
|
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,
|
|
631
|
+
emit(coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches));
|
|
486
632
|
break;
|
|
487
633
|
}
|
|
488
634
|
case "unverified": {
|
|
@@ -495,7 +641,7 @@ switch (cmd) {
|
|
|
495
641
|
let ptext;
|
|
496
642
|
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
497
643
|
catch { console.error(`candor: policy ${policyFile} could not be read`); process.exit(2); }
|
|
498
|
-
const r = coreUnverified(
|
|
644
|
+
const r = coreUnverified(loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
|
|
499
645
|
emit(r);
|
|
500
646
|
process.exit(strict && !r.ok ? 1 : 0);
|
|
501
647
|
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.11";
|
|
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
|
+
}
|