candor-ts 0.33.0 → 0.33.1

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.
Files changed (4) hide show
  1. package/lsp.mjs +38 -6
  2. package/package.json +1 -1
  3. package/query.mjs +152 -38
  4. package/scan.mjs +517 -90
package/lsp.mjs CHANGED
@@ -402,7 +402,14 @@ const zeroRulePolicyWarn = (what) =>
402
402
  * same reason ("there is no line to pin it to"); the activity overlay's line-0 diagnostic is not a
403
403
  * counter-example, because its record NAMES the edited file and this one names no file in the workspace.
404
404
  */
405
- function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules) {
405
+ // `certainViolation`: SPEC §3.1's precedence (`query.mjs`'s `gate --report`: violation (1) > refusal (2) >
406
+ // incomplete (2)) means a policy violation ELSEWHERE in this same report makes the real exit 1, not the 2
407
+ // this function used to assert unconditionally. Measured: a report with an unread file AND a certain `Fs`
408
+ // violation exits 1 over `gate --report` — the incompleteness is still real and still unjudged, but "exits
409
+ // 2 (INCOMPLETE)" / "CI exits 2 over these bytes" is a wrong, checkable claim in exactly that case. Two
410
+ // fixtures with the identical unread-file cause differ only in whether a violation coexists, and only the
411
+ // violation-free one made this text true.
412
+ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, certainViolation) {
406
413
  const causes = [];
407
414
  // The CLI's order (`unanalyzed` → `outOfScope` → `unread` → `unaskedRules`), so a report tripping two
408
415
  // of them reads the same way here as it does in CI. The repairs genuinely differ — a parse to fix, a
@@ -433,15 +440,28 @@ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules) {
433
440
  + `squiggled here — re-run the producing scan under THE SAME policy this editor is applying `
434
441
  + `(candor-ts <dir> --policy <file>), not merely under a policy.`);
435
442
  if (!causes.length) return;
443
+ // ⟨lsp-precedence⟩ the exit-code claim is conditional on whether a certain violation ALSO fires: if one
444
+ // does, Lemma 2 makes it dominate (exit 1), and the incompleteness below is true but not what CI is red
445
+ // over. See `certainViolation`'s definition above.
446
+ const exitClaim = certainViolation
447
+ ? `a certain policy violation ELSEWHERE in this report makes \`gate --report\` over the same bytes exit `
448
+ + `1 — SPEC §3.1's precedence has a firing rule dominate a refusal, so CI is red on THAT, not on this. `
449
+ + `The incompleteness below is still real and still unjudged; it is just not what the exit code names`
450
+ : `\`gate --report\` over the same bytes exits 2 (INCOMPLETE), so the squiggles in this editor are NOT `
451
+ + `the whole verdict`;
452
+ const briefClaim = certainViolation
453
+ ? `candor gate: a certain violation elsewhere in this report already makes CI exit 1 over these bytes — `
454
+ + `but this report ALSO has unjudged code (see the candor log); fixing the violation alone will not `
455
+ + `make it complete.`
456
+ : `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
457
+ + `See the candor log for what went unjudged and how to fix it.`;
436
458
  warnLoudOnce(
437
- `candor-lsp: this report cannot support a GREEN gate — \`gate --report\` over the same bytes exits 2 `
438
- + `(INCOMPLETE), so the squiggles in this editor are NOT the whole verdict:\n`
459
+ `candor-lsp: this report cannot support a GREEN gate — ${exitClaim}:\n`
439
460
  + causes.map((c) => ` · ${c}`).join("\n")
440
461
  + `\n NO diagnostic can be drawn for any of the above: the code it names is not in the report, so it `
441
462
  + `has no line in this editor to sit on. Its ABSENCE from the squiggles is the incompleteness itself, `
442
463
  + `not an all-clear.`,
443
- `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
444
- + `See the candor log for what went unjudged and how to fix it.`);
464
+ briefClaim);
445
465
  }
446
466
 
447
467
  function diagnosticsFor(docPath) {
@@ -490,8 +510,20 @@ function diagnosticsFor(docPath) {
490
510
  // condition is applied HERE, to the value, for the reason the CLI states at its own call site: one
491
511
  // list, one condition, so two consumers of it cannot disagree about a run.
492
512
  const dcomp = Q.reportCompleteness(reportPrefix, dpol.deny);
513
+ // Cheap, side-effect-free pre-check for `discloseIncompleteness`'s exit-code wording (see its own
514
+ // comment): does THIS report already carry a certain violation, over the WHOLE report the way
515
+ // `gate --report` reads it, not just this one document? A redundant pass over an already-loaded report
516
+ // — the real one runs again below, where its `dunevaluated`/`violations` are also needed for the
517
+ // diagnostics themselves and for OTHER `warnOnce` lines whose relative order this must not disturb.
518
+ const dwp0 = wholePolicyUnanswerable(dpol, "the editor's report route");
519
+ const dunits0 = reportUnits(fns);
520
+ const dnet0 = reportNetClasses(fns, { authoritative: true, units: dunits0 });
521
+ const { withhold: dwithhold0 } = unanswerableScoped(dpol, fns,
522
+ resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix), dunits0), dnet0, dunits0);
523
+ const certainViolation = evaluatePolicy(dwp0.answerable, fns, Q.loadCallgraph(reportPrefix),
524
+ new Map(), new Set(), dnet0, dwithhold0, dunits0).length > 0;
493
525
  discloseIncompleteness(dcomp.unanalyzed ?? [], dcomp.outOfScope ?? [],
494
- dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? []);
526
+ dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? [], certainViolation);
495
527
  // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
496
528
  // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
497
529
  // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.33)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/query.mjs CHANGED
@@ -143,14 +143,38 @@ const advisoryUnreadableNote = (verb, unreadable) => {
143
143
  // below, for the reason `incompleteAnswerNote` gives: that note asserts `analyzed.count: 0`, which a
144
144
  // row-3 report never said, and the repair is a producer that emits a manifest rather than a scan that
145
145
  // reaches a conclusion.
146
- const advisoryNoManifestNote = (verb, files) => {
146
+ //
147
+ // ⟨fix, sibling of 658e3c0 / gateLine⟩ BOTH SENTENCES BELOW HAD THE SAME UNCONDITIONED "exits 0" CLAIM
148
+ // gateLine's third branch has, and unlike that branch's remaining causes (`judgedNothing` ALONE, with
149
+ // no other sibling report under the locator, truly cannot carry a violation — there is nothing to
150
+ // evaluate), a MULTI-REPORT prefix breaks the argument: `reportNoManifestFiles`/`reportJudgedNothingFiles`
151
+ // are PER-FILE lists (a locator with one silent/manifest-less sibling among several still hedges), while
152
+ // the real `gate` route's `judgedNothing` is the loadGateReport UNION across siblings — "judged something
153
+ // as soon as ONE of them has" — and a noManifest file is folded into the SAME union the moment it lists
154
+ // real functions. MEASURED: a prefix with report.a.json (a certain `deny Fs` violation) beside
155
+ // report.b.json (an empty bare-array / `analyzed.count: 0` sibling, either cause) makes `gate --report`
156
+ // exit 1 — the violation in `a` dominates — while `unverified`/`fix-gate --strict` printed "`gate --report`
157
+ // exits 0 … NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU" and exited 0 themselves. `certainViolation` is
158
+ // reused from the caller's ALREADY-LOADED policy — cheaper here than the eleven read-only verbs' discovery
159
+ // hack, since whatif/fix-gate/unverified hold their own `pol`/`fgpol`/`upol` already.
160
+ //
161
+ // `advisoryUnreadableNote` just above is DELIBERATELY NOT changed: a report FILE that failed to PARSE is
162
+ // the real gate's `g.hardFail` refusal, and that one is proven to dominate ABSOLUTELY (see the ruling at
163
+ // the `gate` case's own `if (g.hardFail)` branch, "the precedence ruling does NOT reach this exit") —
164
+ // conditioning it on `certainViolation` would be the flip-the-wording regression the correction to
165
+ // `gateLine` above had to undo for the same cause.
166
+ const advisoryNoManifestNote = (verb, files, certainViolation) => {
147
167
  console.error(`candor-ts: ${verb} could NOT fully evaluate — ${files.length} report(s) under this locator carry NO \`analyzed\` manifest at all (SPEC §2 row 3, a pre-⟨0.21⟩ producer), so they make no claim about what was judged and their silence licenses none either:`);
148
168
  for (const f of files) console.error(` ${f}`);
149
- console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. `gate --report` exits 0 over these bytes, so this note is the whole of the warning. Re-scan with a current engine so the report carries its manifest.)");
169
+ console.error(certainViolation
170
+ ? " (`ok` is OMITTED — neither value is a statement this input licenses. A certain policy violation ELSEWHERE in this report makes `gate --report` over the same bytes exit 1, not 0 — SPEC §3.1's precedence has a firing rule dominate; this cause is still real and still unjudged, it is just not what the exit code names. Re-scan with a current engine so the report carries its manifest.)"
171
+ : " (`ok` is OMITTED — neither value is a statement this input licenses. `gate --report` exits 0 over these bytes, so this note is the whole of the warning. Re-scan with a current engine so the report carries its manifest.)");
150
172
  };
151
- const advisoryJudgedNothingNote = (verb) => {
173
+ const advisoryJudgedNothingNote = (verb, certainViolation) => {
152
174
  console.error(`candor-ts: ${verb} could NOT fully evaluate — the report(s) under this locator say they JUDGED NOTHING (⟨0.24⟩ \`analyzed.count\` is 0, absent with no entries, or unreadable), so absence from \`functions\` licenses no purity claim about any unit and there is nothing here to certify`);
153
- console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU: `gate --report` exits 0 over a judged-nothing report and `--strict` does not move either, so this note is the whole of the warning. Re-scan the sources you meant to check.)");
175
+ console.error(certainViolation
176
+ ? " (`ok` is OMITTED — neither value is a statement this input licenses. A certain policy violation ELSEWHERE in this report makes `gate --report` over the same bytes exit 1 — SPEC §3.1's precedence has a firing rule dominate, so CI IS RED on that, not on this judged-nothing sibling; `--strict` here does not move either way. Re-scan the sources you meant to check.)"
177
+ : " (`ok` is OMITTED — neither value is a statement this input licenses. NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU: `gate --report` exits 0 over a judged-nothing report and `--strict` does not move either, so this note is the whole of the warning. Re-scan the sources you meant to check.)");
154
178
  };
155
179
  // The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
156
180
  // with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
@@ -200,11 +224,94 @@ const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data);
200
224
  // unevaluable on that route — so the exit is a certainty once a policy exists; the gap is that none does
201
225
  // here. Checked FIRST, because a report can carry an unread class and unanalyzed units together and this
202
226
  // is the sentence naming the cause the other two do not.
