candor-ts 0.8.5 → 0.8.6
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 +16 -3
- package/README.md +46 -17
- package/lsp.mjs +38 -21
- package/mcp.mjs +81 -31
- package/package.json +1 -1
- package/policy.mjs +5 -2
- package/query-core.mjs +24 -6
- package/query.mjs +26 -55
- package/scan.mjs +24 -4
package/AGENTS.md
CHANGED
|
@@ -43,7 +43,12 @@ This writes `<project-dir>/.candor/report.json` and `.candor/report.callgraph.js
|
|
|
43
43
|
with `--out <prefix>`). **Install the TARGET's dependencies first** (`npm install` in the project)
|
|
44
44
|
— without node_modules, imports don't resolve and most functions honestly read `Unknown` (the
|
|
45
45
|
scanner warns loudly). Add `--policy <file>` (or set `CANDOR_POLICY`) to enforce a §6.2 policy over the
|
|
46
|
-
scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable.
|
|
46
|
+
scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable. `--gate-json <file|->`
|
|
47
|
+
additionally writes the structured verdict `{spec, ok, violations:[{rule,fn,effects,detail}]}`
|
|
48
|
+
(spec §3.3) — the machine-readable form CI/SARIF converters consume, from the SAME violations that
|
|
49
|
+
set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `deps <paths>`, one
|
|
50
|
+
key per line, discovered walking UP from the scan target, relative values anchored to the config's
|
|
51
|
+
repo) is the no-env-wiring floor; flag → env → config → default.
|
|
47
52
|
|
|
48
53
|
**Report shape:** the file is `{ "candor": {version, toolchain, spec}, "functions": [...] }`;
|
|
49
54
|
`functions` is an **array** of entries (not a map — don't index it by name), each carrying **`fn`**
|
|
@@ -73,9 +78,11 @@ Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when
|
|
|
73
78
|
Q where $P <Effect> 1 # {effect, directly, inherited}
|
|
74
79
|
Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
|
|
75
80
|
Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
|
|
81
|
+
Q callers $P <fn-query> --include-unknown 1 # + possibleViaUnknownDispatch: the unresolved-dispatch frontier
|
|
76
82
|
Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
|
|
77
83
|
Q map $P 1 # {module: {effects, functions}}
|
|
78
84
|
Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak)
|
|
85
|
+
Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius
|
|
79
86
|
Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
|
|
80
87
|
Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
|
|
81
88
|
Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
|
|
@@ -84,8 +91,14 @@ Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate w
|
|
|
84
91
|
```
|
|
85
92
|
|
|
86
93
|
And as an MCP server, so an agent pulls these as tools instead of shelling out:
|
|
87
|
-
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where
|
|
88
|
-
`
|
|
94
|
+
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…,
|
|
95
|
+
plus `candor_gate`/`candor_whatif` — a given-but-unreadable `policy` is a loud tool error, never a
|
|
96
|
+
clean verdict). `npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the
|
|
97
|
+
edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor.
|
|
98
|
+
CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` /
|
|
99
|
+
`--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked
|
|
100
|
+
endpoint — internal state, not a report field), so treat a report-side green as advisory and the
|
|
101
|
+
scan-time gate as the CI truth.
|
|
89
102
|
|
|
90
103
|
Name queries resolve exact > segment-suffix (`db.save` matches `src.db.save`, never
|
|
91
104
|
`src.db.save_all`) > substring — the same ladder as the other engines. The trailing `1` is the
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ npm install # typescript + @types/node
|
|
|
19
19
|
node scan.mjs <project-dir> # tsconfig.json honored; tests excluded; writes
|
|
20
20
|
# <dir>/.candor/report.json + .callgraph.json
|
|
21
21
|
node scan.mjs . --policy .candor/policy # the §6.2 gate: exit 1 on violation, 2 if unreadable
|
|
22
|
+
node scan.mjs . --gate-json gate.json # + the structured verdict {spec, ok, violations} (§3.3)
|
|
22
23
|
|
|
23
24
|
node scan.mjs --version # installed build + spec contract (offline), + upgrade line
|
|
24
25
|
|
|
@@ -26,10 +27,18 @@ node query.mjs show .candor/report db.save 1 # a function's effects (match
|
|
|
26
27
|
node query.mjs where .candor/report Net 1 # direct sources vs inheritors
|
|
27
28
|
node query.mjs callers .candor/report db.save 1 # the blast radius (transitive callers)
|
|
28
29
|
node query.mjs map .candor/report 1 # module → effects overview
|
|
30
|
+
node query.mjs containment .candor/report # §6.1 boundary-effect dispersion (+ baseline = ratchet)
|
|
31
|
+
node query.mjs blindspots .candor/report # the Unknown SOURCES, ranked by blast radius
|
|
29
32
|
node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdict (exit 1)
|
|
30
|
-
node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain
|
|
33
|
+
node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain;
|
|
34
|
+
# a baseline from a DIFFERENT build ⇒ disclosed ⚠ + exit 0)
|
|
31
35
|
```
|
|
32
36
|
|
|
37
|
+
A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
|
|
38
|
+
`deps <report paths>` one per line, discovered by walking up from the scan target; relative values
|
|
39
|
+
resolve against the config's repo, so CI is "point at the repo". A configured-but-unusable
|
|
40
|
+
config/policy fails loud (exit 2), never silently gateless.
|
|
41
|
+
|
|
33
42
|
**Staying current:** check your installed version and upgrade — [candor/AGENTS.md §2a](https://github.com/tombaldwin/candor/blob/main/AGENTS.md#2a-staying-current--check-the-version-upgrade). `npx -y candor-ts --version` prints the build, the spec, and the upgrade one-liner (offline; candor never phones home).
|
|
34
43
|
|
|
35
44
|
Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
|
|
@@ -53,10 +62,16 @@ Nest app this makes table-level policy live: `allow Db in article.service articl
|
|
|
53
62
|
the service reaching `user` and `follows`.
|
|
54
63
|
|
|
55
64
|
**The classifier** is curated (the same under-report-and-say-so posture as the other engines): the
|
|
56
|
-
Node builtins (`fs`, `net`/`http`/`tls`, `child_process`, `node:sqlite`,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
Node builtins (`fs`, `net`/`http`/`tls`, `dns`, `child_process`, `worker_threads`, `node:sqlite`,
|
|
66
|
+
`node:vm`, `process.env`, the clock), the HTTP/queue/mail tier (axios/got/node-fetch/undici/ws/
|
|
67
|
+
socket.io/nodemailer, gaxios + googleapis-common + google-auth-library, stripe, @sentry/*,
|
|
68
|
+
posthog-node, bull/bullmq), the database drivers (pg/mysql2/mongodb/redis/ioredis/sqlite3/
|
|
69
|
+
better-sqlite3/knex) **and the ORM tier** (TypeORM — with `@Entity("…")` table extraction —
|
|
70
|
+
Prisma, Mongoose, Sequelize, drizzle-orm), plus execa/cross-spawn/shelljs/open, fs-extra/
|
|
71
|
+
graceful-fs/rimraf/glob/chokidar, dotenv, winston/pino/bunyan. An unlisted package contributes
|
|
72
|
+
nothing — candor never guesses an effect — but the scan **names it**: the receipt's `κ doesn't
|
|
73
|
+
know N packages…` line lists every package the code demonstrably calls that κ neither classifies
|
|
74
|
+
nor has reviewed-pure, and each function carries the `invisible` list it (transitively) reaches.
|
|
60
75
|
|
|
61
76
|
## MCP server — candor as agent ground truth
|
|
62
77
|
|
|
@@ -73,10 +88,21 @@ tracing the call graph by hand (the measured ~700–2000× token win on blast-ra
|
|
|
73
88
|
|
|
74
89
|
Tools: `candor_impact` (backward blast radius), `candor_reachable` (what runs at runtime),
|
|
75
90
|
`candor_where` (effect surface), `candor_path` (how an effect is reached), `candor_callers`,
|
|
76
|
-
`candor_show`, `candor_map`, `candor_whatif` (pre-edit
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
`candor_show`, `candor_map`, `candor_containment`, `candor_blindspots`, `candor_whatif` (pre-edit
|
|
92
|
+
gate check — a given-but-unreadable policy is a loud error, never a clean verdict), `candor_gate`
|
|
93
|
+
(the checked-in `.candor/config` policy verdict), `candor_diff`/`candor_gains` (baseline deltas).
|
|
94
|
+
Each takes an optional `report` prefix (else `$CANDOR_REPORT`); `--root <dir>` locks the server to
|
|
95
|
+
one workspace. The server is **query-only** — it never scans (the analyzer self-boundary, spec
|
|
96
|
+
§7.12: an agent or a hook produces the report; the server reads it, Fs only). The query logic is
|
|
97
|
+
the shared `query-core.mjs`, the same answers the CLI gives.
|
|
98
|
+
|
|
99
|
+
**`candor-lsp`** renders the same report where the code is, for any LSP-native editor (helix,
|
|
100
|
+
neovim; the JetBrains plugin bundles it): a CodeLens per effectful function (`⚡ Db, Net · blast
|
|
101
|
+
radius 12`), hover provenance (the hop chain to where an inherited effect is performed), and the
|
|
102
|
+
repo's policy verdict as diagnostics. Like the MCP server it is a pure report consumer — any
|
|
103
|
+
engine's report — and never scans. (Both report-computed gates are advisory: the engine's own
|
|
104
|
+
`--gate-json` run additionally fails masked/incomplete literal surfaces and is the authoritative
|
|
105
|
+
CI form.)
|
|
80
106
|
|
|
81
107
|
**The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
|
|
82
108
|
about the *current* code, not a stale snapshot:
|
|
@@ -129,7 +155,7 @@ every push to the spec.
|
|
|
129
155
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
130
156
|
| Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
|
|
131
157
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
132
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
158
|
+
| `{ candor: { version, toolchain, spec: "0.8" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
133
159
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
134
160
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
135
161
|
|
|
@@ -147,13 +173,16 @@ read the Rust source".
|
|
|
147
173
|
|
|
148
174
|
## Status
|
|
149
175
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
176
|
+
0.8.x, speaking candor-spec 0.8: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
177
|
+
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
178
|
+
`--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
|
|
179
|
+
real, behaviorally tested (`npm test` — ~360 assertions across six suites), **soundness-fuzzed
|
|
180
|
+
with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
|
|
181
|
+
call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
|
|
182
|
+
npm classifier tier is deliberately curated and keeps growing case-by-case. Entry points
|
|
183
|
+
(Nest/Next populations), `unknownWhy` origins, `reachable`, cross-package inheritance
|
|
184
|
+
(`CANDOR_DEPS` + the spec §2 `hash`, version-trusted per §2.1), and `--allow-js` are all in.
|
|
185
|
+
On npm: `npx -y candor-ts <dir>`.
|
|
157
186
|
|
|
158
187
|
## Development
|
|
159
188
|
|
package/lsp.mjs
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* how many functions transitively call it (who is affected if it changes).
|
|
8
8
|
* • Diagnostics: the repo's architecture-policy verdict (the §6.2 gate, resolved from CANDOR_POLICY
|
|
9
9
|
* or the checked-in .candor/config — spec §3.4) as squiggles at each violating function's line.
|
|
10
|
+
* CAVEAT — a report-computed gate is WEAKER than the engine's own --gate-json run: the scan-time
|
|
11
|
+
* gate also fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint,
|
|
12
|
+
* kept internal per the java/rust engines — not a report field), so no-squiggle here can still be
|
|
13
|
+
* red in CI. The engine's --gate-json is the authoritative form (same caveat as MCP candor_gate).
|
|
10
14
|
* • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
|
|
11
15
|
* performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
|
|
12
16
|
*
|
|
@@ -40,14 +44,7 @@ try { VERSION = createRequire(import.meta.url)("./package.json").version; } catc
|
|
|
40
44
|
let rootPath = null;
|
|
41
45
|
let reportPrefix = process.env.CANDOR_REPORT || process.argv[2] || null;
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
if (!p) return false;
|
|
45
|
-
if (fs.existsSync(`${p}.json`)) return true;
|
|
46
|
-
const base = nodePath.basename(p);
|
|
47
|
-
try {
|
|
48
|
-
return fs.readdirSync(nodePath.dirname(p) || ".").some((f) => f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
|
|
49
|
-
} catch { return false; }
|
|
50
|
-
}
|
|
47
|
+
const hasReport = Q.hasReport; // single-sourced with the loader predicate (query-core) — see mcp.mjs
|
|
51
48
|
|
|
52
49
|
// ---- fn → document mapping --------------------------------------------------------------------------
|
|
53
50
|
// A report `loc` is `<file>:<line>[:col…]` where <file> is either a repo-relative PATH (the scan-source
|
|
@@ -70,29 +67,41 @@ function docMatches(docPath, fn, file) {
|
|
|
70
67
|
const norm = docPath.split(nodePath.sep).join("/");
|
|
71
68
|
return candidatePaths(fn, file).some((c) => norm === c || norm.endsWith("/" + c));
|
|
72
69
|
}
|
|
73
|
-
/** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call
|
|
74
|
-
|
|
70
|
+
/** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call
|
|
71
|
+
* (the per-request re-read is the freshness design); a caller that already has the report passes
|
|
72
|
+
* it via `fns` so one request never parses the same file twice. */
|
|
73
|
+
function entriesInDoc(docPath, fns = null) {
|
|
75
74
|
if (!hasReport(reportPrefix)) return null;
|
|
76
75
|
const out = [];
|
|
77
|
-
for (const e of Q.loadReport(reportPrefix)) {
|
|
76
|
+
for (const e of (fns ?? Q.loadReport(reportPrefix))) {
|
|
78
77
|
const lp = e.loc && locParts(e.loc);
|
|
79
78
|
if (lp && docMatches(docPath, e.fn, lp.file)) out.push({ entry: e, line: lp.line });
|
|
80
79
|
}
|
|
81
80
|
return out;
|
|
82
81
|
}
|
|
83
82
|
|
|
83
|
+
// The transitive-caller COUNT for an exact fn name over an already-inverted graph. The lenses used
|
|
84
|
+
// Q.callers per entry, and every callers() call rebuilt reverseGraph from scratch — a 50-fn document
|
|
85
|
+
// over a JVM-scale callgraph did 50 full graph inversions PER codeLens request (review find). One
|
|
86
|
+
// inversion per request + a plain BFS; report fn names are exact cg keys, so no match ladder needed.
|
|
87
|
+
function transitiveCallerCount(rev, fn) {
|
|
88
|
+
const seen = new Set([fn]);
|
|
89
|
+
const queue = [fn];
|
|
90
|
+
while (queue.length) {
|
|
91
|
+
const n = queue.pop();
|
|
92
|
+
for (const c of rev.get(n) ?? []) if (!seen.has(c)) { seen.add(c); queue.push(c); }
|
|
93
|
+
}
|
|
94
|
+
return seen.size - 1; // minus the target itself
|
|
95
|
+
}
|
|
96
|
+
|
|
84
97
|
// ---- CodeLens ---------------------------------------------------------------------------------------
|
|
85
98
|
function codeLenses(docPath) {
|
|
86
99
|
const found = entriesInDoc(docPath);
|
|
87
100
|
if (found === null) return [];
|
|
88
|
-
|
|
101
|
+
let rev = null;
|
|
102
|
+
try { rev = Q.reverseGraph(Q.loadCallgraph(reportPrefix)); } catch { /* no callgraph — effects-only lens */ }
|
|
89
103
|
return found.map(({ entry, line }) => {
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const c = Q.callers(cg, entry.fn);
|
|
93
|
-
const n = (c && c.transitive && c.transitive.length) || 0;
|
|
94
|
-
blast = ` · blast radius ${n}`;
|
|
95
|
-
} catch { /* no callgraph — effects-only lens */ }
|
|
104
|
+
const blast = rev ? ` · blast radius ${transitiveCallerCount(rev, entry.fn)}` : "";
|
|
96
105
|
const eff = (entry.inferred || []).join(", ") || "pure";
|
|
97
106
|
return {
|
|
98
107
|
range: { start: { line, character: 0 }, end: { line, character: 0 } },
|
|
@@ -107,12 +116,13 @@ function codeLenses(docPath) {
|
|
|
107
116
|
// needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
|
|
108
117
|
// chain to the direct source. unknownWhy rides along when the fn introduces opacity.
|
|
109
118
|
function hoverAt(docPath, line) {
|
|
110
|
-
|
|
119
|
+
if (!hasReport(reportPrefix)) return null;
|
|
120
|
+
const fns = Q.loadReport(reportPrefix); // ONE load per request (entriesInDoc reuses it)
|
|
121
|
+
const found = entriesInDoc(docPath, fns);
|
|
111
122
|
if (!found || !found.length) return null;
|
|
112
123
|
const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
|
|
113
124
|
if (!at) return null;
|
|
114
125
|
const { entry } = at;
|
|
115
|
-
const fns = Q.loadReport(reportPrefix);
|
|
116
126
|
const cg = Q.loadCallgraph(reportPrefix);
|
|
117
127
|
const lines = [`**${entry.fn}** — ⚡ { ${(entry.inferred || []).join(", ") || "pure"} }`];
|
|
118
128
|
for (const eff of entry.inferred || []) {
|
|
@@ -139,9 +149,16 @@ function hoverAt(docPath, line) {
|
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
// ---- Diagnostics (the live gate) ---------------------------------------------------------------------
|
|
152
|
+
const warned = new Set();
|
|
153
|
+
function warnOnce(message) { if (!warned.has(message)) { warned.add(message); logMessage(message); } }
|
|
142
154
|
function activePolicy() {
|
|
143
155
|
const env = process.env.CANDOR_POLICY;
|
|
144
|
-
if (env
|
|
156
|
+
if (env) {
|
|
157
|
+
if (fs.existsSync(env)) return fs.readFileSync(env, "utf8");
|
|
158
|
+
// Set-but-missing must be LOUD (the family's configured-but-unusable posture — scan exits 2 here).
|
|
159
|
+
// This is an advisory surface, so: disclose the policy-source swap, then fall through to discovery.
|
|
160
|
+
warnOnce(`candor-lsp: CANDOR_POLICY is set but ${env} does not exist — falling back to .candor/config discovery (diagnostics may reflect a different policy than you configured)`);
|
|
161
|
+
}
|
|
145
162
|
const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
|
|
146
163
|
const cfg = from ? discoverConfigPolicy(from) : null;
|
|
147
164
|
if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
|
package/mcp.mjs
CHANGED
|
@@ -23,41 +23,57 @@ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from
|
|
|
23
23
|
|
|
24
24
|
const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// CLI: [prefix] [--root <dir>]. `--root` LOCKS the server to a workspace: every report prefix (and
|
|
27
|
+
// therefore every policy read, whose confinement root derives from the prefix) must live inside it.
|
|
28
|
+
// Without it the confinement is only RELATIVE — the `report` arg is client-chosen, so a client that can
|
|
29
|
+
// also plant a parseable report in a target tree could anchor policy reads there (review find).
|
|
30
|
+
const CLI_ARGS = process.argv.slice(2);
|
|
31
|
+
let WORKSPACE_ROOT = null;
|
|
32
|
+
{
|
|
33
|
+
const i = CLI_ARGS.indexOf("--root");
|
|
34
|
+
if (i >= 0) { WORKSPACE_ROOT = nodePath.resolve(CLI_ARGS[i + 1] ?? "."); CLI_ARGS.splice(i, 2); }
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_PREFIX = process.env.CANDOR_REPORT || CLI_ARGS[0]
|
|
27
37
|
|| (fs.existsSync(".candor") ? ".candor/report" : null); // the engines' default --out convention
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
if (fs.existsSync(`${p}.json`)) return true;
|
|
34
|
-
const base = nodePath.basename(p);
|
|
35
|
-
try {
|
|
36
|
-
// SAME predicate (Q.isReport) the loader uses — a prefix whose only sibling is `.encountered-*` /
|
|
37
|
-
// `.calibrated.json` must NOT pass here, else loadReport finds zero functions and the tool returns an
|
|
38
|
-
// authoritative-empty result instead of "no report" (a silent under-report — review find).
|
|
39
|
-
return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
|
|
40
|
-
f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
|
|
41
|
-
} catch { return false; }
|
|
42
|
-
}
|
|
39
|
+
const within = (abs, root) => abs === root || abs.startsWith(root + nodePath.sep);
|
|
40
|
+
// hasReport is the shared query-core check — the SAME predicate (Q.isReport) the loader uses, so a
|
|
41
|
+
// prefix whose only sibling is `.encountered-*`/`.calibrated.json` can't pass existence yet load zero
|
|
42
|
+
// functions (an authoritative-empty result — a silent under-report; review find).
|
|
43
43
|
function resolvePrefix(args) {
|
|
44
44
|
const p = args?.report || DEFAULT_PREFIX;
|
|
45
45
|
if (!p) throw new Error("no report prefix: pass `report`, set $CANDOR_REPORT, or give one as the CLI arg");
|
|
46
|
-
if (
|
|
46
|
+
if (WORKSPACE_ROOT && !within(nodePath.resolve(p), WORKSPACE_ROOT))
|
|
47
|
+
throw new Error(`report prefix \`${clip(p)}\` is outside the served workspace (--root ${WORKSPACE_ROOT}) — refusing`);
|
|
48
|
+
if (!Q.hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
47
49
|
return p;
|
|
48
50
|
}
|
|
49
51
|
// Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
|
|
50
52
|
// verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
|
|
51
53
|
const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
52
|
-
//
|
|
54
|
+
// The confinement root for a caller-supplied policy path: the repo the report belongs to — the
|
|
55
|
+
// .candor/config-discovered repo root when there is one, else the parent of a `.candor/` report
|
|
56
|
+
// directory, else the report's own directory. The old default (always dirname(prefix)) was the
|
|
57
|
+
// `.candor/` dir itself under the standard `.candor/report` layout, so a legitimate repo-root policy —
|
|
58
|
+
// the very layout candor_gate resolves via cfg.repoRoot — was refused (review find).
|
|
59
|
+
function policyRoot(prefix) {
|
|
60
|
+
const cfg = configPolicy(prefix);
|
|
61
|
+
if (cfg) return cfg.repoRoot;
|
|
62
|
+
const dir = nodePath.resolve(nodePath.dirname(prefix));
|
|
63
|
+
return nodePath.basename(dir) === ".candor" ? nodePath.dirname(dir) : dir;
|
|
64
|
+
}
|
|
65
|
+
// Read a caller-supplied policy file CONFINED to the report's repo tree. The MCP surface is
|
|
53
66
|
// report-query-only (spec §7.12); an arbitrary `policy` path (/etc/passwd, ~/.aws/credentials) whose
|
|
54
67
|
// parsed deny-rule scopes are reflected back in violations[].rule is an arbitrary-file-read exfiltration
|
|
55
|
-
// channel — tie the policy to the project it gates.
|
|
56
|
-
|
|
68
|
+
// channel — tie the policy to the project it gates. FAIL CLOSED on an unreadable path: the thrown error
|
|
69
|
+
// surfaces as the tool-level isError — a typo'd policy must be LOUD, never a clean no-policy verdict
|
|
70
|
+
// (the gateless-green shape the CLI's whatif exits 2 on).
|
|
71
|
+
function confinedPolicyRead(policyPath, prefix, root = policyRoot(prefix)) {
|
|
57
72
|
const abs = nodePath.resolve(policyPath);
|
|
58
|
-
if (abs
|
|
59
|
-
throw new Error(`policy must be within the report's
|
|
60
|
-
return fs.readFileSync(abs, "utf8");
|
|
73
|
+
if (!within(abs, root) || (WORKSPACE_ROOT && !within(abs, WORKSPACE_ROOT)))
|
|
74
|
+
throw new Error(`policy must be within the report's repo (${root}) — refusing to read \`${clip(policyPath)}\``);
|
|
75
|
+
try { return fs.readFileSync(abs, "utf8"); }
|
|
76
|
+
catch { throw new Error(`policy \`${clip(policyPath)}\` could not be read — NOT evaluated (a missing gate source must be loud, never a clean verdict)`); }
|
|
61
77
|
}
|
|
62
78
|
// The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
|
|
63
79
|
function configPolicy(prefix) {
|
|
@@ -75,8 +91,32 @@ const reportArg = { report: { type: "string", description: "report prefix (optio
|
|
|
75
91
|
// only shapes the MCP result for its token-sensitive transport). Small results are returned verbatim.
|
|
76
92
|
const MCP_LIST_CAP = 50;
|
|
77
93
|
function capImpact(r) {
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
let out = r;
|
|
95
|
+
if (Array.isArray(r.affected) && r.affected.length > MCP_LIST_CAP) // affectedCount is the full count
|
|
96
|
+
out = { ...out, affected: r.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true };
|
|
97
|
+
if (Array.isArray(r.entryPoints) && r.entryPoints.length > MCP_LIST_CAP)
|
|
98
|
+
out = { ...out, entryPointCount: r.entryPoints.length, entryPoints: r.entryPoints.slice(0, MCP_LIST_CAP), entryPointsTruncated: true };
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
// The same token-amplification argument as capImpact, for the other unbounded lists: `where` on a
|
|
102
|
+
// pervasive effect (Log, Unknown) lists most of a large repo; a blindspot source's `affected` is a
|
|
103
|
+
// transitive-caller list. Counts stay exact; truncation is flagged.
|
|
104
|
+
function capWhere(r) {
|
|
105
|
+
const cap = (k) => Array.isArray(r[k]) && r[k].length > MCP_LIST_CAP;
|
|
106
|
+
if (!cap("directly") && !cap("inherited")) return r;
|
|
107
|
+
return {
|
|
108
|
+
effect: r.effect,
|
|
109
|
+
directlyCount: r.directly.length, directly: r.directly.slice(0, MCP_LIST_CAP),
|
|
110
|
+
inheritedCount: r.inherited.length, inherited: r.inherited.slice(0, MCP_LIST_CAP),
|
|
111
|
+
truncated: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function capBlindspots(r) {
|
|
115
|
+
const sources = (r.sources ?? []).map((s) =>
|
|
116
|
+
Array.isArray(s.affected) && s.affected.length > MCP_LIST_CAP
|
|
117
|
+
? { ...s, affected: s.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true } // `reaches` is the full count
|
|
118
|
+
: s);
|
|
119
|
+
return { ...r, sources };
|
|
80
120
|
}
|
|
81
121
|
function capCallers(r) {
|
|
82
122
|
const d = r.direct ?? [], t = r.transitive ?? [];
|
|
@@ -97,7 +137,7 @@ const TOOLS = {
|
|
|
97
137
|
candor_where: {
|
|
98
138
|
description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
|
|
99
139
|
schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
|
|
100
|
-
run: (a, p) => Q.where(Q.loadReport(p), a.effect),
|
|
140
|
+
run: (a, p) => capWhere(Q.where(Q.loadReport(p), a.effect)),
|
|
101
141
|
},
|
|
102
142
|
candor_reachable: {
|
|
103
143
|
description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
|
|
@@ -128,14 +168,18 @@ const TOOLS = {
|
|
|
128
168
|
description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
|
|
129
169
|
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
|
|
130
170
|
run: (a, p) => {
|
|
131
|
-
|
|
171
|
+
// A GIVEN policy path is always read (confined, fail-closed) — the old `existsSync` guard made a
|
|
172
|
+
// typo'd/missing path silently evaluate with NO policy → `ok:true, violations:[]`, a false green
|
|
173
|
+
// on the agent-facing pre-edit gate (exactly what the CLI whatif exits 2 to prevent). The read's
|
|
174
|
+
// throw lands as the tool-level isError, mirroring the CLI's fail-closed posture.
|
|
175
|
+
const pol = a.policy ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
|
|
132
176
|
const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
|
|
133
177
|
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
134
178
|
return r;
|
|
135
179
|
},
|
|
136
180
|
},
|
|
137
181
|
candor_gate: {
|
|
138
|
-
description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report
|
|
182
|
+
description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
|
|
139
183
|
schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
|
|
140
184
|
run: (a, p) => {
|
|
141
185
|
let text;
|
|
@@ -157,7 +201,7 @@ const TOOLS = {
|
|
|
157
201
|
candor_blindspots: {
|
|
158
202
|
description: "The Unknown SOURCES — calls the engine genuinely could not resolve (reflection, wide dispatch, fn-pointers) — ranked by how many functions inherit Unknown through each. Turns a high-Unknown report into a short worklist.",
|
|
159
203
|
schema: { type: "object", properties: { ...reportArg } },
|
|
160
|
-
run: (_a, p) => Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p)),
|
|
204
|
+
run: (_a, p) => capBlindspots(Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p))),
|
|
161
205
|
},
|
|
162
206
|
candor_diff: {
|
|
163
207
|
description: "The per-function effect delta versus a baseline report: gained (introduced vs inherited) and lost effects. 'What did this change do to the effect surface?'.",
|
|
@@ -211,13 +255,19 @@ function handle(msg) {
|
|
|
211
255
|
if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications: no reply
|
|
212
256
|
if (method === "ping") return result(id, {});
|
|
213
257
|
if (method === "resources/list") {
|
|
214
|
-
try { return result(id, { resources: DEFAULT_PREFIX && hasReport(DEFAULT_PREFIX) ? listResources(DEFAULT_PREFIX) : [] }); }
|
|
258
|
+
try { return result(id, { resources: DEFAULT_PREFIX && Q.hasReport(DEFAULT_PREFIX) ? listResources(DEFAULT_PREFIX) : [] }); }
|
|
215
259
|
catch { return result(id, { resources: [] }); }
|
|
216
260
|
}
|
|
217
261
|
if (method === "resources/read") {
|
|
218
262
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
263
|
+
// Honor the prefix ENCODED in the resource URI (resources/list mints `?prefix=…`) — it was
|
|
264
|
+
// decorative before, always resolving the default (review find). resolvePrefix keeps the
|
|
265
|
+
// existence + --root checks on whatever the client asked for.
|
|
266
|
+
const uri = params?.uri || "";
|
|
267
|
+
let encoded = null;
|
|
268
|
+
try { encoded = new URL(uri).searchParams.get("prefix"); } catch { /* not URL-shaped — default */ }
|
|
269
|
+
const prefix = resolvePrefix(encoded ? { report: encoded } : {});
|
|
270
|
+
const r = readResource(uri, prefix);
|
|
221
271
|
return result(id, { contents: [{ uri: params?.uri, ...r }] });
|
|
222
272
|
} catch (e) { return error(id, -32602, `candor: ${e.message}`); }
|
|
223
273
|
}
|
package/package.json
CHANGED
package/policy.mjs
CHANGED
|
@@ -161,8 +161,11 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
161
161
|
|
|
162
162
|
// ---- .candor/config discovery (spec §3.4) — shared by the MCP + LSP surfaces -----------------------
|
|
163
163
|
// Walk UP from `fromDir` to the nearest .candor/config and return its `policy` entry resolved against
|
|
164
|
-
// that config's repo root: { policyPath, repoRoot } — or null.
|
|
165
|
-
//
|
|
164
|
+
// that config's repo root: { policyPath, repoRoot } — or null. A RELATIVE `policy` value resolves
|
|
165
|
+
// against the repo the config belongs to (the parent of its `.candor/`), NEVER the process CWD — the
|
|
166
|
+
// family rule (scan.mjs configAnchor is the producer-side twin): a checked-in config means the same
|
|
167
|
+
// file wherever the consumer process was launched. Read-only + best-effort (a consumer surface never
|
|
168
|
+
// gates a build; a broken config surfaces as the caller's error).
|
|
166
169
|
import fs from "node:fs";
|
|
167
170
|
import nodePath from "node:path";
|
|
168
171
|
export function discoverConfigPolicy(fromDir) {
|
package/query-core.mjs
CHANGED
|
@@ -22,11 +22,27 @@ function siblings(prefix, predicate) {
|
|
|
22
22
|
.map((f) => nodePath.join(dir, f));
|
|
23
23
|
} catch { return []; }
|
|
24
24
|
}
|
|
25
|
-
// A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger,
|
|
26
|
-
// calibrated-coverage sidecar
|
|
27
|
-
// as the loader — else a prefix whose only
|
|
28
|
-
// existence check but loads ZERO functions →
|
|
29
|
-
|
|
25
|
+
// A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger, a
|
|
26
|
+
// calibrated-coverage sidecar, or a --gate-json verdict written beside the prefix). Exported so
|
|
27
|
+
// `hasReport` (the MCP existence check) uses the SAME predicate as the loader — else a prefix whose only
|
|
28
|
+
// sibling is `.encountered-*`/`.calibrated.json` passes the existence check but loads ZERO functions →
|
|
29
|
+
// an authoritative-empty result (silent under-report; review find). `.gate.json` has no functions array,
|
|
30
|
+
// so merging it "disclosed a malformed report" on every query over the recommended CI layout — noisy, excluded.
|
|
31
|
+
export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json") && !f.endsWith(".gate.json");
|
|
32
|
+
|
|
33
|
+
// A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
|
|
34
|
+
// `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so a
|
|
35
|
+
// consumer (MCP/LSP) serves a report from ANY engine. ONE copy here: the check was triplicated across
|
|
36
|
+
// mcp.mjs/lsp.mjs, and an earlier divergence from the loader predicate was itself a review find.
|
|
37
|
+
export function hasReport(p) {
|
|
38
|
+
if (!p) return false;
|
|
39
|
+
if (fs.existsSync(`${p}.json`)) return true;
|
|
40
|
+
const base = nodePath.basename(p);
|
|
41
|
+
try {
|
|
42
|
+
return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
|
|
43
|
+
f.startsWith(base + ".") && f.endsWith(".json") && isReport(f));
|
|
44
|
+
} catch { return false; }
|
|
45
|
+
}
|
|
30
46
|
|
|
31
47
|
// Defend the queries against a partial/old-engine/hand-edited report: the §2 required fields are
|
|
32
48
|
// defaulted, and a WRONG-TYPE field is coerced — a non-array `inferred` (e.g. the string "Net") must
|
|
@@ -120,7 +136,9 @@ export function matches(names, q) {
|
|
|
120
136
|
return best === 0 ? [] : names.filter((n) => matchTier(n, q) >= best);
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
|
|
139
|
+
// Exported for consumers that answer MANY caller-count questions over one loaded graph (the LSP
|
|
140
|
+
// codeLens): building the inversion once per request instead of once per `callers()` call.
|
|
141
|
+
export function reverseGraph(cg) {
|
|
124
142
|
const rev = new Map();
|
|
125
143
|
for (const [caller, callees] of Object.entries(cg))
|
|
126
144
|
for (const c of callees) {
|
package/query.mjs
CHANGED
|
@@ -29,8 +29,9 @@ import { printAgents } from "./contract.mjs";
|
|
|
29
29
|
import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
30
30
|
show as coreShow, blindspots as coreBlindspots,
|
|
31
31
|
callers as coreCallers, callersFrontier, loadHierarchy,
|
|
32
|
-
containment as coreContainment,
|
|
33
|
-
|
|
32
|
+
containment as coreContainment, diff as coreDiff,
|
|
33
|
+
where as coreWhere, map as coreMap, whatif as coreWhatif,
|
|
34
|
+
loadReport, loadCallgraph, reportVersion } from "./query-core.mjs";
|
|
34
35
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
35
36
|
|
|
36
37
|
// ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
|
|
@@ -115,13 +116,11 @@ switch (cmd) {
|
|
|
115
116
|
break;
|
|
116
117
|
}
|
|
117
118
|
case "where": {
|
|
119
|
+
// Shared query-core (like show/callers) — the CLI and MCP `candor_where` are ONE implementation.
|
|
120
|
+
// Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
|
|
121
|
+
// fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
|
|
118
122
|
const [prefix, eff] = args;
|
|
119
|
-
|
|
120
|
-
emit({
|
|
121
|
-
effect: eff,
|
|
122
|
-
directly: fns.filter((e) => e.direct.includes(eff)).map((e) => e.fn).sort(),
|
|
123
|
-
inherited: fns.filter((e) => e.inferred.includes(eff) && !e.direct.includes(eff)).map((e) => e.fn).sort(),
|
|
124
|
-
});
|
|
123
|
+
emit(coreWhere(loadReport(prefix), eff));
|
|
125
124
|
break;
|
|
126
125
|
}
|
|
127
126
|
case "callers": {
|
|
@@ -136,17 +135,9 @@ switch (cmd) {
|
|
|
136
135
|
break;
|
|
137
136
|
}
|
|
138
137
|
case "map": {
|
|
138
|
+
// Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
|
|
139
139
|
const [prefix] = args;
|
|
140
|
-
|
|
141
|
-
const mods = {};
|
|
142
|
-
for (const e of fns) {
|
|
143
|
-
const mod = e.fn.includes(".") ? e.fn.split(".").slice(0, -1).join(".") : "(root)";
|
|
144
|
-
const m = (mods[mod] ??= { effects: new Set(), functions: 0 });
|
|
145
|
-
for (const x of e.inferred) m.effects.add(x);
|
|
146
|
-
m.functions += 1;
|
|
147
|
-
}
|
|
148
|
-
emit(Object.fromEntries(Object.entries(mods).sort()
|
|
149
|
-
.map(([k, v]) => [k, { effects: [...v.effects].sort(), functions: v.functions }])));
|
|
140
|
+
emit(coreMap(loadReport(prefix)));
|
|
150
141
|
break;
|
|
151
142
|
}
|
|
152
143
|
case "containment": {
|
|
@@ -168,18 +159,13 @@ switch (cmd) {
|
|
|
168
159
|
}
|
|
169
160
|
case "diff": {
|
|
170
161
|
// per-function effect delta vs a baseline: {changes: [{fn, gained, lost}]} — the envelope shape
|
|
171
|
-
// the conformance suite pins (diff-vs-self must be {changes: []}).
|
|
162
|
+
// the conformance suite pins (diff-vs-self must be {changes: []}). Shared query-core: the CLI's
|
|
163
|
+
// former inline copy built `new Map(fns.map((e) => [e.fn, …]))` — the exact last-wins collapse
|
|
164
|
+
// core's effectsByFn was rewritten to avoid (merged multi-report siblings sharing a short fn name
|
|
165
|
+
// dropped one member's effects, so a gained Net could VANISH from diff and its exit-1 contract —
|
|
166
|
+
// a supply-chain miss, and the CLI disagreeing with MCP `candor_diff` on the same reports).
|
|
172
167
|
const [curPrefix, basePrefix] = args;
|
|
173
|
-
const
|
|
174
|
-
const base = new Map(loadReport(basePrefix).map((e) => [e.fn, new Set(e.inferred)]));
|
|
175
|
-
const changes = [];
|
|
176
|
-
for (const fn of new Set([...cur.keys(), ...base.keys()])) {
|
|
177
|
-
const c = cur.get(fn) ?? new Set(), b = base.get(fn) ?? new Set();
|
|
178
|
-
const gained = [...c].filter((e) => !b.has(e)).sort();
|
|
179
|
-
const lost = [...b].filter((e) => !c.has(e)).sort();
|
|
180
|
-
if (gained.length || lost.length) changes.push({ fn, gained, lost });
|
|
181
|
-
}
|
|
182
|
-
changes.sort((a, b) => a.fn.localeCompare(b.fn));
|
|
168
|
+
const { changes } = coreDiff(loadReport(curPrefix), loadReport(basePrefix));
|
|
183
169
|
// §2.1: a baseline is comparable only to its own producing build — disclose a mismatch (the gains
|
|
184
170
|
// may be the engine reclassifying after a coverage batch, not the code changing). Same note + JSON
|
|
185
171
|
// provenance fields as the Rust candor-query (cross-engine parity, item 10).
|
|
@@ -239,26 +225,10 @@ switch (cmd) {
|
|
|
239
225
|
}
|
|
240
226
|
case "whatif": {
|
|
241
227
|
const [prefix, target, eff, maybePolicy] = args;
|
|
242
|
-
const cg = loadCallgraph(prefix);
|
|
243
|
-
const names = Object.keys(cg);
|
|
244
|
-
const targets = matches(names, target);
|
|
245
|
-
if (targets.length === 0) {
|
|
246
|
-
console.error(`candor: no function matching \`${target}\` in the call graph`);
|
|
247
|
-
process.exit(2);
|
|
248
|
-
}
|
|
249
|
-
const rev = new Map();
|
|
250
|
-
for (const [caller, callees] of Object.entries(cg))
|
|
251
|
-
for (const c of callees) (rev.get(c) ?? rev.set(c, []).get(c)).push(caller);
|
|
252
|
-
const affected = new Set(targets);
|
|
253
|
-
const queue = [...targets];
|
|
254
|
-
while (queue.length) {
|
|
255
|
-
const n = queue.pop();
|
|
256
|
-
for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
|
|
257
|
-
}
|
|
258
|
-
const violations = [];
|
|
259
228
|
// A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
|
|
260
229
|
// a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
|
|
261
230
|
// which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
|
|
231
|
+
let pol = null;
|
|
262
232
|
if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
|
|
263
233
|
let text;
|
|
264
234
|
try {
|
|
@@ -267,16 +237,17 @@ switch (cmd) {
|
|
|
267
237
|
console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
|
|
268
238
|
process.exit(2);
|
|
269
239
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
240
|
+
pol = parsePolicy(text);
|
|
241
|
+
}
|
|
242
|
+
// Shared query-core — the CLI and MCP `candor_whatif` are one blast-radius + deny evaluation
|
|
243
|
+
// (the CLI keeps the I/O + exit codes; the core is pure — see `where` above for the drift class).
|
|
244
|
+
const r = coreWhatif(loadCallgraph(prefix), target, eff, pol, scopeMatches);
|
|
245
|
+
if (r === null) {
|
|
246
|
+
console.error(`candor: no function matching \`${target}\` in the call graph`);
|
|
247
|
+
process.exit(2);
|
|
277
248
|
}
|
|
278
|
-
emit(
|
|
279
|
-
process.exit(violations.length ? 1 : 0);
|
|
249
|
+
emit(r);
|
|
250
|
+
process.exit(r.violations.length ? 1 : 0);
|
|
280
251
|
break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
|
|
281
252
|
}
|
|
282
253
|
default:
|
package/scan.mjs
CHANGED
|
@@ -110,6 +110,16 @@ if (target === null) { console.error(usage); process.exit(2); }
|
|
|
110
110
|
// implements `policy` + `deps` — the others are inert here (they drive other engines' gates), and a key
|
|
111
111
|
// OUTSIDE the vocabulary warns (typo protection: a misspelt `policy` must not silently drop the gate).
|
|
112
112
|
const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps"]);
|
|
113
|
+
// The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
|
|
114
|
+
// belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
|
|
115
|
+
// `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
|
|
116
|
+
// process CWD (family rule, matching policy.mjs discoverConfigPolicy's repoRoot): a checked-in config
|
|
117
|
+
// must mean the same file whether the scan is launched from the repo, from $HOME, or from a CI step's
|
|
118
|
+
// working-directory. Env/CLI values stay CWD-relative — they're per-invocation, not checked in.
|
|
119
|
+
function configAnchor(file) {
|
|
120
|
+
const dir = path.dirname(path.resolve(file));
|
|
121
|
+
return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
|
|
122
|
+
}
|
|
113
123
|
function loadCandorConfig(targetPath) {
|
|
114
124
|
let file = process.env.CANDOR_CONFIG ?? null;
|
|
115
125
|
if (file !== null) {
|
|
@@ -146,6 +156,12 @@ function loadCandorConfig(targetPath) {
|
|
|
146
156
|
}
|
|
147
157
|
cfg[key] = val;
|
|
148
158
|
}
|
|
159
|
+
// Resolve the PATH-valued keys against the config's anchor (see configAnchor). `deps` is a path
|
|
160
|
+
// LIST — each token resolves; an empty value stays empty (configured-with-empty fails loud below).
|
|
161
|
+
const anchor = configAnchor(file);
|
|
162
|
+
if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
|
|
163
|
+
if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
|
|
164
|
+
if (cfg.deps) cfg.deps = cfg.deps.split(/[\s:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
|
|
149
165
|
return cfg;
|
|
150
166
|
}
|
|
151
167
|
const candorConfig = loadCandorConfig(target);
|
|
@@ -2128,14 +2144,18 @@ if (unlistedSeen.size > 0) {
|
|
|
2128
2144
|
}
|
|
2129
2145
|
|
|
2130
2146
|
// ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
|
|
2147
|
+
// `!== null`, not truthiness: a CONFIGURED-but-EMPTY policy (a bare `policy` config line, a set-but-
|
|
2148
|
+
// empty CANDOR_POLICY) is "" — falsy, so a truthy check silently skipped the gate, the exact quiet
|
|
2149
|
+
// drop the config comment above promises fails loud. "" now reaches the read, which fails → exit 2
|
|
2150
|
+
// (the Rust engine's behavior on the same input).
|
|
2131
2151
|
let gateViolations = [];
|
|
2132
|
-
if (policyPath) {
|
|
2152
|
+
if (policyPath !== null) {
|
|
2133
2153
|
let text;
|
|
2134
2154
|
try {
|
|
2135
2155
|
text = fs.readFileSync(policyPath, "utf8");
|
|
2136
2156
|
} catch {
|
|
2137
2157
|
// a set-but-unreadable policy must be LOUD — silently passing would let a violation ship
|
|
2138
|
-
console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
|
|
2158
|
+
console.error(`candor-ts: policy ${policyPath === "" ? "(configured empty)" : policyPath} could not be read; gate NOT enforced`);
|
|
2139
2159
|
process.exit(2);
|
|
2140
2160
|
}
|
|
2141
2161
|
// The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
|
|
@@ -2162,8 +2182,8 @@ if (gateJsonPath) {
|
|
|
2162
2182
|
catch (e) { console.error(`candor-ts: could not write --gate-json ${gateJsonPath}: ${e.message}`); }
|
|
2163
2183
|
}
|
|
2164
2184
|
}
|
|
2165
|
-
if (policyPath && gateViolations.length) {
|
|
2185
|
+
if (policyPath !== null && gateViolations.length) {
|
|
2166
2186
|
console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
|
|
2167
2187
|
process.exit(1);
|
|
2168
2188
|
}
|
|
2169
|
-
if (policyPath) console.error("candor-ts: policy ✓");
|
|
2189
|
+
if (policyPath !== null) console.error("candor-ts: policy ✓");
|