candor-ts 0.4.1 → 0.4.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.
Files changed (4) hide show
  1. package/AGENTS.md +10 -1
  2. package/package.json +1 -1
  3. package/query.mjs +11 -0
  4. package/scan.mjs +64 -15
package/AGENTS.md CHANGED
@@ -3,7 +3,13 @@
3
3
  You are working in a TypeScript project. **candor-ts** tells you, for every function, which side
4
4
  effects it can reach — network, filesystem, database, subprocess, env, clock — *including effects
5
5
  inherited transitively through any chain of calls across files*. Use it instead of tracing call
6
- chains by hand. The language-agnostic consumption contract is
6
+ chains by hand.
7
+
8
+ > **This document ships inside the package.** `npx -y candor-ts --agents` prints the contract for
9
+ > the *installed* version — always prefer that over a vendored or fetched copy, which can describe
10
+ > a different candor-ts than the one you are running.
11
+
12
+ The language-agnostic consumption contract is
7
13
  [candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is
8
14
  the TypeScript-specific production + query surface.
9
15
 
@@ -32,6 +38,9 @@ pure functions are omitted** — a function present in the callgraph sidecar but
32
38
  `.functions[]` is pure (as far as the engine resolved). In *neither* file = never analyzed
33
39
  (a test file? an unexported arrow inside an object literal?) — conclude nothing.
34
40
 
41
+ A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
42
+ `unitKind: "export"` (spec 0.5 draft, informative); ordinary functions omit the field.
43
+
35
44
  **Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports
36
45
  (a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
37
46
  report inherits that function's recorded transitive effects and literal surfaces, joined by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.4)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/query.mjs CHANGED
@@ -43,6 +43,17 @@ const emit = (v) => console.log(JSON.stringify(v, null, 1));
43
43
 
44
44
  const [, , cmd, ...args] = process.argv;
45
45
  switch (cmd) {
46
+ case "--agents":
47
+ case "agents": {
48
+ // The agent contract for THE INSTALLED VERSION — ships in the npm tarball, cannot drift.
49
+ const { dirname: qDirname, join: qJoin } = await import("node:path");
50
+ const { fileURLToPath: qFile } = await import("node:url");
51
+ const dir = qDirname(qFile(import.meta.url));
52
+ const semver = JSON.parse(fs.readFileSync(qJoin(dir, "package.json"), "utf8")).version;
53
+ console.log(`<!-- candor-ts ${semver} · the agent contract for this installed version -->`);
54
+ process.stdout.write(fs.readFileSync(qJoin(dir, "AGENTS.md"), "utf8"));
55
+ break;
56
+ }
46
57
  case "parsepolicy": {
47
58
  emit(parsePolicy(fs.readFileSync(args[0], "utf8")));
48
59
  break;
package/scan.mjs CHANGED
@@ -23,14 +23,23 @@ import ts from "typescript";
23
23
  import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { createRequire } from "node:module";
26
27
  import { parsePolicy, evaluatePolicy } from "./policy.mjs";
27
28
 
28
29
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
29
30
 
30
31
  // ---- args ----------------------------------------------------------------------------------------
31
32
  const argv = process.argv.slice(2);
33
+ if (argv.includes("--agents")) {
34
+ // The agent contract for THE INSTALLED VERSION — AGENTS.md ships in the npm tarball, so doc
35
+ // and engine cannot drift (the spec §2.1 version-trust rule applied to documentation).
36
+ const semver = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
37
+ console.log(`<!-- candor-ts ${semver} · the agent contract for this installed version -->`);
38
+ process.stdout.write(fs.readFileSync(path.join(ENGINE_DIR, "AGENTS.md"), "utf8"));
39
+ process.exit(0);
40
+ }
32
41
  if (argv.length === 0) {
33
- console.error("usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>]");
42
+ console.error("usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--agents]");
34
43
  process.exit(2);
35
44
  }
36
45
  const target = argv[0];
@@ -39,7 +48,17 @@ for (let i = 1; i < argv.length; i++) {
39
48
  if (argv[i] === "--out") outPrefix = argv[++i];
40
49
  else if (argv[i] === "--policy") policyPath = argv[++i];
41
50
  else if (argv[i] === "--allow-js") allowJs = true;
42
- else if (!argv[i].startsWith("--") && !outPrefix) outPrefix = argv[i]; // legacy positional prefix
51
+ else if (argv[i].startsWith("--")) {
52
+ // An unknown flag must FAIL, not be silently ignored: a typo'd --policy drops the gate; an
53
+ // agent following a newer doc against an older binary deserves a loud "upgrade me" signal.
54
+ console.error(`candor-ts: unknown flag ${argv[i]} (usage: candor-ts <dir> [--out <prefix>] [--policy <file>] [--allow-js] [--agents])`);
55
+ process.exit(2);
56
+ }
57
+ else if (!outPrefix) outPrefix = argv[i]; // legacy positional prefix
58
+ }
59
+ if (target.startsWith("--")) {
60
+ console.error(`candor-ts: unknown flag ${target} (see usage)`);
61
+ process.exit(2);
43
62
  }
44
63
 
45
64
  // ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
@@ -53,11 +72,34 @@ let rootDir, fileNames, compilerOptions = {
53
72
  types: ["node"],
54
73
  strict: true,
55
74
  };
75
+ // The scanner CLASSIFIES through the builtin typings, so `node` always rides in `types` — a
76
+ // project's `types: []` (legitimate for its own build) would blind the effect analysis itself.
77
+ function withNodeTypes(options) {
78
+ const t = options.types && options.types.length ? options.types : [];
79
+ return { ...options, types: [...new Set([...t, "node"])] };
80
+ }
81
+
56
82
  function fromTsconfig(cfgPath, baseDir) {
57
83
  const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile);
58
84
  const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, baseDir);
59
- compilerOptions = { ...parsed.options, types: parsed.options.types ?? ["node"] };
60
- return parsed.fileNames.filter((f) => !isTestPath(path.relative(baseDir, f)));
85
+ compilerOptions = withNodeTypes(parsed.options);
86
+ let names = parsed.fileNames;
87
+ // SOLUTION-STYLE configs (`files: [], references: [...]` — hono, most monorepo roots) list no
88
+ // sources themselves; follow the references one level and union their file lists (skipping
89
+ // test/bench configs by the same path rule). Found by the published-package probe: hono read
90
+ // "no TypeScript sources".
91
+ if (names.length === 0 && (parsed.projectReferences ?? []).length > 0) {
92
+ for (const ref of parsed.projectReferences) {
93
+ const refPath = ts.resolveProjectReferencePath(ref);
94
+ if (!fs.existsSync(refPath) || isTestPath(path.relative(baseDir, refPath))) continue;
95
+ const sub = ts.readConfigFile(refPath, ts.sys.readFile);
96
+ const subParsed = ts.parseJsonConfigFileContent(sub.config ?? {}, ts.sys, path.dirname(refPath));
97
+ if (names.length === 0) compilerOptions = withNodeTypes(subParsed.options);
98
+ names = names.concat(subParsed.fileNames);
99
+ }
100
+ names = [...new Set(names)];
101
+ }
102
+ return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
61
103
  }
62
104
  const stat = fs.existsSync(target) ? fs.statSync(target) : null;
63
105
  if (!stat) { console.error(`candor-ts: no such path: ${target}`); process.exit(2); }
@@ -89,12 +131,16 @@ if (fileNames.length === 0) { console.error(`candor-ts: no TypeScript sources un
89
131
  // Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
90
132
  // hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
91
133
  // probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
92
- // types). The TARGET's own @types win when present.
134
+ // types). Resolved via the module system, NOT a fixed relative path — npm HOISTS dependencies, so
135
+ // in an npx/install tree @types/node sits BESIDE candor-ts, not inside it (the second probe's
136
+ // catch). The TARGET's own @types win when present.
93
137
  if (!compilerOptions.typeRoots) {
94
- compilerOptions.typeRoots = [
95
- path.join(rootDir, "node_modules", "@types"),
96
- path.join(ENGINE_DIR, "node_modules", "@types"),
97
- ];
138
+ const roots = [path.join(rootDir, "node_modules", "@types")];
139
+ try {
140
+ const req = createRequire(path.join(ENGINE_DIR, "scan.mjs"));
141
+ roots.push(path.dirname(path.dirname(req.resolve("@types/node/package.json"))));
142
+ } catch {}
143
+ compilerOptions.typeRoots = roots;
98
144
  }
99
145
  if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
100
146
  // The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
@@ -134,7 +180,7 @@ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
134
180
  // scan and a .d.ts resolution). Version-aware trust (§2.1): a report from a DIFFERENT engine
135
181
  // version is downgraded to Unknown rather than silently trusted. Duplicate hashes (two same-named
136
182
  // exports in one package) UNION — a sound over-approximation, documented.
137
- const ENGINE_VERSION = "candor-ts-0.4.1";
183
+ const ENGINE_VERSION = "candor-ts-0.4.3";
138
184
  const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
139
185
  // Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
140
186
  // entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
@@ -331,6 +377,8 @@ function moduleOf(sf) {
331
377
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
332
378
  return rel.split(path.sep).join(".");
333
379
  }
380
+ const cjsLocal = new Set(); // CJS export-surface units (spec 0.5 draft unitKind: "export")
381
+ const markCjs = (v) => { if (v) cjsLocal.add(v); return v; };
334
382
  function localName(node) {
335
383
  if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
336
384
  if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
@@ -366,10 +414,10 @@ function localName(node) {
366
414
  if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === node) {
367
415
  const lhs = p.left.getText().replace(/\s+/g, "");
368
416
  if (lhs === "module.exports")
369
- return (ts.isFunctionExpression(node) && node.name?.text)
370
- || path.basename(node.getSourceFile().fileName).replace(/\.[mc]?jsx?$/, "");
417
+ return markCjs((ts.isFunctionExpression(node) && node.name?.text)
418
+ || path.basename(node.getSourceFile().fileName).replace(/\.[mc]?jsx?$/, ""));
371
419
  const m = lhs.match(/^(?:module\.)?exports\.([A-Za-z_$][\w$]*)$/);
372
- if (m) return m[1];
420
+ if (m) return markCjs(m[1]);
373
421
  }
374
422
  if (ts.isPropertyAssignment(p) && p.initializer === node && ts.isObjectLiteralExpression(p.parent)) {
375
423
  const g = p.parent.parent;
@@ -377,7 +425,7 @@ function localName(node) {
377
425
  && g.right === p.parent && g.left.getText().replace(/\s+/g, "") === "module.exports")
378
426
  // .text, not getText(): a string-literal key keeps its quotes under getText, minting a
379
427
  // hash like pkg#"sign" the consumer's pkg#sign join can never hit (/code-review).
380
- return p.name.text ?? p.name.getText();
428
+ return markCjs(p.name.text ?? p.name.getText());
381
429
  }
382
430
  }
383
431
  return null;
@@ -774,11 +822,12 @@ for (const [name, rec] of fns) {
774
822
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
775
823
  if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
776
824
  if (rec.entry) entry.entryPoint = true;
825
+ if (cjsLocal.has(rec.local)) entry.unitKind = "export"; // spec 0.5 draft, informative
777
826
  functions.push(entry);
778
827
  }
779
828
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
780
829
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
781
- const envelope = { candor: { version: "candor-ts-0.4.1", toolchain: `node-${process.versions.node}`, spec: "0.4" },
830
+ const envelope = { candor: { version: "candor-ts-0.4.3", toolchain: `node-${process.versions.node}`, spec: "0.4" },
782
831
  package: pkgName, functions };
783
832
  fs.writeFileSync(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
784
833
  const cg = {};