203
- const gateLine = (comp) => (comp.unread?.length && !comp.unanalyzed.length && !comp.unreadable?.length
204
- ? "`gate --report` exits 2 over these bytes under any policy it can evaluate (they are all `deny`/`pure`), and this verb holds none — so NOTHING DOWNSTREAM IS FAILING CLOSED ON IT HERE and this note is the whole of the warning."
205
- : comp.unanalyzed.length || comp.unreadable?.length
206
- ? "`gate --report` exits 2 over these bytes."
207
- : "NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a judged-nothing report (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole of the warning.");
227
+ //
228
+ // ⟨fix, sibling of 658e3c0⟩ THIS SENTENCE HAS THE IDENTICAL DEFECT THE LSP'S `discloseIncompleteness`
229
+ // HAD: it asserted "exits 2" unconditionally, when SPEC §3.1's precedence (violation (1) > refusal (2)
230
+ // > incomplete (2)) makes a CERTAIN violation elsewhere in the SAME report dominate — the real exit is
231
+ // 1, not 2, and "exits 2 over these bytes" is a false, checkable claim in exactly that case. MEASURED:
232
+ // a report naming an unread exclusion class (branch 1 below) or an unanalyzed unit (branch 2, the
233
+ // `comp.unanalyzed` half only — see next paragraph) ALSO carrying a certain `deny Fs`/`deny Exec`
234
+ // violation elsewhere exits 1 over `gate --report`, not 2. `certainViolationOver` below is the cheap,
235
+ // side-effect-free pre-check; see its own comment for why it must discover a policy rather than read
236
+ // one from these arguments (these eleven verbs hold none of their own).
237
+ //
238
+ // `comp.unreadable` (a report FILE under this locator that failed to PARSE — corrupt or mid-write) IS
239
+ // NOT PART OF THIS. MEASURED against the real `gate` verb's own ruling on this exact question (its
240
+ // `g.hardFail` branch, `grefuse`d before any violation is ever evaluated): "the precedence ruling does
241
+ // NOT reach this exit" — a CORRUPTION refusal says the report cannot be read AS A REPORT, which
242
+ // undermines the very premise Lemma 2 runs on, and it dominates ABSOLUTELY, never the other way round.
243
+ // Confirmed: a prefix with one clean sibling carrying a certain `deny Fs` violation beside a SECOND,
244
+ // unparseable sibling still exits 2, unconditionally. Folding `unreadable` into the same
245
+ // certain-violation branch as `unanalyzed` — the first attempt at this fix did exactly that — flips the
246
+ // wording to claim a "1, not 2" dominance that is false whenever `unreadable` fired, the EXACT opposite
247
+ // error the LSP's own fix warns against. So `unreadable` gets its own, ALWAYS-unconditional arm.
248
+ // Shared by BOTH sibling fixes (this file's `gateLine` and the `advisoryNoManifestNote`/
249
+ // `advisoryJudgedNothingNote` pair below `advisoryUnreadableNote`): given an ALREADY-PARSED, ALREADY-
250
+ // resolved policy object (or `null`, for `whatif`'s optional one), does it find a certain violation
251
+ // anywhere in the report at `prefix`? A zero-rule policy or a load/evaluate exception answers `false`
252
+ // rather than throwing — this is a pre-check for WORDING, never a certifying read, so it must never
253
+ // promote an internal error into an over-claim it cannot support.
254
+ function certainViolationFromPolicy(pol, prefix) {
255
+ if (!pol || policyAskedNothing(pol)) return false;
256
+ try {
257
+ const fns = loadReport(prefix);
258
+ const cg = loadCallgraph(prefix);
259
+ const wp = wholePolicyUnanswerable(pol, "the advisory route's precedence pre-check");
260
+ const units = reportUnits(fns);
261
+ const net = reportNetClasses(fns, { authoritative: true, units });
262
+ const { withhold } = unanswerableScoped(pol, fns, resolveReasonClasses(fns, cg, units), net, units);
263
+ return evaluatePolicy(wp.answerable, fns, cg, new Map(), new Set(), net, withhold, units).length > 0;
264
+ } catch { return false; }
265
+ }
266
+ function certainViolationOver(prefix) {
267
+ const { policyFile } = resolvePolicy(null, null);
268
+ if (!policyFile) return false;
269
+ let text;
270
+ try { text = fs.readFileSync(policyFile, "utf8"); } catch { return false; }
271
+ let pol;
272
+ try {
273
+ const errs = [];
274
+ const aliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyFile, process.cwd())), errs);
275
+ pol = parsePolicy(text, aliases);
276
+ errs.push(...pol.errors);
277
+ if (fatalPolicyErrors(errs).length) return false;
278
+ } catch { return false; }
279
+ return certainViolationFromPolicy(pol, prefix);
280
+ }
281
+ const gateLine = (comp, prefix) => {
282
+ const dominanceNote = (tail) =>
283
+ `a certain policy violation ELSEWHERE in this report makes \`gate --report\` over the same bytes exit `
284
+ + `1 — SPEC §3.1's precedence has a firing rule dominate a refusal, so CI is red on THAT, not on ${tail}. `
285
+ + `The incompleteness above is still real and still unjudged; it is just not what the exit code names.`;
286
+ // `unreadable` FIRST and UNCONDITIONALLY (see the comment above `certainViolationOver`): a corrupt
287
+ // report file refuses absolutely, so this arm never calls the pre-check at all — never mind consults
288
+ // it — and no wording below it is reachable while `unreadable` is non-empty.
289
+ if (comp.unreadable?.length) return "`gate --report` exits 2 over these bytes.";
290
+ const certainViolation = certainViolationOver(prefix) === true;
291
+ // TIER B — DOMINATED BY LEMMA 2, three causes: `unread`, `unanalyzed`, and `outOfScope`.
292
+ // ⟨fix, found widening the inventory for this same defect⟩ `outOfScope` used to be ABSENT from every
293
+ // condition here, so an `outOfScope`-only report (no `unread`, no `unanalyzed`, no `unreadable`) fell
294
+ // through to the "exits 0 … NOTHING DOWNSTREAM WILL CATCH THIS" tail below — but ⟨0.30⟩ made a
295
+ // non-empty `outOfScope` its OWN exit-2 INCOMPLETE cause, and MEASURED it is dominated exactly like
296
+ // `unanalyzed`: a certain violation coexisting with an `outOfScope`-only report exits 1, not 2 (and
297
+ // `outOfScope` ALONE, with no violation anywhere, exits 2, never the 0 this used to claim). That was
298
+ // the WORSE of the two possible errors on this cause — "nothing downstream" is false in BOTH of its
299
+ // sub-cases, not just the dominance one — so `outOfScope` now takes this tier, not the last one.
300
+ return comp.unread?.length && !comp.unanalyzed.length && !comp.outOfScope?.length
301
+ ? (certainViolation ? dominanceNote("this incompleteness")
302
+ : "`gate --report` exits 2 over these bytes under any policy it can evaluate (they are all `deny`/`pure`), and this verb holds none — so NOTHING DOWNSTREAM IS FAILING CLOSED ON IT HERE and this note is the whole of the warning.")
303
+ : comp.unanalyzed.length || comp.outOfScope?.length
304
+ ? (certainViolation ? dominanceNote("this")
305
+ : "`gate --report` exits 2 over these bytes.")
306
+ // TIER C — `judgedNothing`/`noManifest` ALONE (the only causes left once A and B are ruled out). The
307
+ // real gate's union-across-siblings semantics make this exit 0 so long as NOTHING under the locator
308
+ // judged real content; a multi-report locator with one silent/manifest-less sibling beside another
309
+ // that carries a certain violation breaks that (identical fix to `advisoryNoManifestNote`/
310
+ // `advisoryJudgedNothingNote` below `advisoryUnreadableNote` — see their shared comment) — the union
311
+ // has judged SOMETHING, so `gate --report` evaluates it normally and a firing rule exits 1, not 0.
312
+ : (certainViolation ? dominanceNote("this judged-nothing/no-manifest sibling")
313
+ : "NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a judged-nothing report (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole of the warning.");
314
+ };
208
315
 
209
316
  // The HUMAN half. A no-op when there is nothing to disclose, so an ordinary run stays byte-identical, and
210
317
  // printed BEFORE the answer because it qualifies a NON-EMPTY result as much as an empty one: a function in
