candor-ts 0.14.1 → 0.16.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.16)."*
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.16" }, 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.16.x, speaking candor-spec 0.16: 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.1",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.14)",
3
+ "version": "0.16.0",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.16)",
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.16";
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]
@@ -307,7 +307,7 @@ const SUBCOMMANDS = [
307
307
  const usage = () => {
308
308
  const w = Math.max(...SUBCOMMANDS.map(([n, a]) => `${n} ${a}`.trimEnd().length));
309
309
  const lines = SUBCOMMANDS.map(([n, a, d]) => ` ${`${n} ${a}`.trimEnd().padEnd(w)} ${d}`);
310
- lines.push(` ${"-V, --version".padEnd(w)} print the build and spec version (offline)`);
310
+ lines.push(` ${"-V, --version".padEnd(w)} print the installed version + upgrade line (offline)`);
311
311
  lines.push(` ${"-h, --help".padEnd(w)} show this help`);
312
312
  return `USAGE: candor-ts-query <command> [args]\n\n${lines.join("\n")}`;
313
313
  };
@@ -321,12 +321,54 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
321
321
  }
322
322
 
323
323
  // -h / --help: a print-and-exit MODE, handled before the switch (so `-h`'s single dash is never
324
- // mistaken for a command). Banner + USAGE + the full described subcommand list + the github footer.
324
+ // mistaken for a command). House-style page: identity + model paragraph + COMMON/ALL ACTIONS
325
+ // (the action names derived from SUBCOMMANDS, so the list can never go stale) + OPTIONS + footer.
326
+ // The exit-2 error path keeps the denser fully-described usage() above.
325
327
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
326
- console.log(`candor-ts-query ${PKG_VERSION} — read-only queries over a candor report (candor-spec ${SPEC_VERSION})
328
+ const names = SUBCOMMANDS.map(([n]) => n);
329
+ const allActions = [names.slice(0, 9), names.slice(9)].map((row) => ` ${row.join(" ")}`).join("\n");
330
+ console.log(`candor-ts-query — read-only queries over a candor report.
327
331
 
328
- ${usage()}
332
+ Answers come from the report candor-ts wrote — discovered by walking up from the
333
+ cwd to a .candor/ dir (CANDOR_REPORT overrides; --report pins a locator). No
334
+ re-scan, no network. Every engine speaks the same grammar, so these actions and
335
+ flags match the rest of the family.
329
336
 
337
+ USAGE
338
+ candor-ts-query <action> [args] [options]
339
+
340
+ COMMON ACTIONS
341
+ where <Effect> the functions that perform an effect
342
+ path <fn> <Effect> the call path by which a function reaches an effect
343
+ callers <fn> who calls a function, direct and transitive
344
+ tour [N] the N most surprising transitive reaches (default 10)
345
+ blindspots the Unknown sources worth resolving, ranked by reach
346
+ gains <current> <base> what a new version newly reaches (the supply-chain diff)
347
+ fix <fn> <Effect> the boundary hoist that would clear a violation
348
+
349
+ ALL ACTIONS
350
+ ${allActions}
351
+
352
+ OPTIONS (uniform across every engine)
353
+ --report <locator> use this report instead of discovering .candor/
354
+ --policy <file> evaluate a policy — exit 1 on a violation (whatif, fix, fix-gate,
355
+ unverified; CANDOR_POLICY / a .candor/config \`policy\` key when absent)
356
+ --json machine-readable output
357
+ --include-unknown callers: also list the unresolved-dispatch frontier
358
+ --strict unverified: exit 1 on an unverified hole (advisory otherwise)
359
+ -V, --version print the installed version + upgrade line (offline)
360
+ -h, --help show this help
361
+
362
+ diff and gains take two positional report locators: <current> <baseline>. Run
363
+ candor-ts-query with no action for the full per-action argument list.
364
+
365
+ EXAMPLES
366
+ candor-ts-query where Db
367
+ candor-ts-query path app.orders.render Net
368
+ candor-ts-query gains new/.candor/report.json old/.candor/report.json
369
+ candor-ts-query fix-gate --policy candor.policy
370
+
371
+ Docs: candor.poly.io · Verify an install: candor doctor
330
372
  See https://github.com/tombaldwin/candor`);
331
373
  process.exit(0);
332
374
  }
@@ -354,6 +396,9 @@ switch (cmd) {
354
396
  // written; the paths silently vanished) and dropped Exec `cmds` entirely. Call the shared show so
355
397
  // the CLI and the MCP `candor_show` are one implementation that cannot diverge again.
356
398
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
399
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
400
+ // `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
401
+ if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
357
402
  emit(coreShow(loadReportOrDie(prefix), q));
358
403
  break;
359
404
  }
