candor-ts 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -144,7 +144,10 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
144
144
  NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
145
145
  know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
146
146
  "no effect" through a package that line names (the documented weaker edge of the
147
- never-silently-pure promise, same as every candor engine's curated classifier). An uncurated
147
+ never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
148
+ carries an `invisible` list — the κ-unknown packages it (transitively) reaches — so `inferred` is
149
+ never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
150
+ far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
148
151
  dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
149
152
  `package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
150
153
  the declared set instead of contributing nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/policy.mjs CHANGED
@@ -14,7 +14,11 @@ const ASCII_WS = /[ \t\n\v\f\r]+/;
14
14
  const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
15
15
  export function parsePolicy(text) {
16
16
  const deny = [], allow = [], forbid = [];
17
- for (const rawLine of text.split("\n")) {
17
+ // Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
18
+ // breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
19
+ // in-line ASCII-ws token separator (below), so every rule after the first was glued into the first rule's
20
+ // tokens and dropped — a gateless-green divergence (sweep [16]/[17]). \v/\f stay in-line separators.
21
+ for (const rawLine of text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
18
22
  const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
19
23
  if (!line) continue;
20
24
  const t = line.split(ASCII_WS);
@@ -103,7 +107,7 @@ export function literalAllowed(effect, reached, values) {
103
107
  * transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
104
108
  * literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
105
109
  */
106
- export function evaluatePolicy(pol, functions, callgraph) {
110
+ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
107
111
  const out = [];
108
112
  const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
109
113
  for (const f of functions) {
@@ -116,7 +120,11 @@ export function evaluatePolicy(pol, functions, callgraph) {
116
120
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
117
121
  if (!f.inferred.includes(r.effect)) continue;
118
122
  const reached = f[surfaces[r.effect]] ?? [];
119
- if (reached.length === 0) {
123
+ // An INCOMPLETE surface (a structurally-invisible reach — a host-establishing call with a runtime/
124
+ // invisible host) can't be certified even with visible hosts, else a benign literal masks the
125
+ // invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
126
+ const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
127
+ if (reached.length === 0 || surfaceIncomplete) {
120
128
  out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
121
129
  } else {
122
130
  const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
package/scan-core.mjs CHANGED
@@ -39,7 +39,14 @@ export const KAPPA_RULES = [
39
39
  // FABRICATED Net — the cardinal sin — purely from this classification, with no local Net edge). Only
40
40
  // these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
41
41
  // stays Net (the matcher excludes ONLY new + the three validators, nothing else).
42
- [/^(node:)?(net|dgram|tls|http2?|https)$/, /^(?!(new|isIP|isIPv4|isIPv6)$)/, "Net"],
42
+ // ALSO exempt the pure CONFIG/METADATA members the whole-module rule fabricated Net on (sweep [9], the
43
+ // cardinal sin — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
44
+ // (cipher-list + cert helpers), http.validateHeaderName/validateHeaderValue (string validators, like
45
+ // isIP), and a Socket/Server's setKeepAlive/setNoDelay/ref/unref/address (TCP-option + bound-address
46
+ // metadata — no I/O). Every genuine verb still classifies; only these proven-pure names are freed.
47
+ [/^(node:)?(net|dgram|tls|http2?|https)$/,
48
+ /^(?!(new|isIP|isIPv4|isIPv6|getCiphers|createSecureContext|checkServerIdentity|validateHeaderName|validateHeaderValue|setKeepAlive|setNoDelay|ref|unref|address)$)/,
49
+ "Net"],
43
50
  // node:dns — name resolution is NETWORK I/O (lookup/lookupService hit the OS resolver; resolve*/
44
51
  // reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
45
52
  // Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
package/scan.mjs CHANGED
@@ -305,6 +305,25 @@ function firstStringLiteral(node) {
305
305
  return null;
306
306
  }
307
307
 
308
+ // Is this property/element access a SETTER target reached through a destructuring assignment —
309
+ // `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
310
+ // Object|ArrayLiteral wrappers to the enclosing `=`; it is a target only when the wrapping literal is the
311
+ // LHS (`.left`) of the assignment. A property access on the RHS (`src = { k: x.prop }`) walks to a literal
312
+ // that is `.right`, so it stays a getter READ — no false setter attribution.
313
+ function isDestructuringAssignTarget(node) {
314
+ let cur = node, parent = node.parent;
315
+ while (parent) {
316
+ if (ts.isPropertyAssignment(parent) && parent.initializer === cur) { cur = parent; parent = parent.parent; continue; }
317
+ if (ts.isShorthandPropertyAssignment(parent)) return false; // `{prop}` has no access node to attribute
318
+ if (ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent)) { cur = parent; parent = parent.parent; continue; }
319
+ if (ts.isObjectLiteralExpression(parent) || ts.isArrayLiteralExpression(parent)) { cur = parent; parent = parent.parent; continue; }
320
+ if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
321
+ return parent.left === cur;
322
+ return false;
323
+ }
324
+ return false;
325
+ }
326
+
308
327
  // The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
