candor-ts 0.7.2 → 0.7.6

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 CHANGED
@@ -12,7 +12,7 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
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
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts 0.7.1 (spec 0.7)."* On a
16
16
  > build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
@@ -20,8 +20,8 @@ chains by hand.
20
20
  > Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
21
21
  > installed build, the spec contract it speaks, and the upgrade line (`npm install -g
22
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
23
+ > npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts 0.7.2 is available
24
+ > (you're on 0.7.1) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx
25
25
  > -y candor-ts@latest`) only if they agree. Never upgrade silently: an analysis tool's version is
26
26
  > part of its result's provenance, so the user decides when it changes. If it's already current (or
27
27
  > the user declines), just proceed; if candor isn't installed at all, install it normally.
@@ -56,7 +56,7 @@ pure functions are omitted** — a function present in the callgraph sidecar but
56
56
  (a test file? an unexported arrow inside an object literal?) — conclude nothing.
57
57
 
58
58
  A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
59
- `unitKind: "export"` (spec 0.5 draft, informative); ordinary functions omit the field.
59
+ `unitKind: "export"` (spec 0.7, informative); ordinary functions omit the field.
60
60
 
61
61
  **Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports
62
62
  (a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.7.2",
3
+ "version": "0.7.6",
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/query.mjs CHANGED
@@ -17,6 +17,8 @@
17
17
  * node query.mjs whatif <prefix> <fn> <Effect> [policy-file] [0|1]
18
18
  */
19
19
  import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
20
22
 
21
23
  import { parsePolicy, scopeMatches } from "./policy.mjs";
22
24
  import { printAgents } from "./contract.mjs";
@@ -31,6 +33,61 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
31
33
  loadReport, loadCallgraph, matches } from "./query-core.mjs";
32
34
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
33
35
 
36
+ // ONE version + spec source, the SAME way scan.mjs reads them: PKG_VERSION is the bare semver from
37
+ // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
38
+ const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
39
+ const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
40
+ const SPEC_VERSION = "0.7";
41
+
42
+ // The full subcommand catalogue — name + one-line description (derived from the per-subcommand
43
+ // comments + the module-doc header). The single source for the --help list AND the no-arg/unknown
44
+ // usage, so the two can never drift back to a stale hand-list again.
45
+ const SUBCOMMANDS = [
46
+ ["parsepolicy", "<file>", "parse a policy file (candor-spec §6.2) and print it as JSON"],
47
+ ["show", "<prefix> <query> [0|1]", "the effect record(s) for a function — direct, inferred, surfaces"],
48
+ ["where", "<prefix> <Effect> [0|1]", "functions with an effect, split into directly / inherited"],
49
+ ["callers", "<prefix> <query> [0|1]", "who reaches a function: {of, direct, transitive} (--include-unknown)"],
50
+ ["map", "<prefix> [0|1]", "per-module effect rollup: {effects, functions} by module"],
51
+ ["containment", "<prefix> [baseline-prefix]", "§6.1 boundary-effect dispersion; with a baseline, the leak ratchet (exit 1)"],
52
+ ["diff", "<cur-prefix> <base-prefix>", "per-function effect delta vs a baseline: {changes:[{fn,gained,lost}]} (exit 1 on a gain)"],
53
+ ["reachable", "<prefix>", "effects unioned over the entry points: what the app DOES at runtime"],
54
+ ["impact", "<prefix> <query>", "blast radius of a function (backward dual of reachable)"],
55
+ ["blindspots", "<prefix>", "the Unknown sources, ranked by blast radius"],
56
+ ["gains", "<cur-prefix> <base-prefix>", "the supply-chain alarm: what the surface gained between two reports"],
57
+ ["path", "<prefix> <fn> <Effect>", "a call path from a function to where an effect enters"],
58
+ ["whatif", "<prefix> <fn> <Effect> [policy-file] [0|1]", "the impact of giving a function an effect, vs a policy (exit 1 on a violation)"],
59
+ ["agents", "", "print the agent contract for this build (AGENTS.md)"],
60
+ ];
61
+
62
+ // The full usage block — every real subcommand, replacing the stale hand-list. Printed to stderr on
63
+ // the no-arg / unknown-command path (exit 2) and reused in --help (stdout, exit 0).
64
+ const usage = () => {
65
+ const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
66
+ const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
67
+ lines.push(` ${"-V, --version".padEnd(w)} print the build and spec version (offline)`);
68
+ lines.push(` ${"-h, --help".padEnd(w)} show this help`);
69
+ return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
70
+ };
71
+
72
+ // --version / -V: a print-and-exit MODE, handled before the switch so it never depends on a command.
73
+ // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job.
74
+ if (process.argv.includes("--version") || process.argv.includes("-V")) {
75
+ console.log(`candor-ts-query ${PKG_VERSION} (spec ${SPEC_VERSION})`);
76
+ console.log("upgrade: npm install -g candor-ts@latest");
77
+ process.exit(0);
78
+ }
79
+
80
+ // -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
81
+ // mistaken for a command). Banner + USAGE + the full described subcommand list + the github footer.
82
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
83
+ console.log(`candor-ts-query ${PKG_VERSION} — read-only queries over a candor report (candor-spec ${SPEC_VERSION})
84
+
85
+ ${usage()}
86
+
87
+ See https://github.com/tombaldwin/candor`);
88
+ process.exit(0);
89
+ }
90
+
34
91
  const [, , cmd, ...args] = process.argv;