@@ -362,6 +407,9 @@ switch (cmd) {
362
407
  // Hand-copies of core functions in this file have drifted three times (show, callers, diff); the
363
408
  // fix each time was the same: delegate, keep query.mjs as arg-parsing + emit + exit codes only.
364
409
  const { prefix, args: [eff] } = resolveReportVerb(args, 1);
410
+ // A missing/empty <Effect> is a LOUD usage error (exit 2, like candor-java's missing-arg path) —
411
+ // never an authoritative-empty {directly:[],inherited:[]} at exit 0 (a false all-clear shape).
412
+ if (!eff) { console.error("usage: candor-ts-query where <Effect> [--report <locator>] [--json]"); process.exit(2); }
365
413
  emit(coreWhere(loadReportOrDie(prefix), eff));
366
414
  break;
367
415
  }
@@ -370,6 +418,9 @@ switch (cmd) {
370
418
  // it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
371
419
  // shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
372
420
  const { prefix, args: [q], includeUnknown } = resolveReportVerb(args, 1, { includeUnknown: true });
421
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
422
+ // {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
423
+ if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
373
424
  const cg = loadCallgraph(prefix);
374
425
  if (includeUnknown) emit(callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q));
375
426
  else emit(coreCallers(cg, q));
@@ -465,6 +516,9 @@ switch (cmd) {
465
516
  // blast radius (backward dual of reachable) — reuses the shared query-core, the same logic the
466
517
  // MCP server serves. SPEC §3.1: {fn, affectedCount, affected, entryPoints:[{fn,inferred}]}.
467
518
  const { prefix, args: [q] } = resolveReportVerb(args, 1);
519
+ // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
520
+ // affectedCount:0 blast radius at exit 0 for a function that was never named.
521
+ if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
468
522
  emit(coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q));
469
523
  break;
470
524
  }
@@ -571,7 +625,14 @@ switch (cmd) {
571
625
  // a MISSING sidecar loads {} and a corrupt (matched-but-unparseable) one is tagged `partial`
572
626
  // with its edges dropped-and-disclosed: either way "new" is unavailable and origin falls back
573
627
  // 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)) });
628
+ // ⟨0.15 staged⟩ coverage disclosure (COVERAGE-DESIGN.md §3): the CURRENT report's `coverage`
629
+ // envelope rides along (a gained effect in an uncovered dep is invisible — "no gains" must not
630
+ // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
631
+ // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
632
+ // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
633
+ emit({ baseline_version: gbv ?? "", engine_version: gv ?? "",
634
+ ...coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix)),
635
+ ...gainsCoverage(curPrefix, basePrefix) });
575
636
  break;
576
637
  }
