candor-ts 0.5.17 → 0.5.19

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/scan.mjs +222 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
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.mjs CHANGED
@@ -347,6 +347,20 @@ const nodeName = new WeakMap(); // declaration node -> qualified name
347
347
  const entityTables = new Map(); // ClassDeclaration node -> table name
348
348
  const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
349
349
  const classOverrides = new Map(); // base-method MemberDeclaration node -> overriding subclass member nodes (class-CHA)
350
+ // `Object.defineProperty(target, key, { get/set })` runtime accessors (the silent-pure defineProperty
351
+ // hole): the TS checker types `target.key` as a plain DATA property (defineProperty is a runtime
352
+ // construct), so `accessorAt` finds no get-accessor and the forcing site `target.key` reads
353
+ // silent-pure. We index, keyed by the TARGET's symbol → key string → { get, set } descriptor function
354
+ // node, every such accessor seen in the project. The forcing-site arm consults this when the type-level
355
+ // accessor resolution comes up empty (precise edge when target+key resolve; else honest Unknown).
356
+ const definePropAccessors = new Map(); // targetSymbol -> Map(key -> { get?: fnNode, set?: fnNode })
357
+ // A descriptor accessor with a COMPUTED key (`Object.defineProperty(o, k, {get})`) on a RESOLVABLE
358
+ // target: the target symbol is known but the key isn't, so a forcing site `o.anything` MIGHT hit it. We
359
+ // record the target symbol → kinds present, and disclose Unknown at any access onto that target whose
360
+ // type-level / precise-key resolution missed — never silent-pure (matching the syntactic object-literal-
361
+ // getter posture). A descriptor whose TARGET itself is unresolvable can't be tied to any forcing site;
362
+ // its unit is still minted (effects classified, callgraph-visible), and there is nothing more to disclose.
363
+ const definePropDynamicKey = new Map(); // targetSymbol -> Set("get"|"set")
350
364
  // Resolve `extends X` to X's LOCAL ClassDeclaration (through an import alias), or null. Module-level
351
365
  // so both the class-CHA INDEX (below) and the dispatch site's RECEIVER-SUBTREE scoping share one
352
366
  // definition of the local inheritance edge.
@@ -377,6 +391,79 @@ function moduleOf(sf) {
377
391
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
378
392
  return rel.split(path.sep).join(".");
379
393
  }
