candor-ts 0.8.2 → 0.8.4

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 ADDED
@@ -0,0 +1,248 @@
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
+ * • Hover: effect PROVENANCE — for each inherited effect, the `path` hop chain to the function that
11
+ * performs it directly ("Net via mid → leaf (source)"), plus unknownWhy when the fn discloses opacity.
12
+ *
13
+ * The server is a pure CONSUMER of the spec report envelope + callgraph sidecar (any engine — JVM /
14
+ * Rust / TS / Swift / agents; the same read layer as candor-mcp), and it never scans (the analyzer
15
+ * self-boundary, spec §7.12): whatever refreshes the report (candor-ts-watch, the Claude Code stop
16
+ * hook, a build step) refreshes the lenses — reports are re-read per request, so freshness is free.
17
+ * A stale report is a stale map, disclosed by its own provenance (§2.1), never re-derived here.
18
+ *
19
+ * Report prefix resolution: initializationOptions.report → $CANDOR_REPORT → <workspace>/.candor/report.
20
+ * Transport: LSP stdio (Content-Length framed JSON-RPC 2.0).
21
+ *
22
+ * Editor wiring (no dedicated extension needed where the editor speaks LSP natively):
23
+ * helix languages.toml: language-server.candor = { command = "candor-lsp" }
24
+ * neovim vim.lsp.start({ name = "candor", cmd = { "candor-lsp" } })
25
+ * VS Code needs a thin client extension — a later slice.
26
+ */
27
+ import fs from "node:fs";
28
+ import { createRequire } from "node:module";
29
+ import nodePath from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import * as Q from "./query-core.mjs";
32
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy } from "./policy.mjs";
33
+
34
+ // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
35
+ // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
36
+ let VERSION = "bundled";
37
+ try { VERSION = createRequire(import.meta.url)("./package.json").version; } catch { /* bundled */ }
38
+
39
+ // ---- state (set at initialize) ---------------------------------------------------------------------
40
+ let rootPath = null;
41
+ let reportPrefix = process.env.CANDOR_REPORT || process.argv[2] || null;
42
+
43
+ function hasReport(p) {
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
+ }
51
+
52
+ // ---- fn → document mapping --------------------------------------------------------------------------
53
+ // A report `loc` is `<file>:<line>[:col…]` where <file> is either a repo-relative PATH (the scan-source
54
+ // engines) or a BARE filename (JVM bytecode SourceFile) — for the bare form the path is rebuilt from the
55
+ // fn's package segments, the same rule candor-sarif ships. Documents are matched by path SUFFIX (the
56
+ // workspace root need not equal the report's root).
57
+ function locParts(loc) {
58
+ if (typeof loc !== "string") return null;
59
+ const m = loc.match(/^(.*?):(\d+)/);
60
+ return m ? { file: m[1], line: Math.max(0, parseInt(m[2], 10) - 1) } : null;
61
+ }
62
+ function candidatePaths(fn, file) {
63
+ if (file.includes("/")) return [file];
64
+ const parts = fn.split(".");
65
+ const cands = [file];
66
+ if (parts.length >= 3) cands.unshift(parts.slice(0, -2).join("/") + "/" + file);
67
+ return cands;
68
+ }
69
+ function docMatches(docPath, fn, file) {
70
+ const norm = docPath.split(nodePath.sep).join("/");
71
+ return candidatePaths(fn, file).some((c) => norm === c || norm.endsWith("/" + c));
72
+ }
73
+ /** Every report entry whose loc maps into this document: [{ entry, line }]. Loaded FRESH per call. */
74
+ function entriesInDoc(docPath) {
75
+ if (!hasReport(reportPrefix)) return null;
76
+ const out = [];
77
+ for (const e of Q.loadReport(reportPrefix)) {
78
+ const lp = e.loc && locParts(e.loc);
79
+ if (lp && docMatches(docPath, e.fn, lp.file)) out.push({ entry: e, line: lp.line });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // ---- CodeLens ---------------------------------------------------------------------------------------
85
+ function codeLenses(docPath) {
86
+ const found = entriesInDoc(docPath);
87
+ if (found === null) return [];
88
+ const cg = Q.loadCallgraph(reportPrefix);
89
+ return found.map(({ entry, line }) => {
90
+ let blast = "";
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 */ }
96
+ const eff = (entry.inferred || []).join(", ") || "pure";
97
+ return {
98
+ range: { start: { line, character: 0 }, end: { line, character: 0 } },
99
+ command: { title: `⚡ ${eff}${blast}`, command: "" }, // informational lens (no action) — P1
100
+ };
101
+ });
102
+ }
103
+
104
+ // ---- Hover: effect provenance at the cursor ----------------------------------------------------------
105
+ // The entry ENCLOSING the hovered line: the report pins each fn at its declaration line, so the match is
106
+ // the greatest entry line ≤ the cursor (functions are sequential in a file — a sound approximation that
107
+ // needs no parser). For each inferred effect: direct → "performed here"; inherited → the §3.1 `path`
108
+ // chain to the direct source. unknownWhy rides along when the fn introduces opacity.
109
+ function hoverAt(docPath, line) {
110
+ const found = entriesInDoc(docPath);
111
+ if (!found || !found.length) return null;
112
+ const at = found.filter((x) => x.line <= line).sort((a, b) => b.line - a.line)[0];
113
+ if (!at) return null;
114
+ const { entry } = at;
115
+ const fns = Q.loadReport(reportPrefix);
116
+ const cg = Q.loadCallgraph(reportPrefix);
117
+ const lines = [`**${entry.fn}** — ⚡ { ${(entry.inferred || []).join(", ") || "pure"} }`];
118
+ for (const eff of entry.inferred || []) {
119
+ if (eff === "Unknown") continue; // covered by unknownWhy below
120
+ if ((entry.direct || []).includes(eff)) {
121
+ lines.push(`- **${eff}** — performed directly here`);
122
+ continue;
123
+ }
124
+ try {
125
+ const hops = (Q.path(fns, cg, entry.fn, eff)?.path || []).map((h) => h.fn.split(/[.:]+/).pop() + (h.source ? " (source)" : ""));
126
+ lines.push(hops.length > 1 ? `- **${eff}** — via ${hops.slice(1).join(" → ")}` : `- **${eff}** — inherited (source is cross-boundary or framework-synthesised)`);
127
+ } catch { lines.push(`- **${eff}** — inherited`); }
128
+ }
129
+ if (entry.unknownWhy?.length) lines.push(`- **Unknown** — ${entry.unknownWhy.join(", ")}`);
130
+ if (entry.invisible?.length) lines.push(`- _invisible_: ${entry.invisible.join(", ")} (unmodeled — the effect set is a lower bound)`);
131
+ try {
132
+ const c = Q.callers(cg, entry.fn);
133
+ lines.push(`\nBlast radius: **${(c?.transitive || []).length}** transitive caller(s)`);
134
+ } catch { /* no callgraph */ }
135
+ return {
136
+ contents: { kind: "markdown", value: lines.join("\n") },
137
+ range: { start: { line: at.line, character: 0 }, end: { line: at.line, character: 200 } },
138
+ };
139
+ }
140
+
141
+ // ---- Diagnostics (the live gate) ---------------------------------------------------------------------
142
+ function activePolicy() {
143
+ const env = process.env.CANDOR_POLICY;
144
+ if (env && fs.existsSync(env)) return fs.readFileSync(env, "utf8");
145
+ const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
146
+ const cfg = from ? discoverConfigPolicy(from) : null;
147
+ if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
148
+ return null;
149
+ }
150
+ function diagnosticsFor(docPath) {
151
+ const text = activePolicy();
152
+ if (text === null || !hasReport(reportPrefix)) return [];
153
+ const fns = Q.loadReport(reportPrefix);
154
+ const violations = evaluatePolicy(parsePolicy(text), fns, Q.loadCallgraph(reportPrefix));
155
+ const locByFn = new Map(fns.filter((e) => e.loc).map((e) => [e.fn, locParts(e.loc)]));
156
+ const out = [];
157
+ for (const v of violations) {
158
+ const lp = locByFn.get(v.fn);
159
+ if (!lp || !docMatches(docPath, v.fn, lp.file)) continue;
160
+ out.push({
161
+ range: { start: { line: lp.line, character: 0 }, end: { line: lp.line, character: 200 } },
162
+ severity: v.rule === "AS-EFF-007" ? 2 : 1, // the advisory code is a warning, the rest errors
163
+ source: "candor",
164
+ code: v.rule,
165
+ message: v.detail || `${v.fn} violates ${v.rule}`,
166
+ });
167
+ }
168
+ return out;
169
+ }
170
+ function publishDiagnostics(uri) {
171
+ let docPath;
172
+ try { docPath = fileURLToPath(uri); } catch { return; }
173
+ try {
174
+ send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri, diagnostics: diagnosticsFor(docPath) } });
175
+ } catch (e) {
176
+ logMessage(`candor-lsp: diagnostics failed for ${uri}: ${e.message}`);
177
+ }
178
+ }
179
+ function logMessage(message) { send({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 2, message } }); }
180
+
181
+ // ---- the LSP method surface ---------------------------------------------------------------------------
182
+ function handle(msg) {
183
+ const { id, method, params } = msg;
184
+ if (method === "initialize") {
185
+ if (params?.rootUri) { try { rootPath = fileURLToPath(params.rootUri); } catch { /* keep null */ } }
186
+ else if (params?.rootPath) rootPath = params.rootPath;
187
+ if (params?.initializationOptions?.report) reportPrefix = params.initializationOptions.report;
188
+ if (!reportPrefix && rootPath) {
189
+ const cand = nodePath.join(rootPath, ".candor", "report");
190
+ if (hasReport(cand)) reportPrefix = cand;
191
+ }
192
+ return result(id, {
193
+ capabilities: {
194
+ textDocumentSync: { openClose: true, save: true, change: 0 }, // report-backed: buffer edits don't move the map
195
+ codeLensProvider: { resolveProvider: false },
196
+ hoverProvider: true,
197
+ },
198
+ serverInfo: { name: "candor-lsp", version: VERSION },
199
+ });
200
+ }
201
+ if (method === "initialized" || method === "$/cancelRequest" || method === "$/setTrace") return;
202
+ if (method === "textDocument/didOpen") return publishDiagnostics(params.textDocument.uri);
203
+ if (method === "textDocument/didSave") return publishDiagnostics(params.textDocument.uri);
204
+ if (method === "textDocument/didChange") return; // see textDocumentSync: report-backed
205
+ if (method === "textDocument/didClose")
206
+ return send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: { uri: params.textDocument.uri, diagnostics: [] } });
207
+ if (method === "textDocument/hover") {
208
+ try { return result(id, hoverAt(fileURLToPath(params.textDocument.uri), params.position?.line ?? 0)); }
209
+ catch { return result(id, null); } // hover is best-effort — null, never a crash
210
+ }
211
+ if (method === "textDocument/codeLens") {
212
+ try { return result(id, codeLenses(fileURLToPath(params.textDocument.uri))); }
213
+ catch { return result(id, []); } // a non-file URI / unreadable report → no lenses, never a crash
214
+ }
215
+ if (method === "shutdown") return result(id, null);
216
+ if (method === "exit") process.exit(0);
217
+ if (id !== undefined) error(id, -32601, `method not found: ${method}`);
218
+ }
219
+
220
+ // ---- LSP stdio transport (Content-Length framed JSON-RPC) ---------------------------------------------
221
+ function send(msg) {
222
+ const body = Buffer.from(JSON.stringify(msg), "utf8");
223
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
224
+ process.stdout.write(body);
225
+ }
226
+ function result(id, r) { send({ jsonrpc: "2.0", id, result: r }); }
227
+ function error(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
228
+
229
+ let buf = Buffer.alloc(0);
230
+ process.stdin.on("data", (chunk) => {
231
+ buf = Buffer.concat([buf, chunk]);
232
+ for (;;) {
233
+ const headerEnd = buf.indexOf("\r\n\r\n");
234
+ if (headerEnd < 0) return;
235
+ const header = buf.slice(0, headerEnd).toString("utf8");
236
+ const m = header.match(/Content-Length:\s*(\d+)/i);
237
+ if (!m) { buf = buf.slice(headerEnd + 4); continue; } // skip an unframed preamble
238
+ const len = parseInt(m[1], 10);
239
+ if (buf.length < headerEnd + 4 + len) return; // body not fully arrived
240
+ const body = buf.slice(headerEnd + 4, headerEnd + 4 + len).toString("utf8");
241
+ buf = buf.slice(headerEnd + 4 + len);
242
+ let msg;
243
+ try { msg = JSON.parse(body); } catch { continue; }
244
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) continue;
245
+ try { handle(msg); } catch (e) { if (msg.id !== undefined) error(msg.id, -32603, e.message); }
246
+ }
247
+ });
248
+ 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] || null;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
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": {
@@ -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 {