candor-ts 0.7.5 → 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.5",
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.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).
@@ -1993,8 +2016,13 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
1993
2016
  // half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
1994
2017
  // rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
1995
2018
  const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
1996
- writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
1997
- 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
+ }
1998
2026
  // Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
1999
2027
  // the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
2000
2028
  // lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
@@ -2018,8 +2046,21 @@ for (const sf of sources) {
2018
2046
  ts.forEachChild(node, walk);
2019
2047
  })(sf);
2020
2048
  }
2021
- writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
2022
- 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
+ }
2023
2064
  if (unlistedSeen.size > 0) {
2024
2065
  const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2025
2066
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");