577
638
  case "path": {
@@ -580,6 +641,10 @@ switch (cmd) {
580
641
  // pinned JSON shape. parseCanonical otherwise swallows --json, so detect it explicitly (as `tour` does).
581
642
  const wantJson = args.includes("--json");
582
643
  const { prefix, args: [fn, eff] } = resolveReportVerb(args, 2);
644
+ // BOTH positionals are required (`path <fn> <Effect>`) — a missing/empty one is a LOUD usage error
645
+ // (exit 2, like candor-java). Before this gate, one arg slid through as `<fn> undefined` and printed
646
+ // "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
647
+ if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
583
648
  const fns = loadReportOrDie(prefix);
584
649
  const cg = loadCallgraph(prefix);
585
650
  if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
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.16";
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
@@ -55,25 +55,45 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) {
55
55
  // -h / --help: a print-and-exit MODE (like --version), handled before the arg walk so `-h` (a single
56
56
  // dash) is never mistaken for the scan target by the positional fallthrough below.
57
57
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
58
- console.log(`candor-ts ${PKG_VERSION} — TypeScript/JavaScript effect scanner (candor-spec ${SPEC_VERSION})
58
+ console.log(`candor-ts — the TypeScript/JavaScript effect analyzer.
59
59
 
60
- USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--agents] [--version]
60
+ Reads TS/JS source through the TypeScript compiler API — no build needed. Calls
61
+ are resolved through the checker; a call that cannot be resolved reads Unknown,
62
+ never silently pure. The report lands in .candor/, where candor-ts-query and the
63
+ umbrella \`candor\` CLI discover it.
61
64
 
62
- <target> a dir, a .ts file, or a tsconfig.json to scan
63
- --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
64
- --json print the report as JSON to stdout (instead of writing files)
65
- --policy <file> enforce a policy file (deny/pure/allow/forbid, candor-spec §6.2) — exit 1 on a
66
- violation, 2 if unreadable; honours $CANDOR_POLICY when the flag is absent
67
- --gate-json <f> write the structured gate verdict { spec, ok, violations } as JSON (candor-spec §3.3)
68
- --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
69
- --agents print the agent contract for this build (AGENTS.md)
70
- -V, --version print the build and spec version (offline)
71
- -h, --help show this help
65
+ USAGE
66
+ candor-ts <dir | file.ts | tsconfig.json> [flags]
72
67
 
73
- CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005 regression
74
- guard against a saved same-build report: exit 1 when an existing function gained an effect, exit 2
75
- on an unparseable or different-build baseline (never evaluated), a stderr note when absent.
68
+ The target is a project directory, a single .ts file, or a tsconfig.json.
76
69
 
70
+ OPTIONS
71
+ --out <prefix> write the report to <prefix>.json + <prefix>.callgraph.json
72
+ --json print the report as JSON to stdout (instead of writing files)
73
+ --policy <file> enforce a policy file (deny/pure/allow/forbid) — exit 1 on a
74
+ violation, 2 if unreadable
75
+ --gate-json <file> write the structured gate verdict { spec, ok, violations } as JSON
76
+ --allow-js also scan plain JS/Node (.js/.mjs/.cjs), not just TypeScript
77
+ --agents print the agent contract for this build (AGENTS.md)
78
+ -V, --version print the installed version + upgrade line (offline)
79
+ -h, --help show this help
80
+
81
+ ENVIRONMENT / CONFIG
82
+ CANDOR_POLICY=<file> the policy when --policy is absent (a .candor/config
83
+ \`policy\` key works too)
84
+ CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005
85
+ regression guard against a saved same-build report: exit 1
86
+ when an existing function gained an effect, exit 2 on an
87
+ unparseable or different-build baseline (never evaluated),
88
+ a stderr note when absent
89
+
90
+ EXAMPLES
91
+ candor-ts .
92
+ candor-ts src --allow-js
93
+ candor-ts . --policy candor.policy --gate-json gate.json
94
+ candor-ts-query where Db query the report this scan wrote
95
+
96
+ Docs: candor.poly.io · Verify an install: candor doctor
77
97
  See https://github.com/tombaldwin/candor`);
78
98
  process.exit(0);
79
99
  }