394
+ // Is `node` (a function-expression / method-declaration / arrow) the `get` or `set` member of an
395
+ // accessor DESCRIPTOR object passed to `Object.defineProperty(target, key, desc)` /
396
+ // `Object.defineProperties(target, { key: desc, … })` / `Object.create(proto, { key: desc, … })`?
397
+ // Returns { kind:"get"|"set", targetExpr, keyText } when so (keyText is null for a non-literal key),
398
+ // or null. Only an accessor (`get`/`set`) descriptor qualifies — a `value:` (data) descriptor is NOT
399
+ // a function-property-named get/set, so it never matches (no fabrication on data props). The descriptor
400
+ // member may be `get(){}` (method), `get: function(){}` / `get: () => {}` (property-assignment): both
401
+ // have a parent PropertyAssignment-or-MethodDeclaration whose name is the identifier `get`/`set`.
402
+ function definePropertyAccessor(node) {
403
+ let memberName = null, propParent = null;
404
+ const p = node.parent;
405
+ if (!p) return null;
406
+ if ((ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node))
407
+ && ts.isObjectLiteralExpression(node.parent)) {
408
+ // `{ get(){…} }` / `{ get x(){…} }` — but a real get/set-accessor here is the SYNTACTIC object
409
+ // literal getter (already handled honestly); only a plain METHOD named `get`/`set` is a descriptor
410
+ // member. A GetAccessor/SetAccessor inside a descriptor object is not how defineProperty descriptors
411
+ // are written, so restrict to a method whose name is literally `get`/`set`.
412
+ if (ts.isMethodDeclaration(node)) { memberName = node.name?.getText?.(); propParent = node; }
413
+ } else if (ts.isPropertyAssignment(p) && p.initializer === node && ts.isObjectLiteralExpression(p.parent)) {
414
+ memberName = p.name?.getText?.(); propParent = p;
415
+ }
416
+ if (memberName !== "get" && memberName !== "set") return null;
417
+ const descObj = ts.isMethodDeclaration(propParent) ? propParent.parent : propParent.parent; // ObjectLiteral
418
+ // Two shapes for the enclosing call:
419
+ // defineProperty(target, key, descObj) — descObj is arg #2
420
+ // defineProperties(target, { key: descObj }) — descObj is a property value of arg #1
421
+ // create(proto, { key: descObj }) — descObj is a property value of arg #1
422
+ const callOf = (n) => {
423
+ let c = n.parent;
424
+ while (c && !ts.isCallExpression(c)) c = c.parent;
425
+ return c;
426
+ };
427
+ // Walk out at most: descObj -> (its parent is either the defineProperty call's arg, OR a
428
+ // PropertyAssignment in a properties-map -> ObjectLiteral -> defineProperties/create call).
429
+ const fnName = (call) => call && call.expression && call.expression.getText().replace(/\s+/g, "");
430
+ // Case A: descObj is the 3rd argument of Object.defineProperty(target, key, descObj).
431
+ if (descObj.parent && ts.isCallExpression(descObj.parent)) {
432
+ const call = descObj.parent;
433
+ if (fnName(call) === "Object.defineProperty" && call.arguments[2] === descObj) {
434
+ const keyArg = call.arguments[1];
435
+ const keyText = keyArg && ts.isStringLiteralLike(keyArg) ? keyArg.text : null;
436
+ return { kind: memberName, targetExpr: call.arguments[0], keyText };
437
+ }
438
+ return null;
439
+ }
440
+ // Case B: descObj is a property value in a properties-map for defineProperties / create.
441
+ if (descObj.parent && ts.isPropertyAssignment(descObj.parent)
442
+ && ts.isObjectLiteralExpression(descObj.parent.parent)) {
443
+ const keyProp = descObj.parent; // `key: descObj`
444
+ const propsMap = descObj.parent.parent; // `{ key: descObj, … }`
445
+ const call = callOf(propsMap);
446
+ const fn = fnName(call);
447
+ if (call && (fn === "Object.defineProperties" || fn === "Object.create")
448
+ && (call.arguments[1] === propsMap)) {
449
+ const keyText = ts.isStringLiteralLike(keyProp.name) ? keyProp.name.text
450
+ : ts.isIdentifier(keyProp.name) ? keyProp.name.text : null;
451
+ // For defineProperties the target is arg0. For create the NEW object IS the call's result; the
452
+ // forcing site reads it through the binding the call is assigned to (`const o = Object.create(…)`),
453
+ // so the stable target is that VariableDeclaration's name. When create's result isn't bound to a
454
+ // simple identifier, there's no joinable target — targetExpr stays null (unit still minted; the
455
+ // unpinnable marker drives the Unknown disclosure, never silent-pure).
456
+ let targetExpr = null;
457
+ if (fn === "Object.defineProperties") targetExpr = call.arguments[0];
458
+ else if (fn === "Object.create" && call.parent
459
+ && ts.isVariableDeclaration(call.parent) && call.parent.initializer === call
460
+ && ts.isIdentifier(call.parent.name))
461
+ targetExpr = call.parent.name;
462
+ return { kind: memberName, targetExpr, keyText };
463
+ }
464
+ }
465
+ return null;
466
+ }
380
467
  // `_lastCjs` is set by markCjs when localName() returns a CJS export-surface name, read right after
381
468
  // the call to tag THAT unit (spec 0.5 draft unitKind: "export"). Keyed to the unit, not a project-
382
469
  // wide name set — a same-named ordinary TS function in another file must NOT be mislabeled.
