candor-ts 0.5.1 → 0.5.3
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/AGENTS.md +13 -2
- package/package.json +1 -1
- package/scan.mjs +158 -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:
|
|
16
|
-
> it
|
|
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
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) {
|
|
@@ -517,6 +542,60 @@ function rootsAtStdStream(expr) {
|
|
|
517
542
|
}
|
|
518
543
|
}
|
|
519
544
|
|
|
545
|
+
// ---- the implicit/desugared-call surface (the silent-pure holes the AST walk misses) -------------
|
|
546
|
+
// CLASSIFIER §1 says resolve, don't pattern-match — but the walk only sees CallExpression/
|
|
547
|
+
// NewExpression (+ accessor access). Effects reached through a DESUGARED call (a `for-of` lowering to
|
|
548
|
+
// `it[Symbol.iterator]().next()`, a `using` to `r[Symbol.dispose]()`, a tagged template to `tag(...)`)
|
|
549
|
+
// were invisible: reported concrete-PURE (omitted), not even Unknown. We model the desugaring exactly
|
|
550
|
+
// as the spec demands — resolve the implicit target via the compiler API and edge to it when LOCAL.
|
|
551
|
+
// A resolved-but-unseen target follows the existing external/κ posture (OPAQUE + ledger), and a
|
|
552
|
+
// BUILT-IN iterator/disposer (es-lib/@types/node — a plain array's iterator, a stdlib disposable)
|
|
553
|
+
// resolves to a non-local declaration and edges nothing, so it correctly stays pure.
|
|
554
|
+
|
|
555
|
+
// The member symbol for a WELL-KNOWN symbol (`Symbol.iterator`, `Symbol.dispose`, …) on a type. The
|
|
556
|
+
// checker mangles these to an escaped name `__@iterator@<globalId>`; match by the `__@<name>@` prefix
|
|
557
|
+
// (the trailing id is the unique Symbol's identity, not part of the name). `prefixes` is tried in
|
|
558
|
+
// order so a sync site prefers the sync method and an async site its async twin (falling back to sync).
|
|
559
|
+
function wellKnownSymbolMember(type, prefixes) {
|
|
560
|
+
if (!type || !type.getProperties) return null;
|
|
561
|
+
for (const p of type.getProperties()) {
|
|
562
|
+
const n = p.getName();
|
|
563
|
+
for (const pre of prefixes) if (n === pre || n.startsWith(pre + "@")) return p;
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
function declOfSym(sym) { return sym && (sym.valueDeclaration ?? sym.declarations?.[0]); }
|
|
568
|
+
const declIsLocal = (decl) => decl && projectFiles.has(path.resolve(decl.getSourceFile().fileName));
|
|
569
|
+
|
|
570
|
+
// The LOCAL units an ITERATION over `expr` implicitly calls: the iterable's `[Symbol.iterator]` (or
|
|
571
|
+
// `[Symbol.asyncIterator]` for `for await`) method AND the produced iterator's `next()`. The generator
|
|
572
|
+
// case rolls `next`'s body into the iterator-method unit (lexical attribution), and the self-iterator
|
|
573
|
+
// case (`[Symbol.iterator]() { return this }` + a separate effectful `next()`) needs the `next` edge —
|
|
574
|
+
// so we edge to BOTH whenever each is a LOCAL unit. A built-in iterable (plain array/string/Map: the
|
|
575
|
+
// es-lib/@types iterator) resolves non-local → no edge → stays pure (the precision invariant).
|
|
576
|
+
function iterationTargets(expr, isAsync) {
|
|
577
|
+
const t = checker.getTypeAtLocation(expr);
|
|
578
|
+
const iterPrefixes = isAsync ? ["__@asyncIterator", "__@iterator"] : ["__@iterator"];
|
|
579
|
+
const iterDecl = declOfSym(wellKnownSymbolMember(t, iterPrefixes));
|
|
580
|
+
if (!iterDecl) return [];
|
|
581
|
+
const out = [];
|
|
582
|
+
if (declIsLocal(iterDecl)) out.push(iterDecl);
|
|
583
|
+
// the iterator's next(): the return type of the [Symbol.iterator] method
|
|
584
|
+
try {
|
|
585
|
+
const sig = checker.getSignatureFromDeclaration(iterDecl);
|
|
586
|
+
const ret = sig && checker.getReturnTypeOfSignature(sig);
|
|
587
|
+
const nextDecl = declOfSym(ret && ret.getProperties().find((p) => p.getName() === "next"));
|
|
588
|
+
if (nextDecl && declIsLocal(nextDecl) && !out.includes(nextDecl)) out.push(nextDecl);
|
|
589
|
+
} catch { /* unresolved iterator shape — the iterator-method edge already covers the common case */ }
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
// Edge `rec` to each LOCAL desugared target that is a minted unit. Local-only by design: an external
|
|
593
|
+
// iterable/disposer is OPAQUE (the curated-κ caveat — same as an unmatched external call), never a
|
|
594
|
+
// fabricated edge; the existing call machinery + κ ledger already cover any EXPLICIT calls into it.
|
|
595
|
+
function edgeToTargets(rec, decls) {
|
|
596
|
+
for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
|
|
597
|
+
}
|
|
598
|
+
|
|
520
599
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
521
600
|
function visitCalls(node) {
|
|
522
601
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -766,6 +845,85 @@ function visitCalls(node) {
|
|
|
766
845
|
const owner = enclosing(node);
|
|
767
846
|
if (owner) fns.get(owner).direct.add("Env");
|
|
768
847
|
}
|
|
848
|
+
// GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
|
|
849
|
+
// getter, or a property assignment whose target resolves to a setter, is effectively a call into
|
|
850
|
+
// the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
|
|
851
|
+
// call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
|
|
852
|
+
// following the same posture as an unresolvable call (SPEC §4).
|
|
853
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
854
|
+
// Is this property access the TARGET of an assignment (`x.prop = v`)? If so it's a setter site;
|
|
855
|
+
// otherwise it's a read (getter) site. (`x.prop += v` is both a read and a write, but the read
|
|
856
|
+
// side is the produced value — model it as a setter target only when it is the bare LHS of `=`.)
|
|
857
|
+
const p = node.parent;
|
|
858
|
+
const isAssignTarget = p && ts.isBinaryExpression(p) && p.left === node
|
|
859
|
+
&& p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
860
|
+
const hit = isAssignTarget ? accessorAt(node, "set") : accessorAt(node, "get");
|
|
861
|
+
if (hit) {
|
|
862
|
+
const owner = enclosing(node);
|
|
863
|
+
if (owner) {
|
|
864
|
+
const rec = fns.get(owner);
|
|
865
|
+
const t = nodeName.get(hit.decl);
|
|
866
|
+
if (hit.local && t) {
|
|
867
|
+
rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
|
|
868
|
+
} else {
|
|
869
|
+
// resolved to an accessor whose body we can't see → Unknown, never silent-pure (SPEC §4)
|
|
870
|
+
rec.direct.add("Unknown");
|
|
871
|
+
const an = hit.decl.parent?.name?.getText?.() ?? "?";
|
|
872
|
+
rec.why.add(`accessor:${an}.${node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?"}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// ITERATION desugaring (HIGH): `for (const x of bag)`, `for await (…)`, `[...bag]`, `const [a]=bag`,
|
|
878
|
+
// `Array.from(bag)` all lower to `bag[Symbol.iterator]().next()`. Edge the enclosing fn to the
|
|
879
|
+
// iterable's local `[Symbol.iterator]`/`[Symbol.asyncIterator]` method (and the produced iterator's
|
|
880
|
+
// local `next`). A built-in iterable (array/string/Map) resolves non-local → no edge → stays pure.
|
|
881
|
+
{
|
|
882
|
+
let iterExpr = null, iterAsync = false;
|
|
883
|
+
if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
|
|
884
|
+
else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
|
|
885
|
+
else if (ts.isSpreadAssignment(node)) iterExpr = node.expression; // {...bag} — object spread is NOT
|
|
886
|
+
// iteration (it copies own enumerable props, no [Symbol.iterator]); wellKnownSymbolMember simply
|
|
887
|
+
// finds none and edges nothing. Listed for clarity; the resolution self-guards.
|
|
888
|
+
else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
|
|
889
|
+
iterExpr = node.initializer; // const [a] = bag
|
|
890
|
+
else if (ts.isCallExpression(node) && node.arguments?.[0]
|
|
891
|
+
&& node.expression.getText() === "Array.from")
|
|
892
|
+
iterExpr = node.arguments[0]; // Array.from(bag) — the iterable form (arg0 is iterated)
|
|
893
|
+
if (iterExpr) {
|
|
894
|
+
const owner = enclosing(node);
|
|
895
|
+
if (owner) edgeToTargets(fns.get(owner), iterationTargets(iterExpr, iterAsync));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// `using r = expr` / `await using r = expr` (MED): the scope-exit guarantees `r[Symbol.dispose]()` /
|
|
899
|
+
// `r[Symbol.asyncDispose]()`. Edge the enclosing fn to the resolved LOCAL dispose method.
|
|
900
|
+
if (ts.isVariableStatement(node)) {
|
|
901
|
+
const fl = node.declarationList.flags;
|
|
902
|
+
const isUsing = (fl & ts.NodeFlags.Using) || (fl & ts.NodeFlags.AwaitUsing);
|
|
903
|
+
if (isUsing) {
|
|
904
|
+
const isAwait = !!(fl & ts.NodeFlags.AwaitUsing);
|
|
905
|
+
const prefixes = isAwait ? ["__@asyncDispose", "__@dispose"] : ["__@dispose"];
|
|
906
|
+
const owner = enclosing(node);
|
|
907
|
+
for (const d of node.declarationList.declarations) {
|
|
908
|
+
if (!d.initializer || !owner) continue;
|
|
909
|
+
const t = checker.getTypeAtLocation(d.initializer);
|
|
910
|
+
const disposeDecl = declOfSym(wellKnownSymbolMember(t, prefixes));
|
|
911
|
+
if (disposeDecl && declIsLocal(disposeDecl)) edgeToTargets(fns.get(owner), [disposeDecl]);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
|
|
916
|
+
// the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
|
|
917
|
+
// visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
|
|
918
|
+
// edges nothing (pure), matching the external-call posture.
|
|
919
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
920
|
+
const owner = enclosing(node);
|
|
921
|
+
if (owner) {
|
|
922
|
+
const sig = checker.getResolvedSignature(node);
|
|
923
|
+
const decl = sig && sig.declaration;
|
|
924
|
+
if (decl && declIsLocal(decl)) edgeToTargets(fns.get(owner), [decl]);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
769
927
|
ts.forEachChild(node, visitCalls);
|
|
770
928
|
}
|
|
771
929
|
for (const sf of sources) visitCalls(sf);
|