candor-ts 0.14.0 → 0.15.0

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 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 `<version>` (spec 0.14)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.15)."*
16
16
  > On a 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
  >
package/README.md CHANGED
@@ -184,7 +184,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
184
184
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
185
185
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
186
186
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
187
- | `{ candor: { version, toolchain, spec: "0.14" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
187
+ | `{ candor: { version, toolchain, spec: "0.15" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
188
188
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
189
189
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
190
190
 
@@ -202,7 +202,7 @@ read the Rust source".
202
202
 
203
203
  ## Status
204
204
 
205
- 0.14.x, speaking candor-spec 0.14: the analysis core, the gate (`--policy` / `--gate-json` /
205
+ 0.15.x, speaking candor-spec 0.15: the analysis core, the gate (`--policy` / `--gate-json` /
206
206
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
207
207
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
208
208
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
package/mcp.mjs CHANGED
@@ -291,8 +291,12 @@ const TOOLS = {
291
291
  // ⟨spec 0.12 staged⟩ baseline callgraph → byFunction[].origin, same as the CLI (parity). The
292
292
  // loader's non-enumerable `partial` tag rides along: a corrupt baseline sidecar (edges dropped,
293
293
  // disclosed) downgrades origin to "unknown", never a fabricated "new" over a truncated graph.
294
+ // ⟨0.15 staged⟩ coverage disclosure — the SAME gainsCoverage the CLI verb spreads (the parity
295
+ // rule): optional `coverage` (current envelope's ledger) + `coverageDelta` (baseline names
296
+ // differ), both omitted when nothing applies — no other field of the tool result changes.
294
297
  return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
295
- ...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)) };
298
+ ...Q.gains(loadReportLoud(p), loadReportLoud(b), Q.loadCallgraph(b)),
299
+ ...Q.gainsCoverage(p, b) };
296
300
  },
297
301
  },
298
302
  candor_activity: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.14.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.14)",
3
+ "version": "0.15.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.15)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -124,6 +124,57 @@ function packagesLabel(pkgs) {
124
124
  return first.slice(0, n).join(".");
125
125
  }
126
126
 
127
+ /** ⟨0.15 staged⟩ the report's §2 `coverage` envelope field (COVERAGE-DESIGN.md §1) — the κ ledger of
128
+ * packages whose effects were INVISIBLE to the scan (absent, NOT a claim they're pure). Returns the
129
+ * normalized uncovered list [{name, calls}] (multi-report siblings merged, counts summed, sorted the
130
+ * producer's way: count desc, name asc), or null when absent/empty — the pre-0.15 report and the
131
+ * fully-covered report look identical here, and null keeps the consumer's output field OMITTED
132
+ * (never a fabricated `coverage: []` claim over a report that never carried the field). */
133
+ export function reportCoverage(prefix) {
134
+ const files = fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport);
135
+ const merged = new Map();
136
+ for (const f of files) {
137
+ try {
138
+ const unc = JSON.parse(fs.readFileSync(f, "utf8"))?.coverage?.uncovered;
139
+ if (!Array.isArray(unc)) continue; // absent/malformed field → contributes nothing (§2 forward-compat)
140
+ for (const e of unc) {
141
+ // Tolerate a foreign/hand-edited entry: a string `name` is required; a non-numeric `calls`
142
+ // counts as 0 (the entry still NAMES the blind spot — dropping it would under-disclose).
143
+ if (e && typeof e === "object" && typeof e.name === "string" && e.name) {
144
+ const n = typeof e.calls === "number" && Number.isFinite(e.calls) ? e.calls : 0;
145
+ merged.set(e.name, (merged.get(e.name) ?? 0) + n);
146
+ }
147
+ }
148
+ } catch { /* unreadable sibling — the reportVersion posture: keep looking */ }
149
+ }
150
+ if (merged.size === 0) return null;
151
+ return [...merged.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
152
+ .map(([name, calls]) => ({ name, calls }));
153
+ }
154
+
155
+ /** ⟨0.15 staged⟩ gains' coverage disclosure (COVERAGE-DESIGN.md §3) — the OPTIONAL blocks the gains
156
+ * JSON carries, computed from the two reports' envelopes. ONE code path for the CLI verb and the MCP
157
+ * `candor_gains` tool (the parity rule). Returns a spreadable object:
158
+ * · `coverage: {uncovered:[{name,calls}]}` — the CURRENT report's ledger, when non-empty (a gained
159
+ * effect in an uncovered dep is invisible, so "no gains" must not read as total);
160
+ * · `coverageDelta: {nowUncovered:[name], noLongerUncovered:[name]}` — whenever the two ledgers
161
+ * NAME different packages (a dep becoming uncovered between scans is itself a signal). The field
162
+ * names are the java reference engine's exactly (cross-engine wire parity). Keyed on names, not
163
+ * counts: a call-count wobble is ordinary code change, a new blind package is the alarm.
164
+ * Both omitted when nothing applies — a coverage-free comparison is byte-identical to ⟨0.14⟩. */
165
+ export function gainsCoverage(curPrefix, basePrefix) {
166
+ const cur = reportCoverage(curPrefix);
167
+ const base = reportCoverage(basePrefix);
168
+ const out = {};
169
+ if (cur) out.coverage = { uncovered: cur };
170
+ const curNames = new Set((cur ?? []).map((e) => e.name));
171
+ const baseNames = new Set((base ?? []).map((e) => e.name));
172
+ const nowUncovered = [...curNames].filter((n) => !baseNames.has(n)).sort();
173
+ const noLongerUncovered = [...baseNames].filter((n) => !curNames.has(n)).sort();
174
+ if (nowUncovered.length || noLongerUncovered.length) out.coverageDelta = { nowUncovered, noLongerUncovered };
175
+ return out;
176
+ }
177
+
127
178
  // The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