@@ -477,9 +497,122 @@ function programHeadLiteral(node) {
477
497
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
478
498
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
479
499
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
500
+ // CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
501
+ // `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
502
+ // soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
503
+ // `const` (or a `readonly` field) whose initializer is a plain string literal. A `let`/`var` (reassignable),
504
+ // a declaration with no string-literal initializer (runtime value, function result, env read, config field,
505
+ // concatenation, another template), or a symbol with MORE than the string-literal decls we can see → null,
506
+ // so the call stays bare/runtime as before. NEVER guess a value we cannot read off a `const` initializer.
507
+ function constStringValue(expr) {
508
+ if (!ts.isIdentifier(expr)) return null;
509
+ const sym = checker.getSymbolAtLocation(expr);
510
+ const decls = sym?.declarations ?? [];
511
+ if (decls.length === 0) return null;
512
+ let resolved = null;
513
+ for (const d of decls) {
514
+ // A `const x = "..."` variable declaration, or a `readonly x = "..."` class/property field. Both are
515
+ // VariableDeclaration/PropertyDeclaration nodes with an initializer; the immutability gate differs.
516
+ if (ts.isVariableDeclaration(d)) {
517
+ // the enclosing VariableDeclarationList must be `const` — a `let`/`var` can be reassigned later.
518
+ const list = d.parent;
519
+ const isConst = list && ts.isVariableDeclarationList(list)
520
+ && (list.flags & ts.NodeFlags.Const) !== 0;
521
+ if (!isConst || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
522
+ if (resolved != null && resolved !== d.initializer.text) return null; // conflicting decls — bail
523
+ resolved = d.initializer.text;
524
+ } else if (ts.isPropertyDeclaration(d)) {
525
+ const isReadonly = (ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly) !== 0;
526
+ if (!isReadonly || !d.initializer || !ts.isStringLiteral(d.initializer)) return null;
527
+ if (resolved != null && resolved !== d.initializer.text) return null;
528
+ resolved = d.initializer.text;
529
+ } else {
530
+ return null; // any other declaration shape (function, param, import alias, …) → do not resolve
531
+ }
532
+ }
533
+ return resolved;
534
+ }
535
+ // Resolve a URL ARGUMENT EXPRESSION to a statically-known URL/host string when its HOST is anchored by a
536
+ // `const NAME = "literal"` string (java constant-inlining parity). Three shapes, all requiring the host to
537
+ // live at the HEAD of the value:
538
+ // • a bare const identifier fetch(API_BASE) → API_BASE's value
539
+ // • a template whose HEAD is a const fetch(`${API_BASE}/chat`) → value + the literal template tail
540
+ // • a concat whose LEFT is a const fetch(API_BASE + "/chat") → value + the right literal
541
+ // The template tail / concat right are appended ONLY when they are themselves plain literals, so the
542
+ // returned string is a real static URL prefix `hostLiteral` can parse (`https://host/…`). A template with a
543
+ // literal host-bearing PREFIX before the interpolation (`\`https://${h}\``) has a non-empty template HEAD, so
544
+ // its head is NOT a const identifier → not resolved here (and the literal prefix alone never named a full
545
+ // host). Anything else (non-const identifier, interpolation of a runtime value, nested template) → null.
546
+ function resolveConstUrlString(expr) {
547
+ if (expr == null) return null;
548
+ // bare identifier: fetch(API_BASE)
549
+ const bare = constStringValue(expr);
550
+ if (bare != null) return bare;
551
+ // template literal `${HEAD_CONST}<tail literal>`: the const value must sit at the HEAD (empty template
552
+ // head text), and we may only append a SINGLE trailing literal span — a second `${…}` interpolation is a
553
+ // runtime value we will not resolve, but it only follows the host, so the host prefix is still sound.
554
+ if (ts.isTemplateExpression(expr)) {
555
+ if (expr.head.text !== "") return null; // literal prefix before the const → not const-anchored
556
+ const first = expr.templateSpans[0];
557
+ const head = first && constStringValue(first.expression);
558
+ if (head == null) return null; // first interpolation is not a const string
559
+ // append the literal text between the first interpolation and the next (or end) — the URL path segment.
560
+ return head + (first.literal.text ?? "");
561
+ }
562
+ // string concat `CONST + "…"`: left must be a const string; append the right ONLY if it is a plain literal.
563
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
564
+ const left = constStringValue(expr.left);
565
+ if (left == null) return null;
566
+ const right = ts.isStringLiteralLike(expr.right) ? expr.right.text : "";
567
+ return left + right;
568
+ }
569
+ return null;
570
+ }
571
+ // Does a literal URL-head string already contain a COMPLETE authority — i.e. is there a `/` AFTER the
572
+ // `://` still WITHIN the literal text? `https://api.openai.com/v1/` → yes (host fully present, only the
573
+ // PATH follows); `https://api.` / `https://` / `https://api.openai.com:` → no (the authority is not yet
574
+ // terminated, so an interpolation could still be part of the host/port). Requires a `scheme://` prefix;
575
+ // a bare relative path never qualifies.
576
+ function literalHeadCompletesAuthority(head) {
577
+ const m = head.match(/^[a-z][a-z0-9+.-]*:\/\//i);
578
+ if (!m) return false; // no scheme://… → authority not started in the literal
579
+ return head.indexOf("/", m[0].length) >= 0; // a `/` after the `://` terminates the authority
580
+ }
581
+ // LITERAL-HEAD HOST EXTRACTION (java literal-inlining parity): a template `\`https://host/${path}\`` or a
582
+ // concat `"https://host/" + path` whose FIRST STATIC segment (the text before the first interpolation /
583
+ // the concat's left literal) ALREADY contains a complete `scheme://authority/…` carries a statically-known
584
+ // host — the interpolation is only in the PATH. Return that literal head (a real URL prefix `hostLiteral`
585
+ // parses to the authority). If the head does NOT terminate the authority with a `/` (`https://${h}/x`,
586
+ // `https://api.${x}.com/y`, `https://host:${port}/y`, `https://api.openai${x}/v1`) the interpolation could
587
+ // be part of the host/port → return null (safe under-report: stays bare Net). Distinct from
588
+ // resolveConstUrlString, which anchors on a CONST identifier at the head; here the head is a plain LITERAL.
589
+ function literalHeadHostUrl(expr) {
590
+ if (expr == null) return null;
591
+ // template `\`<head>${…}…\``: the literal head is expr.head.text (empty when the interpolation leads).
592
+ if (ts.isTemplateExpression(expr)) {
593
+ const head = expr.head.text;
594
+ return literalHeadCompletesAuthority(head) ? head : null;
595
+ }
596
+ // concat `"<left literal>" + <anything>`: only the LEFT operand's literal text is the static head; the
597
+ // right is a runtime value living in the path. (A nested `"a" + "b" + x` left is a BinaryExpression, not
598
+ // a string literal, so it is not read here — a safe under-report, not a fabrication.)
599
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken
600
+ && ts.isStringLiteralLike(expr.left)) {
601
+ const head = expr.left.text;
602
+ return literalHeadCompletesAuthority(head) ? head : null;
603
+ }
604
+ return null;
605
+ }
480
606
  function urlArgLiteral(node, member) {
481
607
  const args = node.arguments ?? [];
482
- const litAt = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
608
+ const litAt = (i) => {
609
+ const a = args[i];
610
+ if (!a) return null;
611
+ if (ts.isStringLiteralLike(a)) return a.text;
612
+ // const-anchored host (fetch(API_BASE), `${API_BASE}/x`, API_BASE+"/x"), THEN literal-head extraction
613
+ // (`\`https://host/${p}\``, `"https://host/" + p`) when the literal head already completes the authority.
614
+ return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
615
+ };
483
616
  if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
484
617
  return litAt(0);
485
618
  }
@@ -1364,6 +1497,83 @@ const HOF_INVOKERS = new Set([
1364
1497
  "then", "catch", "finally", "nextTick",
1365
1498
  ]);
1366
1499
 
1500
+ // ---- process.env recognition: the direct dot access (`process.env.KEY`) is the JVM System.getenv twin,
1501
+ // but the same environment READ is spelled several other ways that all read silent-pure without help:
1502
+ // bracket access (`process.env[k]`), a local const-alias (`const env = process.env; env.KEY`),
1503
+ // destructuring (`const {KEY} = process.env`), and the `in` operator (`"KEY" in process.env`). Each of
1504
+ // these on process.env (or a confirmed direct alias of it) is Env. SOUNDNESS: only process.env and a
1505
+ // DIRECT `x = process.env` / `const {env} = process` binding trigger — a bracket/alias/destructure/`in`
1506
+ // on any OTHER object stays pure (no fabrication), and a reassigned alias local is cleared.
1507
+ //
1508
+ // `process` here must be Node's process object, NOT a project-local `const process = {…}` shadow
1509
+ // (mirrors the process.hrtime/send guard). It qualifies when it is the ambient GLOBAL (no project
1510
+ // declaration) OR a default-import of the `node:process` builtin (`import process from 'node:process'`,
1511
+ // as chalk's supports-color does) — the two are the same object.
1512
+ const declImportsNodeProcess = (decl) => {
1513
+ // ImportClause default binding or a namespace/named import from 'node:process' | 'process'.
1514
+ let spec = null;
1515
+ if (ts.isImportClause(decl) && decl.parent && ts.isImportDeclaration(decl.parent)) spec = decl.parent.moduleSpecifier;
1516
+ else if (ts.isImportSpecifier(decl)) spec = decl.parent?.parent?.parent?.moduleSpecifier;
1517
+ else if (ts.isNamespaceImport(decl)) spec = decl.parent?.parent?.moduleSpecifier;
1518
+ const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
1519
+ return text === "node:process" || text === "process";
1520
+ };
1521
+ const identIsGlobalProcess = (id) => {
1522
+ if (!ts.isIdentifier(id) || id.text !== "process") return false;
1523
+ const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
1524
+ if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
1525
+ return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))); // else the ambient global
1526
+ };
1527
+ // `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
1528
+ const isProcessEnvExpr = (expr) =>
1529
+ expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
1530
+
1531
+ // The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
1532
+ // sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
1533
+ // to anything else removes it → the alias is cleared, per the spec's reassignment rule).
1534
+ const envAliasSymbols = new Set();
1535
+ {
1536
+ const aliasCandidates = new Set(); // symbol -> declared `= process.env`
1537
+ const disqualified = new Set(); // symbol assigned to something that is NOT process.env
1538
+ const noteBinding = (symbol, init) => {
1539
+ if (!symbol) return;
1540
+ if (init && isProcessEnvExpr(init)) aliasCandidates.add(symbol);
1541
+ else disqualified.add(symbol); // bound/assigned to a non-process.env value → not (or no longer) an alias
1542
+ };
1543
+ const collectAliases = (node) => {
1544
+ // `const env = process.env` / `let`/`var` — a name-identifier binding with an initializer.
1545
+ if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
1546
+ noteBinding(checker.getSymbolAtLocation(node.name), node.initializer ?? null);
1547
+ }
1548
+ // `const { env } = process` — destructuring `env` off the global `process` makes `env` an alias too.
1549
+ else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
1550
+ && node.initializer && ts.isIdentifier(node.initializer) && identIsGlobalProcess(node.initializer)) {
1551
+ for (const el of node.name.elements) {
1552
+ // the property picked off `process` must be `env` (`{env}` or `{env: local}`); the bound name is the alias.
1553
+ const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
1554
+ : (ts.isIdentifier(el.name) ? el.name.text : null);
1555
+ if (propName === "env" && ts.isIdentifier(el.name)) aliasCandidates.add(checker.getSymbolAtLocation(el.name));
1556
+ }
1557
+ }
1558
+ // `env = <expr>` reassignment — a `let`/`var` alias reassigned to a non-process.env value is cleared.
1559
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
1560
+ && ts.isIdentifier(node.left)) {
1561
+ noteBinding(checker.getSymbolAtLocation(node.left), node.right);
1562
+ }
1563
+ ts.forEachChild(node, collectAliases);
1564
+ };
1565
+ for (const sf of sources) collectAliases(sf);
1566
+ for (const s of aliasCandidates) if (s && !disqualified.has(s)) envAliasSymbols.add(s);
1567
+ }
1568
+ // True when `id` is an identifier resolving to a confirmed process.env alias local.
1569
+ const identIsEnvAlias = (id) => {
1570
+ if (!id || !ts.isIdentifier(id)) return false;
1571
+ const sym = checker.getSymbolAtLocation(id);
1572
+ return !!sym && envAliasSymbols.has(sym);
1573
+ };
1574
+ // The receiver expression READS process.env — it is either `process.env` itself or a confirmed alias.
1575
+ const readsProcessEnv = (expr) => isProcessEnvExpr(expr) || identIsEnvAlias(expr);
1576
+
1367
1577
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
1368
1578
  function visitCalls(node) {
1369
1579
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -1911,10 +2121,25 @@ function visitCalls(node) {
1911
2121
  }
1912
2122
  }