@@ -217,7 +324,7 @@ const gateLine = (comp) => (comp.unread?.length && !comp.unanalyzed.length && !c
217
324
  // loses its withdrawal. Every caller is on the PROSE branch (`putAnswer`'s else-arm, `tour`'s human arm,
218
325
  // `gains`' else-arm), so stdout is never carrying a JSON document when this prints — JSON-mode runs take
219
326
  // the machine half (`completenessFields`) instead and this function is not called at all.
220
- const incompleteAnswerNote = (comp, soWhat, tail) => {
327
+ const incompleteAnswerNote = (comp, soWhat, tail, prefix) => {
221
328
  if (!mustHedge(comp)) return;
222
329
  // Three causes, one sentence each, and only true ones: `unreadable` is a report file whose BYTES could
223
330
  // not be parsed — not "declared unanalyzed units" and not "judged nothing", both of which would send
@@ -259,7 +366,7 @@ const incompleteAnswerNote = (comp, soWhat, tail) => {
259
366
  console.log(` ${o.fn ?? "(unnamed)"} — OUTSIDE the producing scan's scope: it performs ${(o.effects ?? []).join(", ")}, and the gate did not judge it`);
260
367
  for (const c of comp.unread ?? [])
261
368
  console.log(` ${c} — this exclusion class went UNREAD (\`excluded[].peeked: false\`): its effects are absent because nothing looked, not because there are none. Re-run the producing scan WITH a \`deny\`/\`pure\` policy so the peek reads it (candor-ts <dir> --policy <file>)`);
262
- console.log(` ${tail} ${gateLine(comp)}`);
369
+ console.log(` ${tail} ${gateLine(comp, prefix)}`);
263
370
  };
264
371
 
265
372
  // The MACHINE half. Spread LAST so the verb's own pinned key order is untouched (JS objects keep insertion
@@ -280,9 +387,9 @@ const withCompleteness = (data, comp) => ({ ...data, ...completenessFields(comp)
280
387
  // half to disagree, which is exactly the mutant (`ec1a441`) that survived a whole suite in candor-rust.
281
388
  // `proseFn` receives the hedge flag as its second argument so it can WITHDRAW its reassuring sentence; the
282
389
  // note alone is not enough, because "no Unknown sources ✓" IS the prose spelling of the empty JSON.
283
- const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
390
+ const putAnswer = (a, data, proseFn, comp, soWhat, tail, prefix) => {
284
391
  if (!proseFn || wantJsonOut(a)) { emit(withCompleteness(data, comp)); return data; }
285
- incompleteAnswerNote(comp, soWhat, tail);
392
+ incompleteAnswerNote(comp, soWhat, tail, prefix);
286
393
  proseFn(data, mustHedge(comp));
287
394
  return data;
288
395
  };
@@ -340,12 +447,12 @@ const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
340
447
  // it delegated to `putAnswer`, whose `{ ...data, ...{} }` turned `show`'s ARRAY into `{"0": {…}}` — the
341
448
  // pinned top-level array destroyed on the very path this rung promises to leave byte-identical. Caught by
342
449
  // the intact-input control before it left the machine. So the healthy arm emits `data` itself.
343
- const putNestedWithCaveat = (a, key, data, proseFn, comp, soWhat, tail) => {
450
+ const putNestedWithCaveat = (a, key, data, proseFn, comp, soWhat, tail, prefix) => {
344
451
  if (!proseFn || wantJsonOut(a)) {
345
452
  emit(mustHedge(comp) ? { [key]: data, ...completenessFields(comp) } : data);
346
453
  return data;
347
454
  }
348
- incompleteAnswerNote(comp, soWhat, tail);
455
+ incompleteAnswerNote(comp, soWhat, tail, prefix);
349
456
  proseFn(data, mustHedge(comp));
350
457
  return data;
351
458
  };
@@ -1333,7 +1440,8 @@ switch (cmd) {
1333
1440
  // under `functions` BESIDE the caveat rather than being replaced by it: `show` certifies nothing.
1334
1441
  putNestedWithCaveat(args, "functions", coreShow(loadReportOrDie(prefix), q), P.show, reportCompleteness(prefix),
1335
1442
  `the function(s) shown below are only those candor could SEE match \`${q}\``,
1336
- "A function in an unread unit is ABSENT from the report, so it cannot be shown here at all. Re-scan for a complete answer.");
1443
+ "A function in an unread unit is ABSENT from the report, so it cannot be shown here at all. Re-scan for a complete answer.",
1444
+ prefix);
1337
1445
  break;
1338
1446
  }
1339
1447
  case "where": {
@@ -1356,7 +1464,8 @@ switch (cmd) {
1356
1464
  // by measurement. The caveat rides the SAME document (see `putAnswer`); the exit code does not move.
1357
1465
  putAnswer(args, coreWhere(fnsW, eff), P.where, reportCompleteness(prefix),
1358
1466
  `the function(s) named below are only those candor could SEE perform ${eff}`,
1359
- `A function in an unread unit is ABSENT from the report, so it cannot appear in either list. Re-scan for a complete answer.`);
1467
+ `A function in an unread unit is ABSENT from the report, so it cannot appear in either list. Re-scan for a complete answer.`,
1468
+ prefix);
1360
1469
  break;
1361
1470
  }
1362
1471
  case "callers": {
@@ -1427,7 +1536,7 @@ switch (cmd) {
1427
1536
  // `{"direct": []}`. The sidecar-absence sentence is a DIFFERENT limitation (an effect-only graph)
1428
1537
  // and does not cover a report whose own `excluded` names a class nothing opened.
1429
1538
  putAnswer(args, {}, () => console.log(`candor: no caller of \`${q}\` in the effect-relevant graph (the full call-graph sidecar is absent; re-scan with --out to see pure-only callers).`),
1430
- reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL);
1539
+ reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL, prefix);
1431
1540
  break;
1432
1541
  }
1433
1542
  console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
@@ -1440,7 +1549,7 @@ switch (cmd) {
1440
1549
  // collision, and the exit does not move. `show`/`map` OVER-hedged (`putNestedWithCaveat` above, and
1441
1550
  // the descriptive/certifying boundary is stated there); these three UNDER-hedged, which is the
1442
1551
  // direction this family calls the cardinal sin.
1443
- putAnswer(args, cres, P.callers, reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL);
1552
+ putAnswer(args, cres, P.callers, reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL, prefix);
1444
1553
  break;
1445
1554
  }
1446
1555
  case "map": {
@@ -1454,7 +1563,8 @@ switch (cmd) {
1454
1563
  // keeps the answer: `map` certifies nothing, so there was never a claim to withhold.
1455
1564
  putNestedWithCaveat(args, "modules", coreMap(loadReportOrDie(prefix)), P.map, reportCompleteness(prefix),
1456
1565
  "the module rows below cover only the source candor read",
1457
- "A module living wholly in an unread unit is MISSING from this overview, and one that IS listed may be missing functions. Re-scan for a complete map.");
1566
+ "A module living wholly in an unread unit is MISSING from this overview, and one that IS listed may be missing functions. Re-scan for a complete map.",
1567
+ prefix);
1458
1568
  break;
1459
1569
  }
1460
1570
  case "containment": {
@@ -1492,12 +1602,14 @@ switch (cmd) {
1492
1602
  putAnswer(args, r, P.containment,
1493
1603
  absorbCompleteness(reportCompleteness(prefix), reportCompleteness(basePrefix)),
1494
1604
  "the leak set below is a difference over only the code candor read on BOTH sides",
1495
- "An unread unit of the current tree hides a leak; an unread unit of the baseline manufactures one. Re-scan both before moving the ratchet.");
1605
+ "An unread unit of the current tree hides a leak; an unread unit of the baseline manufactures one. Re-scan both before moving the ratchet.",
1606
+ prefix);
1496
1607
  process.exit(r.leaks.length ? 1 : 0);
1497
1608
  }
1498
1609
  putAnswer(args, coreContainment(loadReportOrDie(prefix)), P.containment, reportCompleteness(prefix),
1499
1610
  "the containment scores below cover only the boundary effects candor could see",
1500
- "A boundary effect in an unread unit is in no layer's count, so a dispersed effect can score as contained. Re-scan for a complete picture.");
1611
+ "A boundary effect in an unread unit is in no layer's count, so a dispersed effect can score as contained. Re-scan for a complete picture.",
1612
+ prefix);
1501
1613
  break;
1502
1614
  }
1503
1615
  case "diff": {
@@ -1547,9 +1659,9 @@ switch (cmd) {
1547
1659
  if (wantJsonOut(args)) emit(diffDoc);
1548
1660
  else {
1549
1661
  incompleteAnswerNote(dCur, "the changes below are computed only over effects candor read in the CURRENT tree and may be SHORT",
1550
- "An effect gained or lost in an unread unit of the current tree is not in the list below.");
1662
+ "An effect gained or lost in an unread unit of the current tree is not in the list below.", curPrefix);
1551
1663
  incompleteAnswerNote(dBase, "the BASELINE half of this comparison is itself partial and the floor it sets is soft",
1552
- "An effect living in an unread unit of the baseline reads as a newly gained or lost change here.");
1664
+ "An effect living in an unread unit of the baseline reads as a newly gained or lost change here.", basePrefix);
1553
1665
  P.diff(diffDoc, mustHedge(dCur) || mustHedge(dBase));
1554
1666
  }
1555
1667
  // diff DISCLOSES (the posture) — it is not a gate. Its gained-effect exit 1 is a convenience for
@@ -1574,7 +1686,8 @@ switch (cmd) {
1574
1686
  effects: Object.fromEntries(Object.entries(byEff).sort()
1575
1687
  .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable, reportCompleteness(prefix),
1576
1688
  "the runtime effect set below is a union over only the entry points candor could see",
1577
- "An entry point in an unread unit contributes NOTHING to this union, and neither does any effect it reaches. Re-scan before treating this as the program's runtime surface.");
1689
+ "An entry point in an unread unit contributes NOTHING to this union, and neither does any effect it reaches. Re-scan before treating this as the program's runtime surface.",
1690
+ prefix);
1578
1691
  break;
1579
1692
  }
1580
1693
  case "impact": {
@@ -1641,7 +1754,7 @@ switch (cmd) {
1641
1754
  // above for the measurement and the boundary). `affectedCount: 0` is the strongest claim in this
1642
1755
  // verb's vocabulary, and over a report whose own `excluded` names a class nothing opened it rests on
1643
1756
  // a graph missing whatever lives in that class. Fixed key set, so the caveat spreads in at the root.
1644
- putAnswer(args, coreImpact(impFns, impCg, q), P.impact, reportCompleteness(prefix), IMPACT_SOWHAT, IMPACT_TAIL);
1757
+ putAnswer(args, coreImpact(impFns, impCg, q), P.impact, reportCompleteness(prefix), IMPACT_SOWHAT, IMPACT_TAIL, prefix);
1645
1758
  break;
1646
1759
  }
1647
1760
  case "blindspots": {
@@ -1659,9 +1772,9 @@ switch (cmd) {
1659
1772
  const bsSoWhat = "the Unknown sources below are only those rooted in a call candor could see";
1660
1773
  const bsTail = "An unread unit contributes no entry at all, so its own Unknowns are not counted here and cannot be. Re-scan before treating this as the blind-spot inventory.";
1661
1774
  if (args.includes("--stats")) { // ⟨0.20⟩ the reason-class distribution, not the source list
1662
- putAnswer(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats, bsComp, bsSoWhat, bsTail);
1775
+ putAnswer(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats, bsComp, bsSoWhat, bsTail, prefix);
1663
1776
  } else {
1664
- putAnswer(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots, bsComp, bsSoWhat, bsTail);
1777
+ putAnswer(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots, bsComp, bsSoWhat, bsTail, prefix);
1665
1778
  }
1666
1779
  break;
1667
1780
  }
@@ -1743,7 +1856,8 @@ switch (cmd) {
1743
1856
  }
1744
1857
  incompleteAnswerNote(tourComp,
1745
1858
  "the reaches below are ranked over only the call graph candor could see",
1746
- "A surprising reach whose path runs through an unread unit is not ranked here at all, and cannot be. Re-scan for the full tour.");
1859
+ "A surprising reach whose path runs through an unread unit is not ranked here at all, and cannot be. Re-scan for the full tour.",
1860
+ prefix);
1747
1861
  if (finds.length === 0) {
1748
1862
  // Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
1749
1863
  // answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
@@ -1822,9 +1936,9 @@ switch (cmd) {
1822
1936
  if (wantJsonOut(args)) emit(gDoc);
1823
1937
  else {
1824
1938
  incompleteAnswerNote(gCur, "the gained set below names only effects candor read in the CURRENT tree and may be SHORT",
1825
- "An effect introduced in an unread unit of the current tree is not in the list below.");
1939
+ "An effect introduced in an unread unit of the current tree is not in the list below.", curPrefix);
1826
1940
  incompleteAnswerNote(gBase, "the BASELINE half of this comparison is itself partial and the floor it sets is soft",
1827
- "An effect living in an unread unit of the baseline reads as NEWLY gained here — the existing/new origin split is unreliable until the baseline is re-scanned.");
1941
+ "An effect living in an unread unit of the baseline reads as NEWLY gained here — the existing/new origin split is unreliable until the baseline is re-scanned.", basePrefix);
1828
1942
  P.gains(gDoc, mustHedge(gCur) || mustHedge(gBase));
1829
1943
  }
1830
1944
  // Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
@@ -1918,7 +2032,7 @@ switch (cmd) {
1918
2032
  // The note goes on STDOUT with the answer it qualifies, like every other verb here: a caveat on the
1919
2033
  // other stream is one `2>/dev/null` from gone. `renderPathHuman` takes the hedge flag so its two
1920
2034
  // determined-negative sentences ("does not perform", "not statically traceable") can withdraw.
1921
- incompleteAnswerNote(pcomp, PATH_SOWHAT, PATH_TAIL);
2035
+ incompleteAnswerNote(pcomp, PATH_SOWHAT, PATH_TAIL, prefix);
1922
2036
  renderPathHuman(fns, cg, fn, eff, mustHedge(pcomp));
1923
2037
  }
1924
2038
  break;
@@ -1966,8 +2080,8 @@ switch (cmd) {
1966
2080
  // ⟨0.28⟩ …and the count-0 cause, which reaches here through a LIVE §2.2 sidecar: the target resolves
1967
2081
  // over the call graph, so this verb answers `ok: true` where the report-only verbs exit 2 on the name.
1968
2082
  // MEASURED exactly so — the pre-edit gate check, green, over a report that judged nothing.
1969
- if (wcomp.judgedNothing.length) advisoryJudgedNothingNote("whatif");
1970
- if (wcomp.noManifest.length) advisoryNoManifestNote("whatif", wcomp.noManifest);
2083
+ if (wcomp.judgedNothing.length) advisoryJudgedNothingNote("whatif", certainViolationFromPolicy(pol, prefix));
2084
+ if (wcomp.noManifest.length) advisoryNoManifestNote("whatif", wcomp.noManifest, certainViolationFromPolicy(pol, prefix));
1971
2085
  if (wcomp.unreadable.length) advisoryUnreadableNote("whatif", wcomp.unreadable);
1972
2086
  // ⟨0.28⟩ SPEC §2 — a CONFIGURED policy that parsed to zero rules asked nothing, so the pre-edit
1973
2087
  // verdict AND the blast radius it qualifies are withheld in favour of the caveat document. The exit
@@ -2059,8 +2173,8 @@ switch (cmd) {
2059
2173
  // ⟨0.28⟩ …and the count-0 cause, which reaches the DOCUMENT and the PROSE but deliberately NOT the exit
2060
2174
  // below (see `advisoryAnswer`). Leaving it out would have let this verb print a remedy list beside
2061
2175
  // `ok: true` over a report that judged nothing — the same false all-clear, arriving by omission.
2062
- if (fgComp.judgedNothing.length) advisoryJudgedNothingNote("fix-gate");
2063
- if (fgComp.noManifest.length) advisoryNoManifestNote("fix-gate", fgComp.noManifest);
2176
+ if (fgComp.judgedNothing.length) advisoryJudgedNothingNote("fix-gate", certainViolationFromPolicy(fgpol, prefix));
2177
+ if (fgComp.noManifest.length) advisoryNoManifestNote("fix-gate", fgComp.noManifest, certainViolationFromPolicy(fgpol, prefix));
2064
2178
  if (fgComp.unreadable.length) advisoryUnreadableNote("fix-gate", fgComp.unreadable);
2065
2179
  // ⟨0.24⟩ SPEC §3.2 `4fd140c` — and the same posture for a rule the GATE refused: no remedy is computed
2066
2180
  // from evidence the gate declined to read, the refusal is disclosed on both channels, and `--strict`
@@ -2150,8 +2264,8 @@ switch (cmd) {
2150
2264
  if (uUnan.length) advisoryIncompleteNote("unverified", uUnan);
2151
2265
  // ⟨0.28⟩ …and the count-0 cause. MEASURED before this line: `{ok: true, unverified: []}` over a report
2152
2266
  // that judged nothing — this verb certifying a package it never examined. The exit is untouched.
2153
- if (uComp.judgedNothing.length) advisoryJudgedNothingNote("unverified");
2154
- if (uComp.noManifest.length) advisoryNoManifestNote("unverified", uComp.noManifest);
2267
+ if (uComp.judgedNothing.length) advisoryJudgedNothingNote("unverified", certainViolationFromPolicy(upol, prefix));
2268
+ if (uComp.noManifest.length) advisoryNoManifestNote("unverified", uComp.noManifest, certainViolationFromPolicy(upol, prefix));
2155
2269
  if (uComp.unreadable.length) advisoryUnreadableNote("unverified", uComp.unreadable);
2156
2270
  // ⟨0.24⟩ SPEC §3.2 `4fd140c` — the function the gate could not judge is NAMED in `unverified` above,
2157
2271
  // with the missing evidence as its reason; this is the human channel for the same fact.
package/scan.mjs CHANGED
@@ -1883,6 +1883,16 @@ const depChained = () => depReportsRead > 0;
1883
1883
  // serde_json rule the Rust/JVM engines already carry; /code-review found TS missing it). Fed from
1884
1884
  // the envelope's `package` field (works for an all-pure EMPTY report) and from entry hash prefixes.
1885
1885
  const depCoveredPkgs = new Set();
1886
+ // ⟨scan-boundary, dynamic re-export⟩ Packages whose OWN report declares a `dynamicReexport` site (see
1887
+ // the producer-side `dynamicReexportSites` counter): `Object.keys(impl).forEach(k => exports[k] =
1888
+ // impl[k])` and its `Object.defineProperty(exports, k, …)` twin forward names no static matcher can
1889
+ // enumerate, so a chained package doing this cannot be trusted the way SPEC §2 rule 3 ordinarily trusts
1890
+ // a covered package's silence — the missing name might be exactly one the runtime loop forwards. This is
1891
+ // the fifth "neither voice fired" instance (`1d4f648`, filed not fixed): NEVER resolved (minting an alias
1892
+ // for a runtime-computed key is a guess), only disclosed, at the one place `disclosureTail` already
1893
+ // trusts a covered package's silence. `!stale` guards it below, same discipline as `netClass`/`incomplete`
1894
+ // two sections down — an untrusted report's assertions (even an honest-sounding one) are not ours to repeat.
1895
+ const dynamicReexportPkgs = new Set();
1886
1896
  // Packages whose ONLY chained report failed the §2.1 version check. Kept OUT of `depCoveredPkgs`, because a
1887
1897
  // report the engine has decided not to trust cannot make a coverage claim on the package's behalf: §2 rule 3
1888
1898
  // turns a report's SILENCE into a purity claim, and §2.1 exists to say this report's assertions are not ours
@@ -2163,6 +2173,12 @@ const corruptDepPkgs = new Set();
2163
2173
  if (corrupt) console.error(`candor-ts: chained dependency report ${f} has ${corruptKeys.length} present-but-unparseable §2 key(s)`
2164
2174
  + ` — granted NO coverage, so calls into it read as INVISIBLE rather than pure (SPEC §2 ⟨0.24⟩): ${corruptKeys.join("; ")}`);
2165
2175
  if (typeof d.package === "string" && d.package) covers.add(d.package);
2176
+ // ⟨scan-boundary, dynamic re-export⟩ a STALE report's OWN assertion of anything, including this
2177
+ // one, is not ours to repeat (§2.1) — a build we do not trust cannot vouch for its own shape either.
2178
+ // Purely ADDITIVE otherwise: it can only widen `disclosureTail`'s Unknown arm, never narrow it, so a
2179
+ // garbled or over-eager value here can never fabricate a purity claim.
2180
+ if (!stale && d.dynamicReexport && typeof d.package === "string" && d.package)
2181
+ dynamicReexportPkgs.add(d.package);
2166
2182
  // NEVER ITERATE AN UNREADABLE VALUE. `d.functions ?? []` walked the CHARACTERS of `functions: "oops"`,
2167
2183
  // and `strs()` is why `inferred: "Fs"` can no longer arrive at the consumer as the effect set
2168
2184
  // `['F','s']` — the `??` idiom only guards null/undefined, and every other wrong type in this format
@@ -2518,6 +2534,113 @@ function crossesPackageBoundary(file) {
2518
2534
  return !!own && own !== pkgName && own !== rootOwnerPkg;
2519
2535
  }
2520
2536
 
2537
+ // ⟨fabrication, own-name-collides-with-κ⟩ Is `decl` OUR OWN scanned package's code, reached through a
2538
+ // route that makes it LOOK like an external dependency? `declModule`'s `nearestPackageName` fallback
2539
+ // fires exactly when a declaration's file isn't in `projectFiles` and has no `node_modules/` segment —
2540
+ // the shape of a package's own co-located `dist/foo.js` + `dist/foo.d.ts` (⟨scan-boundary,
2541
+ // own-`.d.ts`-shadow⟩ above). That fallback returns the SCANNED package's own `name`, and if that name
2542
+ // collides with a whole-module κ rule that has a `null` member-regex (got/pg/fs-extra/execa/dotenv/
2543
+ // winston/bcrypt/… — every effect in §1 has at least one), `kappa(ownName, anything)` matches
2544
+ // unconditionally: the package's OWN internal calls get charged its own external effect merely because
2545
+ // nobody else installed a package by that name. Measured: `super(x)` into a same-package base class,
2546
+ // `new ns.Ctor()`, `obj.method()`, and a bare call whose `.js` sibling isn't in `projectFiles` all
2547
+ // resolve `mod` to the scanned package's own name with no redirect to catch it (the `.d.ts`-shadow
2548
+ // redirect above only recovers the bare-identifier-with-sibling shape).
2549
+ //
2550
+ // FILE-based, never name-based: a package that genuinely DEPENDS ON an installed node_modules copy of
2551
+ // a same-named package (self-referential publish, `npm link` loops) must still be charged — that file's
2552
+ // path always carries a `node_modules/` segment (a different `declModule` branch entirely), so it's
2553
+ // excluded outright before the name comparison ever runs.
2554
+ //
2555
+ // "our own package" mirrors `crossesPackageBoundary`'s own definition of "us" — both `pkgName` and
2556
+ // `rootOwnerPkg`, because they can differ (see the comment above).
2557
+ function isOwnPackageDecl(decl) {
2558
+ const f = path.resolve(decl.getSourceFile().fileName);
2559
+ if (/node_modules\//.test(f)) return false;
2560
+ const own = nearestPackageName(f);
2561
+ return !!own && (own === pkgName || own === rootOwnerPkg);
2562
+ }
2563
+
2564
+ // ⟨vein: neither-voice-fired, ATTEMPTED AND REVERTED⟩ `crossesPackageBoundary` answers FALSE for two
2565
+ // different reasons that get the same boolean: genuinely OUR OWN package, and `nearestPackageName`
2566
+ // finding no package.json at all above the file (an unowned file, neither ours nor a dependency's). The
2567
+ // second reading looked like the same hole as the `.d.ts`-shadow cardinal sin one level further out, and
2568
+ // a fix was drafted (treat `own === null` as boundary-crossing for disclosure purposes). MEASURED false:
2569
+ // candor-ts's OWN suite hits this exact shape legitimately — a single-file scan target (`scan.mjs
2570
+ // a.ts`, not the whole package directory) whose sibling import (`./mock`) is outside `projectFiles`
2571
+ // (only the given file was walked) and sits in a directory with no `package.json` (a temp fixture dir).
2572
+ // That sibling is real local code, not a foreign package, and the draft fix charged it `blind` —
2573
+ // converting a correct silent-pure into a fabricated over-disclosure, on `test.mjs`'s own pinned CONTROL
2574
+ // row. A single-file scan target has no reliable way to tell "no owner because nobody has ever recorded
2575
+ // one" apart from "no owner because this scan's OWN target set stops here" — that distinction needs the
2576
+ // scan's notion of its own target boundary, not just a file's package.json ancestry, and is a bigger
2577
+ // change than this pass's remit. Left as a filed, un-closed finding rather than shipped: the corpus and
2578
+ // this codebase's own tests could not tell it apart from a real project file.
2579
+
2580
+ // ⟨scan-boundary, own-`.d.ts`-shadow⟩ npm ships `dist/foo.js` beside `dist/foo.d.ts`, and TypeScript's own
2581
+ // module resolution treats the co-located `.d.ts` as the AUTHORITATIVE type source for every IMPORTER of
2582
+ // `foo.js` — even one this same scan (`--allow-js`) is analysing as project source. A same-file reference
2583
+ // (`this.method()`, a call inside the file that DECLARES the callee) never crosses a module boundary, so it
2584
+ // resolves straight to the `.js` implementation and is unaffected; a CROSS-FILE call — the ordinary case
2585
+ // for a helper imported from a sibling module — resolves the checker's signature onto a node IN THE
2586
+ // `.d.ts` instead (measured: got's own `calculateRetryDelay({...})`, called from `core/index.ts`'s
2587
+ // `_beforeError`, and `new RequestError(...)`/`new TimeoutError(...)` alike). `declModule` is RIGHT to
2588
+ // call that `.d.ts` foreign to `projectFiles` (the file walk deliberately excludes every `.d.ts`, ⟨§2⟩) —
2589
+ // but nothing downstream of that classification was built for "foreign file that is secretly the scan's
2590
+ // own code": `crossesPackageBoundary` is (correctly) false for it, so the cross-package arm's `blind`/
2591
+ // `unanswerableKey` disclosures both decline to fire, on file identity they were never wrong about — and
2592
+ // the call vanishes. Silent, on the DIRECT-effect gate this package's retry path failed the source arm on.
2593
+ //
2594
+ // The fix resolves rather than hedges: ask the SIBLING IMPLEMENTATION FILE — same directory, same
2595
+ // basename, an extension this scan actually analysed (`projectFiles`, so an `@types/node`-style
2596
+ // declaration-only package with no such sibling is untouched by any of this) — for its OWN module symbol
2597
+ // directly. That is not a cross-module import lookup (the thing the `.d.ts`-preference rule governs); it
2598
+ // is asking a SourceFile already open in this program about its own exports, and the checker answers with
2599
+ // the REAL declaration (the arrow function's `VariableDeclaration`, the class itself for a constructor —
2600
+ // exactly the nodes `nodeName` already minted units against in pass 1). A callee name that names exactly
2601
+ // one export redirects `decl`/`mod` to it, so every existing `<local>` arm downstream (class-CHA fan-out,
2602
+ // HOF callback flow, coercion desugars) runs precisely as it would have on an ordinary same-package call —
2603
+ // no parallel machinery, no new report shape. A name that matches NOTHING in the sibling (an export
2604
+ // `nodeName` never minted a unit for) or matches MORE THAN ONE declaration (never guess, the family's
2605
+ // standing rule) still owes a disclosure: `Unknown`, not silence, for the same reason the cross-package
2606
+ // arm discloses when it can name an owner but not a body.
2607
+ function siblingImplFile(dtsFileName) {
2608
+ const m = dtsFileName.match(/\.d\.([cm]?)ts$/);
2609
+ if (!m) return null;
2610
+ const stem = dtsFileName.slice(0, -m[0].length);
2611
+ const exts = m[1] === "m" ? [".mjs", ".mts"] : m[1] === "c" ? [".cjs", ".cts"]
2612
+ : [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"];
2613
+ for (const ext of exts) { const cand = stem + ext; if (projectFiles.has(cand)) return cand; }
2614
+ return null;
2615
+ }
2616
+ // implFile -> Map<exportedLocalName, declaration | undefined>. `undefined` marks a name TWO DIFFERENT
2617
+ // declarations in the same file both claim (never happens for ordinary JS/TS top-level bindings, kept as
2618
+ // a guard rather than an assumption) — treated identically to "not found" by every caller (`.get` returns
2619
+ // the same falsy value either way), so a collision degrades to disclosure, never a guess.
2620
+ const siblingModuleNameMap = new Map();
2621
+ function siblingImplExports(implFile) {
2622
+ if (siblingModuleNameMap.has(implFile)) return siblingModuleNameMap.get(implFile);
2623
+ const map = new Map();
2624
+ const sf = program.getSourceFile(implFile);
2625
+ const modSym = sf && checker.getSymbolAtLocation(sf);
2626
+ if (modSym) {
2627
+ let exps = [];
2628
+ try { exps = checker.getExportsOfModule(modSym); } catch { exps = []; }
2629
+ for (const ex of exps) {
2630
+ let target = ex;
2631
+ if (target.flags & ts.SymbolFlags.Alias) { try { target = checker.getAliasedSymbol(target); } catch { continue; } }
2632
+ for (const d of target?.declarations ?? []) {
2633
+ if (d.getSourceFile().fileName !== implFile) continue;
2634
+ const nm = d.name?.getText?.();
2635
+ if (!nm) continue;
2636
+ map.set(nm, map.has(nm) && map.get(nm) !== d ? undefined : d);
2637
+ }
2638
+ }
2639
+ }
2640
+ siblingModuleNameMap.set(implFile, map);
2641
+ return map;
2642
+ }
2643
+
2521
2644
  // ⟨0.19⟩ The bare-package ROOT of an import specifier: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`,
2522
2645
  // a relative/absolute path → null (not a package). Used to match an import against `declaredButUninstalled`.
2523
2646
  function pkgRoot(spec) {
@@ -2864,6 +2987,80 @@ const fns = new Map(); // qualified name -> { direct, edges, hosts, ta
2864
2987
  const bodylessDecls = new Map(); // qual -> {node, mod, abstract, owner, member}
2865
2988
  const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
2866
2989
  const nodeName = new WeakMap(); // declaration node -> qualified name
2990
+ // ⟨scan-boundary, export-alias⟩ `module.exports = { thing: _internalImplName }` / `exports.thing =
2991
+ // _internalImplName` — a RENAMED re-export whose RHS NAMES an EXISTING declaration rather than being an
2992
+ // inline function. `localName` mints a CJS unit for `module.exports = function(){}` and
2993
+ // `exports.foo = function(){}` (the inline-literal shapes), but a reference names no NEW node to hang a
2994
+ // unit off — the referenced function already has its own unit, under its OWN name. Without this, the
2995
+ // package's report keys the effect under `_internalImplName` only; a consumer whose `.d.ts` types the
2996
+ // import as `thing` (the ordinary case: a hand- or tool-written `.d.ts` has no static connection to the
2997
+ // `.js` runtime shape at all) joins a key the report never had, reads SILENT-PURE, and a real effect
2998
+ // vanishes at the package boundary. Candidates are collected here (during the same walk that mints every
2999
+ // unit) and resolved in ONE place — see below — after every source file's units exist, because the
3000
+ // referenced declaration may sit in a later file or a later line of the same one.
3001
+ //
3002
+ // FOUR spellings feed this list, not one: a bare BinaryExpression assignment (`exports.thing = impl`,
3003
+ // `module.exports.thing = impl`, the bracket variants `exports['thing'] = impl`), the whole-object
3004
+ // literal (`module.exports = { thing: impl }`), and `Object.defineProperty(exports, "thing", { get(){
3005
+ // return impl } })` — TSC's OWN standard CommonJS emit for `export { x } from './impl'`, and what
3006
+ // webpack/rollup's ESM<->CJS interop shims emit too, so it is not an exotic shape but a large fraction of
3007
+ // what published packages actually ship. A single-statement enumeration of assignment shapes proved not
3008
+ // to be the family boundary: the descriptor form is a structurally different node (a CallExpression, not
3009
+ // a BinaryExpression) that carries the SAME "name binds to an existing thing" meaning. What IS shared
3010
+ // across every spelling, and is what actually closes the class rather than the next syntax, is `exportAliasRef`
3011
+ // below: the checker's own alias resolution (`getSymbolAtLocation` + `getAliasedSymbol`, used identically
3012
+ // in the resolution pass beneath) walks through a bare identifier AND a namespace-member access
3013
+ // (`impl_1.x`, what TSC emits for a CROSS-FILE re-export's `require()` binding) to the same real
3014
+ // declaration — so every collection site here only needs to recognize the SHAPE that carries a reference,
3015
+ // not resolve it; resolution is one shared mechanism, checked, not matched.
3016
+ const exportAliasCandidates = []; // [{mod, name, ident}] — ident is the reference naming the real decl
3017
+ const aliasHashTail = new Map(); // alias qual -> the exported name `unitHash` must use INSTEAD of rec.local
3018
+ // ⟨scan-boundary, dynamic re-export⟩ THE FIFTH "neither voice fired" instance (see `dynamicReexportPkgs`
3019
+ // at the consumer side; filed, not attempted, at `1d4f648`): `Object.keys(impl).forEach(k => { exports[k]
3020
+ // = impl[k]; })` and its `Object.defineProperty(exports, k, …)` twin (tslib/rollup's `__exportStar`
3021
+ // generalizes into this when the forwarded name is itself computed) bind an export name to a RUNTIME
3022
+ // STRING no static per-statement matcher can read — resolving it would be guessing, not analysis, so
3023
+ // unlike `exportAliasCandidates` above this is never resolved into a name. What CAN be established
3024
+ // without naming a single one: whether this package performs a dynamic re-export AT ALL. A count, not a
3025
+ // boolean, purely for a human reading the report; the consumer-side gate only asks whether it is nonzero.
3026
+ let dynamicReexportSites = 0;
3027
+ // A WELL-KNOWN SYMBOL key (`Symbol.toStringTag`, `Symbol.iterator`, …) is not a forwarded NAME — it is a
3028
+ // fixed, statically-known value the same in every build, and `import { name } from 'pkg'` can never bind
3029
+ // to a Symbol-keyed property in the first place (there is no string spelling that reaches it). MEASURED
3030
+ // as a false-positive source on the real corpus: `Object.defineProperty(exports, Symbol.toStringTag, {
3031
+ // value: "Module" })` is esbuild/Rollup/tsup's own standard ESM-interop stamp, present in a large
3032
+ // fraction of published bundled CJS output (`@simple-git/args-pathspec`, `@simple-git/argv-parser`), and
3033
+ // carries no forwarding semantics at all — flagging it as a dynamic re-export would mark ordinary bundled
3034
+ // packages "dynamic" and disclose Unknown over every one of their calls for no real reason. `Symbol.for(x)`
3035
+ // with a non-literal `x` is a genuinely dynamic symbol and is NOT excluded here — only a plain `Symbol.<id>`
3036
+ // well-known-symbol access is.
3037
+ function isWellKnownSymbolKey(keyArg) {
3038
+ return ts.isPropertyAccessExpression(keyArg) && ts.isIdentifier(keyArg.expression) && keyArg.expression.text === "Symbol";
3039
+ }
3040
+ // True when `lhs` is `exports[K]` / `module.exports[K]` with K NOT a string literal (and not the
3041
+ // well-known-symbol false positive above) — the bracket-alias site above (`cjsExportPropertyName`)
3042
+ // already claims the string-literal case (`exports['thing']`, answerable); this claims exactly what that
3043
+ // one structurally cannot: a key no static read can name. A non-exports element access (`someObj[k] = v`)
3044
+ // is not this pattern and returns false.
3045
+ function isDynamicExportsKeyWrite(lhs) {
3046
+ if (!ts.isElementAccessExpression(lhs) || !lhs.argumentExpression) return false;
3047
+ if (ts.isStringLiteralLike(lhs.argumentExpression)) return false;
3048
+ if (isWellKnownSymbolKey(lhs.argumentExpression)) return false;
3049
+ const b = lhs.expression.getText().replace(/\s+/g, "");
3050
+ return b === "exports" || b === "module.exports";
3051
+ }
3052
+ // The `Object.defineProperty(exports, K, desc)` twin of the above — the descriptor-form alias site
3053
+ // (`Object.defineProperty(exports, "thing", {...})`) requires `keyArg` to be a string literal; a
3054
+ // non-literal, non-well-known-symbol key targeting `exports`/`module.exports` is the identical
3055
+ // dynamic-forwarding shape one call form over.
3056
+ function isDynamicExportsDescriptor(call) {
3057
+ if (!ts.isCallExpression(call) || call.expression.getText().replace(/\s+/g, "") !== "Object.defineProperty") return false;
3058
+ if (call.arguments.length < 2) return false;
3059
+ const targetText = call.arguments[0].getText().replace(/\s+/g, "");
3060
+ if (targetText !== "exports" && targetText !== "module.exports") return false;
3061
+ const keyArg = call.arguments[1];
3062
+ return !ts.isStringLiteralLike(keyArg) && !isWellKnownSymbolKey(keyArg);
3063
+ }
2867
3064
  // ORM table declarations: `@Entity("user")` on a class maps that class to its table — the JVM's
2868
3065
  // read-the-declarations move (TypeORM tables live in decorators, not SQL strings, so the `tables`
2869
3066
  // surface couldn't fire on the most common TS app shape). LITERAL decorator arg only; a no-arg
@@ -3162,6 +3359,49 @@ function localName(node) {
3162
3359
  }
3163
3360
  return null;
3164
3361
  }
3362
+ // A reference the export-alias pass (see `exportAliasCandidates` above) can resolve THROUGH: a bare
3363
+ // local identifier (`_internalImplName`, the same-file case) or a namespace-member access (`impl_1.x`,
3364
+ // what tsc emits for a CROSS-FILE `export { x } from './impl'` — `impl_1` is the `require()` binding,
3365
+ // and `checker.getSymbolAtLocation` walks straight through the member access to the real declaration in
3366
+ // the required file, MEASURED: compiling `export { x } from './impl'` with this repo's own tsc and
3367
+ // resolving the emitted `impl_1.x` returns `impl.js`'s `FunctionDeclaration` directly). Anything else — a
3368
+ // call, a template, a ternary — names no single declaration to alias and is deliberately left alone; the
3369
+ // resolution pass's existing `nodeName.has(decl)` gate is the actual safety net (a reference that resolves
3370
+ // to something this scan never minted a unit for adds nothing), so widening the accepted SHAPE here can't
3371
+ // by itself fabricate an alias.
3372
+ const exportAliasRef = (node) => ts.isIdentifier(node) || ts.isPropertyAccessExpression(node);
3373
+ // The property name a single-property CJS export assignment's LHS names: `exports.NAME` /
3374
+ // `module.exports.NAME` (PropertyAccessExpression) or the bracket spelling `exports['NAME']` /
3375
+ // `module.exports['NAME']` (ElementAccessExpression with a string-literal key — a COMPUTED key names no
3376
+ // single property and returns null, the same "never guess" rule as everywhere else in this pass).
3377
+ function cjsExportPropertyName(lhs) {
3378
+ const baseText = (n) => n.getText().replace(/\s+/g, "");
3379
+ if (ts.isPropertyAccessExpression(lhs)) {
3380
+ const b = baseText(lhs.expression);
3381
+ return (b === "exports" || b === "module.exports") ? lhs.name.text : null;
3382
+ }
3383
+ if (ts.isElementAccessExpression(lhs) && lhs.argumentExpression && ts.isStringLiteralLike(lhs.argumentExpression)) {
3384
+ const b = baseText(lhs.expression);
3385
+ return (b === "exports" || b === "module.exports") ? lhs.argumentExpression.text : null;
3386
+ }
3387
+ return null;
3388
+ }
3389
+ // The reference an accessor DESCRIPTOR property resolves to: a data descriptor's `value: REF`, or a
3390
+ // `get(){ return REF; }` / `get: () => REF` accessor's sole returned expression (checked against
3391
+ // `exportAliasRef` at the call site, same as every other candidate). A getter doing anything more than
3392
+ // returning one expression is left alone — that is a real computed accessor, not a rename.
3393
+ function descriptorAliasRef(descProp) {
3394
+ if (!ts.isPropertyAssignment(descProp) || ts.isComputedPropertyName(descProp.name)) return null;
3395
+ const name = descProp.name.text ?? descProp.name.getText();
3396
+ if (name === "value") return descProp.initializer;
3397
+ if (name !== "get") return null;
3398
+ const fn = descProp.initializer;
3399
+ if (!(ts.isFunctionExpression(fn) || ts.isArrowFunction(fn)) || !fn.body) return null;
3400
+ if (!ts.isBlock(fn.body)) return fn.body; // arrow concise body: () => REF
3401
+ if (fn.body.statements.length !== 1) return null;
3402
+ const s = fn.body.statements[0];
3403
+ return ts.isReturnStatement(s) && s.expression ? s.expression : null;
3404
+ }
3165
3405
  // True when `node` is lexically inside a FUNCTION body — so its bare local name can collide with a
3166
3406
  // module-level or sibling-scope unit of the same name (see the qual disambiguation below). A namespace/
3167
3407
  // module block and the source file are NOT function scopes (their members are top-level units).
@@ -3238,6 +3478,58 @@ for (const sf of sources) {
3238
3478
  }
3239
3479
  nodeName.set(node, ctorQual);
3240
3480
  }
3481
+ // CJS EXPORT-ALIAS candidate (see `exportAliasCandidates` above): `module.exports = { thing:
3482
+ // _internalImplName }` / `exports.thing = _internalImplName` / `module.exports.thing =
3483
+ // _internalImplName` / the bracket spelling `exports['thing'] = _internalImplName`, where the RHS
3484
+ // is a reference (`exportAliasRef`) rather than the inline-function shapes `localName` below already
3485
+ // handles. Recorded, not resolved, here — resolution needs every file's units minted first.
3486
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
3487
+ const lhs = node.left.getText().replace(/\s+/g, "");
3488
+ const rhs = node.right;
3489
+ if (lhs === "module.exports" && ts.isObjectLiteralExpression(rhs)) {
3490
+ for (const p of rhs.properties) {
3491
+ if (ts.isPropertyAssignment(p) && !ts.isComputedPropertyName(p.name) && exportAliasRef(p.initializer))
3492
+ exportAliasCandidates.push({ mod, name: p.name.text ?? p.name.getText(), ident: p.initializer });
3493
+ // ⟨scan-boundary, dynamic re-export⟩ `module.exports = { [k]: impl[k] }` — the same runtime-string
3494
+ // binding as the bracket-write/descriptor forms, one more spelling over: a COMPUTED property name
3495
+ // in the whole-object reassignment itself. A literal-wrapped computed key (`{ ['thing']: fn }`) is
3496
+ // still statically knowable and excluded, same as everywhere else in this pass.
3497
+ else if (ts.isPropertyAssignment(p) && ts.isComputedPropertyName(p.name)
3498
+ && !ts.isStringLiteralLike(p.name.expression) && !isWellKnownSymbolKey(p.name.expression))
3499
+ dynamicReexportSites += 1;
3500
+ }
3501
+ } else {
3502
+ const name = cjsExportPropertyName(node.left);
3503
+ if (name && exportAliasRef(rhs)) exportAliasCandidates.push({ mod, name, ident: rhs });
3504
+ }
3505
+ }
3506
+ // CJS EXPORT-ALIAS candidate, DESCRIPTOR form: `Object.defineProperty(exports, "thing", { get(){
3507
+ // return _internalImplName } })` — TSC's OWN standard CommonJS emit for `export { x } from './impl'`
3508
+ // (verified against this repo's own tsc output), and what webpack/rollup's ESM<->CJS interop shims
3509
+ // emit too. `descriptorAliasRef` pulls the getter's returned expression (or a data descriptor's
3510
+ // `value`); `exportAliasRef` accepts it whether it's a bare identifier (same-file) or a
3511
+ // `require()`-binding member access (cross-file re-export) — the SAME reference shape the
3512
+ // BinaryExpression arm above accepts, resolved by the identical mechanism below.
3513
+ if (ts.isCallExpression(node) && node.expression.getText().replace(/\s+/g, "") === "Object.defineProperty"
3514
+ && node.arguments.length >= 3) {
3515
+ const targetText = node.arguments[0].getText().replace(/\s+/g, "");
3516
+ const keyArg = node.arguments[1], descArg = node.arguments[2];
3517
+ if ((targetText === "exports" || targetText === "module.exports")
3518
+ && ts.isStringLiteralLike(keyArg) && ts.isObjectLiteralExpression(descArg)) {
3519
+ for (const p of descArg.properties) {
3520
+ const ref = descriptorAliasRef(p);
3521
+ if (ref && exportAliasRef(ref)) { exportAliasCandidates.push({ mod, name: keyArg.text, ident: ref }); break; }
3522
+ }
3523
+ }
3524
+ if (isDynamicExportsDescriptor(node)) dynamicReexportSites += 1;
3525
+ }
3526
+ // ⟨scan-boundary, dynamic re-export⟩ `exports[k] = impl[k]` / `module.exports[k] = impl[k]` — the
3527
+ // fifth "neither voice fired" instance. NOT collected into `exportAliasCandidates`: there is no
3528
+ // NAME here to alias, only a runtime string. Counted, so the package-level `dynamicReexport` envelope
3529
+ // field a chained consumer reads is not zero — see `dynamicReexportSites` above for why a count
3530
+ // rather than a bare boolean, and `dynamicReexportPkgs` for what the consumer does with it.
3531
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
3532
+ && isDynamicExportsKeyWrite(node.left)) dynamicReexportSites += 1;
3241
3533
  const n = localName(node);
3242
3534
  const isCjsExport = _lastCjs; // captured immediately: localName set it for THIS node only
3243
3535
  if (n) {
@@ -3307,6 +3599,46 @@ for (const sf of sources) {
3307
3599
  })(sf);
3308
3600
  }
3309
3601
 
3602
+ // Resolve the CJS export-alias candidates collected above, now that every source file's units exist.
3603
+ // `ident` (an Identifier OR a PropertyAccessExpression — see `exportAliasRef`) names a value at its use
3604
+ // site; follow it through an import alias (the got/`.d.ts`-shadow fix's own move) to the declaration it
3605
+ // actually names — the SAME `getSymbolAtLocation` + `getAliasedSymbol` move resolves a bare identifier
3606
+ // (the same-file case) and a `require()`-binding member access (`impl_1.x`, the cross-file re-export
3607
+ // case) identically, with no separate code path — and only act when that declaration is a unit THIS scan
3608
+ // already minted (`nodeName.has`) — a reference that resolves to nothing local, to an ambient/foreign
3609
+ // declaration, or to more than one candidate declaration (never guess) adds nothing, and the existing
3610
+ // disclosure ladder (blind / unanswerableKey) keeps whatever it already said about the miss. A name that
3611
+ // collides with an EXISTING, DIFFERENT unit is skipped rather than overwritten — this pass only ADDS a key
3612
+ // a real declaration is missing, never reassigns one that already means something else. The alias shares
3613
+ // the SAME record as the original (not a copy): every Set it carries (`direct`, `edges`, `hosts`, …) is
3614
+ // the identical object, so the pass-3 fixpoint below — driven by iterating `fns` — computes the identical
3615
+ // effect closure for both names with no separate bookkeeping, and the ONE place that would otherwise
3616
+ // diverge (`unitHash`, keyed off `rec.local`) is corrected via `aliasHashTail` so the alias's report entry
3617
+ // carries the ALIAS's own hash, not the original's.
3618
+ //
3619
+ // CLASSES ARE EXCLUDED ON PURPOSE. `export class Setter {}` compiles to `class Setter {} exports.Setter =
3620
+ // Setter;` — a SELF-referential CJS export, not a rename — and the identifier resolves to the
3621
+ // ClassDeclaration, whose OWN unit is the SYNTHESIZED CONSTRUCTOR (`nodeName` keys it under
3622
+ // `mod.Setter.constructor`, never bare `mod.Setter`; that IS the hash tail an external `new Setter()`
3623
+ // already joins via `chargeExternalDecl`'s `tailOverride`). Treating the export statement as a candidate
3624
+ // would build `aliasQual = mod.Setter` — DIFFERENT from the ctor's own qual — and mint a SPURIOUS second
3625
+ // hash for it. Measured against date-fns's compiled `fp/cdn.js` (a duplicated bundle) and
3626
+ // `parse/_lib/Setter.js`: two UNRELATED classes both named `ValueSetter` already share the bare-name hash
3627
+ // `date-fns#ValueSetter` (a pre-existing collision this fix must not add a THIRD contributor to), and
3628
+ // aliasing the class production would have done exactly that.
3629
+ for (const { mod, name, ident } of exportAliasCandidates) {
3630
+ let sym = checker.getSymbolAtLocation(ident);
3631
+ if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { sym = undefined; } }
3632
+ const decl = (sym?.declarations ?? []).find((d) => nodeName.has(d) && !ts.isClassDeclaration(d));
3633
+ if (!decl) continue;
3634
+ const qual = nodeName.get(decl);
3635
+ const rec = fns.get(qual);
3636
+ const aliasQual = `${mod}.${name}`;
3637
+ if (!rec || aliasQual === qual || fns.has(aliasQual)) continue;
3638
+ fns.set(aliasQual, rec);
3639
+ aliasHashTail.set(aliasQual, name);
3640
+ }
3641
+
3310
3642
  // Class-CHA universe (the override half of the Rust engine's local-trait / bounded-CHA move): a
3311
3643
  // method call on a BASE-class-typed receiver resolves statically to the base method, but a SUBCLASS
3312
3644
  // may override it with an effectful body — `class Dog extends Animal { speak(){ fs.readFileSync() } }`.
@@ -3823,6 +4155,32 @@ function unanswerableKey(decl) {
3823
4155
  if ((ts.isMethodDeclaration(decl) || ts.isPropertyDeclaration(decl)
3824
4156
  || ts.isGetAccessorDeclaration(decl) || ts.isSetAccessorDeclaration(decl))
3825
4157
  && (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Abstract)) return decl; // `abstract` member of a declared class
4158
+ // A BARE call/construct signature reached DIRECTLY — not through a property/method (that shape is
4159
+ // already normalized above by `memberSigOf`) but as the callable shape of the value itself:
4160
+ // `interface Fetcher { (url: string): Response }`, a callable type alias, or an intersection of call
4161
+ // signatures (tar's `TarCommand<AsyncClass, SyncClass>` — `exports.create` is typed this way and
4162
+ // reached as a bare `tar.create(...)` call, not a member dispatch). There is no body under this key in
4163
+ // ANY implementation — a call signature declares a shape, never flesh — so a chained report's silence
4164
+ // answers nothing, the same evidential status as an interface method. Measured: --dep-inits chained a
4165
+ // real `tar` scan (its module units DO carry Unknown-tagged effects) and this call still read
4166
+ // SILENT-PURE, because `unanswerableKey` returned null for it — the join correctly found no crossDeps
4167
+ // hit (a bare call signature mints no name to hash), but with no `abstraction` either, neither
4168
+ // disclosure arm fires and the call vanishes from the callgraph. Restricted to a signature with NO
4169
+ // name (`decl.name` is always absent on Call/ConstructSignatureDeclaration, so this never overlaps a
4170
+ // named member) — the same reason PropertySignature/MethodSignature are unconditionally unanswerable.
4171
+ if (ts.isCallSignatureDeclaration(decl) || ts.isConstructSignatureDeclaration(decl)) return decl;
4172
+ // A bare FUNCTION-TYPE node with no redirect above — `memberSigOf` only lifts one whose PARENT is a
4173
+ // property/method signature; this is everything else the checker can still resolve a call onto: a
4174
+ // VALUE-BINDING export's explicit annotation (`export declare const chownr: (p, uid, gid, cb) =>
4175
+ // void`, the shape `.d.ts` emits for `export const chownr = (p, ...) => {...}` — chownr, and any
4176
+ // package using this ordinary TS style, not just tar's intersection-of-call-signatures), a callable
4177
+ // type alias's aliased type, or a parameter/type-argument position. A FunctionTypeNode is a type
4178
+ // annotation, never a body — the same "shape, not flesh" argument as a call/construct signature above
4179
+ // — so landing here means the checker resolved a TYPE, and the join two blocks up already tried and
4180
+ // failed to build a key from it (`nameDecl.name` is undefined on a FunctionTypeNode, so `localTail`
4181
+ // stayed null and no lookup ran) — there is no report shape that could have answered this key, chained
4182
+ // or not, so its absence is not evidence of anything.
4183
+ if (ts.isFunctionTypeNode(decl)) return decl;
3826
4184
  return null;
3827
4185
  }
3828
4186
  // ⟨scan-boundary, half 1⟩ THE DISCLOSURE ITSELF, in ONE place. It had two copies — the CallExpression arm
@@ -3854,29 +4212,22 @@ const dispatchWhy = (qualifiedOwner, member) =>
3854
4212
  qualifiedOwner && member ? `dispatch:${qualifiedOwner}.${member}`
3855
4213
  : `callback:${qualifiedOwner ?? member ?? "unresolved call"}`;
3856
4214
 
3857
- // Charge `rec` for reaching a resolved EXTERNAL declaration through a DESUGARED site — one that is not a
3858
- // CallExpression, so the (CLASSIFY)/join/ledger arm of the call path never sees it. This is the same
3859
- // decision procedure that arm runs, in the same order: the chained sibling report (the SPEC §2 `hash`
3860
- // join), then the §5.1 package manifest, then the κ-coverage ledger's `invisible`. Nothing is fabricated:
3861
- // a member declared in the ES lib / a project file / an unnameable package resolves to nothing and adds
3862
- // nothing, and a chained report that omits the entry is making its purity claim (SPEC §2 rule 3).
3863
- // `tailOverride` names the dep report's local tail explicitly when the declaration cannot supply it — a
3864
- // CONSTRUCTOR has no `name`, and the producer hashed it as `<Class>.constructor`.
3865
- function chargeExternalDecl(rec, decl, tailOverride) {
3866
- if (!rec || !decl) return;
3867
- const mod = declModule(decl);
3868
- if (!mod || mod.startsWith("<")) return; // project source / the ES lib — not a package reach
3869
- const pkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
3870
- const nameDecl = memberSigOf(decl); // a function-typed property names its member one level up
3871
- const member = nameDecl.name?.getText?.();
3872
- // Owner-prefixed first (`Owner.member` — how the dep's own scan hashes a method), bare member as the
3873
- // fallback (a CJS dist scan hashes a top-level export under its bare name). Identical to the call arm.
3874
- const owner = nameDecl.parent?.name?.getText?.();
3875
- const hit = tailOverride ? crossDeps.get(`${pkg}#${tailOverride}`)
3876
- : member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
3877
- ?? crossDeps.get(`${pkg}#${member}`));
3878
- if (hit) { applyDepHit(rec, hit); return; }
3879
- const file = decl.getSourceFile().fileName;
4215
+ // ⟨THE FUNNEL⟩ Every site that reaches a resolved EXTERNAL declaration whose own κ lookup found nothing
4216
+ // answers the SAME question — chained sibling report, §5.1 manifest, κ-coverage ledger, or the
4217
+ // unanswerable-key disclosure — and it used to answer it up to four times over, independently, in the
4218
+ // CallExpression (CLASSIFY) arm, the desugared-declaration arm (coercion/HOF-ref sites, this function),
4219
+ // and the tagged-template arm. Two of those copies drifted out of step with each other and a third never
4220
+ // grew the unanswerable-key half at all — the exact "two individually-correct checks whose conjunction is
4221
+ // silence" shape hit twice already (⟨0.33⟩ `.d.ts`-shadow, ⟨0.33⟩ `--dep-inits`). `disclosureTail` is now
4222
+ // the ONE place that ordering lives; every caller reaches it (directly, or through this function) rather
4223
+ // than re-deriving the four-conjunct AND. Its contract: given a declaration already established as an
4224
+ // external reach (`mod` computed, not project/lib source), it either adds a real effect (the chained
4225
+ // dep's own recorded hit, a declared-pure `[]`), discloses (`blind`, `Unknown`), or does nothing ONLY
4226
+ // because the reach is legitimately unaccounted-for by this funnel (κ already reviewed it and said pure,
4227
+ // or the dependency's own report already answered by omission — SPEC §2 rule 3) — never a fourth,
4228
+ // unexamined silent case. Called directly for a resolved external CallExpression too (see the CLASSIFY
4229
+ // arm and the tagged-template arm) so the whole family shares one implementation.
4230
+ function disclosureTail(rec, decl, pkg, file) {
3880
4231
  const declared = packageManifestEffects(file);
3881
4232
  if (declared !== null) { for (const e of declared) rec.direct.add(e); return; } // [] = declared pure
3882
4233
  const abstraction = unanswerableKey(decl);
@@ -3892,12 +4243,50 @@ function chargeExternalDecl(rec, decl, tailOverride) {
3892
4243
  if (abstraction && incompleteDepPkgs.has(pkg)) discloseUnanswerableKey(rec, pkg, abstraction);
3893
4244
  return;
3894
4245
  }
3895
- // ⟨scan-boundary, half 1⟩ the UNANSWERABLE key, on the desugared sites too — the CallExpression arm's
3896
- // twin (see there for the argument). `[1].forEach(job.run)` where `job` is typed as a chained dep's
3897
- // INTERFACE hands the join a key no report can carry, and a covered package silences the ledger, so the
3898
- // caller read confidently pure. Same three conjuncts, same reason class.
4246
+ // ⟨scan-boundary, half 1⟩ the UNANSWERABLE key. `[1].forEach(job.run)` where `job` is typed as a chained
4247
+ // dep's INTERFACE hands the join a key no report can carry, and a covered package silences the ledger,
4248
+ // so the caller read confidently pure. Same three conjuncts, same reason class.
3899
4249
  if (abstraction && depCoveredPkgs.has(pkg) && crossesPackageBoundary(file))
3900
4250
  discloseUnanswerableKey(rec, pkg, abstraction);
4251
+ // ⟨scan-boundary, half 2 — dynamic re-export⟩ THE FIFTH "neither voice fired" instance (`1d4f648`,
4252
+ // filed there rather than fixed). SPEC §2 rule 3 reads a covered package's silence under a key as that
4253
+ // key's purity claim — but a package that ALSO performs a dynamic re-export write somewhere
4254
+ // (`dynamicReexportPkgs`, fed by the producer's `dynamicReexportSites`) forwards names no static
4255
+ // matcher could enumerate, so its silence under THIS particular key is no longer distinguishable from
4256
+ // "forwarded through the runtime loop and never independently examined". Guarded on `!abstraction`
4257
+ // (the arm above already disclosed when there was a real reason) and never resolves a name — minting an
4258
+ // alias for a runtime-computed key would be a guess, exactly what this class exists to refuse. A package
4259
+ // with NO dynamic re-export write is unaffected (`dynamicReexportPkgs` empty), byte-identical to
4260
+ // pre-fix output; a package that dynamically re-exports a genuinely PURE function gains this same
4261
+ // `Unknown`, never a fabricated concrete effect — the honest answer either way.
4262
+ if (!abstraction && depCoveredPkgs.has(pkg) && crossesPackageBoundary(file) && dynamicReexportPkgs.has(pkg)) {
4263
+ rec.direct.add("Unknown");
4264
+ rec.why.add(`reflect:dynamic-reexport:${pkg}`);
4265
+ }
4266
+ }
4267
+ // Charge `rec` for reaching a resolved EXTERNAL declaration through a DESUGARED site — one that is not a
4268
+ // CallExpression, so the (CLASSIFY)/join/ledger arm of the call path never sees it. This is the same
4269
+ // decision procedure that arm runs, in the same order: the chained sibling report (the SPEC §2 `hash`
4270
+ // join), then `disclosureTail` (manifest, then the κ-coverage ledger's `invisible`). Nothing is
4271
+ // fabricated: a member declared in the ES lib / a project file / an unnameable package resolves to
4272
+ // nothing and adds nothing, and a chained report that omits the entry is making its purity claim (SPEC §2
4273
+ // rule 3). `tailOverride` names the dep report's local tail explicitly when the declaration cannot supply
4274
+ // it — a CONSTRUCTOR has no `name`, and the producer hashed it as `<Class>.constructor`.
4275
+ function chargeExternalDecl(rec, decl, tailOverride) {
4276
+ if (!rec || !decl) return;
4277
+ const mod = declModule(decl);
4278
+ if (!mod || mod.startsWith("<")) return; // project source / the ES lib — not a package reach
4279
+ const pkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
4280
+ const nameDecl = memberSigOf(decl); // a function-typed property names its member one level up
4281
+ const member = nameDecl.name?.getText?.();
4282
+ // Owner-prefixed first (`Owner.member` — how the dep's own scan hashes a method), bare member as the
4283
+ // fallback (a CJS dist scan hashes a top-level export under its bare name). Identical to the call arm.
4284
+ const owner = nameDecl.parent?.name?.getText?.();
4285
+ const hit = tailOverride ? crossDeps.get(`${pkg}#${tailOverride}`)
4286
+ : member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
4287
+ ?? crossDeps.get(`${pkg}#${member}`));
4288
+ if (hit) { applyDepHit(rec, hit); return; }
4289
+ disclosureTail(rec, decl, pkg, decl.getSourceFile().fileName);
3901
4290
  }
3902
4291
 
3903
4292
  // ---- implicit VALUE-COERCION desugaring (the silent-pure holes where the JS coercion protocol calls a
@@ -4517,7 +4906,7 @@ function visitCalls(node) {
4517
4906
  if (owner) {
4518
4907
  const rec = fns.get(owner);
4519
4908
  const sig = checker.getResolvedSignature(node);
4520
- const decl = sig && sig.declaration;
4909
+ let decl = sig && sig.declaration;
4521
4910
  if (!decl) {
4522
4911
  // `new C()` on a class with an IMPLICIT constructor resolves to no declaration — edge to
4523
4912
  // the class's (synthesized) ctor unit via the class identifier before concluding Unknown.
@@ -4582,7 +4971,27 @@ function visitCalls(node) {
4582
4971
  }
4583
4972
  }
4584
4973
  } else {
4585
- const mod = declModule(decl);
4974
+ let mod = declModule(decl);
4975
+ // OWN-`.d.ts`-SHADOW redirect (see `siblingImplFile`): a cross-file call the checker typed through
4976
+ // a co-located declaration file this scan's file walk excluded from `projectFiles` on purpose.
4977
+ // Gated on the declaration file actually BEING a `.d.ts` with a SIBLING this scan analysed — an
4978
+ // ordinary external dependency (`@types/node`, an uninstalled package's types) has no such sibling
4979
+ // and this never touches its handling below.
4980
+ if (mod !== "<local>" && ts.isIdentifier(node.expression) && /\.d\.[cm]?ts$/.test(decl.getSourceFile().fileName)) {
4981
+ const implFile = siblingImplFile(decl.getSourceFile().fileName);
4982
+ if (implFile) {
4983
+ const redirected = siblingImplExports(implFile).get(node.expression.text);
4984
+ if (redirected && nodeName.has(redirected)) { decl = redirected; mod = "<local>"; }
4985
+ else {
4986
+ // The sibling exists (this IS our own scanned code) but no single export answers the
4987
+ // callee's name — an ambiguous collision, or a shape `nodeName` never minted a unit for.
4988
+ // The ordinary cross-package arm below stays silent for this exact file (same reasoning
4989
+ // `crossesPackageBoundary` already applies to it), so disclose here or the call vanishes.
4990
+ rec.direct.add("Unknown");
4991
+ rec.why.add(`callback:${node.expression.text.slice(0, 40)}`);
4992
+ }
4993
+ }
4994
+ }
4586
4995
  // A LOCAL function/method passed BY REFERENCE to a NON-LOCAL (opaque) callee — `xs.map(loadFree)`,
4587
4996
  // `arr.forEach(this.m)`, `setTimeout(handler)`, `external(cb)` — may be INVOKED by that callee, so
4588
4997
  // its effects are reachable here. The precise callback-flow below only resolves a LOCAL callee's
@@ -4716,7 +5125,10 @@ function visitCalls(node) {
4716
5125
  const kMod = d2 && declModule(d2);
4717
5126
  const kMember = d2?.name?.getText?.()
4718
5127
  ?? (ts.isPropertyAccessExpression(invokedRef) ? invokedRef.name.text : ts.isIdentifier(invokedRef) ? invokedRef.text : null);
4719
- const kEff = kMod && kMember ? kappa(kMod, kMember) : null;
5128
+ // SELF-NAME GUARD (see `isOwnPackageDecl`): the reflectively-invoked reference may be OUR
5129
+ // OWN package's own function, reached through its own `dist/*.d.ts` — never let κ answer
5130
+ // for it, same reasoning as the (CLASSIFY) arm.
5131
+ const kEff = kMod && kMember && !isOwnPackageDecl(d2) ? kappa(kMod, kMember) : null;
4720
5132
  if (kEff) {
4721
5133
  rec.direct.add(kEff);
4722
5134
  if (kEff === "Unknown") rec.why.add(`reflect:${kMod.replace(/^node:/, "")}.${kMember}`);
@@ -4730,6 +5142,13 @@ function visitCalls(node) {
4730
5142
  rec.direct.add("Unknown");
4731
5143
  rec.why.add(`callback:${recvText.slice(0, 40)}.${m}`); // method on an indeterminate-valued receiver (no resolvable owner TYPE) — canonical `callback:`, not the frontier's `dispatch:OWNER.member`
4732
5144
  }
5145
+ // THE FUNNEL: `d2` resolved to a concrete, non-local declaration that named neither a κ effect
5146
+ // nor a value-holder shape above — a reflective `.call`/`.apply`/`Reflect.apply` onto an
5147
+ // uncurated dependency's exported function (`depFn.call(this, x)`), the by-reference sibling
5148
+ // of the HOF-ref arm's own `chargeExternalDecl(rec, d2)` call. Without this the invoke was
5149
+ // NEITHER edged, NOR κ-classified, NOR disclosed — silent-pure on a call that unquestionably
5150
+ // runs caller-reachable code, the same shape as the two cardinal sins this funnel closes.
5151
+ else if (d2 && !declIsLocal(d2)) chargeExternalDecl(rec, d2);
4733
5152
  }
4734
5153
  }
4735
5154
  // EXPLICIT iterator force: `it.next()` / `it.return()` / `it.throw()` on an OPAQUE iterator
@@ -5110,9 +5529,26 @@ function visitCalls(node) {
5110
5529
  let kMod = mod;
5111
5530
  if (ctorClassDecl && !kappa(mod, member)) {
5112
5531
  const cm = declModule(ctorClassDecl);
5113
- if (cm !== mod && kappa(cm, member)) kMod = cm;
5532
+ // The re-key must not LAND us on our own package's name either (`isOwnPackageDecl`,
5533
+ // fabrication vein above): a same-package class with no own constructor, inherited from a
5534
+ // sibling declared in the same `dist/*.d.ts` tree, re-keys onto the scanned package's own
5535
+ // name exactly like the direct case below — never accept that as an external module.
5536
+ if (cm !== mod && !isOwnPackageDecl(ctorClassDecl) && kappa(cm, member)) kMod = cm;
5114
5537
  }
5115
- let eff = kappa(kMod, member); // (CLASSIFY)
5538
+ // SELF-NAME GUARD (fabrication only, not resolution — see `isOwnPackageDecl`): `decl` may be
5539
+ // OUR OWN scanned package's code reached through its own co-located `dist/*.d.ts`, which
5540
+ // `declModule` resolves to the scanned package's own name — a whole-module κ rule (a `null`
5541
+ // member-regex) then matches unconditionally and charges the package's own internal calls its
5542
+ // own effect. This fires for every call SHAPE the `.d.ts`-shadow redirect above doesn't reach
5543
+ // (`super(x)`, `obj.method()`, `new ns.Ctor()`, a bare call whose `.js` sibling isn't in
5544
+ // `projectFiles`) — refusing to let κ answer is enough to stop the fabrication: `mod`/`kMod`
5545
+ // are left exactly as `declModule` computed them (never rewritten to `"<local>"`), so the
5546
+ // κ-coverage ledger below — already keyed on `crossesPackageBoundary`, which already treats
5547
+ // this exact file as OURS — falls silent precisely as it does for an own-package name that
5548
+ // never happened to collide with anything (no new disclosure, no fabrication). A genuine
5549
+ // node_modules-installed dependency of the same name is untouched: `isOwnPackageDecl` excludes
5550
+ // `node_modules/` outright, before the name is ever compared.
5551
+ let eff = isOwnPackageDecl(decl) ? null : kappa(kMod, member); // (CLASSIFY)
5116
5552
  // process.stdout/stderr/stdin are typed `tty.WriteStream`, which EXTENDS `net.Socket`, so a
5117
5553
  // `.write()`/`.end()` on them resolves to `net.Socket.write` and the whole-module Net rule
5118
5554
  // paints it Net. But a console write to fd 0/1/2 is TTY/console I/O, NOT network — there is no
@@ -5331,48 +5767,12 @@ function visitCalls(node) {
5331
5767
  // via @types/lodash was falsely disclosed — kappaKnows saw the unstripped name).
5332
5768
  const pkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
5333
5769
  const file = decl.getSourceFile().fileName;
5334
- // SPEC §5.1: a package that DECLARES its effects (candorEffects in package.json) is read
5335
- // at the declared-not-verified tier — its effects are attributed and it is NOT a blind
5336
- // spot. Otherwise the κ ledger names it (an uncurated dependency the review must read).
5337
- const declared = packageManifestEffects(file);
5338
- const abstraction = unanswerableKey(decl);
5339
- if (declared !== null) {
5340
- for (const e of declared) rec.direct.add(e); // [] = declared pure: covered, adds nothing
5341
- } else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
5342
- unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
5343
- // Per-fn HONESTY: this fn calls into a genuinely-blind package (κ-unknown, not dep-covered).
5344
- // Recorded per fn, propagated transitively, emitted as `invisible` — so `inferred` is never an
5345
- // unqualified completeness claim. This branch already IS the global-blind condition, so no
5346
- // post-filter is needed (κ either knows a package or it doesn't).
5347
- rec.blind.add(pkg);
5348
- // ⟨0.21⟩ …and half 1 STILL speaks for a package whose only chained report declares itself
5349
- // incomplete, which lands here because its coverage was withheld. See the twin in
5350
- // `chargeExternalDecl`: the ledger hedge must ADD to the Unknown, never replace it, or the
5351
- // completeness fix silently narrows `deny E Unknown[dispatch]` (standing bar item 0).
5352
- if (abstraction && incompleteDepPkgs.has(pkg)) discloseUnanswerableKey(rec, pkg, abstraction);
5353
- } else if (abstraction && depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
5354
- // ⟨scan-boundary, half 1⟩ THE UNANSWERABLE KEY, candor-spec DEP-RECEIVER-TYPING-DESIGN.md.
5355
- // A chained lookup that comes back empty has two readings with OPPOSITE evidential weight:
5356
- // * KEYED-AND-MISSED — the key names a BODY the dep scanned (`declare class C { m() }`,
5357
- // `declare function f()`), and the dep's report omits pure functions, so absence IS its
5358
- // answer (SPEC §2 rule 3). Silence is correct; nothing happens here.
5359
- // * NO ANSWERABLE KEY — resolution landed on an ABSTRACTION (an interface method/property
5360
- // signature, an anonymous type-literal member, an `abstract` member). There is no body
5361
- // under that name in the dependency, so its report can NEVER carry the key, whatever the
5362
- // implementations do. No question was asked, and silence answers none.
5363
- // TypeScript reaches this case by a different road than rust: a receiver it genuinely cannot
5364
- // type is `any`, which already reads `callback:` Unknown. Its unformed key is the receiver
5365
- // typed to an abstraction the dependency's report has no vocabulary for — `build(): Fetcher`
5366
- // exported over a `.d.ts` whose only implementation, `Client`, is what the dep actually
5367
- // hashed. Measured: `go` was absent from the report entirely while the dep's own report read
5368
- // `depkit#Client.fetch -> ['Fs']`.
5369
- // THE THIRD CONJUNCT (`depCoveredPkgs.has(pkg)` — the dependency is CHAINED) is the arm
5370
- // above, and it is load-bearing rather than incidental: for an UNCHAINED package the κ ledger
5371
- // already discloses `invisible: [pkg]`, so a second voice would be pure false uncertainty. It
5372
- // is exactly when the package IS covered that the ledger correctly falls silent and that
5373
- // silence becomes the confident purity claim this rung exists to prevent.
5374
- discloseUnanswerableKey(rec, pkg, abstraction);
5375
- }
5770
+ // SPEC §5.1 manifest, then the κ-coverage ledger, then the unanswerable-key disclosure
5771
+ // (candor-spec DEP-RECEIVER-TYPING-DESIGN.md) — THE FUNNEL, see `disclosureTail`. This arm
5772
+ // already ran its own chained-dep lookup above (`inheritedFromDep`, with its constructor/
5773
+ // owner-prefix spelling), so it hands the tail an already-external, already-unmatched `decl`
5774
+ // rather than re-deriving the join.
5775
+ disclosureTail(rec, decl, pkg, file);
5376
5776
  }
5377
5777
  }
5378
5778
  }
@@ -5910,19 +6310,21 @@ function visitCalls(node) {
5910
6310
  // nothing — no fabrication.
5911
6311
  const mod = declModule(decl);
5912
6312
  const member = decl.name ? decl.name.getText() : "";
5913
- const eff = kappa(mod, member);
6313
+ // SELF-NAME GUARD (see `isOwnPackageDecl`): an external-looking tag may be OUR OWN package's
6314
+ // own function, reached through its own `dist/*.d.ts` — never let κ answer for it. `mod` stays
6315
+ // whatever `declModule` computed, so the ledger below (already keyed on
6316
+ // `crossesPackageBoundary`, which already treats this file as ours) falls silent exactly as it
6317
+ // does for an own-package name that never collided with anything.
6318
+ const eff = isOwnPackageDecl(decl) ? null : kappa(mod, member);
5914
6319
  if (eff) { rec.direct.add(eff); if (eff === "Unknown") rec.why.add(`reflect:${mod.replace(/^node:/, "")}.${member}`); }
5915
- else {
5916
- const file = decl.getSourceFile().fileName;
5917
- const pkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
5918
- const declared = packageManifestEffects(file);
5919
- if (declared !== null) { for (const e of declared) rec.direct.add(e); }
5920
- else if (!mod.startsWith("<") && !kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
5921
- && crossesPackageBoundary(file)) {
5922
- unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
5923
- rec.blind.add(pkg);
5924
- }
5925
- }
6320
+ // THE FUNNEL (see `chargeExternalDecl`/`disclosureTail`): this arm used to hand-roll only the
6321
+ // manifest + blind-ledger half — no chained-dep join, no unanswerable-key disclosure — so a
6322
+ // tagged call into a covered-but-abstract dependency (an interface-typed `sql` tag, say) vanished
6323
+ // exactly like the two cardinal sins this funnel exists to prevent, one arm silently missing two
6324
+ // of the four checks the other arms already had. Routing through the shared function costs nothing
6325
+ // for the cases this arm already got right (declared-pure, an uncurated/uncovered blind package —
6326
+ // both unchanged) and closes the two it never had.
6327
+ else chargeExternalDecl(rec, decl);
5926
6328
  }
5927
6329
  }
5928
6330
  }
