candor-ts 0.16.0 → 0.18.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 +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/query.mjs +192 -33
- package/scan.mjs +34 -1
- package/surface.mjs +20 -2
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.18)."*
|
|
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
|
>
|
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
184
184
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
185
185
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
186
186
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
187
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
187
|
+
| `{ candor: { version, toolchain, spec: "0.18" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
188
188
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
189
189
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
190
190
|
|
|
@@ -202,7 +202,7 @@ read the Rust source".
|
|
|
202
202
|
|
|
203
203
|
## Status
|
|
204
204
|
|
|
205
|
-
0.
|
|
205
|
+
0.18.x, speaking candor-spec 0.18: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
206
206
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
207
207
|
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
208
208
|
real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
|
package/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.18.0",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.18)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/query.mjs
CHANGED
|
@@ -41,6 +41,108 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
|
41
41
|
matches as coreMatches, gainsCoverage,
|
|
42
42
|
loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
|
|
43
43
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
44
|
+
// The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
|
|
45
|
+
// with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
|
|
46
|
+
const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
|
|
47
|
+
// Suggest the nearest known flag for a typo (longest shared prefix ≥3): `--polciy` → `--policy` (#2).
|
|
48
|
+
function didYouMeanFlag(unknown) {
|
|
49
|
+
const known = ["--report", "--policy", "--json", "--text", "--strict", "--include-unknown"];
|
|
50
|
+
const u = unknown.replace(/^-+/, "").toLowerCase();
|
|
51
|
+
let best = null, bestLen = 2;
|
|
52
|
+
for (const k of known) {
|
|
53
|
+
const kn = k.replace(/^-+/, "");
|
|
54
|
+
let s = 0; while (s < u.length && s < kn.length && u[s] === kn[s]) s++;
|
|
55
|
+
if (s >= 3 && s > bestLen) { bestLen = s; best = k; }
|
|
56
|
+
}
|
|
57
|
+
return best ? ` — did you mean \`${best}\`?` : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- #8 output mode: PROSE at a TTY, JSON when piped or `--json` — so interactive `candor where Db` reads
|
|
61
|
+
// like candor-java/-rust instead of dumping raw JSON, while a pipe/redirect (never a TTY) still yields the
|
|
62
|
+
// pinned JSON untouched. MCP/LSP call query-core directly (not this CLI), so they're unaffected; conformance
|
|
63
|
+
// passes `--json` or captures over a pipe → JSON. `--json` forces JSON; `--text`/`--human` forces prose. -----
|
|
64
|
+
const wantJsonOut = (a) =>
|
|
65
|
+
a.includes("--json") || (!a.includes("--text") && !a.includes("--human") && !process.stdout.isTTY);
|
|
66
|
+
// Emit the pinned JSON, or render prose via proseFn(data). Returns data so the caller can still exit on it.
|
|
67
|
+
const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data); else proseFn(data); return data; };
|
|
68
|
+
const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
|
|
69
|
+
const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
|
|
70
|
+
// Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
|
|
71
|
+
// terse and scannable, in candor's voice (cf. the existing `tour`/`path` human forms).
|
|
72
|
+
const P = {
|
|
73
|
+
where: (d) => {
|
|
74
|
+
const n = d.directly.length + d.inherited.length;
|
|
75
|
+
if (n === 0) { console.log(`candor: 0 functions perform ${d.effect} in this report.`); return; }
|
|
76
|
+
console.log(`candor where ${d.effect} — ${n} function${n === 1 ? "" : "s"}:`);
|
|
77
|
+
if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
|
|
78
|
+
if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
|
|
79
|
+
},
|
|
80
|
+
callers: (d) => {
|
|
81
|
+
if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
|
|
82
|
+
console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
|
|
83
|
+
console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
|
|
84
|
+
console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
|
|
85
|
+
},
|
|
86
|
+
show: (d) => {
|
|
87
|
+
if (!d.length) { console.log("candor: no effectful function matches that name (pure functions are omitted from the report)."); return; }
|
|
88
|
+
d.forEach((e, i) => {
|
|
89
|
+
if (i) console.log("");
|
|
90
|
+
console.log(`${e.fn}`);
|
|
91
|
+
console.log(` effects: ${csv(e.inferred)}${e.direct && e.direct.length ? ` (direct: ${e.direct.join(", ")})` : ""}`);
|
|
92
|
+
if (e.hosts?.length) console.log(` hosts: ${e.hosts.join(", ")}`);
|
|
93
|
+
if (e.cmds?.length) console.log(` cmds: ${e.cmds.join(", ")}`);
|
|
94
|
+
if (e.paths?.length) console.log(` paths: ${e.paths.join(", ")}`);
|
|
95
|
+
if (e.tables?.length) console.log(` tables: ${e.tables.join(", ")}`);
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
map: (d) => {
|
|
99
|
+
const mods = Object.entries(d);
|
|
100
|
+
if (!mods.length) { console.log("candor: no effectful modules in this report."); return; }
|
|
101
|
+
console.log("candor map — effects by module:");
|
|
102
|
+
for (const [m, v] of mods) console.log(` ${m} — ${csv(v.effects)} (${v.functions} fn${v.functions === 1 ? "" : "s"})`);
|
|
103
|
+
},
|
|
104
|
+
containment: (d) => {
|
|
105
|
+
if ("leaks" in d) { // ratchet (a baseline was given)
|
|
106
|
+
if (!d.leaks.length) console.log("candor containment — no boundary effect reached a new layer vs the baseline. ✓");
|
|
107
|
+
else { console.log(`candor containment — ${d.leaks.length} boundary effect(s) reached a NEW layer (leak):`); rows(d.leaks); }
|
|
108
|
+
if (d.cleanups && d.cleanups.length) { console.log(` no longer present (${d.cleanups.length}):`); rows(d.cleanups); }
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!d.contained.length && !Object.keys(d.ambient).length) { console.log("candor containment — no boundary effects in this report."); return; }
|
|
112
|
+
console.log("candor containment — how well each boundary effect stays in one layer:");
|
|
113
|
+
for (const c of d.contained)
|
|
114
|
+
console.log(` ${c.effect}: ${c.containmentPct}% in \`${c.owner}\` (spread across ${c.layers} layer${c.layers === 1 ? "" : "s"})`);
|
|
115
|
+
const amb = Object.entries(d.ambient);
|
|
116
|
+
if (amb.length) console.log(` ambient (reported, not scored): ${amb.map(([e, n]) => `${e}×${n}`).join(", ")}`);
|
|
117
|
+
},
|
|
118
|
+
reachable: (d) => {
|
|
119
|
+
const effs = Object.entries(d.effects);
|
|
120
|
+
console.log(`candor reachable — what the ${d.entryPoints} entry point${d.entryPoints === 1 ? "" : "s"} do at runtime:`);
|
|
121
|
+
if (!effs.length) { console.log(" no effect reaches an entry point."); return; }
|
|
122
|
+
for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
|
|
123
|
+
},
|
|
124
|
+
impact: (d) => {
|
|
125
|
+
console.log(`candor impact — the blast radius of \`${d.fn}\`:`);
|
|
126
|
+
console.log(` ${d.affectedCount} effectful function(s) transitively call it${d.affected.length ? ":" : "."}`);
|
|
127
|
+
if (d.affected.length) rows(d.affected);
|
|
128
|
+
if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
|
|
129
|
+
},
|
|
130
|
+
blindspots: (d) => {
|
|
131
|
+
if (!d.sources.length) { console.log(`candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`); return; }
|
|
132
|
+
console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
|
|
133
|
+
for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
|
|
134
|
+
},
|
|
135
|
+
gains: (d) => {
|
|
136
|
+
if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
|
|
137
|
+
console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
|
|
138
|
+
for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
|
|
139
|
+
},
|
|
140
|
+
diff: (d) => {
|
|
141
|
+
if (!d.changes.length) { console.log("candor diff — no effect changes vs the baseline. ✓"); return; }
|
|
142
|
+
console.log(`candor diff — ${d.changes.length} function(s) changed vs the baseline:`);
|
|
143
|
+
for (const c of d.changes) console.log(` \`${c.fn}\`${c.gained.length ? ` +${c.gained.join(",")}` : ""}${c.lost.length ? ` -${c.lost.join(",")}` : ""}`);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
44
146
|
|
|
45
147
|
// Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
|
|
46
148
|
// Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
|
|
@@ -94,7 +196,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
94
196
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
95
197
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
96
198
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
97
|
-
const SPEC_VERSION = "0.
|
|
199
|
+
const SPEC_VERSION = "0.18";
|
|
98
200
|
|
|
99
201
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
100
202
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -182,13 +284,21 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
|
|
|
182
284
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
|
|
183
285
|
reportLocator = rawArgs[++i]; continue;
|
|
184
286
|
}
|
|
185
|
-
if (
|
|
287
|
+
if (a === "--policy") { // consumed for EVERY verb (a valid candor flag); used only by policy verbs
|
|
186
288
|
if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
|
|
187
|
-
|
|
289
|
+
const v = rawArgs[++i]; if (policy) policyFile = v; continue;
|
|
290
|
+
}
|
|
291
|
+
if (a === "--json" || a === "--text" || a === "--human") { continue; } // output-mode flags (#8) — consumed by
|
|
292
|
+
// wantJsonOut(rawArgs), never a positional
|
|
293
|
+
if (a === "--strict") { if (strict) wantStrict = true; continue; } // vocabulary — tolerated everywhere,
|
|
294
|
+
if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
|
|
295
|
+
if (a.startsWith("-") && a.length > 1) {
|
|
296
|
+
// An unrecognized flag is a TYPO, not a positional — reject it LOUD (exit 2), never silently swallow.
|
|
297
|
+
// A swallowed `--polciy` runs the query with NO policy and exits green: a CI author who typos --policy
|
|
298
|
+
// ships a gate that never fires (corpus re-audit cardinal sin — a loud error, never a silent guess).
|
|
299
|
+
console.error(`candor-ts-query: unknown flag '${a}'${didYouMeanFlag(a)}\n known flags: --report, --policy, --json, --text, --strict, --include-unknown`);
|
|
300
|
+
process.exit(2);
|
|
188
301
|
}
|
|
189
|
-
if (a === "--json") { continue; } // JSON is candor-ts's only output; accept + ignore
|
|
190
|
-
if (strict && a === "--strict") { wantStrict = true; continue; }
|
|
191
|
-
if (includeUnknown && a === "--include-unknown") { wantIncludeUnknown = true; continue; }
|
|
192
302
|
positionals.push(a);
|
|
193
303
|
}
|
|
194
304
|
// Deprecated trailing `0|1` JSON sentinel (Rust/TS legacy): if the LAST positional is a bare 0 or 1,
|
|
@@ -293,11 +403,11 @@ const SUBCOMMANDS = [
|
|
|
293
403
|
["impact", `<query> ${REPORT_TAIL}`, "blast radius of a function (backward dual of reachable)"],
|
|
294
404
|
["blindspots", REPORT_TAIL, "the Unknown sources, ranked by blast radius"],
|
|
295
405
|
["tour", `[<N>] ${REPORT_TAIL}`, "the N most surprising transitive reaches — the guided cold-repo poke (no re-scan)"],
|
|
296
|
-
["gains", "<current> <baseline> [--json]", "the supply-chain alarm: what the surface gained between two reports"],
|
|
406
|
+
["gains", "<current> <baseline> [--json] [--strict]", "the supply-chain alarm: what the surface gained between two reports (--strict: exit 1 on ANY gain)"],
|
|
297
407
|
["path", `<fn> <Effect> ${REPORT_TAIL}`, "a call path from a function to where an effect enters"],
|
|
298
408
|
["whatif", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
|
|
299
409
|
["fix", `<fn> <Effect> [--policy <file>] ${REPORT_TAIL}`, "the boundary fix: where the effect belongs + the hoist refactor"],
|
|
300
|
-
["fix-gate", `[--policy <file>] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing —
|
|
410
|
+
["fix-gate", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "a fix for EVERY boundary crossing — advisory (--strict: exit 1 while any remains)"],
|
|
301
411
|
["unverified", `[--policy <file>] [--strict] ${REPORT_TAIL}`, "pure/deny layers that PASS but are Unknown (not PROVABLY clean)"],
|
|
302
412
|
["agents", "", "print the agent contract for this build (AGENTS.md)"],
|
|
303
413
|
];
|
|
@@ -353,9 +463,12 @@ OPTIONS (uniform across every engine)
|
|
|
353
463
|
--report <locator> use this report instead of discovering .candor/
|
|
354
464
|
--policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
|
|
355
465
|
unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
|
|
356
|
-
--json machine-readable output
|
|
466
|
+
--json machine-readable JSON (the default when output is piped/redirected)
|
|
467
|
+
--text, --human human-readable prose (the default at a terminal)
|
|
357
468
|
--include-unknown callers: also list the unresolved-dispatch frontier
|
|
358
|
-
--strict
|
|
469
|
+
--strict make an advisory verb a CI gate — exit 1 while a finding remains:
|
|
470
|
+
unverified (an unverified-purity hole), fix-gate (a boundary
|
|
471
|
+
crossing), gains (ANY gained effect). Advisory (exit 0) otherwise.
|
|
359
472
|
-V, --version print the installed version + upgrade line (offline)
|
|
360
473
|
-h, --help show this help
|
|
361
474
|
|
|
@@ -399,7 +512,7 @@ switch (cmd) {
|
|
|
399
512
|
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
|
|
400
513
|
// `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
|
|
401
514
|
if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
402
|
-
|
|
515
|
+
put(args, coreShow(loadReportOrDie(prefix), q), P.show);
|
|
403
516
|
break;
|
|
404
517
|
}
|
|
405
518
|
case "where": {
|
|
@@ -410,7 +523,15 @@ switch (cmd) {
|
|
|
410
523
|
// A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
|
|
411
524
|
// never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
|
|
412
525
|
if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
|
|
413
|
-
|
|
526
|
+
// A typo'd / unknown effect NAME is a LOUD error (exit 2) — never a false-empty {directly:[],inherited:[]}
|
|
527
|
+
// at exit 0, which reads as an authoritative "nothing performs Net" when the user actually typed "Network"
|
|
528
|
+
// (corpus-audit #3). A KNOWN effect that is simply absent stays a valid 0-result; an unknown name that is
|
|
529
|
+
// PRESENT in the report (a spec extension effect) is allowed — so error only when the name is NEITHER.
|
|
530
|
+
const fnsW = loadReportOrDie(prefix);
|
|
531
|
+
if (!KNOWN_EFFECTS.includes(eff) && !new Set(fnsW.flatMap((e) => e.inferred || [])).has(eff)) {
|
|
532
|
+
console.error(`candor-ts-query where: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`); process.exit(2);
|
|
533
|
+
}
|
|
534
|
+
put(args, coreWhere(fnsW, eff), P.where);
|
|
414
535
|
break;
|
|
415
536
|
}
|
|
416
537
|
case "callers": {
|
|
@@ -422,14 +543,20 @@ switch (cmd) {
|
|
|
422
543
|
// {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
|
|
423
544
|
if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
|
|
424
545
|
const cg = loadCallgraph(prefix);
|
|
425
|
-
|
|
426
|
-
|
|
546
|
+
const cres = includeUnknown ? callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(cg, q);
|
|
547
|
+
// A nonexistent function is a LOUD error (exit 2), like path/impact — never an empty {of:[],direct:[],
|
|
548
|
+
// transitive:[]} at exit 0, which reads as an authoritative "nothing calls it" for a fn that doesn't exist
|
|
549
|
+
// (corpus-audit #3). Gated on a NON-empty callgraph so a missing sidecar isn't misreported as "no such fn".
|
|
550
|
+
if (Object.keys(cg).length > 0 && cres.of.length === 0) {
|
|
551
|
+
console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
|
|
552
|
+
}
|
|
553
|
+
put(args, cres, P.callers);
|
|
427
554
|
break;
|
|
428
555
|
}
|
|
429
556
|
case "map": {
|
|
430
557
|
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
431
558
|
const { prefix } = resolveReportVerb(args, 0);
|
|
432
|
-
|
|
559
|
+
put(args, coreMap(loadReportOrDie(prefix)), P.map);
|
|
433
560
|
break;
|
|
434
561
|
}
|
|
435
562
|
case "containment": {
|
|
@@ -459,10 +586,10 @@ switch (cmd) {
|
|
|
459
586
|
process.exit(2);
|
|
460
587
|
}
|
|
461
588
|
const r = coreContainment(loadReportOrDie(prefix), baseFns);
|
|
462
|
-
|
|
589
|
+
put(args, r, P.containment);
|
|
463
590
|
process.exit(r.leaks.length ? 1 : 0);
|
|
464
591
|
}
|
|
465
|
-
|
|
592
|
+
put(args, coreContainment(loadReportOrDie(prefix)), P.containment);
|
|
466
593
|
break;
|
|
467
594
|
}
|
|
468
595
|
case "diff": {
|
|
@@ -491,7 +618,7 @@ switch (cmd) {
|
|
|
491
618
|
const versionMismatch = engineV && baseV && engineV !== baseV;
|
|
492
619
|
if (versionMismatch)
|
|
493
620
|
console.error(`candor-ts: ⚠ baseline @${baseV} ≠ engine @${engineV} — some changes may be the engine reclassifying, not your code. Treat an engine swap as baseline-invalidating: review, then regenerate the baseline.`);
|
|
494
|
-
|
|
621
|
+
put(args, { baseline_version: baseV ?? "", engine_version: engineV ?? "", changes }, P.diff);
|
|
495
622
|
// diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
|
|
496
623
|
// same-build ratchet use; under a version mismatch that signal is BOGUS (unmasking, not regression),
|
|
497
624
|
// so exit 0 and let the ⚠ inform — never deliver the wave as a CI failure (review §2.1: guards fail
|
|
@@ -507,9 +634,9 @@ switch (cmd) {
|
|
|
507
634
|
const roots = fns.filter((e) => e.entryPoint);
|
|
508
635
|
const byEff = {};
|
|
509
636
|
for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
|
|
510
|
-
|
|
637
|
+
put(args, { entryPoints: roots.length,
|
|
511
638
|
effects: Object.fromEntries(Object.entries(byEff).sort()
|
|
512
|
-
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) });
|
|
639
|
+
.map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable);
|
|
513
640
|
break;
|
|
514
641
|
}
|
|
515
642
|
case "impact": {
|
|
@@ -519,14 +646,14 @@ switch (cmd) {
|
|
|
519
646
|
// A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
|
|
520
647
|
// affectedCount:0 blast radius at exit 0 for a function that was never named.
|
|
521
648
|
if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
|
|
522
|
-
|
|
649
|
+
put(args, coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q), P.impact);
|
|
523
650
|
break;
|
|
524
651
|
}
|
|
525
652
|
case "blindspots": {
|
|
526
653
|
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
527
654
|
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
528
655
|
const { prefix } = resolveReportVerb(args, 0);
|
|
529
|
-
|
|
656
|
+
put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix)), P.blindspots);
|
|
530
657
|
break;
|
|
531
658
|
}
|
|
532
659
|
case "tour": {
|
|
@@ -587,13 +714,33 @@ switch (cmd) {
|
|
|
587
714
|
const out = { reaches: finds.map((f) => ({
|
|
588
715
|
effect: f.effect, fn: f.func, hops: f.hops, loc: f.sourceLoc, score: f.score, source: f.source,
|
|
589
716
|
})) };
|
|
717
|
+
// The MACHINE half of the mostly-Unknown disclosure (Fable-review finding E): a JSON consumer (the
|
|
718
|
+
// agent loop) got a bare `{"reaches":[]}` and read it as clean — the same false all-clear the text
|
|
719
|
+
// branch qualifies. ADDITIVE + present only when the ≥⅓-Unknown threshold trips (byte-identical
|
|
720
|
+
// otherwise). Keys sorted after `reaches` (reaches < unknown) to match Rust's serde output.
|
|
721
|
+
const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
|
|
722
|
+
const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
723
|
+
if (teff > 0 && tunk * 3 >= teff) out.unknown = { count: tunk, total: teff };
|
|
590
724
|
console.log(JSON.stringify(out));
|
|
591
725
|
break;
|
|
592
726
|
}
|
|
593
727
|
if (finds.length === 0) {
|
|
594
728
|
// Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
|
|
595
|
-
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine.
|
|
596
|
-
|
|
729
|
+
// answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
|
|
730
|
+
// reassure "nothing hidden" over a meaningfully-Unknown graph (unresolved calls — missing tsconfig /
|
|
731
|
+
// imports): those Unknowns ARE the hidden part, their transitive effects unanalyzed (re-audit cardinal
|
|
732
|
+
// sin). Same ≥⅓-effectful-Unknown gate as the scan opener (surface.mjs emitSurface).
|
|
733
|
+
const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
|
|
734
|
+
const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
735
|
+
if (teff > 0 && tunk * 3 >= teff) {
|
|
736
|
+
console.log(
|
|
737
|
+
`candor: no surprising reaches — but ${tunk} of ${teff} function(s) are Unknown `
|
|
738
|
+
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
|
|
739
|
+
+ `a missing tsconfig.json or unresolvable imports are the usual cause.`,
|
|
740
|
+
);
|
|
741
|
+
} else {
|
|
742
|
+
console.log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
743
|
+
}
|
|
597
744
|
break;
|
|
598
745
|
}
|
|
599
746
|
console.log(`candor tour — the ${finds.length} most surprising reach${finds.length === 1 ? "" : "es"} in ${crateName}:`);
|
|
@@ -610,8 +757,13 @@ switch (cmd) {
|
|
|
610
757
|
// surface gained between two reports (base → cur), the cross-engine machine-readable form.
|
|
611
758
|
// §3.3.1: like diff, two positional locators <current> <baseline> (no discovery), each resolved by
|
|
612
759
|
// the shared locator rule; --json accepted.
|
|
613
|
-
|
|
614
|
-
|
|
760
|
+
// gains has no `--policy` of its own: parseCanonical consumes `--policy` for every verb (a valid flag),
|
|
761
|
+
// which for gains would SILENTLY drop it and exit 0 — a CI author who reaches for `--policy` to gate a
|
|
762
|
+
// supply-chain diff ships a gate that never fires. Reject it loud and point at the real gate. `--strict`
|
|
763
|
+
// (below) fails on ANY gained effect; the effect-SPECIFIC gate is a `deny <E> gained` scan policy.
|
|
764
|
+
if (args.includes("--policy")) { console.error("candor-ts-query gains: unknown flag '--policy' — gains is a diff view; to FAIL CI on a newly-gained effect gate at scan time with a `deny <E> gained` policy (AS-EFF-005), or use `--strict` to fail on ANY gain\n known flags: --json, --strict"); process.exit(2); }
|
|
765
|
+
const { positionals, strict } = parseCanonical(args, { strict: true });
|
|
766
|
+
if (positionals.length < 2) { console.error("usage: candor-ts-query gains <current> <baseline> [--json] [--strict]"); process.exit(2); }
|
|
615
767
|
const [curPrefix, basePrefix] = positionals.map(locatorToPrefix);
|
|
616
768
|
// BOTH locators must name real report files (the Rust engine's no-files check, named per side):
|
|
617
769
|
// a typo'd prefix loaded [] with hardFail=false and emitted an authoritative EMPTY
|
|
@@ -630,10 +782,13 @@ switch (cmd) {
|
|
|
630
782
|
// read as total), plus `coverageDelta` when the baseline names different blind packages. Both
|
|
631
783
|
// OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
|
|
632
784
|
// Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
...gainsCoverage(curPrefix, basePrefix) });
|
|
636
|
-
|
|
785
|
+
const gainsResult = coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix));
|
|
786
|
+
put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
|
|
787
|
+
...gainsResult, ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
|
|
788
|
+
// Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
|
|
789
|
+
// supply-chain CI job can require a bump introduce no new capability (mirrors `unverified --strict`).
|
|
790
|
+
process.exit(strict && (gainsResult.gained?.length ?? 0) > 0 ? 1 : 0);
|
|
791
|
+
break; // unreachable
|
|
637
792
|
}
|
|
638
793
|
case "path": {
|
|
639
794
|
// BOTH a human default AND a --json form (like the Rust/Java engines). The surface opener suggests
|
|
@@ -712,15 +867,19 @@ switch (cmd) {
|
|
|
712
867
|
// A remedy for EVERY deny/pure crossing — the shape the edit-time loop folds into its block message.
|
|
713
868
|
// §3.3.1: `fix-gate [--policy <file>]`, report discovered / --report. DEPRECATED alias: the old
|
|
714
869
|
// `fix-gate <prefix> <policy-file>` (leading report + positional policy).
|
|
715
|
-
|
|
870
|
+
// Advisory by default (exit 0 — the agent fix-loop reads the remedy and edits); `--strict` makes the
|
|
871
|
+
// exit follow `ok`, so CI can REQUIRE zero outstanding crossings (mirrors `unverified --strict`).
|
|
872
|
+
const { prefix, policyFile, strict } = resolveGateVerb(args, { strict: true });
|
|
716
873
|
if (!policyFile) { console.error("candor: fix-gate requires a policy file (pass --policy <file>, or set CANDOR_POLICY / a .candor/config `policy` key)"); process.exit(2); }
|
|
717
874
|
let ptext;
|
|
718
875
|
try { ptext = fs.readFileSync(policyFile, "utf8"); }
|
|
719
876
|
catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
|
|
720
877
|
const cg = loadCallgraph(prefix);
|
|
721
878
|
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); }
|
|
722
|
-
|
|
723
|
-
|
|
879
|
+
const fgr = coreFixGate(cg, loadReportOrDie(prefix), parsePolicy(ptext), scopeMatches);
|
|
880
|
+
emit(fgr);
|
|
881
|
+
process.exit(strict && !fgr.ok ? 1 : 0);
|
|
882
|
+
break; // unreachable
|
|
724
883
|
}
|
|
725
884
|
case "unverified": {
|
|
726
885
|
// PROVABLE-PURITY disclosure: pure/deny layers that PASS but contain Unknown (not provably clean). A
|
package/scan.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
42
42
|
// Reused, never re-littered.
|
|
43
43
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
44
|
-
const SPEC_VERSION = "0.
|
|
44
|
+
const SPEC_VERSION = "0.18";
|
|
45
45
|
|
|
46
46
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
47
47
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -1574,6 +1574,32 @@ const identIsEnvAlias = (id) => {
|
|
|
1574
1574
|
// The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
|
|
1575
1575
|
const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
|
|
1576
1576
|
|
|
1577
|
+
// A bare-identifier call whose callee is DEFAULT- or NAMED-imported from a known HTTP-client package is a
|
|
1578
|
+
// Net call (corpus-audit #13). The κ table lists these packages, but its rule only fires on a MEMBER call
|
|
1579
|
+
// (`axios.get(…)`); a default-imported callable invoked bare — the canonical `import fetch from 'node-fetch';
|
|
1580
|
+
// fetch(url)` — resolves to no signature when the package isn't installed, so it read Unknown (callback:fetch)
|
|
1581
|
+
// instead of Net, the effect users most care about. Resolve the identifier's symbol up to its
|
|
1582
|
+
// ImportDeclaration and match the specifier; used both to CLASSIFY the call Net and to SUPPRESS the spurious
|
|
1583
|
+
// callback-Unknown for the same node.
|
|
1584
|
+
const NET_REQUEST_NAMED = new Set(["fetch", "request", "stream", "pipeline"]); // undici/node-fetch callables
|
|
1585
|
+
const importedFromNetPkg = (id) => {
|
|
1586
|
+
if (!id || !ts.isIdentifier(id)) return false;
|
|
1587
|
+
for (const d of checker.getSymbolAtLocation(id)?.declarations ?? []) {
|
|
1588
|
+
let n = d;
|
|
1589
|
+
while (n && !ts.isImportDeclaration(n)) n = n.parent;
|
|
1590
|
+
if (!(n && ts.isImportDeclaration(n) && ts.isStringLiteralLike(n.moduleSpecifier)
|
|
1591
|
+
&& /^(node-fetch|undici|axios|got|superagent|phin)$/.test(n.moduleSpecifier.text))) continue;
|
|
1592
|
+
// Only the package's CLIENT CALLABLE is Net: the DEFAULT import (`import fetch from 'node-fetch'`,
|
|
1593
|
+
// `import got from 'got'`) or a NAMED request function (`import { fetch, request } from 'undici'`). A
|
|
1594
|
+
// named CLASS/utility export — `Headers`, `Response`, `Request`, `CookieJar`, `FormData` — is NOT a
|
|
1595
|
+
// request and must not be over-reported as Net (review finding). Namespace imports resolve via κ member
|
|
1596
|
+
// calls elsewhere, not as a bare callable here.
|
|
1597
|
+
if (ts.isImportClause(d)) return true; // default import = the client
|
|
1598
|
+
if (ts.isImportSpecifier(d) && NET_REQUEST_NAMED.has((d.propertyName ?? d.name).text)) return true;
|
|
1599
|
+
}
|
|
1600
|
+
return false;
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1577
1603
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
1578
1604
|
function visitCalls(node) {
|
|
1579
1605
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -1630,6 +1656,10 @@ function visitCalls(node) {
|
|
|
1630
1656
|
}
|
|
1631
1657
|
if (kEff) {
|
|
1632
1658
|
rec.direct.add(kEff); // κ-modeled package reached via an uninstalled namespace import
|
|
1659
|
+
} else if (ts.isCallExpression(node) && importedFromNetPkg(node.expression)) {
|
|
1660
|
+
rec.direct.add("Net"); // bare call to an HTTP-client default/named import whose pkg isn't installed
|
|
1661
|
+
// (so its signature didn't resolve) — Net, not Unknown (#13). Host capture
|
|
1662
|
+
// happens in the global/builtin arm below, which fires for the same node.
|
|
1633
1663
|
} else {
|
|
1634
1664
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
1635
1665
|
const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
|
|
@@ -2163,6 +2193,9 @@ function visitCalls(node) {
|
|
|
2163
2193
|
};
|
|
2164
2194
|
if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
|
|
2165
2195
|
else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
|
|
2196
|
+
else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
|
|
2197
|
+
geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
|
|
2198
|
+
|
|
2166
2199
|
else if (ts.isIdentifier(callee) && callee.text === "fetch"
|
|
2167
2200
|
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
2168
2201
|
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
|
package/surface.mjs
CHANGED
|
@@ -215,8 +215,26 @@ export function bestFind(inferred, direct, calls, isTest = () => false) {
|
|
|
215
215
|
// the sink (defaults to console.error). Mirrors surface.rs::emit exactly.
|
|
216
216
|
export function emitSurface(inferred, direct, calls, loc, isTest = () => false, log = console.error) {
|
|
217
217
|
const res = bestFind(inferred, direct, calls, isTest);
|
|
218
|
-
|
|
219
|
-
if (res.winner
|
|
218
|
+
// A real SURPRISING reach is a genuine finding — show it (below), even amid Unknowns.
|
|
219
|
+
if (res !== null && res.winner !== null) { /* fall through to the surprising-reach message */ }
|
|
220
|
+
else {
|
|
221
|
+
// No surprising reach. But do NOT reassure "nothing hidden" over a meaningfully-UNKNOWN graph: those
|
|
222
|
+
// Unknowns (unresolved calls — e.g. a missing tsconfig.json, unresolvable imports) ARE the hidden part,
|
|
223
|
+
// and their transitive effects are unanalyzed. "nothing hidden" there is a false all-clear — the
|
|
224
|
+
// cardinal sin for a tool that sells transitive-reach detection (corpus re-audit). Qualify + point at
|
|
225
|
+
// blindspots. `bestFind` returns null for BOTH "no effectful fns" and "effectful-but-nothing-surprising
|
|
226
|
+
// (incl. all-Unknown)", so measure the Unknown fraction from `inferred` directly, not from `res`.
|
|
227
|
+
const total = [...inferred.values()].filter((s) => s.size > 0).length; // EFFECTFUL fns (pure units excluded)
|
|
228
|
+
const unknown = [...inferred.values()].filter((s) => s.has("Unknown")).length;
|
|
229
|
+
if (total > 0 && unknown * 3 >= total) { // ≥ ~1/3 of effectful functions Unknown → meaningfully unresolved
|
|
230
|
+
log(
|
|
231
|
+
`candor: no surprising reaches — but ${unknown} of ${total} function(s) are Unknown `
|
|
232
|
+
+ `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
|
|
233
|
+
+ `a missing tsconfig.json or unresolvable imports are the usual cause.`,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (res === null) return; // genuinely nothing effectful/surprising and few Unknowns — emit nothing
|
|
220
238
|
log("candor: nothing hidden — every effect sits where its name says it should.");
|
|
221
239
|
return;
|
|
222
240
|
}
|