1913
2123
  }
1914
- // process.env.X — a property READ, not a call (the JVM's System.getenv twin) → Env
1915
- if (ts.isPropertyAccessExpression(node) && node.expression.getText() === "process.env") {
1916
- const owner = enclosing(node);
1917
- if (owner) fns.get(owner).direct.add("Env");
2124
+ // Reading process.env — the JVM System.getenv twin → Env. All the common idioms count, not just the
2125
+ // direct `process.env.KEY` dot access (see the process.env-recognition note above): dot/bracket access
2126
+ // on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
2127
+ {
2128
+ const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
2129
+ // `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
2130
+ if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
2131
+ markEnv();
2132
+ }
2133
+ // `const {KEY} = process.env` / `const {KEY} = env` — the object-binding pattern's initializer reads env.
2134
+ else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
2135
+ && node.initializer && readsProcessEnv(node.initializer)) {
2136
+ markEnv();
2137
+ }
2138
+ // `"KEY" in process.env` / `"KEY" in env` — the `in` operator's right operand reads env.
2139
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
2140
+ && readsProcessEnv(node.right)) {
2141
+ markEnv();
2142
+ }
1918
2143
  }
1919
2144
  // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
1920
2145
  // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
@@ -2297,6 +2522,19 @@ for (const [name, rec] of fns) {
2297
2522
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
2298
2523
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
2299
2524
  package: pkgName, functions };
