candor-ts 0.5.13 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.13",
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);
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
@@ -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)
@@ -981,8 +1014,24 @@ function visitCalls(node) {
981
1014
  // still synthesize "new" and stay pure.
982
1015
  const CONNECTING_CTORS = new Set(["ClientRequest"]);
983
1016
  // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
984
- // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send).
985
- const NET_ESTABLISHING = new Set(["request", "get", "connect", "createConnection", "fetch"]);
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");
986
1035
  const ctorClassName = ts.isNewExpression(node)
987
1036
  ? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
988
1037
  : (decl.name ? decl.name.getText() : ""))
@@ -1010,15 +1059,16 @@ function visitCalls(node) {
1010
1059
  if (h) rec.hosts.add(h);
1011
1060
  // MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
1012
1061
  // built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
1013
- // benign literal can't mask it. ALLOWLIST of establishing forms only (request/get/connect/
1014
- // createConnection/fetch + the connecting ctor) — NEVER use-calls (write/end/send), which would
1015
- // false-positive on `socket.connect("h").write(data)` (the host is captured at connect). Under-
1016
- // catches an unlisted establishing verb (safe direction); never over-flags a use-call.
1017
- else if (NET_ESTABLISHING.has(member) || CONNECTING_CTORS.has(ctorClassName))
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))
1018
1067
  rec.incomplete.add("Net");
1019
1068
  }
1020
1069
  if (eff === "Db") {
1021
1070
  const lit = firstStringLiteral(node);
1071
+ const before = rec.tables.size;
1022
1072
  for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
1023
1073
  // ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
1024
1074
  // type argument names the entity; its `@Entity("user")` decorator names the table.
@@ -1030,6 +1080,11 @@ function visitCalls(node) {
1030
1080
  if (tbl) rec.tables.add(tbl);
1031
1081
  }
1032
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");
1033
1088
  }
1034
1089
  if (eff === "Exec") {
1035
1090
  const lit = firstStringLiteral(node);
@@ -1039,10 +1094,18 @@ function visitCalls(node) {
1039
1094
  // names no static program, so its trailing literal must not fabricate Net (spec §4).
1040
1095
  const head = programHeadLiteral(node);
1041
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");
1042
1101
  }
1043
1102
  if (eff === "Fs") {
1044
1103
  const lit = firstStringLiteral(node);
1045
- 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");
1046
1109
  }
1047
1110
  // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
1048
1111
  // that function's recorded transitive effects (+ literal surfaces) by `hash`.
@@ -1131,8 +1194,19 @@ function visitCalls(node) {
1131
1194
  const callee = node.expression;
1132
1195
  const ctext = callee.getText().replace(/\s+/g, "");
1133
1196
  let geff = null;
1134
- if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
1135
- 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";
1136
1210
  else if (ts.isIdentifier(callee) && callee.text === "fetch"
1137
1211
  && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1138
1212
  .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
@@ -1161,21 +1235,28 @@ function visitCalls(node) {
1161
1235
  // call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
1162
1236
  // following the same posture as an unresolvable call (SPEC §4).
1163
1237
  if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
1164
- // Is this property access the TARGET of an assignment (`x.prop = v`)? If so it's a setter site;
1165
- // otherwise it's a read (getter) site. (`x.prop += v` is both a read and a write, but the read
1166
- // 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]).
1167
1243
  const p = node.parent;
1168
- const isAssignTarget = p && ts.isBinaryExpression(p) && p.left === node
1169
- && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
1170
- const hit = isAssignTarget ? accessorAt(node, "set") : accessorAt(node, "get");
1171
- 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;
1172
1251
  const owner = enclosing(node);
1173
- if (owner) {
1174
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
1175
- const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1176
- recordAccessorHit(owner, hit, `${an}.${pn}`);
1177
- }
1178
- }
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");
1179
1260
  }
1180
1261
  // OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
1181
1262
  // resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to