candor-ts 0.5.6 → 0.5.8

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.6",
3
+ "version": "0.5.8",
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/scan-core.mjs CHANGED
@@ -60,7 +60,11 @@ export const KAPPA_RULES = [
60
60
  // entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
61
61
  // the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
62
62
  // landing on exactly the call a security review cares about).
63
- [/^(node:)?crypto$/, /^random/, "Rand"],
63
+ [/^(node:)?crypto$/, /^(random|getRandomValues)/, "Rand"],
64
+ // node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
65
+ // environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
66
+ // (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
67
+ [/^(node:)?os$/, /^(userInfo|hostname)$/, "Env"],
64
68
  [/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
65
69
  // The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
66
70
  // builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
package/scan.mjs CHANGED
@@ -242,7 +242,12 @@ const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.res
242
242
  function declModule(decl) {
243
243
  const f = path.resolve(decl.getSourceFile().fileName);
244
244
  if (projectFiles.has(f)) return "<local>";
245
- let m = f.match(/@types\/node\/(\w+?)\.d\.ts$/);
245
+ // `(.+?)` not `(\w+?)`: a SUBPATH typing (`@types/node/fs/promises.d.ts`, `dns/promises.d.ts`) carries
246
+ // a `/` that `\w` can't cross, so the module collapsed to `@types/node` (via the node_modules branch
247
+ // below) and the `fs(\/promises)?` / `dns(\/promises)?` κ rules — written to cover exactly these — could
248
+ // never fire (`fs/promises` is the dominant modern Node FS API: a silent-pure under-report). Keep the
249
+ // slash so the module reads `fs/promises`, which the rules match.
250
+ let m = f.match(/@types\/node\/(.+?)\.d\.ts$/);
246
251
  if (m) return m[1];
247
252
  if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
248
253
  m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
@@ -617,9 +622,30 @@ function recordAccessorHit(owner, hit, label) {
617
622
  }
618
623
  }
619
624
 
625
+ // Object PROPERTY-ENUMERATION (`{...obj}`, `const {...rest} = obj`, `Object.assign(t, obj)`): copying an
626
+ // object's own enumerable props INVOKES each source getter — the whole-object analog of `obj.prop`,
627
+ // invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
628
+ // getter on the source type. A rest/spread can't name one key, so ALL getters are enumerated (sound
629
+ // over-approximation); a plain prop resolves to no accessor and adds nothing (no fabrication).
630
+ function enumerateGetters(owner, type) {
631
+ if (!owner || !type || !type.getProperties) return;
632
+ for (const p of type.getProperties()) {
633
+ const hit = accessorFromSym(p, "get");
634
+ if (hit) recordAccessorHit(owner, hit, p.getName());
635
+ }
636
+ }
637
+
620
638
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
621
639
  function enclosing(node) {
622
640
  for (let p = node; p; p = p.parent) {
641
+ // A call/effect lexically inside a DECORATOR (`@factory(arg)`) runs at class-DEFINITION time, NOT in
642
+ // the decorated declaration's body. The parent chain of a decorator's expression is
643
+ // CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
644
+ // lands on the decorated unit and FABRICATES the factory's effects onto that method/class/param and
645
+ // every transitive caller (a cardinal sin — @Entity/@Injectable factories that touch I/O would
646
+ // poison every decorated handler). Stop at the Decorator: the factory's own effects live in its own
647
+ // function unit; the application site attributes to nothing (load-time, like a no-arg decorator).
648
+ if (ts.isDecorator(p)) return null;
623
649
  const n = nodeName.get(p);
624
650
  if (n) return n;
625
651
  }
@@ -698,6 +724,17 @@ function edgeToTargets(rec, decls) {
698
724
  for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
699
725
  }
700
726
 
727
+ // Callee names that INVOKE a function/method argument (so a fn-reference passed to one is reachable
728
+ // through it). Array/iterable HOFs, the timer/microtask schedulers, and Promise continuations. A
729
+ // STORE/compare/log sink (`set`/`push`/`add`/`includes`/`indexOf`/`concat`/`log`/`stringify`/…) is
730
+ // deliberately ABSENT — edging there would fabricate the fn's effects on a pure path (the cardinal sin).
731
+ const HOF_INVOKERS = new Set([
732
+ "map", "forEach", "filter", "reduce", "reduceRight", "find", "findIndex", "findLast", "findLastIndex",
733
+ "some", "every", "flatMap", "sort", "group", "groupBy", "partition", "mapValues", "flatMapDeep",
734
+ "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "requestAnimationFrame", "requestIdleCallback",
735
+ "then", "catch", "finally", "nextTick",
736
+ ]);
737
+
701
738
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
702
739
  function visitCalls(node) {
703
740
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -727,6 +764,30 @@ function visitCalls(node) {
727
764
  }
728
765
  } else {
729
766
  const mod = declModule(decl);
767
+ // A LOCAL function/method passed BY REFERENCE to a NON-LOCAL (opaque) callee — `xs.map(loadFree)`,
768
+ // `arr.forEach(this.m)`, `setTimeout(handler)`, `external(cb)` — may be INVOKED by that callee, so
769
+ // its effects are reachable here. The precise callback-flow below only resolves a LOCAL callee's
770
+ // params; a non-local HOF dropped the reference entirely (a silent-pure hole — confirmed for
771
+ // `map`/`forEach`/`reduce`). Edge to the referenced unit: the sound over-approximation, matching
772
+ // the Rust engine's fn-as-value edge. An inline closure is already charged lexically; a non-fn
773
+ // argument resolves to no minted unit (`nodeName` miss) and adds nothing — no fabrication. Gated
774
+ // on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
775
+ // ONLY a callee that actually INVOKES its fn argument makes the reference reachable here. The
776
+ // earlier version edged for ANY non-local callee — fabricating the fn's effects onto a pure path
777
+ // (the cardinal sin) for STORE/compare/log sinks that never call it (`map.set(k, fn)`,
778
+ // `arr.push(fn)`, `arr.includes(fn)`, `console.log(fn)`, `[fn]`). Gate on a known INVOKING HOF by
779
+ // callee name; a custom non-local HOF that invokes its arg is an honest under-report (sound),
780
+ // never a fabrication. (A LOCAL callee keeps its precise callback-flow below.)
781
+ const calleeName = ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text
782
+ : ts.isIdentifier(node.expression) ? node.expression.text : null;
783
+ if (mod !== "<local>" && calleeName && HOF_INVOKERS.has(calleeName)) {
784
+ for (const a of node.arguments ?? []) {
785
+ if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
786
+ const d2 = realDecl(checker.getSymbolAtLocation(a));
787
+ const t = d2 && nodeName.get(d2);
788
+ if (t) rec.edges.add(t);
789
+ }
790
+ }
730
791
  if (mod === "<local>") {
731
792
  const targetName = nodeName.get(decl);
732
793
  if (targetName) {
@@ -1001,6 +1062,34 @@ function visitCalls(node) {
1001
1062
  const owner = enclosing(node);
1002
1063
  if (owner) fns.get(owner).direct.add("Env");
1003
1064
  }
1065
+ // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
1066
+ // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
1067
+ // channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
1068
+ // callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
1069
+ // symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
1070
+ if (ts.isCallExpression(node)) {
1071
+ const callee = node.expression;
1072
+ const ctext = callee.getText().replace(/\s+/g, "");
1073
+ let geff = null;
1074
+ if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
1075
+ else if (ctext === "process.send") geff = "Ipc";
1076
+ else if (ts.isIdentifier(callee) && callee.text === "fetch"
1077
+ && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1078
+ .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
1079
+ geff = "Net";
1080
+ if (geff) {
1081
+ const owner = enclosing(node);
1082
+ if (owner) fns.get(owner).direct.add(geff);
1083
+ }
1084
+ // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
1085
+ // getters (the object-spread twin). Enumerate the sources' local getters.
1086
+ if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
1087
+ const owner = enclosing(node);
1088
+ for (const src of (node.arguments ?? []).slice(1)) {
1089
+ enumerateGetters(owner, checker.getTypeAtLocation(src));
1090
+ }
1091
+ }
1092
+ }
1004
1093
  // GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
1005
1094
  // getter, or a property assignment whose target resolves to a setter, is effectively a call into
1006
1095
  // the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
@@ -1034,7 +1123,8 @@ function visitCalls(node) {
1034
1123
  if (owner) {
1035
1124
  const recvType = checker.getTypeAtLocation(node.initializer);
1036
1125
  for (const el of node.name.elements) {
1037
- if (el.dotDotDotToken) continue; // `...rest` collects the remaining props, not one getter
1126
+ if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
1127
+ // remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
1038
1128
  const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
1039
1129
  const keyName = ts.isIdentifier(key) ? key.text
1040
1130
  : ts.isStringLiteralLike(key) ? key.text : null;
@@ -1052,9 +1142,12 @@ function visitCalls(node) {
1052
1142
  let iterExpr = null, iterAsync = false;
1053
1143
  if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
1054
1144
  else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
1055
- else if (ts.isSpreadAssignment(node)) iterExpr = node.expression; // {...bag} — object spread is NOT
1056
- // iteration (it copies own enumerable props, no [Symbol.iterator]); wellKnownSymbolMember simply
1057
- // finds none and edges nothing. Listed for clarity; the resolution self-guards.
1145
+ else if (ts.isSpreadAssignment(node)) {
1146
+ iterExpr = node.expression; // {...bag} — object spread is NOT iteration (copies own enumerable
1147
+ // props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
1148
+ // But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
1149
+ enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
1150
+ }
1058
1151
  else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
1059
1152
  iterExpr = node.initializer; // const [a] = bag
1060
1153
  else if (ts.isCallExpression(node) && node.arguments?.[0]