candor-ts 0.8.1 → 0.8.3
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/lsp.mjs +201 -0
- package/mcp.mjs +75 -5
- package/package.json +8 -5
- package/policy.mjs +23 -0
- package/scan.mjs +54 -1
package/lsp.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* candor-lsp — candor's effect map as a Language Server (AGENT-SURFACE-DESIGN bet 2, P1): the report,
|
|
4
|
+
* rendered where the code is.
|
|
5
|
+
*
|
|
6
|
+
* • CodeLens per effectful function: `⚡ Db, Net · blast radius 12` — the transitive effect set and
|
|
7
|
+
* how many functions transitively call it (who is affected if it changes).
|
|
8
|
+
* • Diagnostics: the repo's architecture-policy verdict (the §6.2 gate, resolved from CANDOR_POLICY
|
|
9
|
+
* or the checked-in .candor/config — spec §3.4) as squiggles at each violating function's line.
|
|
10
|
+
*
|
|
11
|
+
* The server is a pure CONSUMER of the spec report envelope + callgraph sidecar (any engine — JVM /
|
|
12
|
+
* Rust / TS / Swift / agents; the same read layer as candor-mcp), and it never scans (the analyzer
|
|
13
|
+
* self-boundary, spec §7.12): whatever refreshes the report (candor-ts-watch, the Claude Code stop
|
|
14
|
+
* hook, a build step) refreshes the lenses — reports are re-read per request, so freshness is free.
|
|
15
|
+
* A stale report is a stale map, disclosed by its own provenance (§2.1), never re-derived here.
|
|
16
|
+
*
|
|
17
|
+
* Report prefix resolution: initializationOptions.report → $CANDOR_REPORT → <workspace>/.candor/report.
|
|
18
|
+
* Transport: LSP stdio (Content-Length framed JSON-RPC 2.0).
|
|
19
|
+
*
|
|
20
|
+
* Editor wiring (no dedicated extension needed where the editor speaks LSP natively):
|
|
21
|
+
* helix languages.toml: language-server.candor = { command = "candor-lsp" }
|
|
22
|
+
* neovim vim.lsp.start({ name = "candor", cmd = { "candor-lsp" } })
|
|
23
|
+
* VS Code needs a thin client extension — a later slice.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import nodePath from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import * as Q from "./query-core.mjs";
|
|
30
|
+
import { discoverConfigPolicy, evaluatePolicy, parsePolicy } from "./policy.mjs";
|
|
31
|
+
|
|
32
|
+
const VERSION = createRequire(import.meta.url)("./package.json").version;
|
|
33
|
+
|
|
34
|
+
// ---- state (set at initialize) ---------------------------------------------------------------------
|
|
35
|
+
let rootPath = null;
|
|
36
|
+
let reportPrefix = process.env.CANDOR_REPORT || process.argv[2] || null;
|
|
37
|
+
|
|
38
|
+
function hasReport(p) {
|
|
39
|
+
if (!p) return false;
|
|
40
|
+
if (fs.existsSync(`${p}.json`)) return true;
|
|
41
|
+
const base = nodePath.basename(p);
|
|
42
|
+
try {
|
|
43
|
+
return fs.readdirSync(nodePath.dirname(p) || ".").some((f) => f.startsWith(base + ".") && f.endsWith(".json") && Q.isReport(f));
|
|
44
|
+
} catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- fn → document mapping --------------------------------------------------------------------------
|
|
48
|
+
// A report `loc` is `<file>:<line>[:col…]` where <file> is either a repo-relative PATH (the scan-source
|
|
49
|
+
// engines) or a BARE filename (JVM bytecode SourceFile) — for the bare form the path is rebuilt from the
|
|
50
|
+
// fn's package segments, the same rule candor-sarif ships. Documents are matched by path SUFFIX (the
|
|
51
|
+
// workspace root need not equal the report's root).
|
|
52
|
+
function locParts(loc) {
|
|
53
|
+
if (typeof loc !== "string") return null;
|
|
54
|
+
const m = loc.match(/^(.*?):(\d+)/);
|
|
55
|
+
return m ? { file: m[1], line: Math.max(0, parseInt(m[2], 10) - 1) } : null;
|
|
56
|
+
}
|
|
57
|
+
function candidatePaths(fn, file) {
|
|
58
|
+
if (file.includes("/")) return [file];
|
|
59
|
+
const parts = fn.split(".");
|
|
60
|
+
const cands = [file];
|
|
61
|
+
if (parts.length >= 3) cands.unshift(parts.slice(0, -2).join("/") + "/" + file);
|
|
62
|
+
return cands;
|
|
63
|
+
}
|
|
64
|
+
function docMatches(docPath, fn, file) {
|
|
65
|
+
const norm = docPath.split(nodePath.sep).join("/");
|
|
66
|
+
return candidatePaths(fn, file).some((c) => norm === c || norm.endsWith("/" + c));
|
|
67
|
+
}
|
|
68
|
+
/** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call. */
|
|
69
|
+
function entriesInDoc(docPath) {
|
|
70
|
+
if (!hasReport(reportPrefix)) return null;
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const e of Q.loadReport(reportPrefix)) {
|
|
73
|
+
const lp = e.loc && locParts(e.loc);
|
|
74
|
+
if (lp && docMatches(docPath, e.fn, lp.file)) out.push({ entry: e, line: lp.line });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- CodeLens ---------------------------------------------------------------------------------------
|
|
80
|
+
function codeLenses(docPath) {
|
|
81
|
+
const found = entriesInDoc(docPath);
|
|
82
|
+
if (found === null) return [];
|
|
83
|
+
const cg = Q.loadCallgraph(reportPrefix);
|
|
84
|
+
return found.map(({ entry, line }) => {
|
|
85
|
+
let blast = "";
|
|
86
|
+
try {
|
|
87
|
+
const c = Q.callers(cg, entry.fn);
|
|
88
|
+
const n = (c && c.transitive && c.transitive.length) || 0;
|
|
89
|
+
blast = ` · blast radius ${n}`;
|
|
90
|
+
} catch { /* no callgraph — effects-only lens */ }
|
|
91
|
+
const eff = (entry.inferred || []).join(", ") || "pure";
|
|
92
|
+
return {
|
|
93
|
+
range: { start: { line, character: 0 }, end: { line, character: 0 } },
|
|
94
|
+
command: { title: `⚡ ${eff}${blast}`, command: "" }, // informational lens (no action) — P1
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- Diagnostics (the live gate) ---------------------------------------------------------------------
|
|
100
|
+
function activePolicy() {
|
|
101
|
+
const env = process.env.CANDOR_POLICY;
|
|
102
|
+
if (env && fs.existsSync(env)) return fs.readFileSync(env, "utf8");
|
|
103
|
+
const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
|
|
104
|
+
const cfg = from ? discoverConfigPolicy(from) : null;
|
|
105
|
+
if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function diagnosticsFor(docPath) {
|
|
109
|
+
const text = activePolicy();
|
|
110
|
+
if (text === null || !hasReport(reportPrefix)) return [];
|
|
111
|
+
const fns = Q.loadReport(reportPrefix);
|
|
112
|
+
const violations = evaluatePolicy(parsePolicy(text), fns, Q.loadCallgraph(reportPrefix));
|
|
113
|
+
const locByFn = new Map(fns.filter((e) => e.loc).map((e) => [e.fn, locParts(e.loc)]));
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const v of violations) {
|
|
116
|
+
const lp = locByFn.get(v.fn);
|
|
117
|
+
if (!lp || !docMatches(docPath, v.fn, lp.file)) continue;
|
|
118
|
+
out.push({
|
|
119
|
+
range: { start: { line: lp.line, character: 0 }, end: { line: lp.line, character: 200 } },
|
|
120
|
+
severity: v.rule === "AS-EFF-007" ? 2 : 1, // the advisory code is a warning, the rest errors
|
|
121
|
+
source: "candor",
|
|
122
|
+
code: v.rule,
|
|
123
|
+
message: v.detail || `${v.fn} violates ${v.rule}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function publishDiagnostics(uri) {
|
|
129
|
+
let docPath;
|
|
130
|
+
try { docPath = fileURLToPath(uri); } catch { return; }
|
|
131
|
+
try {
|
|
132
|
+
send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diagnosticsFor(docPath) } });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
logMessage(`candor-lsp: diagnostics failed for ${uri}: ${e.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function logMessage(message) { send({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 2, message } }); }
|
|
138
|
+
|
|
139
|
+
// ---- the LSP method surface ---------------------------------------------------------------------------
|
|
140
|
+
function handle(msg) {
|
|
141
|
+
const { id, method, params } = msg;
|
|
142
|
+
if (method === "initialize") {
|
|
143
|
+
if (params?.rootUri) { try { rootPath = fileURLToPath(params.rootUri); } catch { /* keep null */ } }
|
|
144
|
+
else if (params?.rootPath) rootPath = params.rootPath;
|
|
145
|
+
if (params?.initializationOptions?.report) reportPrefix = params.initializationOptions.report;
|
|
146
|
+
if (!reportPrefix && rootPath) {
|
|
147
|
+
const cand = nodePath.join(rootPath, ".candor", "report");
|
|
148
|
+
if (hasReport(cand)) reportPrefix = cand;
|
|
149
|
+
}
|
|
150
|
+
return result(id, {
|
|
151
|
+
capabilities: {
|
|
152
|
+
textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
|
|
153
|
+
codeLensProvider: { resolveProvider: false },
|
|
154
|
+
},
|
|
155
|
+
serverInfo: { name: "candor-lsp", version: VERSION },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
|
|
159
|
+
if (method === "textDocument/didOpen") return publishDiagnostics(params.textDocument.uri);
|
|
160
|
+
if (method === "textDocument/didSave") return publishDiagnostics(params.textDocument.uri);
|
|
161
|
+
if (method === "textDocument/didChange") return; // see textDocumentSync: report-backed
|
|
162
|
+
if (method === "textDocument/didClose")
|
|
163
|
+
return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
|
|
164
|
+
if (method === "textDocument/codeLens") {
|
|
165
|
+
try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
|
|
166
|
+
catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
|
|
167
|
+
}
|
|
168
|
+
if (method === "shutdown") return result(id, null);
|
|
169
|
+
if (method === "exit") process.exit(0);
|
|
170
|
+
if (id !== undefined) error(id, -32601, `method not found: ${method}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- LSP stdio transport (Content-Length framed JSON-RPC) ---------------------------------------------
|
|
174
|
+
function send(msg) {
|
|
175
|
+
const body = Buffer.from(JSON.stringify(msg), "utf8");
|
|
176
|
+
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
177
|
+
process.stdout.write(body);
|
|
178
|
+
}
|
|
179
|
+
function result(id, r) { send({ jsonrpc: "2.0", id, result: r }); }
|
|
180
|
+
function error(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
|
|
181
|
+
|
|
182
|
+
let buf = Buffer.alloc(0);
|
|
183
|
+
process.stdin.on("data", (chunk) => {
|
|
184
|
+
buf = Buffer.concat([buf, chunk]);
|
|
185
|
+
for (;;) {
|
|
186
|
+
const headerEnd = buf.indexOf("\r\n\r\n");
|
|
187
|
+
if (headerEnd < 0) return;
|
|
188
|
+
const header = buf.slice(0, headerEnd).toString("utf8");
|
|
189
|
+
const m = header.match(/Content-Length:\s*(\d+)/i);
|
|
190
|
+
if (!m) { buf = buf.slice(headerEnd + 4); continue; } // skip an unframed preamble
|
|
191
|
+
const len = parseInt(m[1], 10);
|
|
192
|
+
if (buf.length < headerEnd + 4 + len) return; // body not fully arrived
|
|
193
|
+
const body = buf.slice(headerEnd + 4, headerEnd + 4 + len).toString("utf8");
|
|
194
|
+
buf = buf.slice(headerEnd + 4 + len);
|
|
195
|
+
let msg;
|
|
196
|
+
try { msg = JSON.parse(body); } catch { continue; }
|
|
197
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) continue;
|
|
198
|
+
try { handle(msg); } catch (e) { if (msg.id !== undefined) error(msg.id, -32603, e.message); }
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
process.stdin.on("end", () => process.exit(0));
|
package/mcp.mjs
CHANGED
|
@@ -19,11 +19,12 @@ import fs from "node:fs";
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import nodePath from "node:path";
|
|
21
21
|
import * as Q from "./query-core.mjs";
|
|
22
|
-
import { parsePolicy, scopeMatches } from "./policy.mjs";
|
|
22
|
+
import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
|
|
23
23
|
|
|
24
24
|
const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
|
|
25
25
|
|
|
26
|
-
const DEFAULT_PREFIX = process.env.CANDOR_REPORT || process.argv[2]
|
|
26
|
+
const DEFAULT_PREFIX = process.env.CANDOR_REPORT || process.argv[2]
|
|
27
|
+
|| (fs.existsSync(".candor") ? ".candor/report" : null); // the engines' default --out convention
|
|
27
28
|
|
|
28
29
|
// A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
|
|
29
30
|
// `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so
|
|
@@ -52,13 +53,16 @@ const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n
|
|
|
52
53
|
// report-query-only (spec §7.12); an arbitrary `policy` path (/etc/passwd, ~/.aws/credentials) whose
|
|
53
54
|
// parsed deny-rule scopes are reflected back in violations[].rule is an arbitrary-file-read exfiltration
|
|
54
55
|
// channel — tie the policy to the project it gates.
|
|
55
|
-
function confinedPolicyRead(policyPath, prefix) {
|
|
56
|
-
const root = nodePath.resolve(nodePath.dirname(prefix));
|
|
56
|
+
function confinedPolicyRead(policyPath, prefix, root = nodePath.resolve(nodePath.dirname(prefix))) {
|
|
57
57
|
const abs = nodePath.resolve(policyPath);
|
|
58
58
|
if (abs !== root && !abs.startsWith(root + nodePath.sep))
|
|
59
59
|
throw new Error(`policy must be within the report's directory (${root}) — refusing to read \`${clip(policyPath)}\``);
|
|
60
60
|
return fs.readFileSync(abs, "utf8");
|
|
61
61
|
}
|
|
62
|
+
// The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
|
|
63
|
+
function configPolicy(prefix) {
|
|
64
|
+
return discoverConfigPolicy(nodePath.dirname(nodePath.resolve(prefix)) || ".");
|
|
65
|
+
}
|
|
62
66
|
|
|
63
67
|
// ---- the tools: name -> {description, schema, run} ------------------------------------------------
|
|
64
68
|
const reportArg = { report: { type: "string", description: "report prefix (optional; defaults to $CANDOR_REPORT)" } };
|
|
@@ -130,8 +134,63 @@ const TOOLS = {
|
|
|
130
134
|
return r;
|
|
131
135
|
},
|
|
132
136
|
},
|
|
137
|
+
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 (an engine's own --gate-json run is the authoritative CI form).",
|
|
139
|
+
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
|
+
run: (a, p) => {
|
|
141
|
+
let text;
|
|
142
|
+
if (a.policy) text = confinedPolicyRead(a.policy, p);
|
|
143
|
+
else {
|
|
144
|
+
const cfg = configPolicy(p);
|
|
145
|
+
if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
|
|
146
|
+
text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
|
|
147
|
+
}
|
|
148
|
+
const v = evaluatePolicy(parsePolicy(text), Q.loadReport(p), Q.loadCallgraph(p));
|
|
149
|
+
return { ok: v.length === 0, violations: v };
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
candor_containment: {
|
|
153
|
+
description: "Per boundary effect (Db/Net/Exec/Fs/Ipc/Clipboard): how contained it is in one architectural layer — the dispersion diagnostic (spec §6.1). Not a score; per-effect facts.",
|
|
154
|
+
schema: { type: "object", properties: { ...reportArg } },
|
|
155
|
+
run: (_a, p) => Q.containment(Q.loadReport(p)),
|
|
156
|
+
},
|
|
157
|
+
candor_blindspots: {
|
|
158
|
+
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
|
+
schema: { type: "object", properties: { ...reportArg } },
|
|
160
|
+
run: (_a, p) => Q.blindspots(Q.loadReport(p), Q.loadCallgraph(p)),
|
|
161
|
+
},
|
|
162
|
+
candor_diff: {
|
|
163
|
+
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?'.",
|
|
164
|
+
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
165
|
+
run: (a, p) => Q.diff(Q.loadReport(p), Q.loadReport(a.baseline)),
|
|
166
|
+
},
|
|
167
|
+
candor_gains: {
|
|
168
|
+
description: "The supply-chain alarm: effects the surface GAINED versus a baseline (package-level + per-function) — 'did this dependency bump add Net/Exec somewhere?'.",
|
|
169
|
+
schema: { type: "object", properties: { baseline: { type: "string", description: "the baseline report prefix" }, ...reportArg }, required: ["baseline"] },
|
|
170
|
+
run: (a, p) => Q.gains(Q.loadReport(p), Q.loadReport(a.baseline)),
|
|
171
|
+
},
|
|
133
172
|
};
|
|
134
173
|
|
|
174
|
+
// ---- MCP resources: the report + the checked-in policy, readable directly --------------------------
|
|
175
|
+
function listResources(prefix) {
|
|
176
|
+
const res = [{ uri: `candor://report?prefix=${encodeURIComponent(prefix)}`, name: "candor report",
|
|
177
|
+
description: "the spec §2 report envelope (all packages under the prefix)", mimeType: "application/json" }];
|
|
178
|
+
const cfg = prefix ? configPolicy(prefix) : null;
|
|
179
|
+
if (cfg && fs.existsSync(cfg.policyPath))
|
|
180
|
+
res.push({ uri: `candor://policy?prefix=${encodeURIComponent(prefix)}`, name: "candor policy",
|
|
181
|
+
description: "the repo's checked-in §6.2 architecture policy (via .candor/config)", mimeType: "text/plain" });
|
|
182
|
+
return res;
|
|
183
|
+
}
|
|
184
|
+
function readResource(uri, prefix) {
|
|
185
|
+
if (uri.startsWith("candor://report")) return { mimeType: "application/json", text: JSON.stringify(Q.loadReport(prefix)) };
|
|
186
|
+
if (uri.startsWith("candor://policy")) {
|
|
187
|
+
const cfg = configPolicy(prefix);
|
|
188
|
+
if (!cfg) throw new Error("no checked-in policy (no .candor/config with a `policy` key)");
|
|
189
|
+
return { mimeType: "text/plain", text: confinedPolicyRead(cfg.policyPath, prefix, cfg.repoRoot) };
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`unknown resource: ${uri}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
135
194
|
// ---- JSON-RPC 2.0 over stdio (newline-delimited; the MCP stdio framing) ---------------------------
|
|
136
195
|
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
137
196
|
function result(id, r) { send({ jsonrpc: "2.0", id, result: r }); }
|
|
@@ -142,13 +201,24 @@ function handle(msg) {
|
|
|
142
201
|
if (method === "initialize") {
|
|
143
202
|
return result(id, {
|
|
144
203
|
protocolVersion: params?.protocolVersion || "2025-06-18",
|
|
145
|
-
capabilities: { tools: {} },
|
|
204
|
+
capabilities: { tools: {}, resources: {} },
|
|
146
205
|
serverInfo: { name: "candor-mcp", version: VERSION },
|
|
147
206
|
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.",
|
|
148
207
|
});
|
|
149
208
|
}
|
|
150
209
|
if (method === "notifications/initialized" || method === "notifications/cancelled") return; // notifications: no reply
|
|
151
210
|
if (method === "ping") return result(id, {});
|
|
211
|
+
if (method === "resources/list") {
|
|
212
|
+
try { return result(id, { resources: DEFAULT_PREFIX && hasReport(DEFAULT_PREFIX) ? listResources(DEFAULT_PREFIX) : [] }); }
|
|
213
|
+
catch { return result(id, { resources: [] }); }
|
|
214
|
+
}
|
|
215
|
+
if (method === "resources/read") {
|
|
216
|
+
try {
|
|
217
|
+
const prefix = resolvePrefix({});
|
|
218
|
+
const r = readResource(params?.uri || "", prefix);
|
|
219
|
+
return result(id, { contents: [{ uri: params?.uri, ...r }] });
|
|
220
|
+
} catch (e) { return error(id, -32602, `candor: ${e.message}`); }
|
|
221
|
+
}
|
|
152
222
|
if (method === "tools/list") {
|
|
153
223
|
return result(id, {
|
|
154
224
|
tools: Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.schema })),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "candor for TypeScript
|
|
3
|
+
"version": "0.8.3",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
"candor-ts": "./scan.mjs",
|
|
12
12
|
"candor-ts-query": "./query.mjs",
|
|
13
13
|
"candor-ts-mcp": "./mcp.mjs",
|
|
14
|
-
"candor-ts-watch": "./watch.mjs"
|
|
14
|
+
"candor-ts-watch": "./watch.mjs",
|
|
15
|
+
"candor-mcp": "./mcp.mjs",
|
|
16
|
+
"candor-lsp": "./lsp.mjs"
|
|
15
17
|
},
|
|
16
18
|
"scripts": {
|
|
17
19
|
"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",
|
|
20
|
+
"test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
|
|
19
21
|
"test:unit": "node --test test-unit.mjs",
|
|
20
22
|
"test:probe": "node fabrication_probe.mjs",
|
|
21
23
|
"test:fuzz": "node fuzz.mjs"
|
|
@@ -50,7 +52,8 @@
|
|
|
50
52
|
"query-core.mjs",
|
|
51
53
|
"scan-core.mjs",
|
|
52
54
|
"mcp.mjs",
|
|
53
|
-
"watch.mjs"
|
|
55
|
+
"watch.mjs",
|
|
56
|
+
"lsp.mjs"
|
|
54
57
|
],
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@eslint/js": "^9.39.4",
|
package/policy.mjs
CHANGED
|
@@ -158,3 +158,26 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
158
158
|
}
|
|
159
159
|
return out;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
// ---- .candor/config discovery (spec §3.4) — shared by the MCP + LSP surfaces -----------------------
|
|
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. Read-only + best-effort (a consumer
|
|
165
|
+
// surface never gates a build; a broken config surfaces as the caller's error).
|
|
166
|
+
import fs from "node:fs";
|
|
167
|
+
import nodePath from "node:path";
|
|
168
|
+
export function discoverConfigPolicy(fromDir) {
|
|
169
|
+
let dir = nodePath.resolve(fromDir);
|
|
170
|
+
for (;;) {
|
|
171
|
+
const cand = nodePath.join(dir, ".candor", "config");
|
|
172
|
+
if (fs.existsSync(cand)) {
|
|
173
|
+
const m = fs.readFileSync(cand, "utf8").split(/\r?\n/)
|
|
174
|
+
.map((l) => l.split("#", 1)[0].trim()).filter(Boolean)
|
|
175
|
+
.map((l) => l.match(/^(\S+)\s*(.*)$/)).find((mm) => mm && mm[1].toLowerCase() === "policy");
|
|
176
|
+
if (!m) return null;
|
|
177
|
+
return { policyPath: nodePath.resolve(dir, m[2].trim()), repoRoot: dir };
|
|
178
|
+
}
|
|
179
|
+
const parent = nodePath.dirname(dir);
|
|
180
|
+
if (parent === dir) return null;
|
|
181
|
+
dir = parent;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/scan.mjs
CHANGED
|
@@ -100,6 +100,59 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
100
100
|
if (wantAgents) { printAgents(); process.exit(0); }
|
|
101
101
|
if (target === null) { console.error(usage); process.exit(2); }
|
|
102
102
|
|
|
103
|
+
// ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
|
|
104
|
+
// Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
|
|
105
|
+
// .candor/config), never the CWD; $CANDOR_CONFIG overrides discovery entirely. Precedence: CLI flag →
|
|
106
|
+
// CANDOR_* env → this file → default. FAIL-CLOSED: a configured-but-unusable file (a set CANDOR_CONFIG
|
|
107
|
+
// naming a missing path; a discovered file that exists but can't be read) exits 2 — a gate source must
|
|
108
|
+
// never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
|
|
109
|
+
// Keys are the shared vocabulary (policy/baseline/strict/no-ambient/closed-world/taint/deps); candor-ts
|
|
110
|
+
// implements `policy` + `deps` — the others are inert here (they drive other engines' gates), and a key
|
|
111
|
+
// OUTSIDE the vocabulary warns (typo protection: a misspelt `policy` must not silently drop the gate).
|
|
112
|
+
const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps"]);
|
|
113
|
+
function loadCandorConfig(targetPath) {
|
|
114
|
+
let file = process.env.CANDOR_CONFIG ?? null;
|
|
115
|
+
if (file !== null) {
|
|
116
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
117
|
+
console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
let dir = path.resolve(targetPath);
|
|
122
|
+
try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
|
|
123
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
124
|
+
const cand = path.join(d, ".candor", "config");
|
|
125
|
+
if (fs.existsSync(cand)) { file = cand; break; }
|
|
126
|
+
if (path.dirname(d) === d) break; // filesystem root
|
|
127
|
+
}
|
|
128
|
+
if (file === null && fs.existsSync(".candor/config")) file = ".candor/config";
|
|
129
|
+
if (file === null) return {};
|
|
130
|
+
}
|
|
131
|
+
let text;
|
|
132
|
+
try { text = fs.readFileSync(file, "utf8"); }
|
|
133
|
+
catch (e) {
|
|
134
|
+
console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
const cfg = {};
|
|
138
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
139
|
+
const line = raw.split("#", 1)[0].trim(); // strip inline comments (§6.2 lexical)
|
|
140
|
+
if (!line) continue;
|
|
141
|
+
const m = line.match(/^(\S+)\s*(.*)$/);
|
|
142
|
+
const key = m[1].toLowerCase(), val = (m[2] ?? "").trim();
|
|
143
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
144
|
+
console.error(`candor-ts: ignoring unknown config key '${key}' in ${file}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
cfg[key] = val;
|
|
148
|
+
}
|
|
149
|
+
return cfg;
|
|
150
|
+
}
|
|
151
|
+
const candorConfig = loadCandorConfig(target);
|
|
152
|
+
// precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
|
|
153
|
+
// A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
|
|
154
|
+
if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
|
|
155
|
+
|
|
103
156
|
// ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
|
|
104
157
|
let rootDir, fileNames, compilerOptions = {
|
|
105
158
|
target: ts.ScriptTarget.ES2022,
|
|
@@ -228,7 +281,7 @@ const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:
|
|
|
228
281
|
// the envelope's `package` field (works for an all-pure EMPTY report) and from entry hash prefixes.
|
|
229
282
|
const depCoveredPkgs = new Set();
|
|
230
283
|
{
|
|
231
|
-
const spec = process.env.CANDOR_DEPS ?? "";
|
|
284
|
+
const spec = process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""; // env overrides the config `deps` key
|
|
232
285
|
const files = [];
|
|
233
286
|
for (const tok of spec.split(/[\s:,]+/).filter(Boolean)) {
|
|
234
287
|
try {
|