309
328
  // Unlike firstStringLiteral (the first literal ANYWHERE in the args), this refuses to refine when
310
329
  // the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
@@ -464,7 +483,7 @@ for (const sf of sources) {
464
483
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
465
484
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
466
485
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
467
- why: new Set(), entry: false,
486
+ blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
468
487
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
469
488
  }
470
489
  nodeName.set(node, ctorQual);
@@ -475,7 +494,7 @@ for (const sf of sources) {
475
494
  const qual = `${mod}.${n}`;
476
495
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
477
496
  fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
478
- cmds: new Set(), paths: new Set(), why: new Set(), entry: false, isCjsExport,
497
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
479
498
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
480
499
  nodeName.set(node, qual);
481
500
  if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
@@ -754,8 +773,22 @@ function visitCalls(node) {
754
773
  // `new ExternalClass()` with an implicit ctor: same posture as an explicit external ctor
755
774
  // the classifier doesn't know — OPAQUE (contributes nothing), not Unknown. Consistency:
756
775
  // whether a library declares its ctor must not change the verdict.
757
- else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName)))
776
+ else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName))) {
758
777
  externalClass = true;
778
+ // …but the construction DOES reach the class's package — disclose it as `invisible` (sweep
779
+ // [13]) so the pure verdict is qualified, exactly like an unmodeled METHOD call below. Without
780
+ // this, `new Pool()` from an unmodeled pkg read plain pure with no disclosure, no κ-ledger.
781
+ const cfile = cd.getSourceFile().fileName;
782
+ const cmod = declModule(cd);
783
+ const cpkg = cmod.startsWith("@types/") ? cmod.slice("@types/".length) : cmod;
784
+ const cdeclared = packageManifestEffects(cfile);
785
+ if (cdeclared !== null) { for (const e of cdeclared) rec.direct.add(e); }
786
+ else if (!cmod.startsWith("<") && !kappaKnows(cpkg) && !depCoveredPkgs.has(cpkg)
787
+ && /node_modules\//.test(cfile) && !/node_modules\/(@types\/node|typescript)\//.test(cfile)) {
788
+ unlistedSeen.set(cpkg, (unlistedSeen.get(cpkg) ?? 0) + 1);
789
+ rec.blind.add(cpkg);
790
+ }
791
+ }
759
792
  }
760
793
  if (!edged && !externalClass) {
761
794
  rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
@@ -980,6 +1013,25 @@ function visitCalls(node) {
980
1013
  // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
981
1014
  // still synthesize "new" and stay pure.
982
1015
  const CONNECTING_CTORS = new Set(["ClientRequest"]);
1016
+ // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
1017
+ // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
1018
+ // a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
1019
+ // URL is the call arg (sweep [18]); `dgram.send(buf,port,host)` is added module-aware below (UDP
1020
+ // has no connect, so send carries the destination — sweep [12]).
1021
+ const NET_ESTABLISHING = new Set(["request", "get", "post", "put", "patch", "delete", "head",
1022
+ "options", "connect", "createConnection", "fetch"]);
1023
+ // Fs/Exec USE-verbs whose LOCATOR was fixed earlier, not an arg of THIS call — so a missing literal
1024
+ // here is the legitimate split-construct/use shape, never the masking signal (the establishing-
1025
+ // allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
1026
+ // ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
1027
+ // (the command was fixed at spawn); the spawn fns are establishing.
1028
+ const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
1029
+ "fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
1030
+ "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"]);
1031
+ const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
1032
+ const netEstablishing = (member) =>
1033
+ CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
1034
+ || (/^(node:)?dgram$/.test(mod) && member === "send");
983
1035
  const ctorClassName = ts.isNewExpression(node)
984
1036
  ? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
985
1037
  : (decl.name ? decl.name.getText() : ""))
