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.
- package/AGENTS.md +10 -1
- package/package.json +1 -1
- package/query.mjs +11 -0
- 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.
|
|
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
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 (
|
|
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 =
|
|
60
|
-
|
|
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).
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
path.join(ENGINE_DIR, "
|
|
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.
|
|
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.
|
|
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 = {};
|