candor-ts 0.4.0

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/scan.mjs ADDED
@@ -0,0 +1,800 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * candor-ts — the TypeScript implementation of candor-spec 0.4.
4
+ *
5
+ * Origin (kept honest): this engine began as the clean-room derivability proof — a single-file
6
+ * slice written from SPEC.md/SEMANTICS.md/CLASSIFIER.md alone, frozen as that claim in git history
7
+ * (`a29b152`). Product growth since (multi-file projects, the literal surfaces, the policy gate)
8
+ * is spec-implemented but post-hoc; its guarantee is the cross-engine conformance suite.
9
+ *
10
+ * Resolve each call via the TypeScript compiler API (CLASSIFIER §1: resolve, don't pattern-match),
11
+ * classify resolved external targets by the curated κ (§3; the I/O boundary), record local edges,
12
+ * propagate to the least fixpoint (SEMANTICS §5), mark unresolvable calls Unknown (SPEC §4 — an
13
+ * `any`-typed callee or a function-valued parameter/field IS the "could not resolve" case), and
14
+ * emit the §2 report envelope + the §2.2 call-graph sidecar (every analyzed function a key). With
15
+ * --policy (or CANDOR_POLICY), evaluate the §6.2 gate (AS-EFF-006/008/009) over the result: exit 1
16
+ * on violation, exit 2 LOUDLY on an unreadable policy.
17
+ *
18
+ * Usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>]
19
+ * node scan.mjs <file.ts> <out-prefix> (legacy positional form)
20
+ * writes <prefix>.json (report) and <prefix>.callgraph.json
21
+ */
22
+ import ts from "typescript";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { parsePolicy, evaluatePolicy } from "./policy.mjs";
26
+
27
+ // ---- args ----------------------------------------------------------------------------------------
28
+ const argv = process.argv.slice(2);
29
+ if (argv.length === 0) {
30
+ console.error("usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>]");
31
+ process.exit(2);
32
+ }
33
+ const target = argv[0];
34
+ let outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false;
35
+ for (let i = 1; i < argv.length; i++) {
36
+ if (argv[i] === "--out") outPrefix = argv[++i];
37
+ else if (argv[i] === "--policy") policyPath = argv[++i];
38
+ else if (argv[i] === "--allow-js") allowJs = true;
39
+ else if (!argv[i].startsWith("--") && !outPrefix) outPrefix = argv[i]; // legacy positional prefix
40
+ }
41
+
42
+ // ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
43
+ function isTestPath(p) {
44
+ return /(^|\/)(node_modules|__tests__|tests?|spec)(\/|$)/.test(p) || /\.(test|spec)\.[mc]?tsx?$/.test(p);
45
+ }
46
+ let rootDir, fileNames, compilerOptions = {
47
+ target: ts.ScriptTarget.ES2022,
48
+ module: ts.ModuleKind.NodeNext,
49
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
50
+ types: ["node"],
51
+ strict: true,
52
+ };
53
+ function fromTsconfig(cfgPath, baseDir) {
54
+ const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile);
55
+ const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, baseDir);
56
+ compilerOptions = { ...parsed.options, types: parsed.options.types ?? ["node"] };
57
+ return parsed.fileNames.filter((f) => !isTestPath(path.relative(baseDir, f)));
58
+ }
59
+ const stat = fs.existsSync(target) ? fs.statSync(target) : null;
60
+ if (!stat) { console.error(`candor-ts: no such path: ${target}`); process.exit(2); }
61
+ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
62
+ rootDir = path.dirname(path.resolve(target));
63
+ fileNames = fromTsconfig(path.resolve(target), rootDir);
64
+ } else if (stat.isFile()) {
65
+ rootDir = path.dirname(path.resolve(target));
66
+ fileNames = [path.resolve(target)];
67
+ } else {
68
+ rootDir = path.resolve(target);
69
+ const tsconfig = path.join(rootDir, "tsconfig.json");
70
+ if (fs.existsSync(tsconfig) && !allowJs) {
71
+ fileNames = fromTsconfig(tsconfig, rootDir);
72
+ } else {
73
+ fileNames = [];
74
+ (function walk(d) {
75
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
76
+ const p = path.join(d, ent.name);
77
+ if (isTestPath(path.relative(rootDir, p))) continue;
78
+ if (ent.isDirectory()) walk(p);
79
+ else if (/\.[mc]?tsx?$/.test(ent.name) && !ent.name.endsWith(".d.ts")) fileNames.push(p);
80
+ else if (allowJs && /\.[mc]?jsx?$/.test(ent.name) && !/\.min\.js$/.test(ent.name)) fileNames.push(p);
81
+ }
82
+ })(rootDir);
83
+ }
84
+ }
85
+ if (fileNames.length === 0) { console.error(`candor-ts: no TypeScript sources under ${target}`); process.exit(2); }
86
+ if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
87
+ // The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
88
+ let pkgName = path.basename(rootDir);
89
+ try {
90
+ const pj = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
91
+ if (pj.name) pkgName = pj.name;
92
+ } catch {}
93
+ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
94
+
95
+ // A target with declared dependencies but no node_modules resolves almost nothing — the scan
96
+ // would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
97
+ // Warn LOUDLY; the report is still written (it is sound), but the cause must be visible.
98
+ {
99
+ const pkg = path.join(rootDir, "package.json");
100
+ if (fs.existsSync(pkg) && !fs.existsSync(path.join(rootDir, "node_modules"))) {
101
+ try {
102
+ const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
103
+ if (Object.keys(deps).length > 0)
104
+ console.error("candor-ts: WARNING — the target declares dependencies but has no node_modules; " +
105
+ "imports will not resolve and most functions will read Unknown. " +
106
+ "Run `npm install` in the target first.");
107
+ } catch {}
108
+ }
109
+ // Prisma's client types are GENERATED — a project with the prisma dependency but no generated
110
+ // client resolves every db.* call to nothing (found on the first Next.js probe: a Prisma-backed
111
+ // app read zero Db until `prisma generate` ran).
112
+ if (fs.existsSync(path.join(rootDir, "node_modules", "@prisma", "client"))
113
+ && !fs.existsSync(path.join(rootDir, "node_modules", ".prisma", "client"))) {
114
+ console.error("candor-ts: WARNING — @prisma/client is installed but its client is not generated; " +
115
+ "db calls will not resolve. Run `npx prisma generate` in the target first.");
116
+ }
117
+ }
118
+ // CANDOR_DEPS (SPEC §2): sibling/dependency reports whose effects a call into that package
119
+ // inherits — the cross-package join the workspace probe measured as missing (trpc client → server:
120
+ // zero edges). The key is the report's `hash` (`package#LocalName` — derivable from BOTH a source
121
+ // scan and a .d.ts resolution). Version-aware trust (§2.1): a report from a DIFFERENT engine
122
+ // version is downgraded to Unknown rather than silently trusted. Duplicate hashes (two same-named
123
+ // exports in one package) UNION — a sound over-approximation, documented.
124
+ const ENGINE_VERSION = "candor-ts-0.4.0";
125
+ const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
126
+ // Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
127
+ // entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
128
+ // serde_json rule the Rust/JVM engines already carry; /code-review found TS missing it). Fed from
129
+ // the envelope's `package` field (works for an all-pure EMPTY report) and from entry hash prefixes.
130
+ const depCoveredPkgs = new Set();
131
+ {
132
+ const spec = process.env.CANDOR_DEPS ?? "";
133
+ const files = [];
134
+ for (const tok of spec.split(/[\s:,]+/).filter(Boolean)) {
135
+ try {
136
+ if (fs.statSync(tok).isDirectory())
137
+ for (const f of fs.readdirSync(tok)) if (f.endsWith(".json") && !f.endsWith(".callgraph.json")) files.push(path.join(tok, f));
138
+ if (fs.statSync(tok).isFile()) files.push(tok);
139
+ } catch { console.error(`candor-ts: CANDOR_DEPS entry unreadable, skipped: ${tok}`); }
140
+ }
141
+ for (const f of files) {
142
+ try {
143
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
144
+ // A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
145
+ // untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
146
+ const stale = d.candor?.version !== ENGINE_VERSION;
147
+ if (typeof d.package === "string" && d.package) depCoveredPkgs.add(d.package);
148
+ for (const e of d.functions ?? []) {
149
+ if (!e.hash) continue;
150
+ const hashPkg = e.hash.split("#")[0];
151
+ if (hashPkg) depCoveredPkgs.add(hashPkg);
152
+ const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), hosts: [], cmds: [], paths: [], tables: [] };
153
+ for (const x of stale ? ["Unknown"] : e.inferred ?? []) cell.inferred.add(x);
154
+ if (!stale) for (const m of ["hosts", "cmds", "paths", "tables"])
155
+ for (const v of e[m] ?? []) if (!cell[m].includes(v)) cell[m].push(v);
156
+ crossDeps.set(e.hash, cell);
157
+ }
158
+ } catch { console.error(`candor-ts: CANDOR_DEPS report unparsable, skipped: ${f}`); }
159
+ }
160
+ }
161
+
162
+ if (allowJs) { compilerOptions.allowJs = true; compilerOptions.checkJs = false; }
163
+ const program = ts.createProgram(fileNames, compilerOptions);
164
+ const checker = program.getTypeChecker();
165
+ const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
166
+ const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
167
+
168
+ // ---- κ — the curated classifier (CLASSIFIER §2: the dispatch/execution boundary, not builders) ----
169
+ // Node builtins + a curated npm tier (the same under-report-and-say-so posture as the crate table:
170
+ // an unlisted package contributes nothing — never a guess).
171
+ // One rules TABLE, two readers: kappa() classifies a call; kappaKnows() answers "is this package
172
+ // curated at all?" for the coverage ledger (a κ-known package whose given call is pure — a TypeORM
173
+ // builder — is covered, not a blind spot). A single source so the two can never drift.
174
+ // [module-name regex, member regex (null = any member), effect]
175
+ const KAPPA_RULES = [
176
+ [/^(node:)?fs(\/promises)?$/, null, "Fs"],
177
+ [/^(node:)?(net|dgram|tls|http2?|https)$/, null, "Net"],
178
+ [/^(node:)?child_process$/, null, "Exec"],
179
+ [/^(node:)?sqlite$/, null, "Db"],
180
+ // the curated npm tier
181
+ [/^(axios|got|node-fetch|undici|ws|socket\.io(-client)?|nodemailer)$/, null, "Net"],
182
+ [/^(pg|mysql2?|mongodb|ioredis|redis|sqlite3|better-sqlite3|knex)$/, null, "Db"],
183
+ [/^(execa|cross-spawn|shelljs)$/, null, "Exec"],
184
+ [/^(fs-extra|graceful-fs|rimraf|glob|chokidar)$/, null, "Fs"],
185
+ [/^dotenv$/, null, "Env"],
186
+ [/^(winston|pino|bunyan|npmlog)$/, null, "Log"],
187
+ // entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
188
+ // the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
189
+ // landing on exactly the call a security review cares about).
190
+ [/^(node:)?crypto$/, /^random/, "Rand"],
191
+ [/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
192
+ // The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
193
+ // builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
194
+ // first framework-APP scan: a TypeORM/Nest application — Db-heavy by construction — read zero
195
+ // Db because the ORM resolved into an unlisted package (the JVM's Spring-Data lesson, replayed).
196
+ [/^(typeorm|@nestjs\/typeorm)$/,
197
+ /^(find|save|remove|softRemove|recover|insert|update|upsert|delete|restore|count|exist|sum|average|minimum|maximum|query|clear|increment|decrement|getMany|getOne|getOneOrFail|getRawMany|getRawOne|getCount|getExists|execute|stream|transaction)/,
198
+ "Db"],
199
+ [/^(@prisma\/client|\.prisma|\.prisma\/client)$/,
200
+ /^(\$?(queryRaw|executeRaw|transaction)|find(Many|Unique|First)|create|createMany|update|updateMany|upsert|delete|deleteMany|aggregate|count|groupBy)/,
201
+ "Db"],
202
+ [/^mongoose$/,
203
+ /^(find|save|create|insertMany|updateOne|updateMany|replaceOne|deleteOne|deleteMany|aggregate|countDocuments|estimatedDocumentCount|distinct|exec|bulkWrite)/,
204
+ "Db"],
205
+ [/^(sequelize|drizzle-orm)$/,
206
+ /^(find|create|update|destroy|upsert|count|max|min|sum|query|select|insert|delete|execute|transaction)/,
207
+ "Db"],
208
+ // Nest's HttpService wraps axios — the request verbs are Net.
209
+ [/^@nestjs\/axios$/, /^(get|post|put|patch|delete|head|request)$/, "Net"],
210
+ ];
211
+ function kappa(moduleName, member) {
212
+ for (const [mre, vre, eff] of KAPPA_RULES) {
213
+ if (mre.test(moduleName) && (!vre || vre.test(member))) return eff;
214
+ }
215
+ return null;
216
+ }
217
+ // Packages REVIEWED and ratified effect-free at the call boundary (decorator/metadata plumbing,
218
+ // pure computation, operator algebras whose side effects live in visible user callbacks). This is
219
+ // the ledger's triage outlet: an unlisted package either earns KAPPA_RULES entries or lands here —
220
+ // never silently. NOT for anything that mints entropy (uuid), reads clocks, or signs with RSA-PSS
221
+ // (jsonwebtoken stays unlisted on purpose).
222
+ const KAPPA_PURE = new Set([
223
+ "@nestjs/common", "@nestjs/core", "@nestjs/swagger", "@nestjs/platform-express",
224
+ "class-validator", "class-transformer", "reflect-metadata",
225
+ "rxjs", "zod", "lodash", "ramda", "date-fns",
226
+ ]);
227
+ function kappaKnows(moduleName) {
228
+ return KAPPA_PURE.has(moduleName) || KAPPA_RULES.some(([mre]) => mre.test(moduleName));
229
+ }
230
+
231
+ // The module a declaration came from: a project file → "<local>", @types/node → the builtin name,
232
+ // node_modules/<pkg> → the package name, the ES lib → "<es-lib>".
233
+ function declModule(decl) {
234
+ const f = path.resolve(decl.getSourceFile().fileName);
235
+ if (projectFiles.has(f)) return "<local>";
236
+ let m = f.match(/@types\/node\/(\w+?)\.d\.ts$/);
237
+ if (m) return m[1];
238
+ if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
239
+ m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
240
+ if (m) return m[1];
241
+ return f;
242
+ }
243
+
244
+ // ---- the literal surfaces (SPEC §2 hosts/cmds/paths/tables): the statically-decidable subset ------
245
+ // Read ONLY from string literals at a classified call — informative, never complete, never inferred.
246
+ function firstStringLiteral(node) {
247
+ for (const a of node.arguments ?? []) {
248
+ if (ts.isStringLiteralLike(a)) return a.text;
249
+ }
250
+ return null;
251
+ }
252
+ // host[:port] from an address/URL literal; non-address strings yield nothing (never fabricate).
253
+ function hostLiteral(s) {
254
+ const m = s.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i); // scheme://host[:port]/…
255
+ if (m) return m[1].replace(/^.*@/, "");
256
+ if (/^[a-z0-9._-]+(:\d+)?$/i.test(s) && s.includes(".")) return s; // bare host[.tld][:port]
257
+ return null;
258
+ }
259
+ // Table-position identifiers in a SQL string literal (SPEC §2 `tables`). Mirrors the Rust
260
+ // tables_in_sql exactly: must open with a statement keyword; FROM/JOIN/INTO anywhere,
261
+ // statement-leading UPDATE/TRUNCATE, TABLE (skipping ONLY/IF NOT EXISTS); a FOR UPDATE locking
262
+ // clause yields nothing. Conservative in the fabrication direction.
263
+ function tablesInSql(sql) {
264
+ const stmt = new Set(["select","insert","update","delete","create","drop","alter","truncate","merge","replace","with"]);
265
+ const skip = new Set(["only","if","not","exists","table"]);
266
+ const stop = new Set(["select","set","where","values","on","using","group","order","by","limit",
267
+ "returning","as","inner","outer","left","right","cross","lateral","natural","union","all",
268
+ "distinct","case","when","null","default","skip","nowait","of","from","join","into","update",
269
+ "delete","insert"]);
270
+ // `,` survives as its OWN token: it lets `FROM t1, t2` continue the table list without
271
+ // fabricating from other comma-ridden positions (column lists, ON clauses).
272
+ const toks = sql.toLowerCase().replace(/[();]/g, " ").replace(/,/g, " , ").trim().split(/\s+/);
273
+ if (!toks.length || !stmt.has(toks[0])) return [];
274
+ const out = [];
275
+ const ident = (raw) => {
276
+ const t = raw.replace(/^["'`]+|["'`]+$/g, "");
277
+ if (!t || stop.has(t) || !/^[a-z_][a-z0-9_.$"`]*$/.test(t)) return null;
278
+ return t.replace(/["`]/g, "");
279
+ };
280
+ for (let i = 0; i < toks.length; i++) {
281
+ const tablePos = ["from","join","into","table"].includes(toks[i])
282
+ || ((toks[i] === "update" || toks[i] === "truncate") && i === 0);
283
+ if (!tablePos) continue;
284
+ let j = i + 1;
285
+ while (j < toks.length && skip.has(toks[j])) j++;
286
+ if (j >= toks.length) continue;
287
+ const first = ident(toks[j]);
288
+ if (first === null) continue;
289
+ if (!out.includes(first)) out.push(first);
290
+ // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
291
+ // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an alias
292
+ // to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column list, whose
293
+ // parens are spaces by the time we tokenize).
294
+ while (j + 2 < toks.length && toks[j + 1] === ",") {
295
+ const more = ident(toks[j + 2]);
296
+ if (more === null) break;
297
+ if (!out.includes(more)) out.push(more);
298
+ j += 2;
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ // ---- pass 1: collect the analyzed functions across the project (SEMANTICS §2's F) -----------------
305
+ // Names are MODULE-QUALIFIED (`src.db.save` for save() in src/db.ts; separators → "." so the §6.2
306
+ // segment-scope rules apply naturally: `deny Net db` matches the db module). A single-file scan
307
+ // qualifies by the file's basename (`Cases.union_a`).
308
+ const fns = new Map(); // qualified name -> { direct, edges, hosts, tables, cmds, paths, loc }
309
+ const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
310
+ const nodeName = new WeakMap(); // declaration node -> qualified name
311
+ // ORM table declarations: `@Entity("user")` on a class maps that class to its table — the JVM's
312
+ // read-the-declarations move (TypeORM tables live in decorators, not SQL strings, so the `tables`
313
+ // surface couldn't fire on the most common TS app shape). LITERAL decorator arg only; a no-arg
314
+ // `@Entity()` (naming-strategy-dependent) contributes nothing — never a guess.
315
+ const entityTables = new Map(); // ClassDeclaration node -> table name
316
+ const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
317
+ function moduleOf(sf) {
318
+ const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
319
+ return rel.split(path.sep).join(".");
320
+ }
321
+ function localName(node) {
322
+ if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
323
+ if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
324
+ return `${node.parent.name.text}.${node.name.getText()}`;
325
+ // `const f = (…) => …` / `const f = function (…) {…}` at any binding site — the dominant style in
326
+ // real TS (rimraf's whole API is arrow consts; the first dogfood analyzed 0 of 50 files without
327
+ // this). The VARIABLE name is the function's name; nodeName is ALSO set on the initializer so a
328
+ // resolved call (whose sig.declaration is the arrow itself) finds the same qualified name.
329
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer
330
+ && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)))
331
+ return node.name.text;
332
+ // CLASS ARROW-PROPERTY methods (`private readonly onError = (e) => …`) — the event-handler idiom.
333
+ // Without this they were not units AT ALL: no callgraph key (a §2.2 violation), body never walked
334
+ // (a silent-pure hole — worse than Unknown), found by the PROVE-IT dogfood on got, where the
335
+ // request pipeline's error handlers live in exactly this form.
336
+ if (ts.isPropertyDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name
337
+ && node.initializer
338
+ && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)))
339
+ return `${node.parent.name.text}.${node.name.getText()}`;
340
+ // Constructors are units too (`new X()` edges to `X.constructor`): a constructor that wires
341
+ // effectful state (got's Request reassigns this.flush to an effectful closure in its ctor) was
342
+ // invisible — same dogfood.
343
+ if (ts.isConstructorDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
344
+ return `${node.parent.name.text}.constructor`;
345
+ // CJS export units (--allow-js, the npm half of report chaining): dist JS exports through
346
+ // assignment, not declarations, so `module.exports = function …` / `exports.foo = …` /
347
+ // `module.exports = { sign: fn }` were not units at all — a dep scan of jsonwebtoken yielded 4
348
+ // shallow fns with the package's whole API invisible. The unit name mirrors what a CONSUMER's
349
+ // resolution lands on: the fn's own name, the exported property, or the file's basename (the
350
+ // `require('./sign')` shape).
351
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
352
+ const p = node.parent;
353
+ if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === node) {
354
+ const lhs = p.left.getText().replace(/\s+/g, "");
355
+ if (lhs === "module.exports")
356
+ return (ts.isFunctionExpression(node) && node.name?.text)
357
+ || path.basename(node.getSourceFile().fileName).replace(/\.[mc]?jsx?$/, "");
358
+ const m = lhs.match(/^(?:module\.)?exports\.([A-Za-z_$][\w$]*)$/);
359
+ if (m) return m[1];
360
+ }
361
+ if (ts.isPropertyAssignment(p) && p.initializer === node && ts.isObjectLiteralExpression(p.parent)) {
362
+ const g = p.parent.parent;
363
+ if (ts.isBinaryExpression(g) && g.operatorToken.kind === ts.SyntaxKind.EqualsToken
364
+ && g.right === p.parent && g.left.getText().replace(/\s+/g, "") === "module.exports")
365
+ // .text, not getText(): a string-literal key keeps its quotes under getText, minting a
366
+ // hash like pkg#"sign" the consumer's pkg#sign join can never hit (/code-review).
367
+ return p.name.text ?? p.name.getText();
368
+ }
369
+ }
370
+ return null;
371
+ }
372
+ for (const sf of sources) {
373
+ const mod = moduleOf(sf);
374
+ (function collect(node) {
375
+ // Every NAMED class gets a `Class.constructor` unit (synthesized when the ctor is implicit):
376
+ // FIELD INITIALIZERS execute at construction (the JVM model — field inits belong to the ctor),
377
+ // so their call sites need a unit to attribute to; without one, `class C { x = fs.readFileSync(…) }`
378
+ // with an innocent explicit ctor was a SILENT-PURE hole (found chasing zod's Unknown profile).
379
+ // The ClassDeclaration itself maps to the ctor unit, so `new C()` with an implicit ctor edges
380
+ // there, and C passed AS A VALUE resolves as a callback target.
381
+ if (ts.isClassDeclaration(node) && node.name) {
382
+ for (const dec of ts.getDecorators?.(node) ?? []) {
383
+ const e = dec.expression;
384
+ if (ts.isCallExpression(e) && e.expression.getText() === "Entity"
385
+ && e.arguments.length > 0 && ts.isStringLiteralLike(e.arguments[0]))
386
+ entityTables.set(node, e.arguments[0].text);
387
+ }
388
+ // The interface-CHA universe (the Rust engine's local-trait move): `class PgStore
389
+ // implements Store` is the edge a `store.save()` dispatch on the INTERFACE type resolves
390
+ // through. Local interfaces only — flagging the lib.dom/lib.es surfaces would flood.
391
+ for (const h of node.heritageClauses ?? []) {
392
+ if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
393
+ for (const t of h.types) {
394
+ // Register under EVERY declaration of the interface symbol: a merged interface (two
395
+ // `interface Store` blocks / module augmentation) resolves a method to whichever block
396
+ // declares it, and keying only declarations[0] silently missed the others (/code-review).
397
+ const sym = checker.getSymbolAtLocation(t.expression);
398
+ const target = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
399
+ for (const idecl of target?.declarations ?? []) {
400
+ if (ts.isInterfaceDeclaration(idecl)
401
+ && projectFiles.has(path.resolve(idecl.getSourceFile().fileName))) {
402
+ if (!interfaceImpls.has(idecl)) interfaceImpls.set(idecl, []);
403
+ interfaceImpls.get(idecl).push(node);
404
+ }
405
+ }
406
+ }
407
+ }
408
+ const ctorQual = `${mod}.${node.name.text}.constructor`;
409
+ if (!fns.has(ctorQual)) {
410
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
411
+ fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
412
+ hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
413
+ why: new Set(), entry: false,
414
+ loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
415
+ }
416
+ nodeName.set(node, ctorQual);
417
+ }
418
+ const n = localName(node);
419
+ if (n) {
420
+ const qual = `${mod}.${n}`;
421
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
422
+ fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
423
+ cmds: new Set(), paths: new Set(), why: new Set(), entry: false,
424
+ loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
425
+ nodeName.set(node, qual);
426
+ if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
427
+ nodeName.set(node.initializer, qual);
428
+ }
429
+ ts.forEachChild(node, collect);
430
+ })(sf);
431
+ }
432
+
433
+ // callback-flow bookkeeping (the Rust engine's callback_named move, ported): for every call that
434
+ // edges to a LOCAL unit, record what each argument position received — a NAMED local unit (a
435
+ // resolvable callback target), or an opaque value (an inline closure stays attributed to the
436
+ // passer; a variable/property could be anything). A function that invokes a callback PARAMETER
437
+ // then resolves to the named targets IF every call site passed one — else honest Unknown.
438
+ const callbackArgs = new Map(); // calleeName -> Map(argIndex -> {targets:Set, opaque:boolean})
439
+ const paramInvokes = new Map(); // fnName -> Set(paramIndex) — this fn calls its own parameter
440
+
441
+ // ── entry points (SPEC §2 `entryPoint`): runtime-invoked roots the framework calls — their
442
+ // effects are never orphaned even with no in-project caller. Two populations for now:
443
+ // Nest HTTP handler decorators, and Next.js route-handler/middleware exports.
444
+ const HTTP_DECORATORS = new Set(["Get", "Post", "Put", "Patch", "Delete", "All", "Head", "Options"]);
445
+ const HTTP_EXPORTS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
446
+ for (const sf of sources) {
447
+ const base = path.basename(sf.fileName).replace(/\.[mc]?[tj]sx?$/, "");
448
+ (function mark(node) {
449
+ const qual = nodeName.get(node);
450
+ if (qual) {
451
+ const rec = fns.get(qual);
452
+ // Nest: a method carrying @Get()/@Post()/… is invoked by the framework router.
453
+ for (const dec of ts.getDecorators?.(node) ?? []) {
454
+ const e = dec.expression;
455
+ const dn = ts.isCallExpression(e) ? e.expression.getText() : e.getText();
456
+ if (HTTP_DECORATORS.has(dn)) rec.entry = true;
457
+ }
458
+ // Next: app-router route handlers (exported GET/POST/… in a `route` file) and middleware.
459
+ const leaf = rec.local.split(".").pop();
460
+ if (base === "route" && HTTP_EXPORTS.has(leaf)) rec.entry = true;
461
+ if (base === "middleware" && leaf === "middleware") rec.entry = true;
462
+ }
463
+ ts.forEachChild(node, mark);
464
+ })(sf);
465
+ }
466
+
467
+ // Resolve a use-site symbol through its IMPORT ALIAS to the real declaration: at `new X()` /
468
+ // `f(callback)` the symbol is the ImportSpecifier, not the class/function it names — without this,
469
+ // imported classes never edged to their ctor units and imported callback targets read opaque.
470
+ function realDecl(sym) {
471
+ if (!sym) return undefined;
472
+ if (sym.flags & ts.SymbolFlags.Alias) {
473
+ try { sym = checker.getAliasedSymbol(sym); } catch {}
474
+ }
475
+ return sym.valueDeclaration ?? sym.declarations?.[0];
476
+ }
477
+
478
+ // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
479
+ function enclosing(node) {
480
+ for (let p = node; p; p = p.parent) {
481
+ const n = nodeName.get(p);
482
+ if (n) return n;
483
+ }
484
+ return null;
485
+ }
486
+
487
+ // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
488
+ function visitCalls(node) {
489
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
490
+ const owner = enclosing(node);
491
+ if (owner) {
492
+ const rec = fns.get(owner);
493
+ const sig = checker.getResolvedSignature(node);
494
+ const decl = sig && sig.declaration;
495
+ if (!decl) {
496
+ // `new C()` on a class with an IMPLICIT constructor resolves to no declaration — edge to
497
+ // the class's (synthesized) ctor unit via the class identifier before concluding Unknown.
498
+ let edged = false, externalClass = false;
499
+ if (ts.isNewExpression(node) && node.expression && ts.isIdentifier(node.expression)) {
500
+ const cd = realDecl(checker.getSymbolAtLocation(node.expression));
501
+ const t = cd && nodeName.get(cd);
502
+ if (t) { rec.edges.add(t); edged = true; }
503
+ // `new ExternalClass()` with an implicit ctor: same posture as an explicit external ctor
504
+ // the classifier doesn't know — OPAQUE (contributes nothing), not Unknown. Consistency:
505
+ // whether a library declares its ctor must not change the verdict.
506
+ else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName)))
507
+ externalClass = true;
508
+ }
509
+ if (!edged && !externalClass) {
510
+ rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
511
+ const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
512
+ rec.why.add(`call:${callee}`); // an `any`-typed/indeterminate callee — named, so triage starts here
513
+ }
514
+ } else {
515
+ const mod = declModule(decl);
516
+ if (mod === "<local>") {
517
+ const targetName = nodeName.get(decl);
518
+ if (targetName) {
519
+ rec.edges.add(targetName); // (EDGE) — cross-FILE edges resolve the same way
520
+ // record what each argument position received (callback-flow, see callbackArgs)
521
+ (node.arguments ?? []).forEach((a, i) => {
522
+ const slot = (callbackArgs.get(targetName) ?? callbackArgs.set(targetName, new Map()).get(targetName));
523
+ const cell = slot.get(i) ?? { targets: new Set(), opaque: false };
524
+ if (ts.isIdentifier(a)) {
525
+ const t = (() => { const d2 = realDecl(checker.getSymbolAtLocation(a)); return d2 && nodeName.get(d2); })();
526
+ if (t) cell.targets.add(t);
527
+ else cell.opaque = true;
528
+ } else if (ts.isArrowFunction(a) || ts.isFunctionExpression(a)) {
529
+ cell.opaque = true; // inline closure: body attributed to the PASSER; opaque to the callee
530
+ } else {
531
+ cell.opaque = true;
532
+ }
533
+ slot.set(i, cell);
534
+ });
535
+ } else if (!ts.isArrowFunction(decl) && !ts.isFunctionExpression(decl)) {
536
+ // Resolution landed on a TYPE (a function-type annotation, a method/property signature),
537
+ // not a body. If that type belongs to a PARAMETER of a unit, defer to callback-flow
538
+ // resolution (pass 2b) — all-named call sites resolve it; otherwise (a field, a
539
+ // signature, a parameter of an un-collected function) the concrete callable is
540
+ // genuinely indeterminate: (UNKNOWN), never silent-pure (SPEC §4). An arrow/fn-
541
+ // expression is fine: its body is visible and already walked lexically (SEMANTICS §2).
542
+ let p = decl;
543
+ while (p && !ts.isParameter(p) && p !== p.parent) p = p.parent;
544
+ const ownerUnit = p && ts.isParameter(p) && p.parent && nodeName.get(p.parent);
545
+ if (ownerUnit) {
546
+ const idx = p.parent.parameters.indexOf(p);
547
+ (paramInvokes.get(ownerUnit) ?? paramInvokes.set(ownerUnit, new Set()).get(ownerUnit)).add(idx);
548
+ } else {
549
+ // Interface-CHA (the Rust engine's local-trait move, the JVM's bounded-CHA bound):
550
+ // a method signature on a LOCAL interface resolves to the local implementing
551
+ // classes' members when the dispatch is narrow (≤12 implementors) — `store.save()`
552
+ // on an injected `Store` edges to `PgStore.save`. No implementor in sight, or too
553
+ // many: honest Unknown, exactly as before.
554
+ // Soundness rule (/code-review): the dispatch suppresses Unknown only when EVERY
555
+ // implementor contributed an edge — an implementor whose member is inherited from a
556
+ // base class (or otherwise not a unit) is genuinely unresolved here, and edging the
557
+ // others while staying silent about it would drop its effects (a §4 regression: the
558
+ // pre-CHA code always read Unknown at this site).
559
+ let edged = false;
560
+ if (ts.isMethodSignature(decl) && decl.parent && ts.isInterfaceDeclaration(decl.parent)) {
561
+ const impls = interfaceImpls.get(decl.parent) ?? [];
562
+ if (impls.length > 0 && impls.length <= 12) {
563
+ const member = decl.name?.getText?.();
564
+ let allResolved = true;
565
+ const targets = [];
566
+ for (const cls of impls) {
567
+ const m = (cls.members ?? []).find((x) =>
568
+ (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x)) && x.name?.getText?.() === member);
569
+ const t = m && nodeName.get(m);
570
+ if (t) targets.push(t);
571
+ else allResolved = false;
572
+ }
573
+ for (const t of targets) rec.edges.add(t);
574
+ edged = targets.length > 0 && allResolved;
575
+ }
576
+ }
577
+ if (!edged) {
578
+ rec.direct.add("Unknown");
579
+ const tn = decl.parent?.name?.getText?.() ?? decl.name?.getText?.() ?? "type";
580
+ rec.why.add(`dispatch:${tn}`); // resolution landed on a type, not a body
581
+ }
582
+ }
583
+ }
584
+ } else if (mod === "<es-lib>") {
585
+ // conventionally-pure ES surface (Array/String/…) — except the clock and entropy (SPEC §1).
586
+ // `new Date()` (no args) captures the current time -> Clock; `Math.random()` -> Rand
587
+ // (both missed on the first real-app dogfood: a JWT issuer's timestamps and a slugifier's
588
+ // entropy were invisible).
589
+ const name = decl.name ? decl.name.getText() : "";
590
+ const parent = decl.parent && decl.parent.name ? decl.parent.name.getText() : "";
591
+ if ((parent === "DateConstructor" && name === "now") || (parent === "Performance" && name === "now"))
592
+ rec.direct.add("Clock");
593
+ if (parent === "Math" && name === "random") rec.direct.add("Rand");
594
+ if (ts.isNewExpression(node) && (node.arguments ?? []).length === 0
595
+ && checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor")
596
+ rec.direct.add("Clock");
597
+ } else {
598
+ const member = decl.name ? decl.name.getText() : "";
599
+ const eff = kappa(mod, member); // (CLASSIFY)
600
+ if (eff) rec.direct.add(eff);
601
+ // the literal surfaces, read only at a CLASSIFIED call (SPEC §2)
602
+ if (eff === "Net") {
603
+ const lit = firstStringLiteral(node);
604
+ const h = lit && hostLiteral(lit);
605
+ if (h) rec.hosts.add(h);
606
+ }
607
+ if (eff === "Db") {
608
+ const lit = firstStringLiteral(node);
609
+ for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
610
+ // ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
611
+ // type argument names the entity; its `@Entity("user")` decorator names the table.
612
+ if (ts.isPropertyAccessExpression(node.expression)) {
613
+ const rt = checker.getTypeAtLocation(node.expression.expression);
614
+ for (const ta of checker.getTypeArguments?.(rt) ?? rt?.typeArguments ?? []) {
615
+ const d = ta?.symbol?.declarations?.[0];
616
+ const tbl = d && entityTables.get(d);
617
+ if (tbl) rec.tables.add(tbl);
618
+ }
619
+ }
620
+ }
621
+ if (eff === "Exec") {
622
+ const lit = firstStringLiteral(node);
623
+ if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]); // the program of a command line
624
+ }
625
+ if (eff === "Fs") {
626
+ const lit = firstStringLiteral(node);
627
+ if (lit && /[\/\\]|^[.~]/.test(lit)) rec.paths.add(lit); // path-shaped literals only
628
+ }
629
+ // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
630
+ // that function's recorded transitive effects (+ literal surfaces) by `hash`.
631
+ let inheritedFromDep = false;
632
+ if (!eff && crossDeps.size > 0 && !mod.startsWith("<")) {
633
+ let localTail = decl.name ? decl.name.getText() : null;
634
+ const owner3 = decl.parent && decl.parent.name ? decl.parent.name.getText() : null;
635
+ if (localTail && owner3 && (ts.isMethodSignature(decl) || ts.isMethodDeclaration(decl) || ts.isPropertySignature(decl)))
636
+ localTail = `${owner3}.${localTail}`;
637
+ // A typed consumer resolves into `@types/<pkg>`; the dep's report hashes under `<pkg>`.
638
+ const depMod = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
639
+ // Owner-prefixed first (Owner.member), bare member as the fallback: a CJS dist scan
640
+ // hashes units under the bare export name, while interface/object-shaped typings (the
641
+ // common @types style) resolve the consumer's call to Owner.member — without the
642
+ // fallback exactly the typed-consumer shape the chain targets never joined.
643
+ const hit = localTail && (crossDeps.get(`${depMod}#${localTail}`)
644
+ ?? (decl.name ? crossDeps.get(`${depMod}#${decl.name.getText()}`) : undefined));
645
+ if (hit) {
646
+ inheritedFromDep = true;
647
+ for (const x of hit.inferred) rec.direct.add(x);
648
+ for (const v of hit.hosts) rec.hosts.add(v);
649
+ for (const v of hit.cmds) rec.cmds.add(v);
650
+ for (const v of hit.paths) rec.paths.add(v);
651
+ for (const v of hit.tables) rec.tables.add(v);
652
+ }
653
+ }
654
+ // unmatched external = (OPAQUE): contributes nothing — the curated-κ caveat C1. The
655
+ // κ-coverage LEDGER makes the caveat per-scan evidence instead of a doc footnote: count
656
+ // every npm package the code demonstrably calls that κ doesn't know and no sibling
657
+ // report covers (the argon2 lesson — the blind spot landed on exactly the call a
658
+ // security review cared about). Builtins are excluded: κ's builtin coverage is the
659
+ // bounded frontier, and an unlisted builtin (path, util) is known-pure, not blind.
660
+ if (!eff && !inheritedFromDep && !mod.startsWith("<")) {
661
+ // The REAL package name first: a typed consumer of an untyped package resolves into
662
+ // @types/<pkg>, and κ's tables/review lists hold the real name (/code-review: lodash
663
+ // via @types/lodash was falsely disclosed — kappaKnows saw the unstripped name).
664
+ const pkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
665
+ const file = decl.getSourceFile().fileName;
666
+ if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
667
+ && /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
668
+ unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
669
+ }
670
+ }
671
+ }
672
+ }
673
+ // the callee EXPRESSION being a plain identifier of function-typed parameter/field:
674
+ // a PARAMETER defers to callback-flow resolution (below) — if every call site of this
675
+ // function passes a NAMED local unit, the invocation resolves to those targets; otherwise
676
+ // (or for fields/signatures) it is (UNKNOWN), never silent-pure (SPEC §4).
677
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
678
+ const sym = checker.getSymbolAtLocation(node.expression);
679
+ const d = sym && sym.valueDeclaration;
680
+ if (d && ts.isParameter(d) && d.parent && nodeName.get(d.parent)) {
681
+ const idx = d.parent.parameters.indexOf(d);
682
+ const owner2 = nodeName.get(d.parent);
683
+ (paramInvokes.get(owner2) ?? paramInvokes.set(owner2, new Set()).get(owner2)).add(idx);
684
+ } else if (d && (ts.isParameter(d) || ts.isPropertyDeclaration(d) || ts.isPropertySignature(d))) {
685
+ rec.direct.add("Unknown"); // a callback value — genuinely indeterminate (SPEC §4)
686
+ rec.why.add(`callback:${node.expression.getText()}`);
687
+ }
688
+ }
689
+ }
690
+ }
691
+ // process.env.X — a property READ, not a call (the JVM's System.getenv twin) → Env
692
+ if (ts.isPropertyAccessExpression(node) && node.expression.getText() === "process.env") {
693
+ const owner = enclosing(node);
694
+ if (owner) fns.get(owner).direct.add("Env");
695
+ }
696
+ ts.forEachChild(node, visitCalls);
697
+ }
698
+ for (const sf of sources) visitCalls(sf);
699
+
700
+ // ---- pass 2b: callback-flow resolution (the callback_named move) ----------------------------------
701
+ // A fn invoking its parameter i resolves to the named targets IF this project shows call sites and
702
+ // EVERY one passed a named local unit at i. Any opaque arg — or NO visible call site (the fn may be
703
+ // exported; outside callers can pass anything) — keeps the honest Unknown.
704
+ for (const [fnName, idxs] of paramInvokes) {
705
+ const rec = fns.get(fnName);
706
+ if (!rec) continue;
707
+ const slots = callbackArgs.get(fnName);
708
+ for (const idx of idxs) {
709
+ const cell = slots?.get(idx);
710
+ if (cell && !cell.opaque && cell.targets.size > 0) {
711
+ for (const t of cell.targets) rec.edges.add(t);
712
+ } else {
713
+ rec.direct.add("Unknown");
714
+ rec.why.add(`callback:param#${idx}`); // an opaque (or externally-callable) callback parameter
715
+ }
716
+ }
717
+ }
718
+
719
+ // ---- pass 3: the least fixpoint (SEMANTICS §5a), effects + the literal surfaces -------------------
720
+ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direct)]));
721
+ let changed = true;
722
+ while (changed) {
723
+ changed = false;
724
+ for (const [name, rec] of fns) {
725
+ const mine = inferred.get(name);
726
+ for (const callee of rec.edges)
727
+ for (const e of inferred.get(callee) ?? [])
728
+ if (!mine.has(e)) { mine.add(e); changed = true; }
729
+ }
730
+ }
731
+ for (const m of ["hosts", "tables", "cmds", "paths"]) {
732
+ let moved = true;
733
+ while (moved) {
734
+ moved = false;
735
+ for (const [, rec] of fns)
736
+ for (const callee of rec.edges)
737
+ for (const v of fns.get(callee)?.[m] ?? [])
738
+ if (!rec[m].has(v)) { rec[m].add(v); moved = true; }
739
+ }
740
+ }
741
+
742
+ // ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
743
+ const functions = [];
744
+ for (const [name, rec] of fns) {
745
+ const inf = [...inferred.get(name)].sort();
746
+ if (inf.length === 0 && !rec.entry) continue; // entry points stay visible even when pure
747
+ const entry = {
748
+ fn: name,
749
+ loc: rec.loc,
750
+ hash: `${pkgName}#${rec.local}`, // SPEC §2: the cross-package join key (package + local tail)
751
+ inferred: inf,
752
+ direct: [...rec.direct].sort(),
753
+ declared: [],
754
+ undeclared: [],
755
+ overdeclared: [],
756
+ unresolved: inf.includes("Unknown"),
757
+ };
758
+ if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
759
+ if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
760
+ if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
761
+ if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
762
+ if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
763
+ if (rec.entry) entry.entryPoint = true;
764
+ functions.push(entry);
765
+ }
766
+ // `package` names what this report COVERS — a consumer chaining it registers coverage even when
767
+ // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
768
+ const envelope = { candor: { version: "candor-ts-0.4.0", toolchain: `node-${process.versions.node}`, spec: "0.4" },
769
+ package: pkgName, functions };
770
+ fs.writeFileSync(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
771
+ const cg = {};
772
+ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
773
+ fs.writeFileSync(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
774
+ console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
775
+ if (unlistedSeen.size > 0) {
776
+ const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
777
+ const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
778
+ const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
779
+ console.error(`candor-ts: κ doesn't know ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
780
+ + `effects through ${top.length === 1 ? "it are" : "them are"} INVISIBLE (not Unknown): ${shown}${more}`);
781
+ }
782
+
783
+ // ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
784
+ if (policyPath) {
785
+ let text;
786
+ try {
787
+ text = fs.readFileSync(policyPath, "utf8");
788
+ } catch {
789
+ // a set-but-unreadable policy must be LOUD — silently passing would let a violation ship
790
+ console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
791
+ process.exit(2);
792
+ }
793
+ const v = evaluatePolicy(parsePolicy(text), functions, cg);
794
+ for (const line of v) console.log(line);
795
+ if (v.length) {
796
+ console.error(`candor-ts: ${v.length} policy violation(s)`);
797
+ process.exit(1);
798
+ }
799
+ console.error("candor-ts: policy ✓");
800
+ }