candor-ts 0.5.1 → 0.5.2

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 (3) hide show
  1. package/AGENTS.md +13 -2
  2. package/package.json +1 -1
  3. package/scan.mjs +54 -0
package/AGENTS.md CHANGED
@@ -9,11 +9,22 @@ chains by hand.
9
9
  > the *installed* version — always prefer that over a vendored or fetched copy, which can describe
10
10
  > a different candor-ts than the one you are running.
11
11
 
12
+ > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
+ > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
+ > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts 0.5.1 (spec 0.5)."* On a
16
+ > build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
+ > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
+ >
12
19
  > **Staying current is your job, not candor's.** candor never phones home — it audits and denies the
13
20
  > Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
14
21
  > installed build, the spec contract it speaks, and the upgrade line (`npm install -g
15
- > candor-ts@latest`) — fully offline. You have the network: read the installed version here, compare
16
- > it against npm, and upgrade if it is stale. Every command is offline.
22
+ > candor-ts@latest`) — fully offline. **You** have the network: compare the installed version against
23
+ > npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts 0.5.2 is available
24
+ > (you're on 0.5.1) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx
25
+ > -y candor-ts@latest`) only if they agree. Never upgrade silently: an analysis tool's version is
26
+ > part of its result's provenance, so the user decides when it changes. If it's already current (or
27
+ > the user declines), just proceed; if candor isn't installed at all, install it normally.
17
28
 
18
29
  The language-agnostic consumption contract is
19
30
  [candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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
@@ -336,6 +336,14 @@ function localName(node) {
336
336
  if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
337
337
  if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
338
338
  return `${node.parent.name.text}.${node.name.getText()}`;
339
+ // GET/SET ACCESSORS are units too — a property read/assignment that resolves to one edges here, so
340
+ // an accessor body that does I/O classifies normally instead of being a SILENT-PURE hole (and its
341
+ // effect is no longer misattributed to the enclosing class's synthesized ctor, which `enclosing()`
342
+ // would otherwise pick as the nearest unit). get/set are DISTINCT units (a class may have both for
343
+ // one name): `Class.get raw` / `Class.set raw`, mirroring how the checker keeps them apart.
344
+ if ((ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node))
345
+ && ts.isClassDeclaration(node.parent) && node.parent.name)
346
+ return `${node.parent.name.text}.${ts.isGetAccessorDeclaration(node) ? "get" : "set"} ${node.name.getText()}`;
339
347
  // `const f = (…) => …` / `const f = function (…) {…}` at any binding site — the dominant style in
340
348
  // real TS (rimraf's whole API is arrow consts; the first dogfood analyzed 0 of 50 files without
341
349
  // this). The VARIABLE name is the function's name; nodeName is ALSO set on the initializer so a
@@ -490,6 +498,23 @@ function realDecl(sym) {
490
498
  return sym.valueDeclaration ?? sym.declarations?.[0];
491
499
  }
492
500
 
501
+ // Accessor resolution (the silent-pure-accessor fix): a property READ (`x.raw`) or property
502
+ // ASSIGNMENT target (`x.path = v`) may resolve to a getter/setter whose body performs effects. We
503
+ // resolve the property-name symbol to its declarations and look for an accessor of the matching
504
+ // kind (get for a read, set for an assignment LHS). Returns { decl, local } where `local` is true
505
+ // when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
506
+ // accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
507
+ // the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
508
+ function accessorAt(propNode, kind /* "get" | "set" */) {
509
+ const sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
510
+ if (!sym) return null;
511
+ const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
512
+ // A symbol is an accessor only if its declarations include an accessor of the wanted kind.
513
+ const decl = (sym.declarations ?? []).find((d) => want(d));
514
+ if (!decl) return null;
515
+ return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
516
+ }
517
+
493
518
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
494
519
  function enclosing(node) {
495
520
  for (let p = node; p; p = p.parent) {
@@ -766,6 +791,35 @@ function visitCalls(node) {
766
791
  const owner = enclosing(node);
767
792
  if (owner) fns.get(owner).direct.add("Env");
768
793
  }
794
+ // GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
795
+ // getter, or a property assignment whose target resolves to a setter, is effectively a call into
796
+ // the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
797
+ // call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
798
+ // following the same posture as an unresolvable call (SPEC §4).
799
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
800
+ // Is this property access the TARGET of an assignment (`x.prop = v`)? If so it's a setter site;
801
+ // otherwise it's a read (getter) site. (`x.prop += v` is both a read and a write, but the read
802
+ // side is the produced value — model it as a setter target only when it is the bare LHS of `=`.)
803
+ const p = node.parent;
804
+ const isAssignTarget = p && ts.isBinaryExpression(p) && p.left === node
805
+ && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
806
+ const hit = isAssignTarget ? accessorAt(node, "set") : accessorAt(node, "get");
807
+ if (hit) {
808
+ const owner = enclosing(node);
809
+ if (owner) {
810
+ const rec = fns.get(owner);
811
+ const t = nodeName.get(hit.decl);
812
+ if (hit.local && t) {
813
+ rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
814
+ } else {
815
+ // resolved to an accessor whose body we can't see → Unknown, never silent-pure (SPEC §4)
816
+ rec.direct.add("Unknown");
817
+ const an = hit.decl.parent?.name?.getText?.() ?? "?";
818
+ rec.why.add(`accessor:${an}.${node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?"}`);
819
+ }
820
+ }
821
+ }
822
+ }
769
823
  ts.forEachChild(node, visitCalls);
770
824
  }
771
825
  for (const sf of sources) visitCalls(sf);