candor-ts 0.4.2 → 0.4.4
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 +71 -19
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
|
@@ -29,19 +29,37 @@ import { parsePolicy, evaluatePolicy } from "./policy.mjs";
|
|
|
29
29
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
30
|
|
|
31
31
|
// ---- args ----------------------------------------------------------------------------------------
|
|
32
|
+
// ONE pass: the first non-flag is the target; value-taking flags consume the next arg and FAIL on a
|
|
33
|
+
// missing/flag-shaped value; an unknown flag fails; flags may precede the target. `--agents` is a
|
|
34
|
+
// flag (a print-and-exit MODE) — it must NOT fire when it is the VALUE of --out/--policy, which the
|
|
35
|
+
// value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
|
|
36
|
+
const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>] [--allow-js] [--agents]";
|
|
32
37
|
const argv = process.argv.slice(2);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, allowJs = false, wantAgents = false;
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === "--agents") wantAgents = true;
|
|
42
|
+
else if (a === "--allow-js") allowJs = true;
|
|
43
|
+
else if (a === "--out" || a === "--policy") {
|
|
44
|
+
const v = argv[i + 1];
|
|
45
|
+
if (v === undefined || v.startsWith("--")) { console.error(`candor-ts: ${a} requires a value (${usage})`); process.exit(2); }
|
|
46
|
+
if (a === "--out") outPrefix = v; else policyPath = v;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else if (a.startsWith("--")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
|
|
50
|
+
else if (target === null) target = a;
|
|
51
|
+
else if (outPrefix === null) outPrefix = a; // legacy positional prefix
|
|
52
|
+
else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
|
|
36
53
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
if (wantAgents) {
|
|
55
|
+
// The agent contract for THE INSTALLED VERSION — AGENTS.md ships in the npm tarball, so doc and
|
|
56
|
+
// engine cannot drift (the spec §2.1 version-trust rule applied to documentation).
|
|
57
|
+
const semver = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
58
|
+
console.log(`<!-- candor-ts ${semver} · the agent contract for this installed version -->`);
|
|
59
|
+
process.stdout.write(fs.readFileSync(path.join(ENGINE_DIR, "AGENTS.md"), "utf8"));
|
|
60
|
+
process.exit(0);
|
|
44
61
|
}
|
|
62
|
+
if (target === null) { console.error(usage); process.exit(2); }
|
|
45
63
|
|
|
46
64
|
// ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
|
|
47
65
|
function isTestPath(p) {
|
|
@@ -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 =
|
|
61
|
-
|
|
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,10 @@ 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
|
-
|
|
183
|
+
// ONE version source: package.json. A second hardcoded literal (the envelope's, the --agents
|
|
184
|
+
// banner's) that drifted from this would make the engine distrust its OWN reports at the §2.1
|
|
185
|
+
// staleness check (`d.candor?.version !== ENGINE_VERSION`), silently downgrading every chained dep.
|
|
186
|
+
const ENGINE_VERSION = `candor-ts-${JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version}`;
|
|
143
187
|
const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
|
|
144
188
|
// Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
|
|
145
189
|
// entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
|
|
@@ -336,7 +380,13 @@ function moduleOf(sf) {
|
|
|
336
380
|
const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
|
|
337
381
|
return rel.split(path.sep).join(".");
|
|
338
382
|
}
|
|
383
|
+
// `_lastCjs` is set by markCjs when localName() returns a CJS export-surface name, read right after
|
|
384
|
+
// the call to tag THAT unit (spec 0.5 draft unitKind: "export"). Keyed to the unit, not a project-
|
|
385
|
+
// wide name set — a same-named ordinary TS function in another file must NOT be mislabeled.
|
|
386
|
+
let _lastCjs = false;
|
|
387
|
+
const markCjs = (v) => { if (v) _lastCjs = true; return v; };
|
|
339
388
|
function localName(node) {
|
|
389
|
+
_lastCjs = false;
|
|
340
390
|
if (ts.isFunctionDeclaration(node) && node.name) return node.name.text;
|
|
341
391
|
if (ts.isMethodDeclaration(node) && ts.isClassDeclaration(node.parent) && node.parent.name)
|
|
342
392
|
return `${node.parent.name.text}.${node.name.getText()}`;
|
|
@@ -371,10 +421,10 @@ function localName(node) {
|
|
|
371
421
|
if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === node) {
|
|
372
422
|
const lhs = p.left.getText().replace(/\s+/g, "");
|
|
373
423
|
if (lhs === "module.exports")
|
|
374
|
-
return (ts.isFunctionExpression(node) && node.name?.text)
|
|
375
|
-
|| path.basename(node.getSourceFile().fileName).replace(/\.[mc]?jsx?$/, "");
|
|
424
|
+
return markCjs((ts.isFunctionExpression(node) && node.name?.text)
|
|
425
|
+
|| path.basename(node.getSourceFile().fileName).replace(/\.[mc]?jsx?$/, ""));
|
|
376
426
|
const m = lhs.match(/^(?:module\.)?exports\.([A-Za-z_$][\w$]*)$/);
|
|
377
|
-
if (m) return m[1];
|
|
427
|
+
if (m) return markCjs(m[1]);
|
|
378
428
|
}
|
|
379
429
|
if (ts.isPropertyAssignment(p) && p.initializer === node && ts.isObjectLiteralExpression(p.parent)) {
|
|
380
430
|
const g = p.parent.parent;
|
|
@@ -382,7 +432,7 @@ function localName(node) {
|
|
|
382
432
|
&& g.right === p.parent && g.left.getText().replace(/\s+/g, "") === "module.exports")
|
|
383
433
|
// .text, not getText(): a string-literal key keeps its quotes under getText, minting a
|
|
384
434
|
// hash like pkg#"sign" the consumer's pkg#sign join can never hit (/code-review).
|
|
385
|
-
return p.name.text ?? p.name.getText();
|
|
435
|
+
return markCjs(p.name.text ?? p.name.getText());
|
|
386
436
|
}
|
|
387
437
|
}
|
|
388
438
|
return null;
|
|
@@ -434,11 +484,12 @@ for (const sf of sources) {
|
|
|
434
484
|
nodeName.set(node, ctorQual);
|
|
435
485
|
}
|
|
436
486
|
const n = localName(node);
|
|
487
|
+
const isCjsExport = _lastCjs; // captured immediately: localName set it for THIS node only
|
|
437
488
|
if (n) {
|
|
438
489
|
const qual = `${mod}.${n}`;
|
|
439
490
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
440
491
|
fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
441
|
-
cmds: new Set(), paths: new Set(), why: new Set(), entry: false,
|
|
492
|
+
cmds: new Set(), paths: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
442
493
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
|
|
443
494
|
nodeName.set(node, qual);
|
|
444
495
|
if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
|
|
@@ -779,11 +830,12 @@ for (const [name, rec] of fns) {
|
|
|
779
830
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
780
831
|
if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
|
|
781
832
|
if (rec.entry) entry.entryPoint = true;
|
|
833
|
+
if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
|
|
782
834
|
functions.push(entry);
|
|
783
835
|
}
|
|
784
836
|
// `package` names what this report COVERS — a consumer chaining it registers coverage even when
|
|
785
837
|
// `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
|
|
786
|
-
const envelope = { candor: { version:
|
|
838
|
+
const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: "0.4" },
|
|
787
839
|
package: pkgName, functions };
|
|
788
840
|
fs.writeFileSync(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
|
|
789
841
|
const cg = {};
|