@@ -1005,9 +1057,18 @@ function visitCalls(node) {
1005
1057
  const lit = firstStringLiteral(node);
1006
1058
  const h = lit && hostLiteral(lit);
1007
1059
  if (h) rec.hosts.add(h);
1060
+ // MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
1061
+ // built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
1062
+ // benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
1063
+ // (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
1064
+ // (the host is captured at connect). Under-catches an unlisted establishing verb (safe
1065
+ // direction); never over-flags a use-call.
1066
+ else if (netEstablishing(member))
1067
+ rec.incomplete.add("Net");
1008
1068
  }
1009
1069
  if (eff === "Db") {
1010
1070
  const lit = firstStringLiteral(node);
1071
+ const before = rec.tables.size;
1011
1072
  for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
1012
1073
  // ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
1013
1074
  // type argument names the entity; its `@Entity("user")` decorator names the table.
@@ -1019,6 +1080,11 @@ function visitCalls(node) {
1019
1080
  if (tbl) rec.tables.add(tbl);
1020
1081
  }
1021
1082
  }
1083
+ // masking: a Db call that surfaced NO table (no SQL literal, no entity-typed receiver) reaches a
1084
+ // runtime/invisible table — a benign sibling query's literal table must not mask it. The entity
1085
+ // route above is NOT a literal so it still counts as visible (a captured table); only a fully
1086
+ // invisible query marks incomplete. `new` (a connection ctor) carries no table — skip it.
1087
+ if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
1022
1088
  }
1023
1089
  if (eff === "Exec") {
1024
1090
  const lit = firstStringLiteral(node);
@@ -1028,10 +1094,18 @@ function visitCalls(node) {
1028
1094
  // names no static program, so its trailing literal must not fabricate Net (spec §4).
1029
1095
  const head = programHeadLiteral(node);
1030
1096
  if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
1097
+ // masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
1098
+ // command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
1099
+ // (kill/send/disconnect/ref/unref) carry no command and are excluded.
1100
+ else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
1031
1101
  }
1032
1102
  if (eff === "Fs") {
1033
1103
  const lit = firstStringLiteral(node);
1034
- if (lit && /[/\\]|^[.~]/.test(lit)) rec.paths.add(lit); // path-shaped literals only
1104
+ const pathCaptured = lit && /[/\\]|^[.~]/.test(lit); // path-shaped literals only
1105
+ if (pathCaptured) rec.paths.add(lit);
1106
+ // masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
1107
+ // path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
1108
+ else if (!FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
1035
1109
  }
1036
1110
  // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
1037
1111
  // that function's recorded transitive effects (+ literal surfaces) by `hash`.
@@ -1079,6 +1153,11 @@ function visitCalls(node) {
1079
1153
  } else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
1080
1154
  && /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
1081
1155
  unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
1156
+ // Per-fn HONESTY: this fn calls into a genuinely-blind package (κ-unknown, not dep-covered).
1157
+ // Recorded per fn, propagated transitively, emitted as `invisible` — so `inferred` is never an
1158
+ // unqualified completeness claim. This branch already IS the global-blind condition, so no
1159
+ // post-filter is needed (κ either knows a package or it doesn't).
1160
+ rec.blind.add(pkg);
1082
1161
  }
1083
1162
  }
1084
1163
  }