@@ -6426,7 +6828,10 @@ for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`
6426
6828
  // name this scan has no record of, and empty is OMITTED from the row rather than guessed at.
6427
6829
  const unitHash = (name) => {
6428
6830
  const rec = fns.get(name);
6429
- return rec ? `${pkgName}#${rec.local}` : "";
6831
+ // An export-alias entry (see `exportAliasCandidates`) SHARES its rec with the original declaration, so
6832
+ // `rec.local` still names the ORIGINAL — `aliasHashTail` carries the alias's own exported name, the tail
6833
+ // its `.d.ts` and a consumer's join actually use.
6834
+ return rec ? `${pkgName}#${aliasHashTail.get(name) ?? rec.local}` : "";
6430
6835
  };
6431
6836
  const functions = [];
6432
6837
  for (const [name, rec] of fns) {
@@ -6999,6 +7404,12 @@ if (partnersUsed.size) {
6999
7404
  // OMITTED when empty — a complete scan stays byte-identical to a pre-rung report — so a MACHINE reading
7000
7405
  // --json sees the incompleteness the stderr warning alone used to hide.
7001
7406
  if (unanalyzedUnits.length) envelope.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
7407
+ // ⟨scan-boundary, dynamic re-export⟩ the fifth "neither voice fired" instance's ONLY provenance across
7408
+ // the package boundary — see `dynamicReexportSites` for the shapes counted and `dynamicReexportPkgs` for
7409
+ // what a chained consumer does with it. OMITTED when zero, the same wire-compatibility rule as every
7410
+ // other envelope field on this file: a package with no dynamic re-export write is byte-identical to a
7411
+ // pre-this-fix report.
7412
+ if (dynamicReexportSites) envelope.dynamicReexport = { count: dynamicReexportSites };
7002
7413
  const cg = {};
7003
7414
  for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
7004
7415
  // Write ATOMICALLY (temp + rename): a concurrent reader — the MCP server or another `query` while
@@ -7054,7 +7465,17 @@ let peekAttempted = false;
7054
7465
  // per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
7055
7466
  const peekUnread = new Set();
7056
7467
  let peekUnattributed = false;
7057
- if (policyPath && excludedFiles.length) {
7468
+ if (policyPath) {
7469
+ // ⟨0.33⟩ NO LONGER GATED ON `excludedFiles.length`. A tree with a policy and NOTHING excluded used to
7470
+ // skip this whole block, so `outOfScopeFindings`/`scannedUnderRules` stayed `null` and both
7471
+ // `outOfScope` and `scannedUnder` were OMITTED — the SAME document a tree with no policy at all
7472
+ // produces. SPEC §2 ⟨0.29⟩/⟨0.33⟩ bind both keys' presence to "a policy was CONFIGURED and HONOURED",
7473
+ // never to "there was something to peek": present-and-empty is *asked-and-clear*, and collapsing it
7474
+ // into *never asked* is the exact ⟨0.26⟩ partial-manifest failure this format exists to prevent.
7475
+ // MEASURED four-way: candor-java and candor-rust both emit `outOfScope: []` and `scannedUnder` over a
7476
+ // no-exclusion tree; this engine emitted neither. The subprocess peek itself still has nothing to do
7477
+ // when there is nothing excluded — that stays conditioned on `excludedFiles.length` below — but the
7478
+ // QUESTION was still asked and the answer is still recorded.
7058
7479
  // ⟨0.30⟩ HOISTED, because the matcher below needs it. It was a `const` inside the try, so the
7059
7480
  // evaluatePolicy call added in this rung threw ReferenceError straight into the "a peek that cannot
7060
7481
  // run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
@@ -7077,8 +7498,9 @@ if (policyPath && excludedFiles.length) {
7077
7498
  // passed at exit 0 while the strictly weaker `deny Exec` exited 2 on the same files. MEASURED four-way.
7078
7499
  if ((peekPolicy?.deny ?? []).length) {
7079
7500
  // ⟨0.32⟩ THE PEEK WAS ATTEMPTED — this run's policy asks a question whose answer depends on code
7080
- // outside the scan's scope, and there is something excluded to look at. The `unreadClasses` rule below
7081
- // keys on THIS, not on whether the peek then succeeded; see the note there.
7501
+ // outside the scan's scope. ⟨0.33⟩ this fires even when NOTHING is excluded — the question was still
7502
+ // put, and `[]` answers it. The `unreadClasses` rule below keys on THIS, not on whether the peek then
7503
+ // succeeded; see the note there.
7082
7504
  peekAttempted = true;
7083
7505
  outOfScopeFindings = [];
7084
7506
  // ⟨0.33⟩ RECORDED HERE, ALONGSIDE `outOfScopeFindings`'s own assignment, and BEFORE any file is
@@ -7087,6 +7509,10 @@ if (policyPath && excludedFiles.length) {
7087
7509
  // is the SAME renderer `ruleUpgrade` quotes back to an operator for the provable-purity upgrade, so
7088
7510
  // the string an operator reads and the string a gate compares cannot become two spellings of one rule.
7089
7511
  scannedUnderRules = canonicalDenySet(peekPolicy.deny);
7512
+ // ⟨0.33⟩ NOTHING TO PEEK: the question above is still asked and answered (`outOfScopeFindings`/
7513
+ // `scannedUnderRules` are already set), but spawning a child scan over an empty file list buys
7514
+ // nothing — skip the subprocess entirely and leave the findings at the trivially-true `[]`.
7515
+ if (excludedFiles.length) {
7090
7516
  // ⟨0.31⟩ A FILE THIS ENGINE CANNOT READ CANNOT BE PEEKED, AND THE CLASS MUST SAY SO.
7091
7517
  //
7092
7518
  // MEASURED: an excluded file performing a denied `Fs`, with the policy `deny Fs`. Readable, the peek
@@ -7274,6 +7700,7 @@ if (policyPath && excludedFiles.length) {
7274
7700
  } finally {
7275
7701
  fs.rmSync(peekDir, { recursive: true, force: true });
7276
7702
  }
7703
+ }
7277
7704
  }
7278
7705
  }
7279
7706
  // ⟨0.30⟩ NOTHING ANALYZABLE, AND THE PEEK FOUND NOTHING EITHER: REFUSE — AND REFUSE BEFORE AN ENVELOPE