128
179
  // yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
129
180
  // doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
package/query.mjs CHANGED
@@ -38,7 +38,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
38
38
  containment as coreContainment, diff as coreDiff,
39
39
  where as coreWhere, map as coreMap, whatif as coreWhatif,
40
40
  fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
41
- matches as coreMatches,
41
+ matches as coreMatches, gainsCoverage,
42
42
  loadReport, loadCallgraph, reportVersion, reportPackage } from "./query-core.mjs";
43
43
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
44
44
 
@@ -94,7 +94,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
94
94
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
95
95
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
96
96
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
97
- const SPEC_VERSION = "0.14";
97
+ const SPEC_VERSION = "0.15";
98
98
 
99
99
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
100
100
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -571,7 +571,14 @@ switch (cmd) {
571
571
  // a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
572
572
  // with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
573
573
  // to "unknown" — the JSON itself discloses, never guessing "new" over a truncated graph.
574
- emit({ baseline_version: gbv ?? "", engine_version: gv ?? "", ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)) });
574
+ // ⟨0.15 staged⟩ coverage disclosure (COVERAGE-DESIGN.md §3): the CURRENT report's `coverage`
575
+ // envelope rides along (a gained effect in an uncovered dep is invisible — "no gains" must not
576
+ // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
577
+ // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
578
+ // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
579
+ emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
580
+ ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
581
+ ...gainsCoverage(curPrefix, basePrefix) });
575
582
  break;
576
583
  }
