candor-ts 0.5.6 → 0.5.7

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.7",
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
  }
@@ -727,6 +753,22 @@ function visitCalls(node) {
727
753
  }
728
754
  } else {
729
755
  const mod = declModule(decl);
756
+ // A LOCAL function/method passed BY REFERENCE to a NON-LOCAL (opaque) callee — `xs.map(loadFree)`,
757
+ // `arr.forEach(this.m)`, `setTimeout(handler)`, `external(cb)` — may be INVOKED by that callee, so
758
+ // its effects are reachable here. The precise callback-flow below only resolves a LOCAL callee's
759
+ // params; a non-local HOF dropped the reference entirely (a silent-pure hole — confirmed for
760
+ // `map`/`forEach`/`reduce`). Edge to the referenced unit: the sound over-approximation, matching
761
+ // the Rust engine's fn-as-value edge. An inline closure is already charged lexically; a non-fn
762
+ // argument resolves to no minted unit (`nodeName` miss) and adds nothing — no fabrication. Gated
763
+ // on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
764
+ if (mod !== "<local>") {
765
+ for (const a of node.arguments ?? []) {
766
+ if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
767
+ const d2 = realDecl(checker.getSymbolAtLocation(a));
768
+ const t = d2 && nodeName.get(d2);
769
+ if (t) rec.edges.add(t);
770
+ }
771
+ }
730
772
  if (mod === "<local>") {
731
773
  const targetName = nodeName.get(decl);
732
774
  if (targetName) {
@@ -1001,6 +1043,34 @@ function visitCalls(node) {
1001
1043
  const owner = enclosing(node);
1002
1044
  if (owner) fns.get(owner).direct.add("Env");
1003
1045
  }
1046
+ // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
1047
+ // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
1048
+ // channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
1049
+ // callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
1050
+ // symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
1051
+ if (ts.isCallExpression(node)) {
1052
+ const callee = node.expression;
1053
+ const ctext = callee.getText().replace(/\s+/g, "");
1054
+ let geff = null;
1055
+ if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
1056
+ else if (ctext === "process.send") geff = "Ipc";
1057
+ else if (ts.isIdentifier(callee) && callee.text === "fetch"
1058
+ && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1059
+ .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
1060
+ geff = "Net";
1061
+ if (geff) {
1062
+ const owner = enclosing(node);
1063
+ if (owner) fns.get(owner).direct.add(geff);
1064
+ }
1065
+ // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
1066
+ // getters (the object-spread twin). Enumerate the sources' local getters.
1067
+ if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
1068
+ const owner = enclosing(node);
1069
+ for (const src of (node.arguments ?? []).slice(1)) {
1070
+ enumerateGetters(owner, checker.getTypeAtLocation(src));
1071
+ }
1072
+ }
1073
+ }
1004
1074
  // GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
1005
1075
  // getter, or a property assignment whose target resolves to a setter, is effectively a call into
1006
1076
  // the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
@@ -1034,7 +1104,8 @@ function visitCalls(node) {
1034
1104
  if (owner) {
1035
1105
  const recvType = checker.getTypeAtLocation(node.initializer);
1036
1106
  for (const el of node.name.elements) {
1037
- if (el.dotDotDotToken) continue; // `...rest` collects the remaining props, not one getter
1107
+ if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
1108
+ // remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
1038
1109
  const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
1039
1110
  const keyName = ts.isIdentifier(key) ? key.text
1040
1111
  : ts.isStringLiteralLike(key) ? key.text : null;
@@ -1052,9 +1123,12 @@ function visitCalls(node) {
1052
1123
  let iterExpr = null, iterAsync = false;
1053
1124
  if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
1054
1125
  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.
1126
+ else if (ts.isSpreadAssignment(node)) {
1127
+ iterExpr = node.expression; // {...bag} object spread is NOT iteration (copies own enumerable
1128
+ // props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
1129
+ // But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
1130
+ enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
1131
+ }
1058
1132
  else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
1059
1133
  iterExpr = node.initializer; // const [a] = bag
1060
1134
  else if (ts.isCallExpression(node) && node.arguments?.[0]