@@ -415,6 +502,19 @@ function localName(node) {
415
502
  // invisible — same dogfood.
416
503
  if (ts.isConstructorDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
417
504
  return `${node.parent.name.text}.constructor`;
505
+ // `Object.defineProperty(target,"key",{ get(){…}/set(){…} })` descriptor accessors — the runtime
506
+ // accessor the TS checker can't see as a get/set (it types target.key as a data prop). Mint the
507
+ // descriptor body as a UNIT so its effects classify normally instead of being a silent-pure hole;
508
+ // the forcing-site arm edges target.key to it. Name keyed by target + key + kind so the same name a
509
+ // forcing site computes joins here. Both shapes (`get(){}` method, `get:fn` property) land here.
510
+ {
511
+ const da = definePropertyAccessor(node);
512
+ if (da) {
513
+ const tn = da.targetExpr ? da.targetExpr.getText().replace(/\s+/g, "") : "<create>";
514
+ const key = da.keyText ?? `[computed@${node.getStart()}]`;
515
+ return `defineProperty(${tn}).${da.kind} ${key}`;
516
+ }
517
+ }
418
518
  // CJS export units (--allow-js, the npm half of report chaining): dist JS exports through
419
519
  // assignment, not declarations, so `module.exports = function …` / `exports.foo = …` /
420
520
  // `module.exports = { sign: fn }` were not units at all — a dep scan of jsonwebtoken yielded 4
@@ -519,6 +619,26 @@ for (const sf of sources) {
519
619
  nodeName.set(node, qual);
520
620
  if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
521
621
  nodeName.set(node.initializer, qual);
622
+ // Index a `Object.defineProperty` descriptor accessor by its target SYMBOL + key, so a forcing
623
+ // site `target.key` (which the checker types as a plain data prop) can edge to this unit. When
624
+ // the target/key can't be pinned to a static symbol/literal, the unit still exists (named above)
625
+ // but no precise edge is possible — record an UNRESOLVED marker so an access onto such a target
626
+ // is disclosed Unknown rather than silently dropped.
627
+ const da = definePropertyAccessor(node);
628
+ if (da) {
629
+ const tsym = da.targetExpr ? checker.getSymbolAtLocation(da.targetExpr) : null;
630
+ if (tsym && da.keyText !== null) {
631
+ if (!definePropAccessors.has(tsym)) definePropAccessors.set(tsym, new Map());
632
+ const byKey = definePropAccessors.get(tsym);
633
+ if (!byKey.has(da.keyText)) byKey.set(da.keyText, {});
634
+ byKey.get(da.keyText)[da.kind] = node;
635
+ } else if (tsym) {
636
+ // computed key on a known target — any access onto this target may hit it: disclose Unknown.
637
+ if (!definePropDynamicKey.has(tsym)) definePropDynamicKey.set(tsym, new Set());
638
+ definePropDynamicKey.get(tsym).add(da.kind);
639
+ }
640
+ // (a wholly-unresolvable target leaves only the minted unit — nothing to join a forcing site to.)
641
+ }
522
642
  }
523
643
  ts.forEachChild(node, collect);
524
644
  })(sf);
@@ -614,6 +734,31 @@ function realDecl(sym) {
614
734
  return sym.valueDeclaration ?? sym.declarations?.[0];
615
735
  }
616
736
 
737
+ // Resolve a value-reference NODE (`expr` in `expr.call(…)`/`expr.apply(…)`) to the qualified name of the
738
+ // FUNCTION UNIT it ultimately denotes — following ONE OR MORE local-variable aliases. The `.call`/`.apply`
739
+ // arm lands on the es-lib member so `getResolvedSignature` never sees the real fn; for a direct identifier
740
+ // (`effectful.call`) `realDecl` → the fn decl → a minted unit. But `const m = effectful; m.call(…)` resolves
741
+ // `m` to its VARIABLE declaration, whose initializer is the bare identifier `effectful` — the variable node
742
+ // itself is NOT a minted unit (only var-decls whose initializer is an arrow/fn-expr are), so the edge was
743
+ // dropped → silent-pure (the cardinal sin). Here we chase the variable's initializer identifier/member to
744
+ // the function it aliases. Returns the unit name, or null if the chain can't be pinned to a function unit.
745
+ // Bounded depth guards a pathological `const a=b, b=a` cycle. NO fabrication: a non-fn binding (or any link
746
+ // that doesn't resolve to a minted fn unit) returns null and the caller adds nothing / discloses Unknown.
747
+ function resolveFnRefUnit(refNode, depth = 0) {
748
+ if (!refNode || depth > 8) return null;
749
+ if (!ts.isIdentifier(refNode) && !ts.isPropertyAccessExpression(refNode)) return null;
750
+ const d = realDecl(checker.getSymbolAtLocation(refNode));
751
+ if (!d) return null;
752
+ // Already a minted unit (the function itself, an arrow/fn-expr const, a class method/property)?
753
+ const direct = nodeName.get(d);
754
+ if (direct) return direct;
755
+ // A local variable / parameter bound to a function reference — follow the initializer alias.
756
+ if ((ts.isVariableDeclaration(d) || ts.isBindingElement(d) || ts.isParameter(d)) && d.initializer
757
+ && (ts.isIdentifier(d.initializer) || ts.isPropertyAccessExpression(d.initializer)))
758
+ return resolveFnRefUnit(d.initializer, depth + 1);
759
+ return null;
760
+ }
761
+
617
762
  // Accessor resolution (the silent-pure-accessor fix): a property READ (`x.raw`) or property