577
584
  case "path": {
package/scan.mjs CHANGED
@@ -41,7 +41,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
41
41
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
42
42
  // Reused, never re-littered.
43
43
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
44
- const SPEC_VERSION = "0.14";
44
+ const SPEC_VERSION = "0.15";
45
45
 
46
46
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
47
47
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -477,9 +477,122 @@ function programHeadLiteral(node) {
477
477
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
478
478
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
479
479
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
480
+ // CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
481
+ // `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
482
+ // soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
483
+ // `const` (or a `readonly` field) whose initializer is a plain string literal. A `let`/`var` (reassignable),
484
+ // a declaration with no string-literal initializer (runtime value, function result, env read, config field,
485
+ // concatenation, another template), or a symbol with MORE than the string-literal decls we can see → null,
486
+ // so the call stays bare/runtime as before. NEVER guess a value we cannot read off a `const` initializer.
487
+ function constStringValue(expr) {
488
+ if (!ts.isIdentifier(expr)) return null;
489
+ const sym = checker.getSymbolAtLocation(expr);
490
+ const decls = sym?.declarations ?? [];
491
+ if (decls.length === 0) return null;
492
+ let resolved = null;
493
+ for (const d of decls) {
494
+ // A `const x = "..."` variable declaration, or a `readonly x = "..."` class/property field. Both are
495
+ // VariableDeclaration/PropertyDeclaration nodes with an initializer; the immutability gate differs.
496
+ if (ts.isVariableDeclaration(d)) {
497
+ // the enclosing VariableDeclarationList must be `const` — a `let`/`var` can be reassigned later.
498
+ const list = d.parent;
499
+ const isConst = list && ts.isVariableDeclarationList(list)
500
+ && (list.flags & ts.NodeFlags.Const) !== 0;
501
+ if (!isConst || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
502
+ if (resolved != null && resolved !== d.initializer.text) return null; // conflicting decls — bail
503
+ resolved = d.initializer.text;
504
+ } else if (ts.isPropertyDeclaration(d)) {
505
+ const isReadonly = (ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly) !== 0;
506
+ if (!isReadonly || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
507
+ if (resolved != null && resolved !== d.initializer.text) return null;
508
+ resolved = d.initializer.text;
509
+ } else {
510
+ return null; // any other declaration shape (function, param, import alias, …) → do not resolve
511
+ }
512
+ }
513
+ return resolved;
514
+ }
515
+ // Resolve a URL ARGUMENT EXPRESSION to a statically-known URL/host string when its HOST is anchored by a
516
+ // `const NAME = "literal"` string (java constant-inlining parity). Three shapes, all requiring the host to
517
+ // live at the HEAD of the value:
518
+ // • a bare const identifier fetch(API_BASE) → API_BASE's value
519
+ // • a template whose HEAD is a const fetch(`${API_BASE}/chat`) → value + the literal template tail
520
+ // • a concat whose LEFT is a const fetch(API_BASE + "/chat") → value + the right literal
521
+ // The template tail / concat right are appended ONLY when they are themselves plain literals, so the
522
+ // returned string is a real static URL prefix `hostLiteral` can parse (`https://host/…`). A template with a
523
+ // literal host-bearing PREFIX before the interpolation (`\`https://${h}\``) has a non-empty template HEAD, so
524
+ // its head is NOT a const identifier → not resolved here (and the literal prefix alone never named a full
525
+ // host). Anything else (non-const identifier, interpolation of a runtime value, nested template) → null.
526
+ function resolveConstUrlString(expr) {
527
+ if (expr == null) return null;
528
+ // bare identifier: fetch(API_BASE)
529
+ const bare = constStringValue(expr);
530
+ if (bare != null) return bare;
531
+ // template literal `${HEAD_CONST}<tail literal>`: the const value must sit at the HEAD (empty template
532
+ // head text), and we may only append a SINGLE trailing literal span — a second `${…}` interpolation is a
533
+ // runtime value we will not resolve, but it only follows the host, so the host prefix is still sound.
534
+ if (ts.isTemplateExpression(expr)) {
535
+ if (expr.head.text !== "") return null; // literal prefix before the const → not const-anchored
536
+ const first = expr.templateSpans[0];
537
+ const head = first && constStringValue(first.expression);
538
+ if (head == null) return null; // first interpolation is not a const string
539
+ // append the literal text between the first interpolation and the next (or end) — the URL path segment.
540
+ return head + (first.literal.text ?? "");
541
+ }
542
+ // string concat `CONST + "…"`: left must be a const string; append the right ONLY if it is a plain literal.
543
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
544
+ const left = constStringValue(expr.left);
545
+ if (left == null) return null;
546
+ const right = ts.isStringLiteralLike(expr.right) ? expr.right.text : "";
547
+ return left + right;
548
+ }
549
+ return null;
550
+ }
551
+ // Does a literal URL-head string already contain a COMPLETE authority — i.e. is there a `/` AFTER the
552
+ // `://` still WITHIN the literal text? `https://api.openai.com/v1/` → yes (host fully present, only the
553
+ // PATH follows); `https://api.` / `https://` / `https://api.openai.com:` → no (the authority is not yet
554
+ // terminated, so an interpolation could still be part of the host/port). Requires a `scheme://` prefix;
555
+ // a bare relative path never qualifies.
556
+ function literalHeadCompletesAuthority(head) {
557
+ const m = head.match(/^[a-z][a-z0-9+.-]*:\/\//i);
558
+ if (!m) return false; // no scheme://… → authority not started in the literal
559
+ return head.indexOf("/", m[0].length) >= 0; // a `/` after the `://` terminates the authority
560
+ }
561
+ // LITERAL-HEAD HOST EXTRACTION (java literal-inlining parity): a template `\`https://host/${path}\`` or a
562
+ // concat `"https://host/" + path` whose FIRST STATIC segment (the text before the first interpolation /
563
+ // the concat's left literal) ALREADY contains a complete `scheme://authority/…` carries a statically-known
564
+ // host — the interpolation is only in the PATH. Return that literal head (a real URL prefix `hostLiteral`
565
+ // parses to the authority). If the head does NOT terminate the authority with a `/` (`https://${h}/x`,
566
+ // `https://api.${x}.com/y`, `https://host:${port}/y`, `https://api.openai${x}/v1`) the interpolation could
567
+ // be part of the host/port → return null (safe under-report: stays bare Net). Distinct from
568
+ // resolveConstUrlString, which anchors on a CONST identifier at the head; here the head is a plain LITERAL.
569
+ function literalHeadHostUrl(expr) {
570
+ if (expr == null) return null;
571
+ // template `\`<head>${…}…\``: the literal head is expr.head.text (empty when the interpolation leads).
572
+ if (ts.isTemplateExpression(expr)) {
573
+ const head = expr.head.text;
574
+ return literalHeadCompletesAuthority(head) ? head : null;
575
+ }
576
+ // concat `"<left literal>" + <anything>`: only the LEFT operand's literal text is the static head; the
577
+ // right is a runtime value living in the path. (A nested `"a" + "b" + x` left is a BinaryExpression, not
578
+ // a string literal, so it is not read here — a safe under-report, not a fabrication.)
579
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken
580
+ && ts.isStringLiteralLike(expr.left)) {
581
+ const head = expr.left.text;
582
+ return literalHeadCompletesAuthority(head) ? head : null;
583
+ }
584
+ return null;
585
+ }
480
586
  function urlArgLiteral(node, member) {
481
587
  const args = node.arguments ?? [];
482
- const litAt = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
588
+ const litAt = (i) => {
589
+ const a = args[i];
590
+ if (!a) return null;
591
+ if (ts.isStringLiteralLike(a)) return a.text;
592
+ // const-anchored host (fetch(API_BASE), `${API_BASE}/x`, API_BASE+"/x"), THEN literal-head extraction
593
+ // (`\`https://host/${p}\``, `"https://host/" + p`) when the literal head already completes the authority.
594
+ return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
595
+ };
483
596
  if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
484
597
  return litAt(0);
485
598
  }
@@ -1114,9 +1227,34 @@ function moduleUnit(sf) {
1114
1227
  }
1115
1228
  return qual;
1116
1229
  }
1230
+ // The synthesized static-initializer unit for a `class C { static { … } }` block (spec §2 unitKind
1231
+ // "initializer"). A static block runs at class-DEFINITION time, not instance construction — but its
1232
+ // body's effects otherwise walked up in `enclosing` to the ClassDeclaration, which maps to the
1233
+ // `C.constructor` unit, so a static-init effect was MISLABELED as the instance ctor (and carried no
1234
+ // unitKind). Mint it as its own unit, lazily, mirroring `moduleUnit`. (An anonymous class expression's
1235
+ // static block keys under `<anonymous>`; there is at most one static-init unit per class name.)
1236
+ function staticBlockUnit(node) {
1237
+ const cls = node.parent;
1238
+ const sf = node.getSourceFile();
1239
+ const mod = moduleOf(sf);
1240
+ const cname = (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && cls.name ? cls.name.text : "<anonymous>";
1241
+ const qual = `${mod}.${cname}.<static-init>`;
1242
+ let rec = fns.get(qual);
1243
+ if (!rec) {
1244
+ rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
1245
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
1246
+ entry: false, unitKind: "initializer",
1247
+ loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1` };
1248
+ fns.set(qual, rec);
1249
+ }
1250
+ return qual;
1251
+ }
1117
1252
  // nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
1118
1253
  function enclosing(node) {
1119
1254
  for (let p = node; p; p = p.parent) {
1255
+ // A `static { … }` block is its own initializer unit (class-definition time), NOT the instance ctor
1256
+ // the ClassDeclaration maps to — intercept before the nodeName lookup would fold it into .constructor.
1257
+ if (ts.isClassStaticBlockDeclaration(p)) return staticBlockUnit(p);
1120
1258
  // A call/effect lexically inside a DECORATOR (`@factory(arg)`) runs at class-DEFINITION time, NOT in
1121
1259
  // the decorated declaration's body. The parent chain of a decorator's expression is
1122
1260
  // CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
@@ -1339,6 +1477,83 @@ const HOF_INVOKERS = new Set([
1339
1477
  "then", "catch", "finally", "nextTick",
1340
1478
  ]);
1341
1479
 
1480
+ // ---- process.env recognition: the direct dot access (`process.env.KEY`) is the JVM System.getenv twin,
1481
+ // but the same environment READ is spelled several other ways that all read silent-pure without help:
1482
+ // bracket access (`process.env[k]`), a local const-alias (`const env = process.env; env.KEY`),
1483
+ // destructuring (`const {KEY} = process.env`), and the `in` operator (`"KEY" in process.env`). Each of
1484
+ // these on process.env (or a confirmed direct alias of it) is Env. SOUNDNESS: only process.env and a
1485
+ // DIRECT `x = process.env` / `const {env} = process` binding trigger — a bracket/alias/destructure/`in`
1486
+ // on any OTHER object stays pure (no fabrication), and a reassigned alias local is cleared.
1487
+ //
1488
+ // `process` here must be Node's process object, NOT a project-local `const process = {…}` shadow
1489
+ // (mirrors the process.hrtime/send guard). It qualifies when it is the ambient GLOBAL (no project
1490
+ // declaration) OR a default-import of the `node:process` builtin (`import process from 'node:process'`,
1491
+ // as chalk's supports-color does) — the two are the same object.
1492
+ const declImportsNodeProcess = (decl) => {
1493
+ // ImportClause default binding or a namespace/named import from 'node:process' | 'process'.
1494
+ let spec = null;
1495
+ if (ts.isImportClause(decl) && decl.parent && ts.isImportDeclaration(decl.parent)) spec = decl.parent.moduleSpecifier;
1496
+ else if (ts.isImportSpecifier(decl)) spec = decl.parent?.parent?.parent?.moduleSpecifier;
1497
+ else if (ts.isNamespaceImport(decl)) spec = decl.parent?.parent?.moduleSpecifier;
1498
+ const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
1499
+ return text === "node:process" || text === "process";
1500
+ };
1501
+ const identIsGlobalProcess = (id) => {
1502
+ if (!ts.isIdentifier(id) || id.text !== "process") return false;
1503
+ const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
1504
+ if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
1505
+ return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))); // else the ambient global
1506
+ };
1507
+ // `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
1508
+ const isProcessEnvExpr = (expr) =>
1509
+ expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
1510
+
1511
+ // The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
1512
+ // sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
1513
+ // to anything else removes it → the alias is cleared, per the spec's reassignment rule).
1514
+ const envAliasSymbols = new Set();
1515
+ {
1516
+ const aliasCandidates = new Set(); // symbol -> declared `= process.env`
1517
+ const disqualified = new Set(); // symbol assigned to something that is NOT process.env
1518
+ const noteBinding = (symbol, init) => {
1519
+ if (!symbol) return;
1520
+ if (init && isProcessEnvExpr(init)) aliasCandidates.add(symbol);
1521
+ else disqualified.add(symbol); // bound/assigned to a non-process.env value → not (or no longer) an alias
1522
+ };
1523
+ const collectAliases = (node) => {
1524
+ // `const env = process.env` / `let`/`var` — a name-identifier binding with an initializer.
1525
+ if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
1526
+ noteBinding(checker.getSymbolAtLocation(node.name), node.initializer ?? null);
1527
+ }
1528
+ // `const { env } = process` — destructuring `env` off the global `process` makes `env` an alias too.
1529
+ else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
1530
+ && node.initializer && ts.isIdentifier(node.initializer) && identIsGlobalProcess(node.initializer)) {
1531
+ for (const el of node.name.elements) {
1532
+ // the property picked off `process` must be `env` (`{env}` or `{env: local}`); the bound name is the alias.
1533
+ const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
1534
+ : (ts.isIdentifier(el.name) ? el.name.text : null);
1535
+ if (propName === "env" && ts.isIdentifier(el.name)) aliasCandidates.add(checker.getSymbolAtLocation(el.name));
1536
+ }
1537
+ }
1538
+ // `env = <expr>` reassignment — a `let`/`var` alias reassigned to a non-process.env value is cleared.
1539
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
1540
+ && ts.isIdentifier(node.left)) {
1541
+ noteBinding(checker.getSymbolAtLocation(node.left), node.right);
1542
+ }
1543
+ ts.forEachChild(node, collectAliases);
1544
+ };
1545
+ for (const sf of sources) collectAliases(sf);
1546
+ for (const s of aliasCandidates) if (s && !disqualified.has(s)) envAliasSymbols.add(s);
1547
+ }
1548
+ // True when `id` is an identifier resolving to a confirmed process.env alias local.
1549
+ const identIsEnvAlias = (id) => {
1550
+ if (!id || !ts.isIdentifier(id)) return false;
1551
+ const sym = checker.getSymbolAtLocation(id);
1552
+ return !!sym && envAliasSymbols.has(sym);
1553
+ };
1554
+ // The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
1555
+ const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
1556
+
1342
1557
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
1343
1558
  function visitCalls(node) {
1344
1559
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -1886,10 +2101,25 @@ function visitCalls(node) {
1886
2101
  }
1887
2102
  }
1888
2103
  }
1889
- // process.env.X — a property READ, not a call (the JVM's System.getenv twin) → Env
1890
- if (ts.isPropertyAccessExpression(node) && node.expression.getText() === "process.env") {
1891
- const owner = enclosing(node);
1892
- if (owner) fns.get(owner).direct.add("Env");
2104
+ // Reading process.env — the JVM System.getenv twin → Env. All the common idioms count, not just the
2105
+ // direct `process.env.KEY` dot access (see the process.env-recognition note above): dot/bracket access
2106
+ // on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
2107
+ {
2108
+ const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
2109
+ // `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
2110
+ if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
2111
+ markEnv();
2112
+ }
2113
+ // `const {KEY} = process.env` / `const {KEY} = env` — the object-binding pattern's initializer reads env.
2114
+ else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
2115
+ && node.initializer && readsProcessEnv(node.initializer)) {
2116
+ markEnv();
2117
+ }
2118
+ // `"KEY" in process.env` / `"KEY" in env` — the `in` operator's right operand reads env.
2119
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
2120
+ && readsProcessEnv(node.right)) {
2121
+ markEnv();
2122
+ }
1893
2123
  }