2525
+ // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
2526
+ // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
2527
+ // the --gate-json advisory, so the three can never tell different stories.
2528
+ const uncoveredLedger = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2529
+ // ⟨0.15 staged⟩ `coverage` envelope field — the stderr disclosure travels WITH the artifact, so a
2530
+ // report-consuming verb can no longer read a partially-covered report as total. Same names/counts as
2531
+ // the stderr line. OMITTED entirely when nothing is uncovered (the `extensions`-field precedent): a
2532
+ // fully-covered report stays byte-identical to a ⟨0.14⟩ one, so the rung is wire-compatible. The
2533
+ // per-function posture is UNCHANGED: a resolvable-but-uncovered call keeps `invisible`, an
2534
+ // unresolvable one keeps the stronger `Unknown` (COVERAGE-DESIGN.md §2 blesses both).
2535
+ if (uncoveredLedger.length) {
2536
+ envelope.coverage = { uncovered: uncoveredLedger.map(([name, calls]) => ({ name, calls })) };
2537
+ }
2300
2538
  const cg = {};
2301
2539
  for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
2302
2540
  // Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
@@ -2350,7 +2588,7 @@ if (!wantJson) {
2350
2588
  }
2351
2589
  }
2352
2590
  if (unlistedSeen.size > 0) {
2353
- const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2591
+ const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
2354
2592
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
2355
2593
  const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
2356
2594
  console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
@@ -2398,6 +2636,20 @@ let gateViolations = [];
2398
2636
  // · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
2399
2637
  // violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
2400
2638
  // such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
2639
+ //
2640
+ // ⟨0.16⟩ Callgraph-aware existence (SPEC §7 item 5). Reports OMIT pure functions, so a fn that
2641
+ // shipped PURE and now performs an effect is absent from the baseline report and reads as exempt "new
2642
+ // code" — the sharpest supply-chain shape escaping the guard. Fix: key existence on the baseline
2643
+ // CALLGRAPH sidecar (<baseline>.callgraph.json, §2.2 — it lists every project fn INCLUDING pure
2644
+ // leaves), exactly as `gains`'s `origin` existence test does (query-core.mjs `gains`: a fn is
2645
+ // "existing" if it is a baseline-callgraph node — a caller key or a callee):
2646
+ // · sidecar PRESENT + loaded → a fn that is a baseline-callgraph node has baseline effect set ∅
2647
+ // (pure → omitted from the report) and any effect now is a GAIN violation. pure→effectful is caught.
2648
+ // A fn in NEITHER report nor callgraph genuinely did not exist → stays exempt "new".
2649
+ // · sidecar ABSENT → degrade to report-only existence (pre-⟨0.16⟩: a formerly-pure fn reads as new;
2650
+ // still catches an already-effectful fn WIDENING). One stderr note that the guard is weaker.
2651
+ // · sidecar PRESENT-but-CORRUPT → fail closed (exit 2), like a corrupt baseline: a broken sidecar
2652
+ // must not silently NARROW the guard back to report-only.
2401
2653
  if (baselinePath !== null) {
2402
2654
  const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
2403
2655
  if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
@@ -2429,14 +2681,66 @@ if (baselinePath !== null) {
2429
2681
  for (const e of arr) {
2430
2682
  if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
2431
2683
  }
2684
+ // ⟨0.16⟩ Load the baseline callgraph sidecar next to the baseline report. The sidecar for a
2685
+ // report at <stem>.json is <stem>.callgraph.json (scan.mjs writes exactly this pair). Three states:
2686
+ // loaded — a parsed object → its node set (every key + every callee) keys existence, mirroring
2687
+ // the `gains` origin test (query-core.mjs). A baseline-callgraph node whose baseline
2688
+ // effects are ∅ (pure → omitted from the report) that now performs an effect is a GAIN.
2689
+ // absent — no sidecar file → degrade to report-only existence + one stderr note (guard weaker).
2690
+ // corrupt — file present but not parseable / not a plain object → fail closed (exit 2). A broken
2691
+ // sidecar must not silently narrow the guard (SPEC §7 item 5).
2692
+ // shownB may be "(configured empty)"; the real path is baselinePath here (non-null, exists).
2693
+ const sidecarPath = baselinePath.replace(/\.json$/i, "") + ".callgraph.json";
2694
+ let cgNodes = null; // null = sidecar absent (report-only degrade)
2695
+ if (fs.existsSync(sidecarPath)) {
2696
+ let baseCg = null;
2697
+ try { baseCg = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); } catch { baseCg = undefined; }
2698
+ // A non-object parse (null / array / number) is a corrupt sidecar: it cannot list nodes, and
2699
+ // treating it as "absent" would silently narrow the guard — fail closed like a corrupt baseline.
2700
+ if (baseCg === undefined || baseCg === null || typeof baseCg !== "object" || Array.isArray(baseCg)) {
2701
+ console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
2702
+ + `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
2703
+ + `report-only. Regenerate the baseline with this build.`);
2704
+ process.exit(2);
2705
+ }
2706
+ // The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
2707
+ // as `gains` computes cgNodes. Non-array edge values are tolerated (skipped), matching loadCallgraph.
2708
+ cgNodes = new Set(Object.entries(baseCg).flatMap(([k, vs]) => [k, ...(Array.isArray(vs) ? vs : [])]));
2709
+ } else {
2710
+ console.error(`candor-ts: no baseline callgraph sidecar at ${sidecarPath} — the AS-EFF-005 guard is `
2711
+ + `WEAKER: existence falls back to the report, which omits pure functions, so a formerly-PURE fn `
2712
+ + `turning effectful reads as new code and is NOT caught (only an already-effectful fn widening is). `
2713
+ + `Regenerate the baseline with --out so the .callgraph.json is written alongside it.`);
2714
+ }
2715
+ const unknownOnly = []; // ⟨0.16⟩ advisory: fns that gained ONLY Unknown vs the baseline
2432
2716
  for (const name of [...inferred.keys()].sort()) {
2433
2717
  const prior = base.get(name);
2434
- if (prior === undefined) continue; // new function — not a regression
2435
- const gained = [...inferred.get(name)].filter((x) => !prior.has(x)).sort();
2436
- if (gained.length) {
2437
- gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: gained,
2438
- detail: `\`${name}\` gained effect { ${gained.join(", ")} } not present in the baseline` });
2439
- }
2718
+ // ⟨0.16⟩ Existence ladder: in the baseline REPORT → its recorded inferred set is the prior;
2719
+ // else a baseline-callgraph NODE (sidecar present) → it existed and was pure, so prior = ∅ (any
2720
+ // effect now is a gain); else genuinely absent → new code, exempt. Without the sidecar (cgNodes
2721
+ // null) only the report path decides, the pre-⟨0.16⟩ semantics.
2722
+ const priorSet = prior !== undefined ? prior
2723
+ : (cgNodes !== null && cgNodes.has(name)) ? new Set() // baseline-pure node → ∅ prior
2724
+ : null; // new function — not a regression
2725
+ if (priorSet === null) continue;
2726
+ const gained = [...inferred.get(name)].filter((x) => !priorSet.has(x)).sort();
2727
+ if (!gained.length) continue;
2728
+ // ⟨0.16⟩ the ratchet fires only on gaining a REAL boundary effect. An Unknown-ONLY gain is
2729
+ // the §4 trust marker, not an effect (`pure` policies exclude it), and on version bumps it is
2730
+ // dominated by resolution noise — DISCLOSE it (advisory), never fail the gate on it. Mirrors the
2731
+ // reference engine (candor-scan gate.rs check_baseline).
2732
+ const real = gained.filter((x) => x !== "Unknown");
2733
+ if (!real.length) { unknownOnly.push(name); continue; }
2734
+ gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: real,
2735
+ detail: `\`${name}\` gained effect { ${real.join(", ")} } not present in the baseline` });
2736
+ }
2737
+ if (unknownOnly.length) {
2738
+ unknownOnly.sort();
2739
+ const shown = unknownOnly.slice(0, 3).join(", ");
2740
+ const more = unknownOnly.length > 3 ? ` (+${unknownOnly.length - 3} more)` : "";
2741
+ console.error(`candor-ts: note — ${unknownOnly.length} function(s) gained an unresolved call `
2742
+ + `(Unknown) vs the baseline but no real effect — advisory, NOT a regression (Unknown is the §4 `
2743
+ + `trust marker, dominated by resolution noise on version bumps): ${shown}${more}`);
2440
2744
  }
