candor-ts 0.4.6 → 0.5.1
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 +21 -4
- package/README.md +66 -1
- package/mcp.mjs +191 -0
- package/package.json +21 -6
- package/policy.mjs +9 -4
- package/query-core.mjs +302 -0
- package/query.mjs +32 -31
- package/scan-core.mjs +161 -0
- package/scan.mjs +145 -149
- package/watch.mjs +126 -0
package/AGENTS.md
CHANGED
|
@@ -9,6 +9,12 @@ chains by hand.
|
|
|
9
9
|
> the *installed* version — always prefer that over a vendored or fetched copy, which can describe
|
|
10
10
|
> a different candor-ts than the one you are running.
|
|
11
11
|
|
|
12
|
+
> **Staying current is your job, not candor's.** candor never phones home — it audits and denies the
|
|
13
|
+
> Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
|
|
14
|
+
> installed build, the spec contract it speaks, and the upgrade line (`npm install -g
|
|
15
|
+
> candor-ts@latest`) — fully offline. You have the network: read the installed version here, compare
|
|
16
|
+
> it against npm, and upgrade if it is stale. Every command is offline.
|
|
17
|
+
|
|
12
18
|
The language-agnostic consumption contract is
|
|
13
19
|
[candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is
|
|
14
20
|
the TypeScript-specific production + query surface.
|
|
@@ -54,20 +60,28 @@ downgraded to `Unknown` rather than silently trusted (spec §2.1). Caveat: a typ
|
|
|
54
60
|
Q() { npx -y candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh
|
|
55
61
|
Q show $P <fn-query> 1 # a function's effects (+ hosts/tables when visible)
|
|
56
62
|
Q where $P <Effect> 1 # {effect, directly, inherited}
|
|
57
|
-
Q
|
|
63
|
+
Q impact $P <fn-query> # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints}
|
|
64
|
+
Q callers $P <fn-query> 1 # the lower-level form: {of, direct, transitive} — works for pure fns
|
|
65
|
+
Q path $P <fn> <Effect> # how a fn reaches an effect: the chain to the nearest source
|
|
58
66
|
Q map $P 1 # {module: {effects, functions}}
|
|
59
67
|
Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it would violate)
|
|
60
68
|
Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
|
|
69
|
+
Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
|
|
61
70
|
Q reachable $P 1 # what the app DOES at runtime: effects over the entry points
|
|
62
71
|
Q parsepolicy <policy-file> # the canonical §6.2 parse (what the gate will enforce)
|
|
63
72
|
```
|
|
64
73
|
|
|
74
|
+
And as an MCP server, so an agent pulls these as tools instead of shelling out:
|
|
75
|
+
`CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…).
|
|
76
|
+
`npx -y candor-ts-watch <dir>` keeps the report fresh as you edit (and reports the edit-delta).
|
|
77
|
+
|
|
65
78
|
Name queries resolve exact > segment-suffix (`db.save` matches `src.db.save`, never
|
|
66
79
|
`src.db.save_all`) > substring — the same ladder as the other engines. The trailing `1` is the
|
|
67
80
|
want-JSON flag.
|
|
68
81
|
|
|
69
|
-
- **Blast radius of editing a function** → `
|
|
70
|
-
function itself does). Works pre-edit for a
|
|
82
|
+
- **Blast radius of editing a function** → `impact <fn>` (the `affected` list + downstream
|
|
83
|
+
`entryPoints`; NOT its `inferred`, which is what the function itself does). Works pre-edit for a
|
|
84
|
+
still-pure function. `callers <fn>` is the lower-level raw-callers form.
|
|
71
85
|
- **Decide BEFORE you edit** → `whatif <fn> <Effect> [policy]` — every transitive caller gains the
|
|
72
86
|
effect, crossed with the policy.
|
|
73
87
|
- **After you change code** → `diff` against a baseline report; a gained `Net`/`Db`/`Exec`/`Fs` you
|
|
@@ -119,4 +133,7 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
|
|
|
119
133
|
NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
|
|
120
134
|
know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
|
|
121
135
|
"no effect" through a package that line names (the documented weaker edge of the
|
|
122
|
-
never-silently-pure promise, same as every candor engine's curated classifier).
|
|
136
|
+
never-silently-pure promise, same as every candor engine's curated classifier). An uncurated
|
|
137
|
+
dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
|
|
138
|
+
`package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
|
|
139
|
+
the declared set instead of contributing nothing.
|
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ node scan.mjs <project-dir> # tsconfig.json honored; tests exclu
|
|
|
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
22
|
|
|
23
|
+
node scan.mjs --version # installed build + spec contract (offline), + upgrade line
|
|
24
|
+
|
|
23
25
|
node query.mjs show .candor/report db.save 1 # a function's effects (match ladder)
|
|
24
26
|
node query.mjs where .candor/report Net 1 # direct sources vs inheritors
|
|
25
27
|
node query.mjs callers .candor/report db.save 1 # the blast radius (transitive callers)
|
|
@@ -28,6 +30,8 @@ node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdi
|
|
|
28
30
|
node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain)
|
|
29
31
|
```
|
|
30
32
|
|
|
33
|
+
**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
|
+
|
|
31
35
|
Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
|
|
32
36
|
naturally:
|
|
33
37
|
|
|
@@ -54,10 +58,53 @@ plus a small npm tier (axios/got/node-fetch/undici/ws, pg/mysql2/mongodb/redis/k
|
|
|
54
58
|
execa/cross-spawn, fs-extra/rimraf/glob, dotenv, winston/pino). An unlisted package contributes
|
|
55
59
|
nothing — candor never guesses an effect.
|
|
56
60
|
|
|
61
|
+
## MCP server — candor as agent ground truth
|
|
62
|
+
|
|
63
|
+
`candor-ts-mcp` exposes the read-only queries as an [MCP](https://modelcontextprotocol.io) server, so
|
|
64
|
+
a coding agent can ask **"if I change this, what's the runtime blast radius?"** or **"what reaches the
|
|
65
|
+
network?"** and get deterministic ground truth from a precomputed report — instead of burning tokens
|
|
66
|
+
tracing the call graph by hand (the measured ~700–2000× token win on blast-radius questions).
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
// in an MCP client config — point it at a report you've already scanned
|
|
70
|
+
{ "command": "npx", "args": ["-y", "candor-ts-mcp"],
|
|
71
|
+
"env": { "CANDOR_REPORT": ".candor/report.myPkg.scan" } }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Tools: `candor_impact` (backward blast radius), `candor_reachable` (what runs at runtime),
|
|
75
|
+
`candor_where` (effect surface), `candor_path` (how an effect is reached), `candor_callers`,
|
|
76
|
+
`candor_show`, `candor_map`, `candor_whatif` (pre-edit gate check). Each takes an optional `report`
|
|
77
|
+
prefix (else `$CANDOR_REPORT`). The server is **query-only** — it never scans (the analyzer
|
|
78
|
+
self-boundary, spec §7.12: an agent or a hook produces the report; the server reads it, Fs only). The
|
|
79
|
+
query logic is the shared `query-core.mjs`, the same answers the CLI gives.
|
|
80
|
+
|
|
81
|
+
**The live loop** — `candor-ts-watch` keeps the report fresh as the agent edits, so the answers are
|
|
82
|
+
about the *current* code, not a stale snapshot:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
candor-ts-watch ./src --out .candor/report # re-scans only when a tracked source actually changes
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It tracks the project's sources by content hash and re-scans on a real change (a no-op save or an
|
|
89
|
+
unrelated write does nothing), writing the same prefix the MCP server reads. So: **agent edits →
|
|
90
|
+
watcher refreshes the report → agent asks `candor_impact` and gets the post-edit answer.** And it
|
|
91
|
+
reports the **edit-delta** — not just that the report is fresh but *what the edit did* to the effect
|
|
92
|
+
surface (`re-scanned (1 changed: app.ts) — Δ f +Net`), so the agent learns the consequence of its
|
|
93
|
+
own change. v1 runs a full (sound) scan per change; the deeper *perf* optimisation — re-analysing
|
|
94
|
+
only the changed file's subgraph instead of the whole project — is the staged next step (the
|
|
95
|
+
content-hash gate is its first increment).
|
|
96
|
+
|
|
57
97
|
## Trust contract (spec §4)
|
|
58
98
|
|
|
59
99
|
Anything candor-ts can't resolve is `Unknown`, never silently pure: a function-valued parameter or
|
|
60
100
|
field being called, an `any`-typed callee, resolution landing on a type rather than a body.
|
|
101
|
+
|
|
102
|
+
An **uncurated dependency** can opt out of `Unknown`/silent-pure by **declaring its effects** in its
|
|
103
|
+
`package.json` — `"candorEffects": ["Net"]` (spec §5.1, the effect manifest). candor-ts reads it as
|
|
104
|
+
the declared-not-verified tier: the package's calls classify to the declared set, and it stops being
|
|
105
|
+
a κ-ledger blind spot. A name outside the §1 vocabulary voids the declaration loudly (a typo must not
|
|
106
|
+
silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
|
|
107
|
+
delta — the effects a surface *gained* between two reports.
|
|
61
108
|
Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
|
|
62
109
|
functions analyzed): its DI-style fs injection means many functions honestly read `Unknown` —
|
|
63
110
|
that's the contract working, not noise. The report says "can reach", never "does"; an absent
|
|
@@ -82,7 +129,7 @@ every push to the spec.
|
|
|
82
129
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
83
130
|
| Unmatched external calls contribute nothing (curated-κ caveat) | SEMANTICS §8 C1 |
|
|
84
131
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
85
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
132
|
+
| `{ candor: { version, toolchain, spec: "0.5" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
86
133
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
87
134
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
88
135
|
|
|
@@ -107,3 +154,21 @@ conformance-held. The npm classifier tier is
|
|
|
107
154
|
deliberately curated and will keep growing case-by-case. Entry points (Nest/Next populations),
|
|
108
155
|
`unknownWhy` origins, `reachable`, cross-package inheritance (`CANDOR_DEPS` + the spec §2 `hash`,
|
|
109
156
|
version-trusted per §2.1), and `--allow-js` are all in. On npm: `npx -y candor-ts <dir>`.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
No build step — the engine runs on Node directly.
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npm install
|
|
164
|
+
npm test # the full CI gate: lint + unit (node:test) + behavioural + MCP + watch + the
|
|
165
|
+
# fabrication probe + the §7.13 soundness fuzzer
|
|
166
|
+
npm run test:unit # just the native unit tests — the query algebra + policy DSL + the scan-core
|
|
167
|
+
# classifier/literal leaves (query-core / policy / scan-core)
|
|
168
|
+
npm run lint # eslint (the recommended ruleset; the CI lint gate)
|
|
169
|
+
node scan.mjs <dir | file.ts | tsconfig.json> --out .candor/report # scan a project
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The pure cores are factored into importable modules — `query-core.mjs` (the §3.1 queries),
|
|
173
|
+
`policy.mjs` (the §6.2 DSL + literal matchers), and `scan-core.mjs` (the κ classifier + the SQL/
|
|
174
|
+
command/host extractors) — so they're unit-tested directly; the TS-compiler-driven walk stays in `scan.mjs`.
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* candor-mcp — candor's read-only query surface as an MCP server (roadmap direction #1: candor as
|
|
4
|
+
* agent infrastructure). An agent asks "if I change X, what's the runtime blast radius?" or "what
|
|
5
|
+
* reaches the network?" and gets DETERMINISTIC ground truth from a precomputed report in ~zero
|
|
6
|
+
* exploration tokens — the measured ~700-2000x token win over grepping to the same answer.
|
|
7
|
+
*
|
|
8
|
+
* Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio framing), implemented directly
|
|
9
|
+
* so candor-ts's `npx` scan/query path stays dependency-free. Query logic is the shared query-core.mjs
|
|
10
|
+
* (one source of truth with the CLI). The server is QUERY-ONLY (it never scans — the analyzer self-
|
|
11
|
+
* boundary, SPEC §7.12; an agent/hook produces the report, the server reads it: Fs only).
|
|
12
|
+
*
|
|
13
|
+
* The report to query is resolved per call from the tool's `report` arg, else $CANDOR_REPORT, else
|
|
14
|
+
* the first CLI arg. A `<prefix>` names `<prefix>.json` + `<prefix>.callgraph.json`.
|
|
15
|
+
*
|
|
16
|
+
* CANDOR_REPORT=.candor/report.myCrate.scan npx -y candor-ts mcp
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import nodePath from "node:path";
|
|
21
|
+
import * as Q from "./query-core.mjs";
|
|
22
|
+
import { parsePolicy, scopeMatches } from "./policy.mjs";
|
|
23
|
+
|
|
24
|
+
const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PREFIX = process.env.CANDOR_REPORT || process.argv[2] || null;
|
|
27
|
+
|
|
28
|
+
// A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
|
|
29
|
+
// `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so
|
|
30
|
+
// the MCP server serves a report from ANY engine, not just candor-ts's.
|
|
31
|
+
function hasReport(p) {
|
|
32
|
+
if (fs.existsSync(`${p}.json`)) return true;
|
|
33
|
+
const base = nodePath.basename(p);
|
|
34
|
+
try {
|
|
35
|
+
// SAME predicate (Q.isReport) the loader uses — a prefix whose only sibling is `.encountered-*` /
|
|
36
|
+
// `.calibrated.json` must NOT pass here, else loadReport finds zero functions and the tool returns an
|
|
37
|
+
// authoritative-empty result instead of "no report" (a silent under-report — review find).
|
|
38
|
+
return fs.readdirSync(nodePath.dirname(p) || ".").some((f) =>
|
|
39
|
+
f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
function resolvePrefix(args) {
|
|
43
|
+
const p = args?.report || DEFAULT_PREFIX;
|
|
44
|
+
if (!p) throw new Error("no report prefix: pass `report`, set $CANDOR_REPORT, or give one as the CLI arg");
|
|
45
|
+
if (!hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- the tools: name -> {description, schema, run} ------------------------------------------------
|
|
50
|
+
const reportArg = { report: { type: "string", description: "report prefix (optional; defaults to $CANDOR_REPORT)" } };
|
|
51
|
+
|
|
52
|
+
// Bound a blast-radius/caller LIST for the agent transport: on a large repo a single fn can have
|
|
53
|
+
// hundreds-to-thousands of transitive callers, an unbounded multi-thousand-token answer. The agent's
|
|
54
|
+
// question ("how big is the blast radius / where does it surface") is answered by the COUNT + the entry
|
|
55
|
+
// points + the top names — so cap the list to MCP_LIST_CAP, keep the exact count, and flag truncation.
|
|
56
|
+
// The full list stays available from the CLI / `--json` (the spec-pinned §3.1 shape is UNCHANGED — this
|
|
57
|
+
// only shapes the MCP result for its token-sensitive transport). Small results are returned verbatim.
|
|
58
|
+
const MCP_LIST_CAP = 50;
|
|
59
|
+
function capImpact(r) {
|
|
60
|
+
if (!Array.isArray(r.affected) || r.affected.length <= MCP_LIST_CAP) return r; // affectedCount is the full count
|
|
61
|
+
return { ...r, affected: r.affected.slice(0, MCP_LIST_CAP), affectedTruncated: true };
|
|
62
|
+
}
|
|
63
|
+
function capCallers(r) {
|
|
64
|
+
const d = r.direct ?? [], t = r.transitive ?? [];
|
|
65
|
+
if (d.length <= MCP_LIST_CAP && t.length <= MCP_LIST_CAP) return r;
|
|
66
|
+
return {
|
|
67
|
+
of: r.of,
|
|
68
|
+
directCount: d.length, direct: d.slice(0, MCP_LIST_CAP),
|
|
69
|
+
transitiveCount: t.length, transitive: t.slice(0, MCP_LIST_CAP),
|
|
70
|
+
truncated: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const TOOLS = {
|
|
74
|
+
candor_impact: {
|
|
75
|
+
description: "Backward blast radius: every effectful function that transitively calls `fn`, and which runtime entry points are downstream. Answers 'if I change this, what surfaces at runtime?' — the cheapest possible alternative to tracing callers by hand.",
|
|
76
|
+
schema: { type: "object", properties: { fn: { type: "string", description: "the function/unit to assess" }, ...reportArg }, required: ["fn"] },
|
|
77
|
+
run: (a, p) => capImpact(Q.impact(Q.loadReport(p), Q.loadCallgraph(p), a.fn)),
|
|
78
|
+
},
|
|
79
|
+
candor_where: {
|
|
80
|
+
description: "Which functions perform a given effect (e.g. Net, Db, Exec, Fs) — `directly` vs `inherited` via a callee. The effect-surface map.",
|
|
81
|
+
schema: { type: "object", properties: { effect: { type: "string", description: "Net|Fs|Db|Exec|Env|Clock|Ipc|Log|Rand|Clipboard|Unknown" }, ...reportArg }, required: ["effect"] },
|
|
82
|
+
run: (a, p) => Q.where(Q.loadReport(p), a.effect),
|
|
83
|
+
},
|
|
84
|
+
candor_reachable: {
|
|
85
|
+
description: "What the program/fleet actually DOES at runtime: effects unioned over the entry points, with how many roots reach each and via which.",
|
|
86
|
+
schema: { type: "object", properties: { ...reportArg } },
|
|
87
|
+
run: (_a, p) => Q.reachable(Q.loadReport(p)),
|
|
88
|
+
},
|
|
89
|
+
candor_path: {
|
|
90
|
+
description: "Forward provenance: the shortest call chain from `fn` to the nearest function that performs `effect` DIRECTLY — 'this reaches Net through WHAT?'.",
|
|
91
|
+
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
|
|
92
|
+
run: (a, p) => Q.path(Q.loadReport(p), Q.loadCallgraph(p), a.fn, a.effect),
|
|
93
|
+
},
|
|
94
|
+
candor_callers: {
|
|
95
|
+
description: "Who calls `fn` — direct (one hop) and transitive callers over the effect-relevant call graph.",
|
|
96
|
+
schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
|
|
97
|
+
run: (a, p) => capCallers(Q.callers(Q.loadCallgraph(p), a.fn)),
|
|
98
|
+
},
|
|
99
|
+
candor_show: {
|
|
100
|
+
description: "A function's effects (inferred = transitive, direct = own body) plus its literal surfaces (hosts/cmds/paths/tables) when present.",
|
|
101
|
+
schema: { type: "object", properties: { fn: { type: "string" }, ...reportArg }, required: ["fn"] },
|
|
102
|
+
run: (a, p) => Q.show(Q.loadReport(p), a.fn),
|
|
103
|
+
},
|
|
104
|
+
candor_map: {
|
|
105
|
+
description: "Per-module effect overview: each module's union of effects and function count. The architecture-at-a-glance.",
|
|
106
|
+
schema: { type: "object", properties: { ...reportArg } },
|
|
107
|
+
run: (_a, p) => Q.map(Q.loadReport(p)),
|
|
108
|
+
},
|
|
109
|
+
candor_whatif: {
|
|
110
|
+
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.",
|
|
111
|
+
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"] },
|
|
112
|
+
run: (a, p) => {
|
|
113
|
+
const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(fs.readFileSync(a.policy, "utf8")) : null;
|
|
114
|
+
const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
|
|
115
|
+
if (r === null) throw new Error(`no function matching \`${a.fn}\` in the call graph`);
|
|
116
|
+
return r;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ---- JSON-RPC 2.0 over stdio (newline-delimited; the MCP stdio framing) ---------------------------
|
|
122
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
123
|
+
function result(id, r) { send({ jsonrpc: "2.0", id, result: r }); }
|
|
124
|
+
function error(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
|
|
125
|
+
|
|
126
|
+
function handle(msg) {
|
|
127
|
+
const { id, method, params } = msg;
|
|
128
|
+
if (method === "initialize") {
|
|
129
|
+
return result(id, {
|
|
130
|
+
protocolVersion: params?.protocolVersion || "2025-06-18",
|
|
131
|
+
capabilities: { tools: {} },
|
|
132
|
+
serverInfo: { name: "candor-mcp", version: VERSION },
|
|
133
|
+
instructions: "candor's read-only effect queries. Prefer candor_impact/candor_reachable/candor_where over manually tracing the call graph — they return deterministic ground truth from a precomputed report. Run a candor scan first to produce the report.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications: no reply
|
|
137
|
+
if (method === "ping") return result(id, {});
|
|
138
|
+
if (method === "tools/list") {
|
|
139
|
+
return result(id, {
|
|
140
|
+
tools: Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.schema })),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (method === "tools/call") {
|
|
144
|
+
const t = TOOLS[params?.name];
|
|
145
|
+
if (!t) return error(id, -32602, `unknown tool: ${params?.name}`);
|
|
146
|
+
try {
|
|
147
|
+
const args = params.arguments || {};
|
|
148
|
+
// Enforce the tool's declared required args server-side — a missing `fn` must be a clear error,
|
|
149
|
+
// not a silently-empty result (a defensive server doesn't trust the client to validate).
|
|
150
|
+
const missing = (t.schema.required || []).filter((k) => args[k] === undefined || args[k] === "");
|
|
151
|
+
if (missing.length)
|
|
152
|
+
return result(id, { content: [{ type: "text", text: `candor: missing required argument(s): ${missing.join(", ")}` }], isError: true });
|
|
153
|
+
const prefix = resolvePrefix(args);
|
|
154
|
+
// A tool that targets a `fn` gets a clear "not found" rather than a silently-empty result —
|
|
155
|
+
// an agent must distinguish "no such function" from "found, nothing calls it".
|
|
156
|
+
if (args.fn !== undefined) {
|
|
157
|
+
const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...Q.loadReport(prefix).map((e) => e.fn)])];
|
|
158
|
+
if (Q.matches(names, args.fn).length === 0)
|
|
159
|
+
return result(id, { content: [{ type: "text", text: `candor: no function matching \`${args.fn}\` in this report` }], isError: true });
|
|
160
|
+
}
|
|
161
|
+
const out = t.run(args, prefix);
|
|
162
|
+
// Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
|
|
163
|
+
// was ~25-30% of every result's tokens for no benefit. The CLI keeps its human-readable shapes.
|
|
164
|
+
return result(id, { content: [{ type: "text", text: JSON.stringify(out) }] });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// A tool-level failure is reported in the result (isError), not as a protocol error.
|
|
167
|
+
return result(id, { content: [{ type: "text", text: `candor: ${e.message}` }], isError: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (id !== undefined) error(id, -32601, `method not found: ${method}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let buf = "";
|
|
174
|
+
process.stdin.setEncoding("utf8");
|
|
175
|
+
process.stdin.on("data", (chunk) => {
|
|
176
|
+
buf += chunk;
|
|
177
|
+
let nl;
|
|
178
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
179
|
+
const line = buf.slice(0, nl).trim();
|
|
180
|
+
buf = buf.slice(nl + 1);
|
|
181
|
+
if (!line) continue;
|
|
182
|
+
let msg;
|
|
183
|
+
try { msg = JSON.parse(line); } catch { continue; } // ignore unparseable frames
|
|
184
|
+
// A JSON-RPC frame is a (non-null, non-array) object. `null`, a bare primitive, or a batch array
|
|
185
|
+
// would crash `handle`'s destructure — and the catch's own `msg.id` deref re-threw OUTSIDE the
|
|
186
|
+
// handler, killing the whole server (and the agent's session) on a single `null\n` line (review find).
|
|
187
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) continue;
|
|
188
|
+
try { handle(msg); } catch (e) { if (msg.id !== undefined) error(msg.id, -32603, e.message); }
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
process.stdin.on("end", () => process.exit(0));
|
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.5.1",
|
|
4
|
+
"description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
11
|
"candor-ts": "./scan.mjs",
|
|
12
|
-
"candor-ts-query": "./query.mjs"
|
|
12
|
+
"candor-ts-query": "./query.mjs",
|
|
13
|
+
"candor-ts-mcp": "./mcp.mjs",
|
|
14
|
+
"candor-ts-watch": "./watch.mjs"
|
|
13
15
|
},
|
|
14
16
|
"scripts": {
|
|
15
|
-
"
|
|
17
|
+
"lint": "eslint *.mjs",
|
|
18
|
+
"test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
|
|
19
|
+
"test:unit": "node --test test-unit.mjs",
|
|
20
|
+
"test:probe": "node fabrication_probe.mjs",
|
|
21
|
+
"test:fuzz": "node fuzz.mjs"
|
|
16
22
|
},
|
|
17
23
|
"license": "(MIT OR Apache-2.0)",
|
|
18
24
|
"repository": {
|
|
@@ -40,6 +46,15 @@
|
|
|
40
46
|
"AGENTS.md",
|
|
41
47
|
"PROVE-IT.md",
|
|
42
48
|
"LICENSE-MIT",
|
|
43
|
-
"LICENSE-APACHE"
|
|
44
|
-
|
|
49
|
+
"LICENSE-APACHE",
|
|
50
|
+
"query-core.mjs",
|
|
51
|
+
"scan-core.mjs",
|
|
52
|
+
"mcp.mjs",
|
|
53
|
+
"watch.mjs"
|
|
54
|
+
],
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@eslint/js": "^9.39.4",
|
|
57
|
+
"eslint": "^9.39.4",
|
|
58
|
+
"globals": "^17.6.0"
|
|
59
|
+
}
|
|
45
60
|
}
|
package/policy.mjs
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
export const EFFECTS = ["Net", "Fs", "Db", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard"];
|
|
8
8
|
const ALLOW_EFFECTS = new Set(["Net", "Exec", "Fs", "Db"]); // the four literal surfaces
|
|
9
9
|
|
|
10
|
+
// The §6.2 token separator: ASCII whitespace ONLY (space/tab/LF/VT/FF/CR). JS `\s`/`String.trim` strip
|
|
11
|
+
// Unicode spaces (NBSP, ideographic, …) that Java drops — a gateless-green cross-engine divergence
|
|
12
|
+
// (adversarial DSL review). A non-ASCII space stays part of its token → the rule is malformed, dropped.
|
|
13
|
+
const ASCII_WS = /[ \t\n\v\f\r]+/;
|
|
14
|
+
const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
|
|
10
15
|
export function parsePolicy(text) {
|
|
11
16
|
const deny = [], allow = [], forbid = [];
|
|
12
17
|
for (const rawLine of text.split("\n")) {
|
|
13
|
-
const line = rawLine.split("#")[0].
|
|
18
|
+
const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
|
|
14
19
|
if (!line) continue;
|
|
15
|
-
const t = line.split(
|
|
20
|
+
const t = line.split(ASCII_WS);
|
|
16
21
|
const warn = (why) => console.error(`candor: ignoring policy rule (${why}): ${line}`);
|
|
17
22
|
if (t[0] === "deny") {
|
|
18
23
|
const effects = [];
|
|
@@ -22,7 +27,7 @@ export function parsePolicy(text) {
|
|
|
22
27
|
else { scope = tok; break; }
|
|
23
28
|
}
|
|
24
29
|
if (effects.length === 0) { warn("deny names no known effect"); continue; }
|
|
25
|
-
deny.push({ effects: effects.sort(), scope, raw: line });
|
|
30
|
+
deny.push({ effects: [...new Set(effects)].sort(), scope, raw: line }); // dedup: a set, like rust/java
|
|
26
31
|
} else if (t[0] === "pure") {
|
|
27
32
|
deny.push({ effects: [], scope: t[1] ?? "", raw: line });
|
|
28
33
|
} else if (t[0] === "allow") {
|
|
@@ -32,7 +37,7 @@ export function parsePolicy(text) {
|
|
|
32
37
|
if (t[2] === "in") { scope = t[3] ?? ""; vi = 4; }
|
|
33
38
|
const values = t.slice(vi);
|
|
34
39
|
if (values.length === 0) { warn("allow names no values"); continue; }
|
|
35
|
-
allow.push({ effect: t[1], scope, values: values.sort(), raw: line });
|
|
40
|
+
allow.push({ effect: t[1], scope, values: [...new Set(values)].sort(), raw: line }); // dedup (set)
|
|
36
41
|
} else if (t[0] === "forbid") {
|
|
37
42
|
// Token-wise like the Rust/JVM parsers: the arrow must be its own whitespace-separated token
|
|
38
43
|
// (`a->b` glued is malformed), and tokens past `b` are ignored. A regex here once accepted and
|