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 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 callers $P <fn-query> 1 # the BLAST RADIUS: {of, direct, transitive} — works for pure fns
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** → `callers <fn>` (NOT its `inferred`, which is what the
70
- function itself does). Works pre-edit for a still-pure function.
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.4" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
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.6",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.4)",
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
- "test": "node test.mjs"
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].trim();
18
+ const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
14
19
  if (!line) continue;
15
- const t = line.split(/\s+/);
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