618
763
  // ASSIGNMENT target (`x.path = v`) may resolve to a getter/setter whose body performs effects. We
619
764
  // resolve the property-name symbol to its declarations and look for an accessor of the matching
@@ -647,6 +792,33 @@ function accessorAt(propNode, kind /* "get" | "set" */) {
647
792
  }
648
793
  return accessorFromSym(sym, kind);
649
794
  }
795
+ // A `Object.defineProperty` descriptor accessor for the forcing site `recv.key` (read → get, assign →
796
+ // set), consulted ONLY when the type-level `accessorAt` came up empty (the checker types target.key as
797
+ // a data prop, so defineProperty accessors are invisible to it). Resolve the receiver expression to its
798
+ // binding symbol and the key to a static string; look both up in `definePropAccessors`. Returns the
799
+ // descriptor function NODE (a minted unit) when found, or null. NO fabrication: a data (`value:`)
800
+ // descriptor was never indexed, an absent target/key returns null.
801
+ function definePropForceTarget(propNode, kind /* "get" | "set" */) {
802
+ if (definePropAccessors.size === 0) return null;
803
+ let recvExpr, keyText;
804
+ if (ts.isElementAccessExpression(propNode)) {
805
+ recvExpr = propNode.expression;
806
+ const arg = propNode.argumentExpression;
807
+ keyText = arg && ts.isStringLiteralLike(arg) ? arg.text : null;
808
+ } else if (ts.isPropertyAccessExpression(propNode)) {
809
+ recvExpr = propNode.expression;
810
+ keyText = propNode.name?.getText?.();
811
+ } else return null;
812
+ if (keyText == null) return null;
813
+ // Resolve the receiver to the SAME symbol the defineProperty target identifier resolved to. Follow an
814
+ // import alias so a cross-module `import { config }` access joins the defining module's index entry.
815
+ const rsym0 = checker.getSymbolAtLocation(recvExpr);
816
+ if (!rsym0) return null;
817
+ const rsym = rsym0.flags & ts.SymbolFlags.Alias ? (() => { try { return checker.getAliasedSymbol(rsym0); } catch { return rsym0; } })() : rsym0;
818
+ const byKey = definePropAccessors.get(rsym) ?? definePropAccessors.get(rsym0);
819
+ const entry = byKey?.get(keyText);
820
+ return entry?.[kind] ?? null;
821
+ }
650
822
  // Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
651
823
  // it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
652
824
  // silent-pure, SPEC §4). `label` tags the §-why disclosure.