@@ -1115,8 +1194,19 @@ function visitCalls(node) {
1115
1194
  const callee = node.expression;
1116
1195
  const ctext = callee.getText().replace(/\s+/g, "");
1117
1196
  let geff = null;
1118
- if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
1119
- else if (ctext === "process.send") geff = "Ipc";
1197
+ // `process.*` is matched by exact text, so a project's OWN `const process = {…}` shadow would
1198
+ // fabricate Clock/Ipc on a pure local method (sweep [31]) — guard it like the `fetch` arm: resolve the
1199
+ // ROOT `process` identifier and fire only when it is NOT a project-local declaration (i.e. the global).
1200
+ const processIsGlobal = () => {
1201
+ if (!ts.isPropertyAccessExpression(callee)) return false;
1202
+ let root = callee.expression;
1203
+ while (ts.isPropertyAccessExpression(root)) root = root.expression; // process.hrtime.bigint → process
1204
+ if (!ts.isIdentifier(root) || root.text !== "process") return false;
1205
+ return !(checker.getSymbolAtLocation(root)?.declarations ?? [])
1206
+ .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
1207
+ };
1208
+ if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
1209
+ else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
1120
1210
  else if (ts.isIdentifier(callee) && callee.text === "fetch"
1121
1211
  && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1122
1212
  .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
@@ -1145,21 +1235,28 @@ function visitCalls(node) {
1145
1235
  // call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
1146
1236
  // following the same posture as an unresolvable call (SPEC §4).
1147
1237
  if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
1148
- // Is this property access the TARGET of an assignment (`x.prop = v`)? If so it's a setter site;
1149
- // otherwise it's a read (getter) site. (`x.prop += v` is both a read and a write, but the read
1150
- // side is the produced value — model it as a setter target only when it is the bare LHS of `=`.)
1238
+ // Is this property access an assignment TARGET? A simple `x.prop = v` invokes the SETTER only. A
1239
+ // COMPOUND/LOGICAL assignment (`+=`,`-=`,`??=`,`||=`,`&&=`,…) reads the current value AND writes — both
1240
+ // the getter and the setter run (sweep [10]; pre-fix only a bare `=` was a setter site, so an effectful
1241
+ // setter under `+=`/`??=` read PURE). A DESTRUCTURING-assignment target (`({k: x.prop} = src)` /
1242
+ // `[x.prop] = arr`) is also a setter site, invisible to the simple-LHS test (sweep [32]).
1151
1243
  const p = node.parent;
1152
- const isAssignTarget = p && ts.isBinaryExpression(p) && p.left === node
1153
- && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
1154
- const hit = isAssignTarget ? accessorAt(node, "set") : accessorAt(node, "get");
1155
- if (hit) {
1244
+ const isBinAssign = p && ts.isBinaryExpression(p) && p.left === node;
1245
+ const simpleAssign = isBinAssign && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
1246
+ const compoundAssign = isBinAssign && !simpleAssign
1247
+ && p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
1248
+ const recordKind = (kind) => {
1249
+ const hit = accessorAt(node, kind);
1250
+ if (!hit) return;
1156
1251
  const owner = enclosing(node);
1157
- if (owner) {
1158
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
1159
- const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1160
- recordAccessorHit(owner, hit, `${an}.${pn}`);
1161
- }
1162
- }
1252
+ if (!owner) return;
1253
+ const an = hit.decl.parent?.name?.getText?.() ?? "?";
1254
+ const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1255
+ recordAccessorHit(owner, hit, `${an}.${pn}`);
1256
+ };
1257
+ if (simpleAssign || isDestructuringAssignTarget(node)) recordKind("set");
1258
+ else if (compoundAssign) { recordKind("get"); recordKind("set"); }
1259
+ else recordKind("get");
1163
1260
  }
1164
1261
  // OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
1165
1262
  // resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
@@ -1271,7 +1368,7 @@ while (changed) {
1271
1368
  if (!mine.has(e)) { mine.add(e); changed = true; }
1272
1369
  }
1273
1370
  }
1274
- for (const m of ["hosts", "tables", "cmds", "paths"]) {
1371
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
1275
1372
  let moved = true;
1276
1373
  while (moved) {
1277
1374
  moved = false;
@@ -1286,7 +1383,9 @@ for (const m of ["hosts", "tables", "cmds", "paths"]) {
1286
1383
  const functions = [];
1287
1384
  for (const [name, rec] of fns) {
1288
1385
  const inf = [...inferred.get(name)].sort();
1289
- if (inf.length === 0 && !rec.entry) continue; // entry points stay visible even when pure
1386
+ // entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
1387
+ // on exactly the `inferred: []` fns that need it.
1388
+ if (inf.length === 0 && !rec.entry && rec.blind.size === 0) continue;
1290
1389
  const entry = {
1291
1390
  fn: name,
1292
1391
  loc: rec.loc,
@@ -1303,6 +1402,9 @@ for (const [name, rec] of fns) {
1303
1402
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
1304
1403
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
1305
1404
  if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
1405
+ // HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
1406
+ // them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
1407
+ if (rec.blind.size) entry.invisible = [...rec.blind].sort();
1306
1408
  if (rec.entry) entry.entryPoint = true;
1307
1409
  if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
1308
1410
  functions.push(entry);
@@ -1339,7 +1441,11 @@ if (policyPath) {
1339
1441
  console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
1340
1442
  process.exit(2);
1341
1443
  }
1342
- const v = evaluatePolicy(parsePolicy(text), functions, cg);
1444
+ // The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
1445
+ // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
1446
+ const incompleteMap = new Map();
1447
+ for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
1448
+ const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
1343
1449
  for (const line of v) console.log(line);
1344
1450
  if (v.length) {
1345
1451
  console.error(`candor-ts: ${v.length} policy violation(s)`);