candor-ts 0.5.0 → 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.
- package/AGENTS.md +17 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/scan.mjs +74 -3
package/AGENTS.md
CHANGED
|
@@ -9,6 +9,23 @@ 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
|
+
>
|
|
19
|
+
> **Staying current is your job, not candor's.** candor never phones home — it audits and denies the
|
|
20
|
+
> Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
|
|
21
|
+
> installed build, the spec contract it speaks, and the upgrade line (`npm install -g
|
|
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.
|
|
28
|
+
|
|
12
29
|
The language-agnostic consumption contract is
|
|
13
30
|
[candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is
|
|
14
31
|
the TypeScript-specific production + query surface.
|
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ node scan.mjs <project-dir> # tsconfig.json honored; tests exclu
|
|
|
20
20
|
# <dir>/.candor/report.json + .callgraph.json
|
|
21
21
|
node scan.mjs . --policy .candor/policy # the §6.2 gate: exit 1 on violation, 2 if unreadable
|
|
22
22
|
|
|
23
|
+
node scan.mjs --version # installed build + spec contract (offline), + upgrade line
|
|
24
|
+
|
|
23
25
|
node query.mjs show .candor/report db.save 1 # a function's effects (match ladder)
|
|
24
26
|
node query.mjs where .candor/report Net 1 # direct sources vs inheritors
|
|
25
27
|
node query.mjs callers .candor/report db.save 1 # the blast radius (transitive callers)
|
|
@@ -28,6 +30,8 @@ node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdi
|
|
|
28
30
|
node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain)
|
|
29
31
|
```
|
|
30
32
|
|
|
33
|
+
**Staying current:** check your installed version and upgrade — [candor/AGENTS.md §2a](https://github.com/tombaldwin/candor/blob/main/AGENTS.md#2a-staying-current--check-the-version-upgrade). `npx -y candor-ts --version` prints the build, the spec, and the upgrade one-liner (offline; candor never phones home).
|
|
34
|
+
|
|
31
35
|
Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
|
|
32
36
|
naturally:
|
|
33
37
|
|
package/package.json
CHANGED
package/scan.mjs
CHANGED
|
@@ -30,12 +30,29 @@ import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesI
|
|
|
30
30
|
|
|
31
31
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
32
32
|
|
|
33
|
+
// The single version + spec sources, read once. PKG_VERSION is the bare semver from package.json
|
|
34
|
+
// (e.g. "0.5.0"); ENGINE_VERSION (below) prefixes it for the report envelope's `version` field, and
|
|
35
|
+
// `--version` prints the bare form. SPEC_VERSION is the spec contract this build speaks — the SAME
|
|
36
|
+
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
37
|
+
// Reused, never re-littered.
|
|
38
|
+
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
39
|
+
const SPEC_VERSION = "0.5";
|
|
40
|
+
|
|
41
|
+
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
42
|
+
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
43
|
+
// build + upgrade line here, then (the agent has the network) compare against npm and upgrade.
|
|
44
|
+
if (process.argv.includes("--version")) {
|
|
45
|
+
console.log(`candor-ts ${PKG_VERSION} (spec ${SPEC_VERSION})`);
|
|
46
|
+
console.log("upgrade: npm install -g candor-ts@latest");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
// ---- args ----------------------------------------------------------------------------------------
|
|
34
51
|
// ONE pass: the first non-flag is the target; value-taking flags consume the next arg and FAIL on a
|
|
35
52
|
// missing/flag-shaped value; an unknown flag fails; flags may precede the target. `--agents` is a
|
|
36
53
|
// flag (a print-and-exit MODE) — it must NOT fire when it is the VALUE of --out/--policy, which the
|
|
37
54
|
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
38
|
-
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--allow-js] [--agents]";
|
|
55
|
+
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--allow-js] [--agents] [--version]";
|
|
39
56
|
const argv = process.argv.slice(2);
|
|
40
57
|
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false;
|
|
41
58
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -175,7 +192,7 @@ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
|
175
192
|
// ONE version source: package.json. A second hardcoded literal (the envelope's, the --agents
|
|
176
193
|
// banner's) that drifted from this would make the engine distrust its OWN reports at the §2.1
|
|
177
194
|
// staleness check (`d.candor?.version !== ENGINE_VERSION`), silently downgrading every chained dep.
|
|
178
|
-
const ENGINE_VERSION = `candor-ts-${
|
|
195
|
+
const ENGINE_VERSION = `candor-ts-${PKG_VERSION}`;
|
|
179
196
|
const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
|
|
180
197
|
// Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
|
|
181
198
|
// entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
|
|
@@ -319,6 +336,14 @@ function localName(node) {
|
|
|
319
336
|
if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
|
|
320
337
|
if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
|
|
321
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()}`;
|
|
322
347
|
// `const f = (…) => …` / `const f = function (…) {…}` at any binding site — the dominant style in
|
|
323
348
|
// real TS (rimraf's whole API is arrow consts; the first dogfood analyzed 0 of 50 files without
|
|
324
349
|
// this). The VARIABLE name is the function's name; nodeName is ALSO set on the initializer so a
|
|
@@ -473,6 +498,23 @@ function realDecl(sym) {
|
|
|
473
498
|
return sym.valueDeclaration ?? sym.declarations?.[0];
|
|
474
499
|
}
|
|
475
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
|
+
|
|
476
518
|
// nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
|
|
477
519
|
function enclosing(node) {
|
|
478
520
|
for (let p = node; p; p = p.parent) {
|
|
@@ -749,6 +791,35 @@ function visitCalls(node) {
|
|
|
749
791
|
const owner = enclosing(node);
|
|
750
792
|
if (owner) fns.get(owner).direct.add("Env");
|
|
751
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
|
+
}
|
|
752
823
|
ts.forEachChild(node, visitCalls);
|
|
753
824
|
}
|
|
754
825
|
for (const sf of sources) visitCalls(sf);
|
|
@@ -822,7 +893,7 @@ for (const [name, rec] of fns) {
|
|
|
822
893
|
}
|
|
823
894
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
824
895
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
825
|
-
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec:
|
|
896
|
+
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
|
|
826
897
|
package: pkgName, functions };
|
|
827
898
|
const cg = {};
|
|
828
899
|
for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|