2441
2745
  }
2442
2746
  }
@@ -2481,7 +2785,17 @@ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2481
2785
  // the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
2482
2786
  // ok:true,[] when no gate is configured. Must precede the exit(1) below.
2483
2787
  if (gateJsonPath) {
2484
- const verdict = JSON.stringify({ spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations }, null, 1);
2788
+ const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0, violations: gateViolations };
2789
+ // ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
2790
+ // verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
2791
+ // auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
2792
+ // gate does not fail on uncovered deps (nearly every real scan has some); the policy author sees
2793
+ // the note and decides — `deny Unknown` remains the opt-in strict posture. OMITTED when fully
2794
+ // covered, so a pre-0.15 consumer's verdict is byte-identical.
2795
+ if (uncoveredLedger.length) {
2796
+ verdictObj.coverage = { uncovered: uncoveredLedger.length, packages: uncoveredLedger.map(([p]) => p) };
2797
+ }
2798
+ const verdict = JSON.stringify(verdictObj, null, 1);
2485
2799
  if (gateJsonPath === "-") console.log(verdict);
2486
2800
  else {
2487
2801
  // The verdict is a SURFACING side-output: an unwritable path must be one stderr line, never a raw
@@ -2493,6 +2807,10 @@ if (gateJsonPath) {
2493
2807
  // gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
2494
2808
  if (gateViolations.length) {
2495
2809
  console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
2810
+ // FAILURE-only pointer at the engine's own remedy verb (append-only, same stream as the summary; a
2811
+ // zero-violation run is byte-identical — the exit code, violation lines and summary text are pinned
2812
+ // by the conformance suite and stay untouched).
2813
+ console.error("→ candor-ts-query fix-gate names the remedy for each");
2496
2814
  process.exit(1);
2497
2815
  }
2498
2816
  if (policyPath !== null) console.error("candor-ts: policy ✓");