35
92
  switch (cmd) {
36
93
  case "--agents":
@@ -191,6 +248,8 @@ switch (cmd) {
191
248
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
192
249
  }
193
250
  default:
194
- console.error("usage: node query.mjs <parsepolicy|show|where|callers|map|whatif> …");
251
+ // no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
252
+ if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
253
+ console.error(usage());
195
254
  process.exit(2);
196
255
  }
package/scan-core.mjs CHANGED
@@ -63,6 +63,13 @@ export const KAPPA_RULES = [
63
63
  [/^(node:)?worker_threads$/, /^(postMessage|receiveMessageOnPort)$/, "Ipc"],
64
64
  // node:cluster — `fork()` spawns a worker PROCESS and wires its IPC channel.
65
65
  [/^(node:)?cluster$/, /^fork$/, "Ipc"],
66
+ // node:vm executes a runtime-supplied code STRING in-process — `runInThisContext`/`runInContext`/
67
+ // `runInNewContext`/`compileFunction`, and the same verbs on a `new vm.Script(code)`. Like `eval`,
68
+ // the effects are whatever the code does (opaque) → genuinely Unknown (NOT Exec: no subprocess).
69
+ // Was unmodeled inside the κ-covered @types/node, so `vm.runInThisContext(code)` read SILENT-PURE
70
+ // (a code-execution sink reported pure — found by real-world corpus testing). The why is attached at
71
+ // the classify site (the only κ rule that resolves to the Unknown trust-marker, SPEC §4).
72
+ [/^(node:)?vm$/, /^(runInThisContext|runInContext|runInNewContext|compileFunction)$/, "Unknown"],
66
73
  [/^(node:)?sqlite$/, null, "Db"],
67
74
  // the curated npm tier
68
75
  [/^(axios|got|node-fetch|undici|ws|socket\.io(-client)?|nodemailer)$/, null, "Net"],
package/scan.mjs CHANGED
@@ -41,23 +41,45 @@ const SPEC_VERSION = "0.7";
41
41
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
42
42
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
43
43
  // build + upgrade line here, then (the agent has the network) compare against npm and upgrade.
44
- if (process.argv.includes("--version")) {
44
+ if (process.argv.includes("--version") || process.argv.includes("-V")) {
45
45
  console.log(`candor-ts ${PKG_VERSION} (spec ${SPEC_VERSION})`);
46
46
  console.log("upgrade: npm install -g candor-ts@latest");
47
47
  process.exit(0);
48
48
  }
49
49
 
50
+ // -h / --help: a print-and-exit MODE (like --version), handled before the arg walk so `-h` (a single
51
+ // dash) is never mistaken for the scan target by the positional fallthrough below.
52
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
53
+ console.log(`candor-ts ${PKG_VERSION} — TypeScript/JavaScript effect scanner (candor-spec ${SPEC_VERSION})
54
+
55
+ USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version]
56
+
57
+ <target> a dir, a .ts file, or a tsconfig.json to scan
58
+ --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
59
+ --json print the report as JSON to stdout (instead of writing files)
60
+ --policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
61
+ violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
62
+ --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
63
+ --agents print the agent contract for this build (AGENTS.md)
64
+ -V, --version print the build and spec version (offline)
65
+ -h, --help show this help
66
+
67
+ See https://github.com/tombaldwin/candor`);
68
+ process.exit(0);
69
+ }
70
+
50
71
  // ---- args ----------------------------------------------------------------------------------------
51
72
  // ONE pass: the first non-flag is the target; value-taking flags consume the next arg and FAIL on a
52
73
  // missing/flag-shaped value; an unknown flag fails; flags may precede the target. `--agents` is a
53
74
  // flag (a print-and-exit MODE) — it must NOT fire when it is the VALUE of --out/--policy, which the
54
75
  // value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
55
- const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--allow-js] [--agents] [--version]";
76
+ const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--allow-js] [--agents] [--version] [--help]";
56
77
  const argv = process.argv.slice(2);
