candor-ts 0.7.2 → 0.7.6
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 +4 -4
- package/package.json +1 -1
- package/query.mjs +60 -1
- package/scan-core.mjs +7 -0
- package/scan.mjs +80 -10
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.
|
|
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.
|
|
24
|
-
> (you're on 0.
|
|
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.
|
|
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
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":
|
|
@@ -191,6 +248,8 @@ switch (cmd) {
|
|
|
191
248
|
break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
|
|
192
249
|
}
|
|
193
250
|
default:
|
|
194
|
-
|
|
251
|
+
// no command (cmd === undefined) or an unknown one: the FULL usage, not the stale 6-item list.
|
|
252
|
+
if (cmd !== undefined) console.error(`candor-ts-query: unknown command '${cmd}'`);
|
|
253
|
+
console.error(usage());
|
|
195
254
|
process.exit(2);
|
|
196
255
|
}
|
package/scan-core.mjs
CHANGED
|
@@ -63,6 +63,13 @@ export const KAPPA_RULES = [
|
|
|
63
63
|
[/^(node:)?worker_threads$/, /^(postMessage|receiveMessageOnPort)$/, "Ipc"],
|
|
64
64
|
// node:cluster — `fork()` spawns a worker PROCESS and wires its IPC channel.
|
|
65
65
|
[/^(node:)?cluster$/, /^fork$/, "Ipc"],
|
|
66
|
+
// node:vm executes a runtime-supplied code STRING in-process — `runInThisContext`/`runInContext`/
|
|
67
|
+
// `runInNewContext`/`compileFunction`, and the same verbs on a `new vm.Script(code)`. Like `eval`,
|
|
68
|
+
// the effects are whatever the code does (opaque) → genuinely Unknown (NOT Exec: no subprocess).
|
|
69
|
+
// Was unmodeled inside the κ-covered @types/node, so `vm.runInThisContext(code)` read SILENT-PURE
|
|
70
|
+
// (a code-execution sink reported pure — found by real-world corpus testing). The why is attached at
|
|
71
|
+
// the classify site (the only κ rule that resolves to the Unknown trust-marker, SPEC §4).
|
|
72
|
+
[/^(node:)?vm$/, /^(runInThisContext|runInContext|runInNewContext|compileFunction)$/, "Unknown"],
|
|
66
73
|
[/^(node:)?sqlite$/, null, "Db"],
|
|
67
74
|
// the curated npm tier
|
|
68
75
|
[/^(axios|got|node-fetch|undici|ws|socket\.io(-client)?|nodemailer)$/, null, "Net"],
|
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];
|
|
@@ -152,13 +174,14 @@ if (!compilerOptions.typeRoots) {
|
|
|
152
174
|
compilerOptions.typeRoots = roots;
|
|
153
175
|
}
|
|
154
176
|
if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
|
|
177
|
+
// --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
|
|
155
178
|
// The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
|
|
156
179
|
let pkgName = path.basename(rootDir);
|
|
157
180
|
try {
|
|
158
181
|
const pj = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
159
182
|
if (pj.name) pkgName = pj.name;
|
|
160
183
|
} catch {}
|
|
161
|
-
fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
184
|
+
if (!wantJson) fs.mkdirSync(path.dirname(path.resolve(outPrefix)), { recursive: true });
|
|
162
185
|
|
|
163
186
|
// A target with declared dependencies but no node_modules resolves almost nothing — the scan
|
|
164
187
|
// would "succeed" with a near-total-Unknown report a fresh user could ship (CTA-dogfood finding).
|
|
@@ -251,7 +274,17 @@ function declModule(decl) {
|
|
|
251
274
|
if (m) return m[1];
|
|
252
275
|
if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
|
|
253
276
|
m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
|
|
254
|
-
if (m)
|
|
277
|
+
if (m) {
|
|
278
|
+
// `@types/X` (DefinitelyTyped) provides types for the RUNTIME package X — map it to X so the curated κ
|
|
279
|
+
// tier (keyed by the runtime name: pg/ws/…) fires. Without this a package typed via @types resolved to
|
|
280
|
+
// "@types/pg", the `pg`→Db rule never matched, and the resolved-but-unmodeled external decl read
|
|
281
|
+
// SILENT-PURE — `pool.query()` in a real TS Postgres app (which MUST have @types/pg installed to use
|
|
282
|
+
// pg) reported pure (found by a node_modules corpus run). Scoped runtime pkgs use the `__` convention:
|
|
283
|
+
// `@types/babel__core` → `@babel/core`.
|
|
284
|
+
const tm = m[1].match(/^@types\/(.+)$/);
|
|
285
|
+
if (tm) return tm[1].includes("__") ? "@" + tm[1].replace("__", "/") : tm[1];
|
|
286
|
+
return m[1];
|
|
287
|
+
}
|
|
255
288
|
return f;
|
|
256
289
|
}
|
|
257
290
|
|
|
@@ -1489,7 +1522,13 @@ function visitCalls(node) {
|
|
|
1489
1522
|
if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
|
|
1490
1523
|
&& rootsAtStdStream(node.expression.expression))
|
|
1491
1524
|
eff = null;
|
|
1492
|
-
if (eff)
|
|
1525
|
+
if (eff) {
|
|
1526
|
+
rec.direct.add(eff);
|
|
1527
|
+
// a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
|
|
1528
|
+
// Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
|
|
1529
|
+
// the κ table is concrete effects, which carry no why.)
|
|
1530
|
+
if (eff === "Unknown") rec.why.add(`reflect:${mod.replace(/^node:/, "")}.${member}`);
|
|
1531
|
+
}
|
|
1493
1532
|
// the literal surfaces, read only at a CLASSIFIED call (SPEC §2)
|
|
1494
1533
|
if (eff === "Net") {
|
|
1495
1534
|
const lit = firstStringLiteral(node);
|
|
@@ -1658,6 +1697,19 @@ function visitCalls(node) {
|
|
|
1658
1697
|
const owner = enclosing(node);
|
|
1659
1698
|
if (owner) fns.get(owner).direct.add(geff);
|
|
1660
1699
|
}
|
|
1700
|
+
// dynamic `require(<non-literal>)` — the CJS twin of `import(m)` (which already discloses Unknown):
|
|
1701
|
+
// it loads an arbitrary module and runs its top-level code, so the effects are opaque → Unknown. A
|
|
1702
|
+
// LITERAL `require('fs')` is a static, resolvable load (pure until a member call), so ONLY a
|
|
1703
|
+
// non-literal arg is the escape. Gated like `fetch`: a bare `require` whose symbol is NOT a project
|
|
1704
|
+
// declaration (a project's own `function require()` shadow never fabricates). Under-disclose Unknown,
|
|
1705
|
+
// never a concrete effect. (Found by real-world corpus testing; sibling of the node:vm fix.)
|
|
1706
|
+
if (ts.isIdentifier(callee) && callee.text === "require"
|
|
1707
|
+
&& node.arguments?.length === 1 && !ts.isStringLiteralLike(node.arguments[0])
|
|
1708
|
+
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
1709
|
+
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) {
|
|
1710
|
+
const owner = enclosing(node);
|
|
1711
|
+
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
|
|
1712
|
+
}
|
|
1661
1713
|
// Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
|
|
1662
1714
|
// getters (the object-spread twin). Enumerate the sources' local getters.
|
|
1663
1715
|
if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
|
|
@@ -1964,8 +2016,13 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
1964
2016
|
// half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
|
|
1965
2017
|
// rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
|
|
1966
2018
|
const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
|
|
1967
|
-
|
|
1968
|
-
|
|
2019
|
+
// --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
|
|
2020
|
+
if (wantJson) {
|
|
2021
|
+
console.log(JSON.stringify(envelope, null, 1));
|
|
2022
|
+
} else {
|
|
2023
|
+
writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
|
|
2024
|
+
writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
|
|
2025
|
+
}
|
|
1969
2026
|
// Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
|
|
1970
2027
|
// the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
|
|
1971
2028
|
// lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
|
|
@@ -1989,8 +2046,21 @@ for (const sf of sources) {
|
|
|
1989
2046
|
ts.forEachChild(node, walk);
|
|
1990
2047
|
})(sf);
|
|
1991
2048
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
2049
|
+
if (!wantJson) {
|
|
2050
|
+
writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
|
|
2051
|
+
console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
|
|
2052
|
+
}
|
|
2053
|
+
{
|
|
2054
|
+
// Effect breakdown — make the result visible at a glance, not just a count + a file path.
|
|
2055
|
+
const counts = {};
|
|
2056
|
+
for (const e of functions) for (const x of e.inferred) counts[x] = (counts[x] || 0) + 1;
|
|
2057
|
+
const breakdown = ["Net", "Fs", "Db", "Exec", "Ipc", "Env", "Clipboard", "Clock", "Log", "Rand"]
|
|
2058
|
+
.filter((k) => counts[k]).map((k) => `${k} ${counts[k]}`).join(" · ");
|
|
2059
|
+
const unknown = counts.Unknown || 0;
|
|
2060
|
+
if (breakdown || unknown) {
|
|
2061
|
+
console.error(` ${breakdown}${unknown ? `${breakdown ? " · " : ""}Unknown ${unknown} (disclosed)` : ""}`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
1994
2064
|
if (unlistedSeen.size > 0) {
|
|
1995
2065
|
const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
1996
2066
|
const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
|