candor-ts 0.4.2 → 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 +54 -10
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.2",
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
@@ -30,8 +30,16 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
 
31
31
  // ---- args ----------------------------------------------------------------------------------------
32
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
+ }
33
41
  if (argv.length === 0) {
34
- 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]");
35
43
  process.exit(2);
36
44
  }
37
45
  const target = argv[0];
@@ -40,7 +48,17 @@ for (let i = 1; i < argv.length; i++) {
40
48
  if (argv[i] === "--out") outPrefix = argv[++i];
41
49
  else if (argv[i] === "--policy") policyPath = argv[++i];
42
50
  else if (argv[i] === "--allow-js") allowJs = true;
43
- 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);
44
62
  }
45
63
 
46
64
  // ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
@@ -54,11 +72,34 @@ let rootDir, fileNames, compilerOptions = {
54
72
  types: ["node"],
55
73
  strict: true,
56
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
+
57
82
  function fromTsconfig(cfgPath, baseDir) {
58
83
  const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile);
59
84
  const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, baseDir);
60
- compilerOptions = { ...parsed.options, types: parsed.options.types ?? ["node"] };
61
- 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)));
62
103
  }
63
104
  const stat = fs.existsSync(target) ? fs.statSync(target) : null;
64
105
  if (!stat) { console.error(`candor-ts: no such path: ${target}`); process.exit(2); }
@@ -139,7 +180,7 @@ fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
139
180
  // scan and a .d.ts resolution). Version-aware trust (§2.1): a report from a DIFFERENT engine
140
181
  // version is downgraded to Unknown rather than silently trusted. Duplicate hashes (two same-named
141
182
  // exports in one package) UNION — a sound over-approximation, documented.
142
- const ENGINE_VERSION = "candor-ts-0.4.2";
183
+ const ENGINE_VERSION = "candor-ts-0.4.3";
143
184
  const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
144
185
  // Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
145
186
  // entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
@@ -336,6 +377,8 @@ function moduleOf(sf) {
336
377
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
337
378
  return rel.split(path.sep).join(".");
338
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; };
339
382
  function localName(node) {
340
383
  if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
341
384
  if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
@@ -371,10 +414,10 @@ function localName(node) {
371
414
  if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === node) {
372
415
  const lhs = p.left.getText().replace(/\s+/g, "");
373
416
  if (lhs === "module.exports")
374
- return (ts.isFunctionExpression(node) && node.name?.text)
375
- || 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?$/, ""));
376
419
  const m = lhs.match(/^(?:module\.)?exports\.([A-Za-z_$][\w$]*)$/);
377
- if (m) return m[1];
420
+ if (m) return markCjs(m[1]);
378
421
  }
379
422
  if (ts.isPropertyAssignment(p) && p.initializer === node && ts.isObjectLiteralExpression(p.parent)) {
380
423
  const g = p.parent.parent;
@@ -382,7 +425,7 @@ function localName(node) {
382
425
  && g.right === p.parent && g.left.getText().replace(/\s+/g, "") === "module.exports")
383
426
  // .text, not getText(): a string-literal key keeps its quotes under getText, minting a
384
427
  // hash like pkg#"sign" the consumer's pkg#sign join can never hit (/code-review).
385
- return p.name.text ?? p.name.getText();
428
+ return markCjs(p.name.text ?? p.name.getText());
386
429
  }
387
430
  }
388
431
  return null;
@@ -779,11 +822,12 @@ for (const [name, rec] of fns) {
779
822
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
780
823
  if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
781
824
  if (rec.entry) entry.entryPoint = true;
825
+ if (cjsLocal.has(rec.local)) entry.unitKind = "export"; // spec 0.5 draft, informative
782
826
  functions.push(entry);
783
827
  }
784
828
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
785
829
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
786
- const envelope = { candor: { version: "candor-ts-0.4.2", 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" },
787
831
  package: pkgName, functions };
788
832
  fs.writeFileSync(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
789
833
  const cg = {};