57
- let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false;
78
+ let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false, wantJson = false;
58
79
  for (let i = 0; i < argv.length; i++) {
59
80
  const a = argv[i];
60
81
  if (a === "--agents") wantAgents = true;
82
+ else if (a === "--json") wantJson = true;
61
83
  else if (a === "--allow-js") allowJs = true;
62
84
  else if (a === "--out" || a === "--policy") {
63
85
  const v = argv[i + 1];
@@ -152,13 +174,14 @@ if (!compilerOptions.typeRoots) {
152
174
  compilerOptions.typeRoots = roots;
153
175
  }
154
176
  if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
177
+ // --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
155
178
  // The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
156
179
  let pkgName = path.basename(rootDir);
157
180
  try {
158
181
  const pj = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
159
182
  if (pj.name) pkgName = pj.name;
160
183
  } catch {}
161
- fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
184
+ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
162
185
 
163
186
  // A target with declared dependencies but no node_modules resolves almost nothing — the scan
164
187
  // would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
@@ -251,7 +274,17 @@ function declModule(decl) {
251
274
  if (m) return m[1];
252
275
  if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
253
276
  m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
254
- if (m) return m[1];
277
+ if (m) {
278
+ // `@types/X` (DefinitelyTyped) provides types for the RUNTIME package X — map it to X so the curated κ
279
+ // tier (keyed by the runtime name: pg/ws/…) fires. Without this a package typed via @types resolved to
280
+ // "@types/pg", the `pg`→Db rule never matched, and the resolved-but-unmodeled external decl read
281
+ // SILENT-PURE — `pool.query()` in a real TS Postgres app (which MUST have @types/pg installed to use
282
+ // pg) reported pure (found by a node_modules corpus run). Scoped runtime pkgs use the `__` convention:
283
+ // `@types/babel__core` → `@babel/core`.
284
+ const tm = m[1].match(/^@types\/(.+)$/);
285
+ if (tm) return tm[1].includes("__") ? "@" + tm[1].replace("__", "/") : tm[1];
286
+ return m[1];
287
+ }
255
288
  return f;
256
289
  }
257
290
 
@@ -1489,7 +1522,13 @@ function visitCalls(node) {
1489
1522
  if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
1490
1523
  && rootsAtStdStream(node.expression.expression))
1491
1524
  eff = null;
1492
- if (eff) rec.direct.add(eff);
1525
+ if (eff) {
1526
+ rec.direct.add(eff);
1527
+ // a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
1528
+ // Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
1529
+ // the κ table is concrete effects, which carry no why.)
1530
+ if (eff === "Unknown") rec.why.add(`reflect:${mod.replace(/^node:/, "")}.${member}`);
1531
+ }
1493
1532
  // the literal surfaces, read only at a CLASSIFIED call (SPEC §2)
1494
1533
  if (eff === "Net") {
1495
1534
  const lit = firstStringLiteral(node);
@@ -1658,6 +1697,19 @@ function visitCalls(node) {
1658
1697
  const owner = enclosing(node);
1659
1698
  if (owner) fns.get(owner).direct.add(geff);
1660
1699
  }
1700
+ // dynamic `require(<non-literal>)` — the CJS twin of `import(m)` (which already discloses Unknown):
1701
+ // it loads an arbitrary module and runs its top-level code, so the effects are opaque → Unknown. A
1702
+ // LITERAL `require('fs')` is a static, resolvable load (pure until a member call), so ONLY a
1703
+ // non-literal arg is the escape. Gated like `fetch`: a bare `require` whose symbol is NOT a project
1704
+ // declaration (a project's own `function require()` shadow never fabricates). Under-disclose Unknown,
1705
+ // never a concrete effect. (Found by real-world corpus testing; sibling of the node:vm fix.)
1706
+ if (ts.isIdentifier(callee) && callee.text === "require"
1707
+ && node.arguments?.length === 1 && !ts.isStringLiteralLike(node.arguments[0])
1708
+ && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
1709
+ .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) {
1710
+ const owner = enclosing(node);
1711
+ if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
1712
+ }
1661
1713
  // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
1662
1714
  // getters (the object-spread twin). Enumerate the sources' local getters.
1663
1715
  if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
@@ -1964,8 +2016,13 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
1964
2016
  // half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
1965
2017
  // rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
1966
2018
  const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
1967
- writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
1968
- writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
2019
+ // --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
2020
+ if (wantJson) {
2021
+ console.log(JSON.stringify(envelope, null, 1));
2022
+ } else {
2023
+ writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
2024
+ writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
2025
+ }
1969
2026
  // Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
1970
2027
  // the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
1971
2028
  // lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
@@ -1989,8 +2046,21 @@ for (const sf of sources) {
1989
2046
  ts.forEachChild(node, walk);
1990
2047
  })(sf);
1991
2048
  }
1992
- writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
1993
- console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
2049
+ if (!wantJson) {
2050
+ writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
2051
+ console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
2052
+ }
2053
+ {
2054
+ // Effect breakdown — make the result visible at a glance, not just a count + a file path.
2055
+ const counts = {};
2056
+ for (const e of functions) for (const x of e.inferred) counts[x] = (counts[x] || 0) + 1;
2057
+ const breakdown = ["Net", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
2058
+ .filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
2059
+ const unknown = counts.Unknown || 0;
2060
+ if (breakdown || unknown) {
2061
+ console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
2062
+ }
2063
+ }
1994
2064
  if (unlistedSeen.size > 0) {
1995
2065
  const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1996
2066
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");