@@ -879,8 +1051,22 @@ function visitCalls(node) {
879
1051
  invokedRef = (node.arguments ?? [])[0] ?? null;
880
1052
  if (invokedRef && (ts.isIdentifier(invokedRef) || ts.isPropertyAccessExpression(invokedRef))) {
881
1053
  const d2 = realDecl(checker.getSymbolAtLocation(invokedRef));
882
- const t = d2 && nodeName.get(d2);
1054
+ // Resolve the receiver/arg0 to its function unit, FOLLOWING local-variable aliases
1055
+ // (`const m = effectful; m.call(…)`) — the direct-identifier form already landed on a minted
1056
+ // unit, but an aliased local var resolves to its VARIABLE decl (not a unit), which dropped the
1057
+ // edge silent-pure. `resolveFnRefUnit` chases the initializer alias to the real fn.
1058
+ const t = (d2 && nodeName.get(d2)) || resolveFnRefUnit(invokedRef);
883
1059
  if (t) rec.edges.add(t);
1060
+ // HONESTY: the receiver IS a local variable/parameter (it resolved to a value declaration)
1061
+ // but we could NOT pin it to a function unit — e.g. bound to a param, a reassigned/branched
1062
+ // value, an `any`-typed holder. The `.call`/`.apply` still INVOKES whatever it holds, so a
1063
+ // silent-pure verdict would be the cardinal sin. Disclose Unknown instead. (A direct fn
1064
+ // identifier / known fn always resolves above, so this never fires for the precise forms; a
1065
+ // non-value receiver — a type, a literal — resolves to no decl and stays out, no fabrication.)
1066
+ else if (d2 && (ts.isVariableDeclaration(d2) || ts.isBindingElement(d2) || ts.isParameter(d2))) {
1067
+ rec.direct.add("Unknown");
1068
+ rec.why.add(`call:${recvText.slice(0, 40)}.${m}`);
1069
+ }
884
1070
  }
885
1071
  }
886
1072
  if (mod === "<local>") {
@@ -1288,12 +1474,41 @@ function visitCalls(node) {
1288
1474
  && p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
1289
1475
  const recordKind = (kind) => {
1290
1476
  const hit = accessorAt(node, kind);
1291
- if (!hit) return;
1292
- const owner = enclosing(node);
1293
- if (!owner) return;
1294
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
1295
- const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1296
- recordAccessorHit(owner, hit, `${an}.${pn}`);
1477
+ if (hit) {
1478
+ const owner = enclosing(node);
1479
+ if (!owner) return;
1480
+ const an = hit.decl.parent?.name?.getText?.() ?? "?";
1481
+ const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
1482
+ recordAccessorHit(owner, hit, `${an}.${pn}`);
1483
+ return;
1484
+ }
1485
+ // No type-level accessor — try the `Object.defineProperty` runtime-accessor index. The checker
1486
+ // types target.key as a data prop, so an effectful defineProperty getter/setter is invisible to
1487
+ // accessorAt; consult definePropForceTarget so the forcing site edges to the descriptor unit
1488
+ // (precise) instead of reading silent-pure (the cardinal sin). A descriptor we minted is always
1489
+ // local, so this is an EDGE; never Unknown for a resolved-and-seen descriptor.
1490
+ const dpNode = definePropForceTarget(node, kind);
1491
+ if (dpNode) {
1492
+ const owner = enclosing(node);
1493
+ const t = owner && nodeName.get(dpNode);
1494
+ if (t) fns.get(owner).edges.add(t);
1495
+ return;
1496
+ }
1497
+ // A computed-key descriptor accessor on this receiver's target means `recv.<anything>` MIGHT
1498
+ // invoke an effectful accessor whose key we couldn't pin — disclose Unknown (never silent-pure),
1499
+ // matching the syntactic object-literal-getter posture. Only when the receiver binds to a target
1500
+ // that carries a dynamic-key descriptor of the right kind.
1501
+ if (definePropDynamicKey.size > 0) {
1502
+ const rsym0 = ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)
1503
+ ? checker.getSymbolAtLocation(node.expression) : null;
1504
+ const rsym = rsym0 && (rsym0.flags & ts.SymbolFlags.Alias)
1505
+ ? (() => { try { return checker.getAliasedSymbol(rsym0); } catch { return rsym0; } })() : rsym0;
1506
+ const kinds = (rsym && definePropDynamicKey.get(rsym)) || (rsym0 && definePropDynamicKey.get(rsym0));
1507
+ if (kinds && kinds.has(kind)) {
1508
+ const owner = enclosing(node);
1509
+ if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`defineProperty:dynamic-key`); }
1510
+ }
1511
+ }
1297
1512
  };
1298
1513
  if (simpleAssign || isDestructuringAssignTarget(node)) recordKind("set");
1299
1514
  else if (compoundAssign) { recordKind("get"); recordKind("set"); }