candor-ts 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
21
21
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
22
22
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
23
23
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
24
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.29)."*
24
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.31)."*
25
25
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
26
26
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
27
27
  >
@@ -292,3 +292,12 @@ far as candor could see, but it could not see through these" (a LOWER bound), no
292
292
  dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
293
293
  `package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
294
294
  the declared set instead of contributing nothing.
295
+
296
+ - **⟨0.30⟩ A GREEN GATE CAN NOW EXIT 2 — read `outOfScope` before you trust a pass.** When a policy is
297
+ configured, candor also reads the files the scan EXCLUDED (test files, build scripts, archives under the
298
+ root, files outside the build's program) and reports any that perform an effect the policy DENIES, under
299
+ the report's `outOfScope` key. A non-empty block makes the verdict `ok:false`, `incomplete:true`, exit 2
300
+ — *"I could not see enough of this tree to certify it"*, which is NOT the same as "your code violates":
301
+ those functions are never in `violations`, because the gate did not judge them. Branch on `incomplete`
302
+ to tell the two apart. An absent key means the producer was never asked (no policy at scan time), and an
303
+ empty one means asked-and-clear.
package/README.md CHANGED
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
192
192
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
193
193
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
194
194
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
195
- | `{ candor: { version, toolchain, spec: "0.29" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
195
+ | `{ candor: { version, toolchain, spec: "0.31" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
196
196
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
197
197
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
198
198
 
@@ -210,7 +210,7 @@ read the Rust source".
210
210
 
211
211
  ## Status
212
212
 
213
- 0.28.0, speaking candor-spec 0.29: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.30.0, speaking candor-spec 0.31: the analysis core, the gate (`--policy` / `--gate-json` /
214
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
215
215
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
216
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.29.1",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.29)",
3
+ "version": "0.31.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.31)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -66,6 +66,7 @@
66
66
  "verify-emit.mjs",
67
67
  "verify-loader.mjs",
68
68
  "verify-syscall.mjs",
69
+ "scratch.mjs",
69
70
  "sensitivity.mjs",
70
71
  "transitive-recall.mjs"
71
72
  ],
package/query-core.mjs CHANGED
@@ -557,7 +557,16 @@ export function reportCompleteness(prefix) {
557
557
  // want different repairs and because `judgedNothing` is PINNED to "reports declaring `analyzed.count:
558
558
  // 0`", which a row-3 report is not.
559
559
  return { unanalyzed: reportUnanalyzed(prefix), judgedNothing: reportJudgedNothingFiles(prefix),
560
- noManifest: reportNoManifestFiles(prefix), unreadable: reportUnreadableFiles(prefix) };
560
+ noManifest: reportNoManifestFiles(prefix),
561
+ // ⟨0.30⟩ the peek's findings, so an advisory verb keying `--strict` on this object is at least
562
+ // as pessimistic as the gate over the same bytes (the ⟨0.24⟩ MUST).
563
+ ...(() => {
564
+ const o = reportOutOfScope(prefix);
565
+ // A corrupt key rides `unreadable`, which is ALREADY an arm of the strict exit — so the
566
+ // fail-closed path is the one the ⟨0.24⟩ rule established, not a new one beside it.
567
+ return { outOfScope: o.findings,
568
+ unreadable: [...reportUnreadableFiles(prefix), ...o.corrupt] };
569
+ })() };
561
570
  }
562
571
 
563
572
  /**
@@ -574,7 +583,38 @@ export function reportCompleteness(prefix) {
574
583
  * exit code; see `advisoryAnswer`, whose exit-bearing callers key `--strict` on manifest + unreadable.
575
584
  */
576
585
  export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
577
- || c.noManifest?.length || c.unreadable?.length));
586
+ || c.noManifest?.length || c.unreadable?.length
587
+ || c.outOfScope?.length));
588
+
589
+ /** ⟨0.30⟩ The peek's findings across the reports under a locator. Read leniently HERE (a malformed key is
590
+ * the gate's refusal to make, and this feeds a disclosure) but non-emptiness raises the same hedge the
591
+ * gate raises, which is what keeps the advisory verbs bound to it. */
592
+ export function reportOutOfScope(prefix) {
593
+ const out = [];
594
+ // ACCEPT A FULL PATH AS WELL AS A PREFIX. `reportFilesAt` appends `.json`, so a locator that already
595
+ // ends in `.json` — which is exactly what `--report <file>` gives — expanded to nothing and this
596
+ // returned `[]`. The hedge then never fired and `unverified --strict` certified a report the gate
597
+ // refuses. `loadGateReport` tolerates both spellings, so the two disagreed about the same locator.
598
+ const files = (prefix.endsWith(".json") && fs.existsSync(prefix)) ? [prefix] : reportFilesAt(prefix);
599
+ const corrupt = [];
600
+ for (const f of files) {
601
+ try {
602
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
603
+ // PRESENT-BUT-NOT-A-LIST IS CORRUPT, and it must reach the ADVISORY verbs too. Read leniently here,
604
+ // the key vanished and `--strict` certified a report `gate --report` refuses at exit 2 — the ⟨0.24⟩
605
+ // relation broken one shape over from where it was closed. The gate already refuses this; an
606
+ // advisory verb that does not is LESS pessimistic than the gate over the same bytes.
607
+ if (d?.outOfScope !== undefined && !Array.isArray(d.outOfScope)) { corrupt.push(f); continue; }
608
+ if (Array.isArray(d?.outOfScope)) {
609
+ for (const e of d.outOfScope) {
610
+ if (e && typeof e === "object") out.push(e);
611
+ else { corrupt.push(f); break; }
612
+ }
613
+ }
614
+ } catch { /* unparseable TEXT is `unreadable`'s business, not this key's */ }
615
+ }
616
+ return { findings: out, corrupt };
617
+ }
578
618
 
579
619
  /**
580
620
  * ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
@@ -740,7 +780,7 @@ export function loadReport(prefix) {
740
780
  */
741
781
  export function loadGateReport(prefix) {
742
782
  const files = reportFilesAt(prefix);
743
- const functions = [], unanalyzed = [], cov = new Map(), corrupt = [];
783
+ const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [], netPartners = [];
744
784
  let hardFail = false, analyzed = 0;
745
785
  // ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
746
786
  // siblings, because the union of several reports has judged something as soon as ONE of them has — the
@@ -799,6 +839,38 @@ export function loadGateReport(prefix) {
799
839
  else unanalyzed.push({ path: typeof u.path === "string" ? u.path : "", reason: typeof u.reason === "string" ? u.reason : "" });
800
840
  }
801
841
  }
842
+ // ⟨0.30⟩ THE PEEK'S FINDINGS, off the report rather than recomputed — this route CANNOT peek (it has
843
+ // no target, only a document), and that is exactly why the field rides the report. Concatenated across
844
+ // siblings like `functions` and `unanalyzed` above. ABSENT stays absent: ⟨0.26⟩ makes an absent key
845
+ // "this producer cannot answer", and a report produced with no policy was never asked, so it must not
846
+ // trigger the ⟨0.30⟩ verdict. Only well-formed entries count — a malformed one is corrupt input, and
847
+ // silently dropping it is how a fail-closed rung turns back into a green one.
848
+ const oos = parsed.outOfScope;
849
+ // PRESENT-BUT-NOT-A-LIST IS CORRUPT, NOT ABSENT. This had no `else` on the Array.isArray guard, so
850
+ // `"outOfScope": "oops"` was silently coerced to nothing and `gate --report` answered exit 0,
851
+ // `ok:true`, "no violations" over a report whose peek had found a denied effect — the exact
852
+ // fail-open coercion the strict read exists to prevent, in the commit that claims to prevent it.
853
+ // rust, java and swift all refuse this shape; only this route did not. (Found by review, MEASURED.)
854
+ if (oos !== undefined && !Array.isArray(oos))
855
+ corrupt.push(`${f}: \`outOfScope\` is present and is not a list`);
856
+ else if (Array.isArray(oos))
857
+ for (const e of oos)
858
+ if (e && typeof e === "object" && Array.isArray(e.effects) && e.effects.length)
859
+ outOfScope.push(e);
860
+ else corrupt.push(`${f}: \`outOfScope\` (an element is not an object carrying a non-empty \`effects\`)`);
861
+ // ⟨0.31⟩ the producer's PARTNER PROVENANCE, carried through verbatim and never recomputed — this
862
+ // route has no target to anchor `net-partner` at, and re-classifying through the consumer's own
863
+ // config is the re-derivation §3.1 forbids. A prefix can match several reports (a workspace writes
864
+ // one per member), each anchoring its own config, so this is collected as a LIST even though a
865
+ // single report carries a single record. Shape-checked like every other key read off the wire: a
866
+ // malformed one is corruption, not something to quietly drop.
867
+ const nps = parsed.netPartners;
868
+ if (nps !== undefined) {
869
+ if (nps && typeof nps === "object" && !Array.isArray(nps)
870
+ && typeof nps.config === "string" && Array.isArray(nps.hosts))
871
+ netPartners.push({ config: nps.config, hosts: nps.hosts.map(String) });
872
+ else corrupt.push(`${f}: \`netPartners\` is present and is not { config: string, hosts: [string] }`);
873
+ }
802
874
  // ⟨0.15⟩ the κ ledger. Merged + re-sorted the PRODUCER's way (count desc, name asc by code point —
803
875
  // reportCoverage's rule, and scan.mjs's), so a single-report prefix reproduces the emitted order exactly.
