candor-ts 0.7.5 → 0.7.7

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.7",
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/policy.mjs CHANGED
@@ -56,10 +56,12 @@ export function parsePolicy(text) {
56
56
  return { deny, allow, forbid };
57
57
  }
58
58
 
59
- /** §6.2 scope match: by NAME SEGMENT over ".", last segment a prefix. */
59
+ /** §6.2 scope match: by NAME SEGMENT, last segment a prefix.
60
+ * Segments split on BOTH "." and "::" — Rust/Java qualify with "::" while TS uses ".", and a shared
61
+ * policy must match across engines (a `Foo::bar` scope authored against Rust was inert in TS before). */
60
62
  export function scopeMatches(name, scope) {
61
- const segs = name.split(".");
62
- const parts = scope.split(".");
63
+ const segs = name.split(/[.:]+/).filter(Boolean);
64
+ const parts = scope.split(/[.:]+/).filter(Boolean);
63
65
  if (parts.length === 0 || parts.length > segs.length) return false;
64
66
  const last = parts[parts.length - 1], init = parts.slice(0, -1);
65
67
  outer: for (let i = 0; i + parts.length <= segs.length; i++) {
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":
@@ -38,7 +95,15 @@ switch (cmd) {
38
95
  printAgents(); // shared with scan.mjs — one implementation, can't diverge within an install
39
96
  break;
40
97
  case "parsepolicy": {
41
- emit(parsePolicy(fs.readFileSync(args[0], "utf8")));
98
+ // An unreadable/missing file is a clean exit-2 error, not an uncaught readFileSync stack trace.
99
+ let text;
100
+ try {
101
+ text = fs.readFileSync(args[0], "utf8");
102
+ } catch {
103
+ console.error(`candor: policy ${args[0] ?? "(no file given)"} could not be read`);
104
+ process.exit(2);
105
+ }
106
+ emit(parsePolicy(text));
42
107
  break;
43
108
  }
44
109
  case "show": {
@@ -177,8 +242,18 @@ switch (cmd) {
177
242
  for (const c of rev.get(n) ?? []) if (!affected.has(c)) { affected.add(c); queue.push(c); }
178
243
  }
179
244
  const violations = [];
180
- if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1" && fs.existsSync(maybePolicy)) {
181
- const pol = parsePolicy(fs.readFileSync(maybePolicy, "utf8"));
245
+ // A present policy arg (anything but the 0/1 verbosity sentinels) MUST exist and be readable —
246
+ // a typo'd path must be LOUD, not silently "no policy → ok:true, exit 0" (mirrors scan's --policy,
247
+ // which exits 2 on an unreadable file: a gate that can't read its policy can't certify anything).
248
+ if (maybePolicy && maybePolicy !== "0" && maybePolicy !== "1") {
249
+ let text;
250
+ try {
251
+ text = fs.readFileSync(maybePolicy, "utf8");
252
+ } catch {
253
+ console.error(`candor: policy ${maybePolicy} could not be read; whatif NOT evaluated against it`);
254
+ process.exit(2);
255
+ }
256
+ const pol = parsePolicy(text);
182
257
  for (const r of pol.deny) {
183
258
  if (r.effects.length && !r.effects.includes(eff)) continue; // pure ([]) forbids ANY effect
184
259
  for (const fn of affected)
@@ -191,6 +266,8 @@ switch (cmd) {
191
266
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
192
267
  }
193
268
  default:
194
- console.error("usage: node query.mjs <parsepolicy|show|where|callers|map|whatif> …");
269
+ // no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
270
+ if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
271
+ console.error(usage());
195
272
  process.exit(2);
196
273
  }
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];
@@ -65,7 +87,11 @@ for (let i = 0; i < argv.length; i++) {
65
87
  if (a === "--out") outPrefix = v; else policyPath = v;
66
88
  i++;
67
89
  }
68
- else if (a.startsWith("--")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
90
+ // Any leading-dash token that isn't a recognized flag is an unknown flag — NOT a positional target
91
+ // (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
92
+ // a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
93
+ // target would silently scan the wrong thing.
94
+ else if (a.startsWith("-")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
69
95
  else if (target === null) target = a;
70
96
  else if (outPrefix === null) outPrefix = a; // legacy positional prefix
71
97
  else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
@@ -152,13 +178,14 @@ if (!compilerOptions.typeRoots) {
152
178
  compilerOptions.typeRoots = roots;
153
179
  }
154
180
  if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
181
+ // --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
155
182
  // The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
156
183
  let pkgName = path.basename(rootDir);
157
184
  try {
158
185
  const pj = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
159
186
  if (pj.name) pkgName = pj.name;
160
187
  } catch {}
161
- fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
188
+ if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
162
189
 
163
190
  // A target with declared dependencies but no node_modules resolves almost nothing — the scan
164
191
  // would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
@@ -170,8 +197,8 @@ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
170
197
  const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
171
198
  if (Object.keys(deps).length > 0)
172
199
  console.error("candor-ts: WARNING — the target declares dependencies but has no node_modules; " +
173
- "imports will not resolve and most functions will read Unknown. " +
174
- "Run `npm install` in the target first.");
200
+ "imports won't resolve, so calls into those packages read pure/invisible (not Unknown) " +
201
+ "and effects through them are silently dropped. Run `npm install` in the target first.");
175
202
  } catch {}
176
203
  }
177
204
  // Prisma's client types are GENERATED — a project with the prisma dependency but no generated
@@ -1993,8 +2020,13 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
1993
2020
  // half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
1994
2021
  // rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
1995
2022
  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));
2023
+ // --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
2024
+ if (wantJson) {
2025
+ console.log(JSON.stringify(envelope, null, 1));
2026
+ } else {
2027
+ writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
2028
+ writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
2029
+ }
1998
2030
  // Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
1999
2031
  // the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
2000
2032
  // lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
@@ -2018,8 +2050,21 @@ for (const sf of sources) {
2018
2050
  ts.forEachChild(node, walk);
2019
2051
  })(sf);
2020
2052
  }
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`);
2053
+ if (!wantJson) {
2054
+ writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
2055
+ console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
2056
+ }
2057
+ {
2058
+ // Effect breakdown — make the result visible at a glance, not just a count + a file path.
2059
+ const counts = {};
2060
+ for (const e of functions) for (const x of e.inferred) counts[x] = (counts[x] || 0) + 1;
2061
+ const breakdown = ["Net", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
2062
+ .filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
2063
+ const unknown = counts.Unknown || 0;
2064
+ if (breakdown || unknown) {
2065
+ console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
2066
+ }
2067
+ }
2023
2068
  if (unlistedSeen.size > 0) {
2024
2069
  const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2025
2070
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
@@ -2043,7 +2088,10 @@ if (policyPath) {
2043
2088
  const incompleteMap = new Map();
2044
2089
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2045
2090
  const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2046
- for (const line of v) console.log(line);
2091
+ // In --json mode stdout is the §2 envelope and must stay pure JSON — route the gate's
2092
+ // [AS-EFF-…] violation lines to stderr so a `candor-ts --json --policy … | jq` pipe never breaks.
2093
+ const emitViolation = wantJson ? (l) => console.error(l) : (l) => console.log(l);
2094
+ for (const line of v) emitViolation(line);
2047
2095
  if (v.length) {
2048
2096
  console.error(`candor-ts: ${v.length} policy violation(s)`);
2049
2097
  process.exit(1);