candor-ts 0.5.22 → 0.5.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.22",
3
+ "version": "0.5.25",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/query-core.mjs CHANGED
@@ -205,6 +205,32 @@ export function impact(fns, cg, q) {
205
205
  return { fn: tgt ?? q, affectedCount: affected.length, affected, entryPoints };
206
206
  }
207
207
 
208
+ // blindspots (SPEC §3.1 ⟨0.6⟩): the Unknown SOURCES — fns whose OWN body has an unresolvable call (so
209
+ // they carry `unknownWhy`), each ranked by its Unknown blast radius (the transitive callers that inherit
210
+ // Unknown through it). The actionable inverse of a widely-propagated Unknown: a report can read mostly
211
+ // Unknown from a handful of root causes — this names them, ranked, to declare/resolve/accept. Matches
212
+ // candor-java/candor-query: { sources:[{fn,why,reaches,affected}], totalUnknown }.
213
+ export function blindspots(fns, cg) {
214
+ const rev = reverseGraph(cg);
215
+ const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
216
+ const sources = [];
217
+ for (const e of fns) {
218
+ const why = e.unknownWhy ?? [];
219
+ if (why.length === 0) continue; // a SOURCE carries its own unknownWhy; a purely-transitive Unknown does not
220
+ const reached = new Set();
221
+ const queue = [e.fn];
222
+ const seen = new Set([e.fn]);
223
+ while (queue.length) {
224
+ const n = queue.pop();
225
+ for (const c of rev.get(n) ?? []) if (!seen.has(c)) { seen.add(c); reached.add(c); queue.push(c); }
226
+ }
227
+ const affected = [...reached].sort();
228
+ sources.push({ fn: e.fn, why, reaches: affected.length, affected });
229
+ }
230
+ sources.sort((a, b) => b.reaches - a.reaches || a.fn.localeCompare(b.fn)); // most-smearing first, stable
231
+ return { sources, totalUnknown };
232
+ }
233
+
208
234
  // path: the FORWARD provenance — a shortest BFS over the calls graph from `fn` to the nearest unit
209
235
  // that performs `eff` DIRECTLY (the source). Matches candor-query's {effect, fn, path:[{fn,loc,source}]}.