1894
2124
  // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
1895
2125
  // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
@@ -2272,6 +2502,19 @@ for (const [name, rec] of fns) {
2272
2502
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
2273
2503
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
2274
2504
  package: pkgName, functions };
2505
+ // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
2506
+ // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
2507
+ // the --gate-json advisory, so the three can never tell different stories.
2508
+ const uncoveredLedger = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2509
+ // ⟨0.15 staged⟩ `coverage` envelope field — the stderr disclosure travels WITH the artifact, so a
2510
+ // report-consuming verb can no longer read a partially-covered report as total. Same names/counts as
2511
+ // the stderr line. OMITTED entirely when nothing is uncovered (the `extensions`-field precedent): a
2512
+ // fully-covered report stays byte-identical to a ⟨0.14⟩ one, so the rung is wire-compatible. The
2513
+ // per-function posture is UNCHANGED: a resolvable-but-uncovered call keeps `invisible`, an
2514
+ // unresolvable one keeps the stronger `Unknown` (COVERAGE-DESIGN.md §2 blesses both).
2515
+ if (uncoveredLedger.length) {
2516
+ envelope.coverage = { uncovered: uncoveredLedger.map(([name, calls]) => ({ name, calls })) };
2517
+ }
2275
2518
  const cg = {};
2276
2519
  for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