804
876
  const unc = parsed.coverage?.uncovered;
@@ -811,7 +883,7 @@ export function loadGateReport(prefix) {
811
883
  }
812
884
  const coverage = [...cov.entries()].sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
813
885
  .map(([name, calls]) => ({ name, calls }));
814
- return { functions, analyzed, unanalyzed, coverage, judgedNothing, hardFail: hardFail || corrupt.length > 0, corrupt };
886
+ return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, netPartners, hardFail: hardFail || corrupt.length > 0, corrupt };
815
887
  }
816
888
  // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
817
889
  // true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
package/query.mjs CHANGED
@@ -509,7 +509,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
509
509
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
510
510
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
511
511
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
512
- const SPEC_VERSION = "0.29";
512
+ const SPEC_VERSION = "0.31";
513
513
 
514
514
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
515
515
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -1852,7 +1852,12 @@ switch (cmd) {
1852
1852
  // --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
1853
1853
  // got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
1854
1854
  // bounded by the gate's while this line did not read the field — a documented limitation, unmeasured.
1855
- process.exit(fgUnan.length || fgComp.unreadable.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
1855
+ // ⟨0.30⟩ …and the peek's findings, because ⟨0.24⟩ binds this verb to the GATE's pessimism: "AN
1856
+ // ADVISORY VERB MUST NEVER BE LESS SENSITIVE TO INCOMPLETENESS THAN THE GATE OVER THE SAME BYTES",
1857
+ // and "THE SAME RULE BINDS EVERY ADVISORY VERB THAT ANSWERS `ok` — `unverified`, `fix-gate`, and any
1858
+ // later sibling". ⟨0.30⟩ moved the gate to exit 2 on this cause and left these verbs certifying:
1859
+ // MEASURED, `gate --report` exited 2 while this printed a clean answer at exit 0 over the same bytes.
1860
+ process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
1856
1861
  break; // unreachable
1857
1862
  }
1858
1863
  case "unverified": {
@@ -1917,7 +1922,8 @@ switch (cmd) {
1917
1922
  // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
1918
1923
  // before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
1919
1924
  // `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
1920
- process.exit(uUnan.length || uComp.unreadable.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
1925
+ // ⟨0.30⟩ the peek's findings too — see the fix-gate exit above for the ⟨0.24⟩ MUST this satisfies.
1926
+ process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
1921
1927
  break; // unreachable
1922
1928
  }
1923
1929
  case "gate": {
@@ -2146,7 +2152,11 @@ switch (cmd) {
2146
2152
  // ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
2147
2153
  // exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
2148
2154
  // follows from it. A real violation (exit 1) dominates, as it does there.
2149
- const gincomplete = g.unanalyzed.length > 0;
2155
+ // ⟨0.30⟩ the SECOND cause, read off the report because this route cannot peek — the report carries the
2156
+ // peek's findings, which is what makes §3.1 byte-equality hold here by construction rather than by two
2157
+ // authors agreeing. ABSENT is not empty: a report produced with no policy was never asked.
2158
+ const gscope = g.outOfScope ?? [];
2159
+ const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0;
2150
2160
  // The verdict document — the SAME builder shape scan.mjs writes, field for field and in the same key
2151
2161
  // order, because §3.1 ⟨0.24⟩ makes byte-equality with `scan --policy`'s `--gate-json` the acceptance
2152
2162
  // test. `analyzed.count`, `incomplete`/`unanalyzed` and the ⟨0.15⟩ coverage advisory all come off the
@@ -2160,6 +2170,14 @@ switch (cmd) {
2160
2170
  const gverdictObj = { spec: SPEC_VERSION, ok: gviol.length === 0 && !gincomplete,
2161
2171
  analyzed: { count: g.analyzed } };
2162
2172
  if (gvocab) gverdictObj.policyVocabulary = gvocab;
2173
+ // ⟨0.31⟩ the partner declaration that moved the producer's classification, READ OFF THE REPORT —
2174
+ // never recomputed here. This route has no target to anchor `net-partner` at, and the note above
2175
+ // says why recomputing would be wrong anyway: `netClass` is read verbatim off the wire, so
2176
+ // re-classifying through THIS machine's config is the re-derivation §3.1 forbids and would make the
2177
+ // verdict depend on the consumer's CWD. The producer recorded which of its declared partners
2178
+ // actually participated; both routes copy that one record, and byte-equality holds by construction.
2179
+ // Same position as the scan route puts it, for the same reason as `policyVocabulary` above.
2180
+ if (g.netPartners?.length) gverdictObj.netPartners = g.netPartners;
2163
2181
  gverdictObj.violations = gviol;
2164
2182
  if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
2165
2183
  // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
@@ -2172,7 +2190,12 @@ switch (cmd) {
2172
2190
  // not be answered, this carries text that never became a rule at all. Omitted when empty; `ok` and
2173
2191
  // the exit do not consult it (the line-level leniency is unchanged, only disclosed).
2174
2192
  if (gpol.ignored?.length) gverdictObj.ignored = gpol.ignored;
2175
- if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
2193
+ if (gincomplete) {
2194
+ gverdictObj.incomplete = true;
2195
+ if (g.unanalyzed.length) gverdictObj.unanalyzed = g.unanalyzed;
2196
+ }
2197
+ // ⟨0.30⟩ same key, same position as the scan route's — §3.1's byte-equality is the acceptance test.
2198
+ if (gscope.length) gverdictObj.outOfScope = gscope;
2176
2199
  if (g.coverage.length)
2177
2200
  gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
2178
2201
  if (gviol.length) {
@@ -2186,7 +2209,12 @@ switch (cmd) {
2186
2209
  if (gunevaluated.length)
2187
2210
  grefuse(`${gunevaluated.length} policy rule(s) could not be evaluated against this report`, gunevaluated);
2188
2211
  if (gincomplete) {
2189
- const why = `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`;
2212
+ // ⟨0.30⟩ NAME THE CAUSE THAT ACTUALLY FIRED. Two causes reach this exit now, and a message that
2213
+ // always says "could not analyze" would report the wrong repair for the scope one — the operator
2214
+ // would go looking for a parse failure that is not there.
2215
+ const why = g.unanalyzed.length
2216
+ ? `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`
2217
+ : `gate NOT certified — the report names ${gscope.length} function(s) OUTSIDE the scan's scope performing an effect this policy denies; the gate did not judge them, so the verdict is incomplete rather than a pass`;
2190
2218
  console.error(`candor-ts: ${why}`);
2191
2219
  // The INCOMPLETE verdict is a JUDGEMENT, not a refusal: it names what was analyzed and what was not,
2192
2220
  // and §3.1 makes byte-equality with `scan --policy`'s document the acceptance test for exactly it.
package/scan-core.mjs CHANGED
@@ -404,11 +404,26 @@ export function isTelemetryHost(hostLiteral) { return hostInSet(hostLiteral, TEL
404
404
  // Literals.netDestClass.
405
405
  export function netDestClass(hostLiteral, partners) {
406
406
  if (isTelemetryHost(hostLiteral)) return "known-telemetry";
407
- const host = normHost(hostLiteral);
408
- const partnerMatch = partners && (partners.has(host) || [...partners].some((p) => host.endsWith("." + p)));
409
- if (partnerMatch || isModelHost(hostLiteral)) return "known-partner";
407
+ if (partnerFor(hostLiteral, partners) !== null || isModelHost(hostLiteral)) return "known-partner";
410
408
  return "unknown-host";
411
409
  }
410
+ /**
411
+ * ⟨0.31⟩ WHICH declared partner a host matched, or null — the SAME match `netDestClass` decides on,
412
+ * extracted so the DISCLOSURE and the DECISION cannot use different rules.
413
+ *
414
+ * That is not a stylistic preference. The first attempt at the `net-partner` disclosure re-implemented
415
+ * this match and normalised differently from the classifier — `partner.example:443` never equalled the
416
+ * declared `partner.example` — so the disclosure came back SILENTLY EMPTY on every real run while the
417
+ * verdict it was reporting on had flipped. A disclosure normalised differently from the decision it
418
+ * reports can only be wrong, and the way to make that impossible is one function with two callers.
419
+ */
420
+ export function partnerFor(hostLiteral, partners) {
421
+ if (!partners || !partners.size) return null;
422
+ const host = normHost(hostLiteral);
423
+ if (partners.has(host)) return host;
424
+ for (const p of partners) if (host.endsWith("." + p)) return p;
425
+ return null;
426
+ }
412
427
  // ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
413
428
  export const NET_DEST_CLASSES = ["known-telemetry", "known-partner", "unknown-host"];
414
429
  // ⟨0.20⟩ The `Net` destination classes an fn reaches — the SINGLE derivation shared by the report's
package/scan.mjs CHANGED
@@ -34,7 +34,7 @@ import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNe
34
34
  import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
35
35
  import { printAgents, writeStdoutSync } from "./contract.mjs";
36
36
  import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
37
- modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
37
+ modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf, partnerFor } from "./scan-core.mjs";
38
38
  import { emitSurface } from "./surface.mjs";
39
39
 
40
40
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -45,7 +45,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
45
45
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
46
46
  // Reused, never re-littered.
47
47
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
48
- const SPEC_VERSION = "0.29";
48
+ const SPEC_VERSION = "0.31";
49
49
 
50
50
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
51
51
  //
@@ -489,7 +489,30 @@ const failClosedReportDoc = (reason) => {
489
489
  const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\n\r]/g, " ");
490
490
  return `{\n "candor": {\n "version": "candor-ts-${PKG_VERSION}",\n "toolchain": "node-${process.versions.node}",\n "spec": "${SPEC_VERSION}"\n },\n "functions": [],\n "analyzed": { "count": 0 },\n "unanalyzed": [\n { "path": "<run>", "reason": "${esc(reason)}" }\n ]\n}`;
491
491
  };
492
- const refuseEarlyToStream = (why) => {
492
+ // ⟨0.31⟩ The gate sink this run ARMED, and the ONLY path `refuseEarly` may overwrite.
493
+ //
494
+ // The first version of that helper wrote straight to `preGateSink`, which broke the ⟨0.28⟩ rule it sits
495
+ // beside: a `--gate-json` naming a source file under the scan target is REFUSED and nothing is written
496
+ // there, precisely so arming cannot destroy the source it is about to scan. Writing the refusal document
497
+ // to the same path bypassed that guard and truncated the fixture's own source — six ⟨0.28⟩ rows caught
498
+ // it. A refusal may replace this run's OWN placeholder; it may not write anywhere the arming declined to.
499
+ let armedGateSink = null;
500
+ const refuseEarly = (why) => {
501
+ // ⟨0.31⟩ A FILE `--gate-json` SINK GETS THE REASON TOO, and until now it did not: this only ever
502
+ // wrote to STREAM sinks, so a run refusing with `--gate-json g.json` left the ARMING STUB in place —
503
+ // "the gate did not complete — this document was written when the run STARTED and was never replaced
504
+ // … the run failed, crashed or was killed". That describes a crash. This run did none of those things;
505
+ // it deliberately refused, and it knows exactly why.
506
+ //
507
+ // ⟨0.24⟩ pins the refusal document's `reason` as a string NAMING THE CAUSE, and a false description is
508
+ // rated worse here than a missing one — an operator reading "crashed" goes looking for a crash. The
509
+ // stub is the right answer only while the run's fate is genuinely unknown; once it has decided to
510
+ // refuse, leaving the stub is a stale document, which is the thing this whole sink exists to prevent.
511
+ if (armedGateSink) {
512
+ try {
513
+ fs.writeFileSync(armedGateSink, JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1) + "\n");
514
+ } catch { /* an unwritable sink is already refused elsewhere; never mask the real cause with this */ }
515
+ }
493
516
  if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
494
517
  else if (preWantJson && !reportStreamWritten) {
495
518
  // Skipped when stdout is already claimed by `--gate-json -` (the two-stream case is refused
@@ -770,7 +793,7 @@ const disarmUnwrittenOutReports = () => {
770
793
  console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
771
794
  + `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
772
795
  + `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
773
- refuseEarlyToStream(`--gate-json ${gate} names the same file as ${flag} ${other}`);
796
+ refuseEarly(`--gate-json ${gate} names the same file as ${flag} ${other}`);
774
797
  process.exit(2);
775
798
  }
776
799
  }
@@ -782,7 +805,7 @@ const disarmUnwrittenOutReports = () => {
782
805
  + `about to read and then scan the wreckage. A non-source sink under the target `
783
806
  + `(${path.join(targetContainmentRoot(preTarget) ?? ".", ".candor", "verdict.json")}, say) is the `
784
807
  + `recommended layout and stays permitted.`);
785
- refuseEarlyToStream(`--gate-json ${gate} is a source file under the scan target ${preTarget}`);
808
+ refuseEarly(`--gate-json ${gate} is a source file under the scan target ${preTarget}`);
786
809
  process.exit(2);
787
810
  }
788
811
  // `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
@@ -795,13 +818,13 @@ const disarmUnwrittenOutReports = () => {
795
818
  console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
796
819
  + `is armed before the config is read, so this would destroy the config that configures this `
797
820
  + `run. Nothing was written; give the verdict its own path.`);
798
- refuseEarlyToStream(`--gate-json ${gate} is a .candor/config`); // ⟨0.28⟩ report stream too
821
+ refuseEarly(`--gate-json ${gate} is a .candor/config`); // ⟨0.28⟩ report stream too
799
822
  process.exit(2);
800
823
  }
801
824
  }
802
825
  if (gate && singleSink && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
803
826
  console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
804
- refuseEarlyToStream(`--gate-json ${gate} names the same file as CANDOR_CONFIG`); // ⟨0.28⟩
827
+ refuseEarly(`--gate-json ${gate} names the same file as CANDOR_CONFIG`); // ⟨0.28⟩
805
828
  process.exit(2);
806
829
  }
807
830
  // ⟨0.28⟩ A REPEATED `--gate-json` IS REFUSED, AND EVERY PATH NAMED GETS THE REFUSAL. Placed after the
@@ -856,7 +879,7 @@ const disarmUnwrittenOutReports = () => {
856
879
  if (bad) offending.add(g);
857
880
  }
858
881
  if (offending.size === distinct.length) {
859
- refuseEarlyToStream(`every named --gate-json path collides with an input`); // ⟨0.28⟩ report stream
882
+ refuseEarly(`every named --gate-json path collides with an input`); // ⟨0.28⟩ report stream
860
883
  process.exit(2);
861
884
  }
862
885
  }
@@ -878,7 +901,7 @@ const disarmUnwrittenOutReports = () => {
878
901
  // (its verdict arm keys on `preGateSink === "-"` and doesn't know one was just printed). When `-`
879
902
  // isn't in the list, refuseEarlyToStream's report arm handles the `--json` case; when it is, the
880
903
  // verdict is already there and `--json` was refused earlier (line 333), so nothing more is owed.
881
- if (!distinct.includes("-")) refuseEarlyToStream(`--gate-json was given more than once (${list})`);
904
+ if (!distinct.includes("-")) refuseEarly(`--gate-json was given more than once (${list})`);
882
905
  process.exit(2);
883
906
  }
884
907
  if (gate && gate !== "-") armGateJsonFailClosed(gate);
@@ -932,7 +955,7 @@ const disarmUnwrittenOutReports = () => {
932
955
  const inputs = runInputs(preTarget, policy);
933
956
  for (const p of namedOuts) armOutPrefixFailClosed(p, inputs);
934
957
  }
935
- refuseEarlyToStream(`--out was given more than once (${list}) — a run writes one report set to one prefix`);
958
+ refuseEarly(`--out was given more than once (${list}) — a run writes one report set to one prefix`);
936
959
  process.exit(2);
937
960
  }
938
961
  if (preOut && !preWantJson) armOutPrefixFailClosed(preOut, runInputs(preTarget, policy));
@@ -957,7 +980,7 @@ for (let i = 0; i < argv.length; i++) {
957
980
  const v = argv[i + 1];
958
981
  if (v === undefined || v.startsWith("--")) {
959
982
  console.error(`candor-ts: ${a} requires a value (${usage})`);
960
- refuseEarlyToStream(`${a} requires a value`);
983
+ refuseEarly(`${a} requires a value`);
961
984
  process.exit(2);
962
985
  }
963
986
  if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
@@ -971,21 +994,21 @@ for (let i = 0; i < argv.length; i++) {
971
994
  console.error(`candor-ts: unknown flag ${a} (${usage})`);
972
995
  // ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
973
996
  // refusal document too (see refuseEarlyToStream — the file sink is already armed).
974
- refuseEarlyToStream(`unknown flag ${a}`);
997
+ refuseEarly(`unknown flag ${a}`);
975
998
  process.exit(2);
976
999
  }
977
1000
  else if (target === null) target = a;
978
1001
  else if (outPrefix === null) outPrefix = a; // legacy positional prefix
979
1002
  else {
980
1003
  console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
981
- refuseEarlyToStream(`unexpected extra argument ${a}`);
1004
+ refuseEarly(`unexpected extra argument ${a}`);
982
1005
  process.exit(2);
983
1006
  }
984
1007
  }
985
1008
  if (wantAgents) { printAgents(); process.exit(0); }
986
1009
  if (target === null) {
987
1010
  console.error(usage);
988
- refuseEarlyToStream("no scan target");
1011
+ refuseEarly("no scan target");
989
1012
  process.exit(2);
990
1013
  }
991
1014
 
@@ -1092,7 +1115,7 @@ function enforceEnginePin(targetPath) {
1092
1115
  // duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
1093
1116
  // cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
1094
1117
  // belongs there.
1095
- refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
1118
+ refuseEarly(`.candor/config pins engine ${pin}, which this build does not satisfy`);
1096
1119
  process.exit(2);
1097
1120
  }
1098
1121
 
@@ -1136,7 +1159,7 @@ function loadCandorConfig(targetPath, { lenient = false } = {}) {
1136
1159
  // The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
1137
1160
  // armed for — which is exactly why it was the last one still leaving stdout empty. Found by
1138
1161
  // PART 36 (b11), a row written before this line was.
1139
- refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
1162
+ refuseEarly(`CANDOR_CONFIG set but ${file} is not a readable file`);
1140
1163
  process.exit(2);
1141
1164
  }
1142
1165
  } else {
@@ -1148,7 +1171,7 @@ function loadCandorConfig(targetPath, { lenient = false } = {}) {
1148
1171
  catch (e) {
1149
1172
  if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
1150
1173
  console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
1151
- refuseEarlyToStream(`config ${file} exists but could not be read`);
1174
+ refuseEarly(`config ${file} exists but could not be read`);
1152
1175
  process.exit(2);
1153
1176
  }
1154
1177
  const cfg = {};
@@ -1230,6 +1253,7 @@ function writeSinkAtomic(p, text) {
1230
1253
  }
1231
1254
 
1232
1255
  function armGateJsonFailClosed(p) {
1256
+ armedGateSink = p; // ⟨0.31⟩ this path is now ours to replace if the run refuses
1233
1257
  try {
1234
1258
  writeSinkAtomic(p, JSON.stringify({
1235
1259
  spec: SPEC_VERSION, ok: false, refused: true,
@@ -1323,18 +1347,37 @@ if (!stat) {
1323
1347
  // RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
1324
1348
  // own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
1325
1349
  // exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
1326
- refuseEarlyToStream(`no such path: ${target}`);
1350
+ refuseEarly(`no such path: ${target}`);
1327
1351
  process.exit(2);
1328
1352
  }
1329
1353
  let usedTsconfig = null; // the tsconfig this run actually read, so a later disclosure can name the
1330
1354
  // cause that APPLIES rather than the one that usually does.
1355
+ // ⟨0.31⟩ THE one admission rule. The directory walk had it inline and the single-file branch had none,
1356
+ // which is how a target this engine cannot read reached a green verdict. `.d.ts` is excluded because a
1357
+ // declaration carries no body to analyse, and `.min.js` because a minified bundle is not a source.
1358
+ const admitsAsSource = (name) =>
1359
+ (/\.[mc]?tsx?$/.test(name) && !name.endsWith(".d.ts"))
1360
+ || (allowJs && /\.[mc]?jsx?$/.test(name) && !/\.min\.js$/.test(name));
1361
+
1331
1362
  if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1332
1363
  rootDir = path.dirname(path.resolve(target));
1333
1364
  usedTsconfig = path.resolve(target);
1334
1365
  fileNames = fromTsconfig(path.resolve(target), rootDir);
1335
1366
  } else if (stat.isFile()) {
1336
1367
  rootDir = path.dirname(path.resolve(target));
1337
- fileNames = [path.resolve(target)];
1368
+ // ⟨0.31⟩ A SINGLE-FILE TARGET IS ADMITTED BY THE SAME RULE AS A FILE INSIDE A DIRECTORY, and this
1369
+ // branch used to admit it UNCONDITIONALLY. MEASURED: `candor-ts x.txt --policy p` printed
1370
+ // `policy ✓` and exited 0 with `ok: true` over a file this engine cannot read — the compiler
1371
+ // silently dropped it and `NO_SOURCES` was false because the name was in the list. rust, java and
1372
+ // swift all refuse the same input at exit 2.
1373
+ //
1374
+ // That is precisely the §3.3 ⟨0.31⟩ cause this rung adds — "the walk admitted no file this engine
1375
+ // can read" — answered with a green gate, in the release that introduces the clause. A typo'd path
1376
+ // in CI (`--policy` pointed at a file, a glob that matched a README) is a permanent pass.
1377
+ //
1378
+ // Single-file targets remain supported: a real `.ts` still scans. What changed is that the file has
1379
+ // to be one this engine parses, which is what the directory walk always required.
1380
+ fileNames = admitsAsSource(path.basename(target)) ? [path.resolve(target)] : [];
1338
1381
  } else {
1339
1382
  rootDir = path.resolve(target);
1340
1383
  const tsconfig = path.join(rootDir, "tsconfig.json");
@@ -1348,8 +1391,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1348
1391
  const p = path.join(d, ent.name);
1349
1392
  if (!IS_PEEK && isTestPath(path.relative(rootDir, p))) continue; // ⟨0.29⟩ see IS_PEEK
1350
1393
  if (ent.isDirectory()) walk(p);
1351
- else if (/\.[mc]?tsx?$/.test(ent.name) && !ent.name.endsWith(".d.ts")) fileNames.push(p);
1352
- else if (allowJs && /\.[mc]?jsx?$/.test(ent.name) && !/\.min\.js$/.test(ent.name)) fileNames.push(p);
1394
+ else if (admitsAsSource(ent.name)) fileNames.push(p);
1353
1395
  }
1354
1396
  })(rootDir);
1355
1397
  }
@@ -1397,13 +1439,43 @@ const EXCLUDED_REASON = {
1397
1439
  + "analyzed — a file your build excludes may still run in CI or at install time",
1398
1440
  "not-a-parsed-source": "not in this run's parse set",
1399
1441
  };
1400
- if (fileNames.length === 0) {
1442
+ // ⟨0.30⟩ NO ANALYZABLE SOURCE IS STILL A REASON TO LOOK AT WHAT WAS EXCLUDED.
1443
+ //
1444
+ // This refusal used to exit here, before the peek existed in the run at all. Measured on the PUBLISHED
1445
+ // 0.30.0: a declarations-only package whose `.js` performs the denied effect answered `no TypeScript
1446
+ // sources`, exit 2, and named NOTHING — while candor-rust, over the analogous shape (no `.rs` sources, a
1447
+ // `build.rs` running `curl`), reached its peek and named `build::main performs Exec`. Both fail closed,
1448
+ // so no gate went green; but naming the function IS this rung's premise, and whether a user got names
1449
+ // turned on where a package happens to keep its declarations — `axios` ships `index.d.ts` at the root and
1450
+ // got 13 findings, `ky` ships them under `distribution/` and got a bare refusal.
1451
+ //
1452
+ // So: when a policy is configured and there IS something excluded to look at, the run continues to the
1453
+ // peek and the findings are named. The refusal itself does not move — see the NO_SOURCES arm beside the
1454
+ // other exit-2 causes at the end of this file. THAT ORDER IS THE WHOLE CARE POINT: the first version of
1455
+ // this change let the run fall through to a normal ending, and a clean-sibling tree — zero files
1456
+ // analyzed, nothing wrong in the excluded `.js` — answered `policy ✓` at EXIT 0. A green over a tree the
1457
+ // engine never read is the cardinal sin, introduced by a disclosure fix, and it was caught by writing
1458
+ // that control fixture BEFORE the fix rather than after.
1459
+ const NO_SOURCES = fileNames.length === 0;
1460
+ if (NO_SOURCES && !(policyPath && excludedFiles.length)) {
1401
1461
  console.error(`candor-ts: no TypeScript sources under ${target}`);
1462
+ // §3.3(d) MUST: the refusal names what a target of this engine's kind looks like. Without it the
1463
+ // message says only that something is wrong, and the commonest cause — a path that moved, or one
1464
+ // pointing at a file this engine does not parse — has an obvious fix the reader is left to guess.
1465
+ console.error(` candor-ts reads .ts/.tsx/.mts/.cts (and .js with --allow-js) — point it at a `
1466
+ + `project directory or a TypeScript file. Exit 2 (unevaluable): a target this engine `
1467
+ + `cannot read is not a clean scan.`);
1402
1468
  // An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
1403
1469
  // get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
1404
- refuseEarlyToStream(`no TypeScript sources under ${target}`);
1470
+ refuseEarly(`no TypeScript sources under ${target} — candor-ts reads .ts/.tsx/.mts/.cts `
1471
+ + `(and .js with --allow-js); point it at a project directory or a TypeScript file. `
1472
+ + `Exit 2 (unevaluable): a target this engine cannot read is not a clean scan.`);
1405
1473
  process.exit(2);
1406
1474
  }
1475
+ if (NO_SOURCES) {
1476
+ console.error(`candor-ts: no TypeScript sources under ${target} — reading what this scan excluded, `
1477
+ + `because a policy is configured and there are excluded files to look at`);
1478
+ }
1407
1479
  // Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
1408
1480
  // hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
1409
1481
  // probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
@@ -1436,7 +1508,7 @@ if (!wantJson) {
1436
1508
  console.error(`candor-ts: the report set at --out ${outPrefix} includes ${w}, which is the SAME `
1437
1509
  + `FILE as ${label} ${other} — refusing (exit 2). Writing the report there would destroy an `
1438
1510
  + `input of this run. Nothing was scanned; give the report set its own prefix.`);
1439
- refuseEarlyToStream(`the report set at ${outPrefix} would overwrite ${label} ${other}`);
1511
+ refuseEarly(`the report set at ${outPrefix} would overwrite ${label} ${other}`);
1440
1512
  process.exit(2);
1441
1513
  }
1442
1514
  }
@@ -1944,7 +2016,7 @@ const corruptDepPkgs = new Set();
1944
2016
  // The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
1945
2017
  // one was not — three exits for one rule, two of them answered on the machine channel and one
1946
2018
  // silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
1947
- refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
2019
+ refuseEarly(`configured dependency ${tok} is not a readable file or directory`);
1948
2020
  process.exit(2);
1949
2021
  }
1950
2022
  }
@@ -1968,7 +2040,7 @@ const corruptDepPkgs = new Set();
1968
2040
  console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
1969
2041
  console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
1970
2042
  console.error(` about code this scan never saw.`);
1971
- refuseEarlyToStream(`configured dependency report ${f} could not be read`);
2043
+ refuseEarly(`configured dependency report ${f} could not be read`);
1972
2044
  process.exit(2);
1973
2045
  }
1974
2046
  let parsed;
@@ -1978,7 +2050,7 @@ const corruptDepPkgs = new Set();
1978
2050
  console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
1979
2051
  console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
1980
2052
  console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
1981
- refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
2053
+ refuseEarly(`configured dependency report ${f} is not valid JSON`);
1982
2054
  process.exit(2);
1983
2055
  }
1984
2056
  try {
@@ -3463,6 +3535,74 @@ function enclosing(node) {
3463
3535
  // and the whole-module Net rule paints them — but console fd 0/1/2 I/O is not Net (§1 has no Console effect).
3464
3536
  // `net.Socket.on`/`.write` return the stream (`this`), so a chained call's receiver is still the std stream;
3465
3537
  // the exact-string check missed it (the receiver is the inner CallExpression). Walk the chain to its head.
3538
+ // R54 — THE STD-STREAM CARVE-OUT, DECIDED FROM THE RECEIVER'S TYPE RATHER THAN ITS SPELLING.
3539
+ //
3540
+ // `rootsAtStdStream` above matches the TEXT of the receiver chain, so it only ever recognised
3541
+ // `process.stdout.write(...)` written literally. MEASURED on `execa` in the ⟨0.30⟩ blast-radius sweep:
3542
+ // one level of indirection defeats it —
3543
+ //
3544
+ // const pick = fd => (fd === 1 ? process.stdout : createWriteStream("", {fd}));
3545
+ // pick(3).write("hello"); // charged Net, netClass unknown-host. There is no socket.
3546
+ //
3547
+ // The receiver types as `tty.WriteStream | fs.WriteStream`, `tty.WriteStream` EXTENDS `net.Socket`, so
3548
+ // resolving `.write` lands in the net cluster's typings and the whole-module Net rule paints it. It
3549
+ // accounted for 2 of the 31 ⟨0.30⟩ verdict flips — the only two of that sweep that were not genuine.
3550
+ //
3551
+ // DENYLIST, NOT ALLOWLIST, and the direction is stated so it can be checked: this suppresses Net ONLY
3552
+ // when EVERY constituent of the receiver's type is a PROVEN non-network stream class. An unknown
3553
+ // constituent, an `any`, a project type, or a real `net.Socket` all fail the test and KEEP the charge.
3554
+ // The failure mode is therefore an over-report, never a false all-clear.
3555
+ //
3556
+ // THE SAFE SET IS CONCRETE CLASSES ONLY. `stream.Writable`/`Readable` are deliberately ABSENT: a real
3557
+ // `net.Socket` IS a `stream.Duplex` and therefore satisfies them, so admitting the base types would
3558
+ // suppress Net on genuine sockets — the exact silent under-report this family has measured arriving in
3559
+ // 4 of 5 over-charge fixes. Only `tty`/`fs` stream classes, and only when declared in @types/node's own
3560
+ // `tty.d.ts`/`fs.d.ts`, so a project class named `WriteStream` cannot buy the carve-out.
3561
+ const SAFE_STREAM_CLASSES = new Set(["WriteStream", "ReadStream"]);
3562
+ // `process` is in the set because that is where @types/node DECLARES the std streams' own
3563
+ // `WriteStream`/`ReadStream` (process.stdout is `WriteStream & { fd: 1 }`, declared in process.d.ts and
3564
+ // extending tty's). Measured: without it the predicate rejected the very case it exists for.
3565
+ const SAFE_STREAM_MODULES = new Set(["tty", "fs", "process"]);
3566
+ function receiverIsProvenNonNetworkStream(expr) {
3567
+ if (!expr) return false;
3568
+ let t;
3569
+ try { t = checker.getTypeAtLocation(expr); } catch { return false; }
3570
+ if (!t) return false;
3571
+ const parts = (t.isUnion && t.isUnion()) ? t.types : [t];
3572
+ if (!parts.length) return false;
3573
+ // INTERSECTIONS CARRY NO SINGLE SYMBOL, and every real case here is one: `process.stdout` types as
3574
+ // `tty.WriteStream & { fd: 1 }`, and the helper's union is `(WriteStream & {fd:1}) | WriteStream`.
3575
+ // The first attempt asked each union part for its symbol, got none for the intersection, and
3576
+ // suppressed nothing — measured, before this comment existed.
3577
+ const flatten = (ty) => (ty.isIntersection && ty.isIntersection()) ? ty.types : [ty];
3578
+ const declaredSafely = (sym) => {
3579
+ const decls = sym.getDeclarations?.() ?? [];
3580
+ if (!decls.length) return false;
3581
+ return decls.every((d) => {
3582
+ const f = path.resolve(d.getSourceFile().fileName);
3583
+ const m = f.match(/@types\/node\/(.+?)\.d\.ts$/);
3584
+ return !!m && SAFE_STREAM_MODULES.has(m[1]);
3585
+ });
3586
+ };
3587
+ return parts.every((part) => {
3588
+ let sawSafe = false;
3589
+ for (const member of flatten(part)) {
3590
+ const sym = member.getSymbol?.() ?? member.symbol;
3591
+ const name = sym?.getName?.();
3592
+ // An ANONYMOUS shape (`{ fd: 1 }`) carries no class identity: it neither proves nor disproves,
3593
+ // so it is skipped. If EVERY member is anonymous, `sawSafe` stays false and the charge is kept.
3594
+ // TypeScript names anonymous shapes SYNTHETICALLY — `__type` for a type literal,
3595
+ // `__object` for an object literal — so `!name` alone does not skip them and the
3596
+ // `{ fd: 1 }` half of `process.stdout`'s intersection fell through to the rejection
3597
+ // below. Measured: this is what still suppressed nothing after the intersection fix.
3598
+ if (!name || name.startsWith("__")) continue;
3599
+ if (!SAFE_STREAM_CLASSES.has(name) || !declaredSafely(sym)) return false;
3600
+ sawSafe = true;
3601
+ }
3602
+ return sawSafe;
3603
+ });
3604
+ }
3605
+
3466
3606
  function rootsAtStdStream(expr) {
3467
3607
  let e = expr;
3468
3608
  for (;;) {
@@ -4848,6 +4988,14 @@ function visitCalls(node) {
4848
4988
  if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
4849
4989
  && rootsAtStdStream(node.expression.expression))
4850
4990
  eff = null;
4991
+ // R54 — the same carve-out through a helper, decided from the receiver's TYPE. SCOPED TO Net
4992
+ // DELIBERATELY: an `fs.WriteStream` receiver legitimately carries Fs, and suppressing whatever
4993
+ // effect happened to resolve would swallow that — the same under-report one door along. Only
4994
+ // the fabricated network charge is removed.
4995
+ if (eff === "Net"
4996
+ && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
4997
+ && receiverIsProvenNonNetworkStream(node.expression.expression))
4998
+ eff = null;
4851
4999
  if (eff) {
4852
5000
  rec.direct.add(eff);
4853
5001
  // SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
@@ -6092,6 +6240,8 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
6092
6240
  // nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
6093
6241
  const netPartnerErrs = [];
6094
6242
  const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
6243
+ // ⟨0.31⟩ the declared partners that actually MOVED a classification in this run — see the envelope key.
6244
+ const partnersUsed = new Set();
6095
6245
  for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
6096
6246
  const functions = [];
6097
6247
  for (const [name, rec] of fns) {
@@ -6123,7 +6273,16 @@ for (const [name, rec] of fns) {
6123
6273
  // ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
6124
6274
  // transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
6125
6275
  // .incomplete has Net) OR a Net with no visible host. The class travels the call graph like the effect.
6126
- if (inf.includes("Net")) entry.netClass = netClassesOf([...rec.hosts], rec.incomplete.has("Net"), netPartners);
6276
+ if (inf.includes("Net")) {
6277
+ entry.netClass = netClassesOf([...rec.hosts], rec.incomplete.has("Net"), netPartners);
6278
+ // ⟨0.31⟩ RECORD WHICH DECLARED PARTNER PARTICIPATED, at the point the class is decided. Asking
6279
+ // `partnerFor` — the function `netDestClass` itself asks — is what keeps the disclosure and the
6280
+ // decision on one rule; the reverted first attempt re-matched and normalised differently.
6281
+ for (const h of rec.hosts) {
6282
+ const pm = partnerFor(h, netPartners);
6283
+ if (pm) partnersUsed.add(pm);
6284
+ }
6285
+ }
6127
6286
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
6128
6287
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
6129
6288
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
@@ -6566,7 +6725,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
6566
6725
  // ⟨0.28⟩ report stream: this exit-2 fires BEFORE the envelope is printed, so a `--json` run would
6567
6726
  // otherwise leave stdout empty for the whole class of trust-marker contradictions. The latch is
6568
6727
  // still unset at this point; the helper writes the fail-closed doc as stdout's only content.
6569
- refuseEarlyToStream(`${contradictions.length} report entr${contradictions.length === 1 ? "y contradicts its" : "ies contradict their"} own trust markers`);
6728
+ refuseEarly(`${contradictions.length} report entr${contradictions.length === 1 ? "y contradicts its" : "ies contradict their"} own trust markers`);
6570
6729
  process.exit(2);
6571
6730
  }
6572
6731
  }
@@ -6631,6 +6790,26 @@ if (uncoveredLedger.length) {
6631
6790
  // and NOT cross-engine comparable — qualifiers differ). ALWAYS present.
6632
6791
  const analyzedQuals = [...fns.keys()].sort();
6633
6792
  envelope.analyzed = { count: fns.size, digest: fnv1aHex(analyzedQuals) };
6793
+ // ⟨0.31⟩ `netPartners` — WHICH ambient `.candor/config` declared a partner, and which of its hosts
6794
+ // actually participated. MEASURED: `deny Net[unknown-host]` over a call to `partner.example` exits 1;
6795
+ // adding `net-partner partner.example` to `.candor/config` exits 0 with `ok:true`, and nothing in the
6796
+ // verdict named the file, its path, or the host. An operator reading that green could not tell an
6797
+ // ambient file turned a red into it — the same failure §3.1's `policyVocabulary` exists to refuse for
6798
+ // `unknown-alias`, whose argument reaches this key and whose MUST did not.
6799
+ //
6800
+ // IT LIVES IN THE REPORT, and that is what makes it emittable at all. `net-partner` anchors at the
6801
+ // TARGET, and `gate --report` has no target — it reads the producer's already-computed `netClass`. A
6802
+ // verdict-only disclosure is therefore computable on one route and not the other, which breaks §3.1's
6803
+ // byte-equality and is exactly why the first attempt was reverted (candor-ts's own suite: "pure: NOT
6804
+ // byte-equal"). Recorded by the PRODUCER, both routes read the same source, and they agree by
6805
+ // construction — the shape ⟨0.30⟩'s `outOfScope` already uses for the same structural reason.
6806
+ //
6807
+ // Omitted when empty, so a project declaring no partners — or declaring some that never matched — is
6808
+ // byte-identical to a pre-rung report. A declaration that changed nothing is not provenance.
6809
+ if (partnersUsed.size) {
6810
+ const cfg = discoverConfigPath(target);
6811
+ if (cfg) envelope.netPartners = { config: cfg, hosts: [...partnersUsed].sort() };
6812
+ }
6634
6813
  // ⟨0.21⟩ COMPLETENESS MANIFEST (Gap 2): the target's own source candor could NOT analyze (unparsed .ts).
6635
6814
  // OMITTED when empty — a complete scan stays byte-identical to a pre-rung report — so a MACHINE reading
6636
6815
  // --json sees the incompleteness the stderr warning alone used to hide.
@@ -6646,7 +6825,8 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
6646
6825
  const writeAtomic = (file, text) => writeSinkAtomic(file, text);
6647
6826
  // ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
6648
6827
  // Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
6649
- // DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
6828
+ // DENIES. ⟨0.30⟩ THE VERDICT DOES MOVE — see the exit site below: a non-empty block makes it
6829
+ // `ok:false, incomplete:true` at exit 2. It is still never a violation, because a file
6650
6830
  // the gate declined to judge must not decide an exit code.
6651
6831
  //
6652
6832
  // A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
@@ -6678,7 +6858,11 @@ let peekRead = false;
6678
6858
  const peekUnread = new Set();
6679
6859
  let peekUnattributed = false;
6680
6860
  if (policyPath && excludedFiles.length) {
6681
- let denied = new Set();
6861
+ // ⟨0.30⟩ HOISTED, because the matcher below needs it. It was a `const` inside the try, so the
6862
+ // evaluatePolicy call added in this rung threw ReferenceError straight into the "a peek that cannot
6863
+ // run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
6864
+ // hiding behind it is not, which is why the trigger below is now a POSITIVE test on the rules.
6865
+ let peekPolicy = null;
6682
6866
  try {
6683
6867
  const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
6684
6868
  // ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
@@ -6687,11 +6871,39 @@ if (policyPath && excludedFiles.length) {
6687
6871
  // parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
6688
6872
  // candor-java already withheld here; this engine, candor-rust and candor-swift did not.
6689
6873
  if (!fatalPolicyErrors(pol.errors).length) {
6690
- denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
6874
+ peekPolicy = pol;
6691
6875
  }
6692
6876
  } catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
6693
- if (denied.size) {
6877
+ // ⟨0.30⟩ THE TRIGGER IS "ARE THERE DENY RULES", not "is the flattened effect-name set non-empty". The
6878
+ // old test read the name set, and `pure` is a deny rule with an EMPTY effect list meaning "every effect
6879
+ // except Unknown" — so under the STRICTEST policy the set was empty, the peek never ran, and the tree
6880
+ // passed at exit 0 while the strictly weaker `deny Exec` exited 2 on the same files. MEASURED four-way.
6881
+ if ((peekPolicy?.deny ?? []).length) {
6694
6882
  outOfScopeFindings = [];
6883
+ // ⟨0.31⟩ A FILE THIS ENGINE CANNOT READ CANNOT BE PEEKED, AND THE CLASS MUST SAY SO.
6884
+ //
6885
+ // MEASURED: an excluded file performing a denied `Fs`, with the policy `deny Fs`. Readable, the peek
6886
+ // finds it and the gate exits 2 naming the function. `chmod 000` on that ONE file and the same run
6887
+ // exits 0 with `ok: true`, `outOfScope: []` and `excluded: [{class: …, peeked: true}]` — byte-
6888
+ // identical to a genuinely clean peek. The only difference between a red gate and a green one is
6889
+ // whether the engine could open the file, and nothing in the report says so.
6890
+ //
6891
+ // The child's TypeScript program drops an unreadable file SILENTLY: no diagnostic, and no entry in
6892
+ // `unanalyzed`, so the `peekUnread` accounting below never fires. candor-rust answers `peeked: false`
6893
+ // on the same shape, so this was ts alone.
6894
+ //
6895
+ // Asked HERE, in the parent, because the parent is the process that decided these files are in the
6896
+ // peek's scope and can check the premise directly. A file the parent cannot read is one the child
6897
+ // cannot read either. This is ⟨0.26⟩'s manifest rule applied to the peek: a claim of completeness
6898
+ // over a set requires having actually covered the set, and a PARTIAL peek publishing `peeked: true`
6899
+ // answers worse than one that admits it read nothing.
6900
+ for (const e of excludedFiles) {
6901
+ try {
6902
+ fs.accessSync(path.resolve(rootDir, e.path), fs.constants.R_OK);
6903
+ } catch {
6904
+ peekUnread.add(e.cls); // its class withdraws the completeness claim; the gate stays cautious
6905
+ }
6906
+ }
6695
6907
  const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
6696
6908
  try {
6697
6909
  fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
@@ -6712,17 +6924,122 @@ if (policyPath && excludedFiles.length) {
6712
6924
  stdio: ["ignore", "pipe", "ignore"] });
6713
6925
  const doc = JSON.parse(out);
6714
6926
  peekRead = true; // the child returned a report this run could read — see `peekRead`
6927
+ // ⟨0.31⟩ ONE LOOKUP FOR ALL THREE CALLERS. This was written THREE times — the matcher's
6928
+ // qualifier, the finding's path, and the `unanalyzed`→`peekUnread` attribution below — and every
6929
+ // copy carried the same defect:
6930
+ // find(e => loc.endsWith(e.path) || loc.endsWith(basename(e.path)))
6931
+ // The `||` sits INSIDE one `.find`, so a BASENAME match on an earlier entry beats a FULL PATH
6932
+ // match on a later one. MEASURED: with `src/one/dup.test.ts` and `src/two/dup.test.ts`, a
6933
+ // function from `two` was disclosed at `one`. A previous fix collapsed two of the three copies
6934
+ // and its commit message said so; the third survived here, where the failure mode is not a
6935
+ // fabricated locator but CLASS MIS-ATTRIBUTION feeding `peeked` — worse in kind.
6936
+ //
6937
+ // AND SUFFIX MATCHING IS ITSELF THE BUG. The replacement tried "longest suffix wins" and argued
6938
+ // no counterexample existed; here is one: a project directory literally NAMED `app`, holding
6939
+ // `x.test.ts` and `app/x.test.ts`. The root file's absolute path `/…/app/x.test.ts` genuinely
6940
+ // ends with the NESTED file's relative path `app/x.test.ts`, on a proper segment boundary, and
6941
+ // it is the longer match — so the root function is disclosed at the nested file. A
6942
+ // segment-boundary test would not have caught it either.
6943
+ //
6944
+ // So: RESOLVE, don't match. The child is handed absolute paths under `rootDir`, so its reported
6945
+ // location relativises back to exactly the `e.path` this run recorded. Suffix and basename
6946
+ // remain only as fallbacks for a child that reported something unrelativisable, and the basename
6947
+ // one is used only when it names exactly ONE excluded file. When nothing resolves this returns
6948
+ // null and the caller reports the child's own path — an ugly true locator beats a tidy false one.
6949
+ const locateExcluded = (childLoc) => {
6950
+ if (!childLoc) return null;
6951
+ // RESOLVE AGAINST THE CHILD'S OWN ROOT. The child scan runs under a tsconfig in `peekDir`, and
6952
+ // reports locations RELATIVE TO THAT — `../../../../../private/tmp/…/app/x.test.ts`. Resolving
6953
+ // such a path against the parent's cwd yields nonsense, which is why the first attempt at an
6954
+ // exact match never matched anything and quietly fell through to the suffix arm that carries
6955
+ // the bug. Resolve against `peekDir`, then relativise to `rootDir`, and the result is exactly
6956
+ // the `e.path` this run recorded.
6957
+ const abs = path.resolve(peekDir, childLoc);
6958
+ let rel = path.relative(rootDir, abs);
6959
+ let exact = excludedFiles.find((e) => e.path === rel);
6960
+ // …and once more through realpath, because macOS resolves `/tmp` to `/private/tmp` and a run
6961
+ // invoked through the symlinked spelling would otherwise miss its own files.
6962
+ if (!exact) {
6963
+ try {
6964
+ rel = path.relative(fs.realpathSync(rootDir), fs.realpathSync(abs));
6965
+ exact = excludedFiles.find((e) => e.path === rel);
6966
+ } catch { /* the file may not exist from here; fall through to the suffix arms */ }
6967
+ }
6968
+ if (exact) return exact;
6969
+ let best = null;
6970
+ for (const e of excludedFiles) {
6971
+ const sfx = path.sep + e.path;
6972
+ if ((childLoc === e.path || childLoc.endsWith(sfx) || childLoc.endsWith("/" + e.path))
6973
+ && (!best || e.path.length > best.path.length)) best = e;
6974
+ }
6975
+ if (best) return best;
6976
+ const base = path.basename(childLoc);
6977
+ const byBase = excludedFiles.filter((e) => path.basename(e.path) === base);
6978
+ return byBase.length === 1 ? byBase[0] : null;
6979
+ };
6715
6980
  for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
6716
6981
  const where = String(u?.path ?? "");
6717
- const hit = excludedFiles.find((e) => where.endsWith(e.path)
6718
- || where.endsWith(path.basename(e.path)));
6982
+ const hit = locateExcluded(where);
6719
6983
  // The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
6720
6984
  // cannot attribute — fail closed across every class rather than let one unattributable file leave
6721
6985
  // all of them claiming completeness.
6722
6986
  if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
6723
6987
  }
6988
+ // ⟨0.30⟩ THE PEEK ASKS THE GATE'S OWN MATCHER, not a flat set of effect NAMES. §6.2 already
6989
+ // requires it — "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME RULE, AND SHOULD SHARE THE SAME
6990
+ // CODE" — and the name-set approximation was wrong in BOTH directions once ⟨0.30⟩ made this
6991
+ // verdict-bearing (both MEASURED four-way in review):
6992
+ //
6993
+ // OVER-CHARGE: `deny Net[known-partner]` denies only that destination class, but the name set
6994
+ // held bare "Net", so a peeked fn fetching an UNKNOWN host — which the rule does not deny —
6995
+ // turned the verdict red, while the identical code IN scope passed. Rule SCOPES were dropped the
6996
+ // same way: a layer-scoped `deny Exec server` fired on a test file the scope excludes.
6997
+ //
6998
+ // UNDER-REPORT: `pure` is a deny rule with an EMPTY effect list, meaning "every effect except
6999
+ // Unknown". Flattened, it contributed NOTHING, so the denied set was empty and the peek never
7000
+ // ran — the STRICTEST policy silently disarmed the rung while the strictly weaker `deny Exec`
7001
+ // exited 2 on the identical tree. A four-way false all-clear.
7002
+ //
7003
+ // Only the DENY rules are handed over: ⟨0.29⟩ bounds this block to "effects that policy DENIES",
7004
+ // and evaluating `allow`/`forbid`/`only` here would widen it past the bound that keeps it quiet.
7005
+ // ⟨0.30⟩ THE PROJECT'S OWN `net-partner` SET, not an empty one. The child scan runs over a temp
7006
+ // tsconfig and never sees the project's `.candor/config`, and this call passed `new Set()` on top of
7007
+ // that — so a `deny Net[known-partner]` peek asked "is this host a declared partner?" against a set
7008
+ // that was always empty. MEASURED both ways: a test file reaching a DECLARED partner answered exit 0
7009
+ // where the same code in scope exits 1 (a false all-clear), and `deny Net[unknown-host]` answered
7010
+ // exit 2 over that same declared partner where in scope it exits 0 (the mirror over-charge).
7011
+ // candor-rust never had this — its peek runs in-process and inherits the config.
7012
+ //
7013
+ // `netClasses` stays null so the resolver recomputes each entry's class from the `hosts` the child
7014
+ // DID capture, against these partners — the child's own `netClass` was computed partner-blind and
7015
+ // must not be trusted here.
7016
+ // ⟨0.30⟩ MATCH SCOPES AGAINST A PROJECT-RELATIVE QUALIFIER, not the child's. The child scan runs
7017
+ // under a temp-directory tsconfig, so its `fn` is derived from THAT root — an absolute dotted path.
7018
+ // Handing those to the matcher made a rule's SCOPE match segments of the checkout directory: on a
7019
+ // tree at `…/fresh/src/execa`, `deny Net src` armed the peek while binding nothing in scope, so the
7020
+ // verdict depended on where the repo happened to be cloned. CI checkouts live under names nobody
7021
+ // chose (Bitbucket uses `agent/build`), which makes that a verdict decided by infrastructure.
7022
+ //
7023
+ // The project-relative path is already known here — it is what this run disclosed as excluded — and
7024
+ // the finding below is already named from it. The MATCHER must see the same thing the FINDING does.
7025
+ const relQual = (f) => {
7026
+ const childLoc = (f.loc ?? "").split(":")[0] ?? "";
7027
+ const hit = locateExcluded(childLoc);
7028
+ if (!hit) return f.fn;
7029
+ const stem = hit.path.replace(/\.[cm]?[jt]sx?$/, "").split(path.sep).join(".");
7030
+ const leaf = f.fn.split(".").pop() ?? f.fn;
7031
+ return `${stem}.${leaf}`;
7032
+ };
7033
+ const peekEntries = (doc.functions ?? []).map((f) => ({ ...f, fn: relQual(f) }));
7034
+ const peekViolations = evaluatePolicy({ ...peekPolicy, allow: [], forbid: [], only: [] },
7035
+ peekEntries, {}, new Map(), netPartners, null, null);
7036
+ const deniedByFn = new Map();
7037
+ for (const v of peekViolations) {
7038
+ if (!deniedByFn.has(v.fn)) deniedByFn.set(v.fn, new Set());
7039
+ for (const e of v.effects ?? []) deniedByFn.get(v.fn).add(e);
7040
+ }
6724
7041
  for (const f of doc.functions ?? []) {
6725
- const hits = (f.inferred ?? []).filter((e) => denied.has(e));
7042
+ const hits = [...(deniedByFn.get(relQual(f)) ?? [])].sort();
6726
7043
  if (!hits.length) continue;
6727
7044
  // NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
6728
7045
  // temp directory, so it derives module qualifiers from THAT root and the fn came out as a
@@ -6730,14 +7047,15 @@ if (policyPath && excludedFiles.length) {
6730
7047
  // exists by the time anyone reads it. The project-relative path is already known here (it is
6731
7048
  // what was excluded), so match on basename and report the path we already trust.
6732
7049
  const childLoc = (f.loc ?? "").split(":")[0] ?? "";
6733
- const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
6734
- || childLoc.endsWith(path.basename(e.path)));
7050
+ const hit = locateExcluded(childLoc);
6735
7051
  const where = hit?.path ?? childLoc;
6736
7052
  const cls = hit?.cls ?? "excluded";
6737
7053
  outOfScopeFindings.push({
6738
7054
  fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
6739
7055
  reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
6740
- + "The effect is real; the verdict does not account for it.",
7056
+ + "candor's ANALYSIS of that file reaches this effect; the gate did not judge it, so "
7057
+ + "the verdict is INCOMPLETE rather than a pass. (An analysis result, not a claim about "
7058
+ + "what the code does at runtime — see the release notes' known over-charge.)",
6741
7059
  });
6742
7060
  }
6743
7061
  outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
@@ -6751,6 +7069,31 @@ if (policyPath && excludedFiles.length) {
6751
7069
  }
6752
7070
  }
6753
7071
  }
7072
+ // ⟨0.30⟩ NOTHING ANALYZABLE, AND THE PEEK FOUND NOTHING EITHER: REFUSE — AND REFUSE BEFORE AN ENVELOPE
7073
+ // EXISTS. The peek above is the whole reason this run got past the early refusal; if it named something,
7074
+ // the ⟨0.30⟩ arm at the end reports it and exits 2 with `outOfScope` IN the report, so `gate --report` over
7075
+ // that report reaches the same 2 and §3.1 holds. If it named nothing there is nothing to report, and the
7076
+ // run must go back to being a refusal.
7077
+ //
7078
+ // WHY HERE AND NOT AT THE END, measured: the first version of this change let the clean case fall through
7079
+ // to a normal ending and exit 2 from an arm after the verdict was written — so the process exited 2 while
7080
+ // `--gate-json` said `ok: true`, and `gate --report` over the report it left behind exited 0. The exit code
7081
+ // was right and the DOCUMENT was green; a SARIF consumer reads the document. §3.1's byte-equality is
7082
+ // quantified over "any report a scan produced", so the only safe refusal is one that produces no report at
7083
+ // all — once an envelope exists, the scan route owns the gate route's answer.
7084
+ if (NO_SOURCES && !(Array.isArray(outOfScopeFindings) && outOfScopeFindings.length)) {
7085
+ console.error(`candor-ts: gate NOT certified — no analyzable TypeScript source under ${target}, and the `
7086
+ + `files this scan excluded perform no effect this policy denies; a gate cannot be green over a tree `
7087
+ + `it did not read`);
7088
+ // §3.3(d) MUST — the same remedy the no-policy branch names. This arm explains WHY the gate is not
7089
+ // certified and, without this, still leaves the reader to guess what a valid target looks like.
7090
+ console.error(` candor-ts reads .ts/.tsx/.mts/.cts (and .js with --allow-js) — point it at a `
7091
+ + `project directory or a TypeScript file.`);
7092
+ refuseEarly(`no analyzable TypeScript source under ${target} — candor-ts reads `
7093
+ + `.ts/.tsx/.mts/.cts (and .js with --allow-js); point it at a project directory or a `
7094
+ + `TypeScript file.`);
7095
+ process.exit(2);
7096
+ }
6754
7097
  if (outOfScopeFindings) {
6755
7098
  envelope.outOfScope = outOfScopeFindings;
6756
7099
  // SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
@@ -6761,7 +7104,7 @@ if (outOfScopeFindings) {
6761
7104
  if (f.path) console.error(` ${f.path}`);
6762
7105
  }
6763
7106
  if (outOfScopeFindings.length) {
6764
- console.error(" The verdict does not account for "
7107
+ console.error(" The verdict below is INCOMPLETE because of "
6765
7108
  + (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
6766
7109
  }
6767
7110
  }
@@ -7356,12 +7699,24 @@ if (gateJsonPath) {
7356
7699
  // must NOT read green — those effects are invisible, so a `deny`/`pure` that "passes" over them is a
7357
7700
  // false-pure. `ok` requires BOTH no violation AND a complete analysis. `analyzed:{count}` (Gap 1) mirrors
7358
7701
  // the report envelope so a --gate-json consumer sees the scan's scope from the verdict alone.
7359
- const incomplete = unanalyzedUnits.length > 0;
7702
+ // ⟨0.30⟩ THE SECOND CAUSE OF INCOMPLETENESS — a peeked function performs an effect the policy DENIES.
7703
+ // ⟨0.29⟩ required the verdict NOT to move here, on the assumption the peek surfaces uncertainty a gate
7704
+ // may decline to act on. It does not: measured on published 0.29.1 the peek resolves a CONCRETE denied
7705
+ // effect and names the function (axios 37 × `performs Net`, exit 0, `policy ✓`). Reported through
7706
+ // `incomplete`, never through `violations`, because the gate did not JUDGE these units — see the exit
7707
+ // site below for why that makes the code 2 and not 1.
7708
+ const scopeIncomplete = Array.isArray(outOfScopeFindings) && outOfScopeFindings.length > 0;
7709
+ const incomplete = unanalyzedUnits.length > 0 || scopeIncomplete;
7360
7710
  const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0 && !incomplete,
7361
7711
  analyzed: { count: fns.size } };
7362
7712
  // ⟨0.24⟩ the vocabulary file that moved the verdict, in the SAME position `gate --report` puts it, because
7363
7713
  // §3.1 makes byte-equality between the two routes the acceptance test. Omitted when no alias was used.
7364
7714
  if (policyVocabulary) verdictObj.policyVocabulary = policyVocabulary;
7715
+ // ⟨0.31⟩ …and the partner declaration that moved it, COPIED FROM THE REPORT rather than recomputed.
7716
+ // Copying is the point: `gate --report` cannot compute this (no target), so if the two routes derived
7717
+ // it independently they could not agree. Reading the producer's record on both routes is what makes
7718
+ // the byte-equality hold instead of break. Same position in both, for the same reason as the line above.
7719
+ if (envelope.netPartners) verdictObj.netPartners = [envelope.netPartners];
7365
7720
  verdictObj.violations = gateViolations;
7366
7721
  // ⟨0.24⟩ …and when a certain violation DOMINATED a policy refusal, the refusal is disclosed here rather
7367
7722
  // than deleted (SPEC §3.1: "the RAW policy line, verbatim", one entry per rule, omitted when empty). A
@@ -7388,8 +7743,14 @@ if (gateJsonPath) {
7388
7743
  // incomplete:true is honest — never a fabricated pass. OMITTED when complete (byte-compatible verdict).
7389
7744
  if (incomplete) {
7390
7745
  verdictObj.incomplete = true;
7391
- verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
7392
- }
7746
+ if (unanalyzedUnits.length)
7747
+ verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
7748
+ }
7749
+ // ⟨0.30⟩ …and WHICH functions made it incomplete, in the machine channel. The same array the report
7750
+ // carries, so `gate --report` re-emits it from the report and §3.1 byte-equality holds by construction —
7751
+ // this is the anchor the `net-partner` attempt lacked. Omitted when empty, so a clean verdict is
7752
+ // byte-identical to a pre-⟨0.30⟩ one.
7753
+ if (scopeIncomplete) verdictObj.outOfScope = outOfScopeFindings;
7393
7754
  // ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
7394
7755
  // verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
7395
7756
  // auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
@@ -7429,5 +7790,21 @@ if (gateConfigured && unanalyzedUnits.length) {
7429
7790
  console.error(`candor-ts: gate NOT certified — ${unanalyzedUnits.length} source file(s) could not be analyzed (see above); a gate cannot be green over unanalyzed code`);
7430
7791
  process.exit(2);
7431
7792
  }
7793
+ // ⟨0.30⟩ THE SCOPE HALF OF THE SAME POSTURE. `unanalyzed` above is "I opened this file and could not read
7794
+ // it"; this is "I never opened it, and when I looked afterwards it performed the effect you denied". Both
7795
+ // mean the same thing to a consumer — the gate could not see enough of this tree to certify it — so both
7796
+ // are exit 2.
7797
+ //
7798
+ // EXIT 2, NOT 1, DELIBERATELY: these functions are not in `violations` and not in `functions`, because the
7799
+ // gate did not judge them. Exit 1 would claim "I judged your code and it breaks the policy", which is
7800
+ // false in the other direction. Exit 2 says "I could not see enough to answer", which is what happened.
7801
+ // A real violation (exit 1, above) still dominates: certain beats unevaluable.
7802
+ if (gateConfigured && Array.isArray(outOfScopeFindings) && outOfScopeFindings.length) {
7803
+ const n = outOfScopeFindings.length;
7804
+ console.error(`candor-ts: gate NOT certified — ${n} function(s) OUTSIDE this scan's scope perform an `
7805
+ + `effect this policy denies (named above); the gate did not judge them, so the verdict is `
7806
+ + `incomplete rather than a pass`);
7807
+ process.exit(2);
7808
+ }
7432
7809
  if (policyPath !== null) console.error("candor-ts: policy ✓");
7433
7810
  if (baselinePath !== null && fs.existsSync(baselinePath)) console.error("candor-ts: baseline ✓"); // absent = inactive (noted above)
package/scratch.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Scratch directories for the harnesses, removed when the run ends.
6
+ //
7
+ // WHY THIS EXISTS. Every harness here made fixture trees with a bare `fs.mkdtempSync` and removed none
8
+ // of them. Measured 2026-08-14: 46,919 `candor-*` directories in $TMPDIR, ~7,300 of them from test.mjs's
9
+ // `project()` alone, which mints one per fixture and is called ~1,300 times a run. It is not only
10
+ // untidy — it made a single `candor-swift privacy-manifest --verify` take 72 seconds, because listing
11
+ // the plist's ancestor meant listing all of $TMPDIR. The engine side of that was fixed in 2026-08-07;
12
+ // this is the side that keeps refilling the directory.
13
+ //
14
+ // KEPT ON FAILURE, DELIBERATELY. A failing assertion prints the path to its fixture tree, and deleting
15
+ // it on the way out would remove the evidence at exactly the moment someone needs it. `keepOnFailure()`
16
+ // lets a harness say "this run failed" and the trees survive, with a line saying where they are.
17
+ // Success is the common case and the one that accumulates.
18
+ //
19
+ // SIGINT/SIGTERM are handled because the killed-mid-run case was the obvious contributor — though 46,919
20
+ // is not all killed runs.
21
+ const made = [];
22
+ let keep = false;
23
+
24
+ export function scratch(prefix) {
25
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
26
+ made.push(d);
27
+ return d;
28
+ }
29
+
30
+ /** Call before exiting when the run FAILED — the trees are evidence, so they stay. */
31
+ export function keepOnFailure() { keep = true; }
32
+
33
+ function sweep() {
34
+ if (keep) {
35
+ if (made.length) console.log(` (${made.length} fixture tree(s) kept for inspection under ${os.tmpdir()})`);
36
+ return;
37
+ }
38
+ for (const d of made) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
39
+ made.length = 0;
40
+ }
41
+
42
+ process.on("exit", sweep);
43
+ // An uncaught throw still runs `exit` handlers — with `keep` false, so a harness that dies mid-assertion
44
+ // would sweep away the very trees whose paths the crash just printed. Treat any abnormal end as failure.
45
+ for (const ev of ["uncaughtException", "unhandledRejection"]) {
46
+ process.on(ev, (e) => { keepOnFailure(); console.error(e); process.exit(1); });
47
+ }
48
+ // A signal does NOT run `exit` handlers on its own, which is the killed-mid-run leak. Re-raise after
49
+ // sweeping so the exit status still reflects the signal rather than becoming a clean 0.
50
+ //
51
+ // `removeAllListeners` FIRST, and it is the whole trick: installing a listener REPLACES Node's default
52
+ // disposition for that signal, so `process.kill(process.pid, sig)` re-enters this same handler. The
53
+ // first version of this shipped without it and made the harness unkillable — Ctrl-C swept, re-signalled,
54
+ // swept, forever, and a second Ctrl-C did not help either. Removing the listener restores the default,
55
+ // so the re-raise terminates with the right status.
56
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
57
+ process.on(sig, () => { sweep(); process.removeAllListeners(sig); process.kill(process.pid, sig); });
58
+ }