210
236
  export function path(fns, cg, fnQ, eff) {
package/query.mjs CHANGED
@@ -25,7 +25,7 @@ import { printAgents } from "./contract.mjs";
25
25
  // a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
26
26
  // JVM `Type#method` report). Importing the shared functions removes all three divergences (review find).
27
27
  import { impact as coreImpact, path as corePath, gains as coreGains,
28
- show as coreShow,
28
+ show as coreShow, blindspots as coreBlindspots,
29
29
  loadReport, loadCallgraph, matches } from "./query-core.mjs";
30
30
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
31
31
 
@@ -127,6 +127,13 @@ switch (cmd) {
127
127
  emit(coreImpact(loadReport(prefix), loadCallgraph(prefix), q));
128
128
  break;
129
129
  }
130
+ case "blindspots": {
131
+ // the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
132
+ // Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
133
+ const [prefix] = args;
134
+ emit(coreBlindspots(loadReport(prefix), loadCallgraph(prefix)));
135
+ break;
136
+ }
130
137
  case "gains": {
131
138
  // the supply-chain alarm (SPEC §5.1): {gained:[Effect], byFunction:[{fn,effect}]} — what the
132
139
  // surface gained between two reports (base → cur), the cross-engine machine-readable form.
package/scan-core.mjs CHANGED
@@ -66,11 +66,60 @@ export const KAPPA_RULES = [
66
66
  [/^(node:)?sqlite$/, null, "Db"],
67
67
  // the curated npm tier
68
68
  [/^(axios|got|node-fetch|undici|ws|socket\.io(-client)?|nodemailer)$/, null, "Net"],
69
+ // gaxios is the axios-like HTTP client under googleapis (request/get/post/put/patch/delete/head do
70
+ // the network; it has no notable pure surface, but be VERB-precise like the rest of the Net tier so a
71
+ // future config accessor can't fabricate). `createAPIRequest` is googleapis-common's transport entry
72
+ // (every googleapis service method funnels through it → the real network). The deeper `googleapis`
73
+ // service chains (`calendar.events.insert()`) resolve their verb into the `googleapis` package, but
74
+ // those verbs are GENERIC (insert/list/get/update) and shared with pure builders — modeling them by
75
+ // name would fabricate; the actual network is the gaxios/createAPIRequest transport, modeled here, so
76
+ // a googleapis call that reaches the wire does so through a modeled unit when its source is scanned.
77
+ [/^gaxios$/, /^(request|get|post|put|patch|delete|head)$/, "Net"],
78
+ [/^googleapis-common$/, /^createAPIRequest$/, "Net"],
79
+ // google-auth-library mints/refreshes OAuth tokens and verifies ID tokens over the network. The
80
+ // verb surface only (the GoogleAuth/OAuth2Client/JWT constructors are config — inert until a verb).
81
+ [/^google-auth-library$/,
82
+ /^(request|getClient|getAccessToken|getRequestHeaders|authorize|refreshAccessToken|refreshToken|getTokenInfo|verifyIdToken|fetchIdToken|getCredentials|getProjectId|getSignedJwt)$/,
83
+ "Net"],
84
+ // stripe: methods land on a `new Stripe()` instance's resource chains
85
+ // (`stripe.customers.create()`, `stripe.checkout.sessions.create()`, `charges.*`, `paymentIntents.*`).
86
+ // A chained member call resolves its verb's DECLARATION into the `stripe` package (declModule keys on
87
+ // the source file, not the chain depth — verified), so keying on stripe's resource VERBS catches the
88
+ // deep chains. VERB-precise: the I/O verbs only (the SDK's resources share these); pure helpers
89
+ // (toString/JSON) and inert `new Stripe()` construction stay pure.
90
+ [/^stripe$/,
91
+ /^(create|retrieve|update|list|listLineItems|listPaymentMethods|del|delete|cancel|capture|confirm|expire|finalizeInvoice|pay|sendInvoice|markUncollectible|voidInvoice|refund|reverse|verify|search|approve|decline|attach|detach|deactivate)$/,
92
+ "Net"],
93
+ // error/telemetry SaaS — the capture/flush verbs ship the payload over the network. init/config are
94
+ // inert. @sentry/* re-exports captureException etc. from @sentry/core/@sentry/browser, so a consumer's
95
+ // import may resolve into any @sentry sub-package — match the whole scope, verb-precise.
96
+ [/^@sentry\/[^/]+$/,
97
+ /^(captureException|captureMessage|captureEvent|captureCheckIn|flush|close)$/, "Net"],
98
+ // posthog-node: capture/identify/group enqueue then flush over HTTP; flush/shutdown/captureImmediate
99
+ // and the feature-flag fetches (isFeatureEnabled/getFeatureFlag*) hit the API. Verb-precise; the
100
+ // `new PostHog()` ctor is inert (config).
101
+ [/^posthog-node$/,
102
+ /^(capture|captureImmediate|identify|identifyImmediate|alias|groupIdentify|flush|shutdown|isFeatureEnabled|getFeatureFlag|getFeatureFlagPayload|getAllFlags|getAllFlagsAndPayloads|getRemoteConfigPayload|reloadFeatureFlags)$/,
103
+ "Net"],
69
104
  [/^(pg|mysql2?|mongodb|ioredis|redis|sqlite3|better-sqlite3|knex)$/, null, "Db"],
105
+ // bull/bullmq are Redis-backed job queues — the queue/worker/job ops issue Redis commands (Db). Their
106
+ // surface is almost entirely I/O, but be VERB-precise (the I/O ops) so inert event-wiring
107
+ // (`queue.on(...)`) and `new Queue()`/`new Worker()` construction (which only opens a lazy connection)
108
+ // don't fabricate. The connection IS Redis — Db, consistent with the ioredis/redis classification.
109
+ [/^(bull|bullmq)$/,
110
+ /^(add|addBulk|getJob|getJobs|getJobCounts|getJobCountByTypes|getWaiting|getActive|getCompleted|getFailed|getDelayed|getWaitingChildren|getRepeatableJobs|removeRepeatable|removeRepeatableByKey|getMetrics|count|pause|resume|isPaused|drain|clean|obliterate|empty|close|remove|retry|retryJobs|promote|moveToCompleted|moveToFailed|updateData|updateProgress|process|waitUntilReady|getState|getDependencies|getChildrenValues)$/,
111
+ "Db"],
70
112
  [/^(execa|cross-spawn|shelljs)$/, null, "Exec"],
113
+ // the `open` package spawns the OS handler (xdg-open/open/start) — Exec. Default export `open(target)`
114
+ // resolves to member `open` (its declared fn name — verified); `openApp` likewise. The `apps` const is
115
+ // pure (a property read, never a call).
116
+ [/^open$/, /^(open|openApp)$/, "Exec"],
71
117
  [/^(fs-extra|graceful-fs|rimraf|glob|chokidar)$/, null, "Fs"],
72
118
  [/^dotenv$/, null, "Env"],
73
119
  [/^(winston|pino|bunyan|npmlog)$/, null, "Log"],
120
+ // nest-winston wraps winston; the injected logger's level verbs are the Log boundary (the
121
+ // WinstonModule.createLogger/forRoot config is inert).
122
+ [/^nest-winston$/, /^(log|info|warn|error|debug|verbose|silly|http)$/, "Log"],
74
123
  // entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
75
124
  // the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
76
125
  // landing on exactly the call a security review cares about).
@@ -78,6 +127,14 @@ export const KAPPA_RULES = [
78
127
  // were silently pure inside the covered `crypto` module (the κ-coverage floor can't tell an unmodeled
79
128
  // entropy draw from a pure unmodeled member; the fix is to MODEL the member, not drop coverage).
80
129
  [/^(node:)?crypto$/, /^(random|getRandomValues|generateKey|generatePrime)/, "Rand"],
130
+ // uuid: the random-based generators draw from the CSPRNG (v4) / clock+MAC+random (v1) / random (v6/v7).
131
+ // v3 (MD5) and v5 (SHA-1) are DETERMINISTIC namespace hashes — same input, same UUID — so they are
132
+ // PURE and excluded. parse/stringify/validate/version/NIL/MAX are pure too (not matched).
133
+ [/^uuid$/, /^(v1|v4|v6|v7)$/, "Rand"],
134
+ // nanoid: nanoid()/customRandom() draw from crypto.getRandomValues; customAlphabet() returns a
135
+ // generator that does the same. `nanoid/non-secure` uses Math.random — still Rand. The `urlAlphabet`
136
+ // const is pure (a property read). Sound over-approximation: the factory call is the resolvable site.
137
+ [/^nanoid(\/non-secure)?$/, /^(nanoid|customAlphabet|customRandom)$/, "Rand"],
81
138
  // node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
82
139
  // environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
83
140
  // (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
package/scan.mjs CHANGED
@@ -36,7 +36,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
36
36
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
37
37
  // Reused, never re-littered.
38
38
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
39
- const SPEC_VERSION = "0.5";
39
+ const SPEC_VERSION = "0.6";
40
40
 
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
@@ -1453,9 +1453,25 @@ function visitCalls(node) {
1453
1453
  : (decl.name ? decl.name.getText() : ""))
1454
1454
  : "";
1455
1455
  const isConstruction = ts.isConstructorDeclaration(decl) || ts.isNewExpression(node);
1456
+ // The κ member token. A named decl (function/method declaration) carries its own name; but a
1457
+ // VALUE-BINDING export — `export const v4 = (...) => ...` (the shape REAL uuid v9+/nanoid ship,
1458
+ // and the `type v4 = v4Buffer & v4String` callable type-alias of @types/uuid v8) resolves to an
1459
+ // ANONYMOUS arrow/function-type whose `decl.name` is empty, so κ saw `""` and the package's
1460
+ // entropy/net verb read silent-pure (verified against installed uuid/nanoid). Fall back to the
1461
+ // BINDING name: an arrow/fn-expr's parent VariableDeclaration / PropertyAssignment / property,
1462
+ // or a callable type-alias's TypeAliasDeclaration. Precision no-op where the old path already
1463
+ // had a name (this only fills a former `""`); never synthesizes a name for `new`.
1464
+ const bindingName = (d) => {
1465
+ const p = d.parent;
1466
+ if (!p) return "";
1467
+ if ((ts.isVariableDeclaration(p) || ts.isPropertyDeclaration(p) || ts.isPropertyAssignment(p)
1468
+ || ts.isPropertySignature(p) || ts.isBindingElement(p) || ts.isTypeAliasDeclaration(p))
1469
+ && p.name && ts.isIdentifier(p.name)) return p.name.getText();
1470
+ return "";
1471
+ };
1456
1472
  const member = isConstruction
1457
1473
  ? (CONNECTING_CTORS.has(ctorClassName) ? ctorClassName : "new")
1458
- : (decl.name ? decl.name.getText() : "");
1474
+ : (decl.name ? decl.name.getText() : bindingName(decl));
1459
1475
  let eff = kappa(mod, member); // (CLASSIFY)
1460
1476
  // process.stdout/stderr/stdin are typed `tty.WriteStream`, which EXTENDS `net.Socket`, so a
1461
1477
  // `.write()`/`.end()` on them resolves to `net.Socket.write` and the whole-module Net rule
@@ -1919,7 +1935,11 @@ for (const [name, rec] of fns) {
1919
1935
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
1920
1936
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
1921
1937
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
1922
- if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
1938
+ // ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
1939
+ // so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
1940
+ // (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
1941
+ // never left un-tagged — the source/transitive split the `blindspots` query needs (SPEC §3.1/§4).
1942
+ if (rec.direct.has("Unknown")) entry.unknownWhy = rec.why.size ? [...rec.why].sort() : ["unresolved"];
1923
1943
  // HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
1924
1944
  // them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
1925
1945
  if (rec.blind.size) entry.invisible = [...rec.blind].sort();