2277
2520
  // Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
@@ -2325,7 +2568,7 @@ if (!wantJson) {
2325
2568
  }
2326
2569
  }
2327
2570
  if (unlistedSeen.size > 0) {
2328
- const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2571
+ const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
2329
2572
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
2330
2573
  const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
2331
2574
  console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
@@ -2456,7 +2699,17 @@ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2456
2699
  // the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
2457
2700
  // ok:true,[] when no gate is configured. Must precede the exit(1) below.
2458
2701
  if (gateJsonPath) {
2459
- const verdict = JSON.stringify({ spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations }, null, 1);
2702
+ const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations };
2703
+ // ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
2704
+ // verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
2705
+ // auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
2706
+ // gate does not fail on uncovered deps (nearly every real scan has some); the policy author sees
2707
+ // the note and decides — `deny Unknown` remains the opt-in strict posture. OMITTED when fully
2708
+ // covered, so a pre-0.15 consumer's verdict is byte-identical.
2709
+ if (uncoveredLedger.length) {
2710
+ verdictObj.coverage = { uncovered: uncoveredLedger.length, packages: uncoveredLedger.map(([p]) => p) };
2711
+ }
2712
+ const verdict = JSON.stringify(verdictObj, null, 1);
2460
2713
  if (gateJsonPath === "-") console.log(verdict);
2461
2714
  else {
2462
2715
  // The verdict is a SURFACING side-output: an unwritable path must be one stderr line, never a raw