candor-ts 0.36.2 → 0.37.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
@@ -23,7 +23,7 @@ the TypeScript-specific production + query surface.
23
23
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
24
24
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
25
25
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
26
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.36)."*
26
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.37)."*
27
27
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
28
28
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
29
29
  >
package/README.md CHANGED
@@ -201,7 +201,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
201
201
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
202
202
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
203
203
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
204
- | `{ candor: { version, toolchain, spec: "0.36" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
204
+ | `{ candor: { version, toolchain, spec: "0.37" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
205
205
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
206
206
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
207
207
 
@@ -219,7 +219,7 @@ read the Rust source".
219
219
 
220
220
  ## Status
221
221
 
222
- 0.30.0, speaking candor-spec 0.36: the analysis core, the gate (`--policy` / `--gate-json` /
222
+ 0.30.0, speaking candor-spec 0.37: the analysis core, the gate (`--policy` / `--gate-json` /
223
223
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
224
224
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
225
225
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
@@ -268,7 +268,13 @@ did not already choose to install.
268
268
 
269
269
  Discovery is passive: the manifest is [`server.json`](server.json), published to the official
270
270
  [MCP Registry](https://registry.modelcontextprotocol.io) so clients and directories can find candor
271
- without being handed a script. The registry verifies namespace ownership through the marker below.
271
+ without being handed a script.
272
+
273
+ The registry verifies namespace ownership from the **`mcpName` field in `package.json`** — that is the
274
+ npm mechanism. (A `mcp-name:` README marker is the *crates.io* convention and does nothing here; the
275
+ first cut of this used it, and the registry refused the publish with
276
+ `missing required 'mcpName' field`. The line below is kept only because it is how a human greps for the
277
+ namespace.)
272
278
 
273
279
  mcp-name: io.github.tombaldwin/candor
274
280
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.36.2",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.36)",
3
+ "version": "0.37.0",
4
+ "mcpName": "io.github.tombaldwin/candor",
5
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.37)",
5
6
  "type": "module",
6
7
  "dependencies": {
7
8
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import fs from "node:fs";
12
12
  import nodePath from "node:path";
13
+ import { RESERVED_SIDECAR_SEGMENTS } from "./scan-core.mjs";
13
14
  import { reasonClass, REASON_CLASSES, DYNAMIC_CLASSES, resolveReasonClasses, reasonClassesMatch,
14
15
  classFilterExcludes, netClassResolver, reportNetClasses, unanswerableScoped,
15
16
  effectiveInferred, reportUnits } from "./policy.mjs";
@@ -44,7 +45,14 @@ function siblings(prefix, predicate) {
44
45
  // exists to prevent. CAUGHT BY PART 56 the moment ⟨0.32⟩ landed in this engine: a refusal "left a report",
45
46
  // because discovery counted the marker the refusal had just written. Adding a file kind beside the
46
47
  // reports means teaching discovery about it in the same change.
47
- export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json") && !f.endsWith(".gate.json") && !f.endsWith(".refused.json");
48
+ // DERIVED from SPEC §2.2's reserved set, not restated. This was seven chained `endsWith` calls and it
49
+ // was MISSING `layerreach` — so `<prefix>.<crate>.<kind>.layerreach.json`, which candor-rust really
50
+ // writes, came back `true` from a predicate whose whole job is "is this a report". Measured: a
51
+ // `map --json` over a good rust report flipped to the INCOMPLETE shape with two malformed-report
52
+ // diagnostics. `encountered-*` stays a separate `.includes()` because it is a prefix FAMILY, not a
53
+ // fixed trailing segment.
54
+ export const isReport = (f) =>
55
+ !RESERVED_SIDECAR_SEGMENTS.some((seg) => f.endsWith(`.${seg}.json`)) && !f.includes(".encountered-");
48
56
 
49
57
  // A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
50
58
  // `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so a
@@ -153,7 +161,11 @@ const reportFilesAt = (prefix) => (fs.existsSync(`${prefix}.json`) ? [`${prefix}
153
161
  // beside-the-report layout — the exact spelling `--gate-json` exists for, pinned by the control test —
154
162
  // and `encountered-*` because it is engine-local scan bookkeeping no query reads. Existing files only:
155
163
  // the guard protects data, and a sidecar not on disk has none to lose.
156
- const PAIRED_SIDECAR_SEGMENTS = ["calibrated", "callgraph", "hierarchy", "layerreach", "locs"];
164
+ // A DELIBERATE SUBSET of the reserved set, with both exclusions NAMED rather than achieved by omission
165
+ // — a copy that is shorter than its source cannot be read as intentional. Derived, so an eighth
166
+ // reserved segment arrives here automatically.
167
+ const PAIRED_SIDECAR_EXCLUDED = new Set(["gate", "refused"]);
168
+ const PAIRED_SIDECAR_SEGMENTS = RESERVED_SIDECAR_SEGMENTS.filter((s) => !PAIRED_SIDECAR_EXCLUDED.has(s));
157
169
  export function gateReportInputFiles(prefix) {
158
170
  if (!prefix) return [];
159
171
  const out = [];
package/query.mjs CHANGED
@@ -717,7 +717,7 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
717
717
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
718
718
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
719
719
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
720
- const SPEC_VERSION = "0.36";
720
+ const SPEC_VERSION = "0.37";
721
721
 
722
722
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
723
723
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
package/scan-core.mjs CHANGED
@@ -191,6 +191,104 @@ export const CLOCK_READING_CONSOLE_MEMBERS = /^(time|timeEnd|timeLog)$/;
191
191
  export const CONNECTING_WEB_CTORS = /^(WebSocket|EventSource)$/;
192
192
  export const WEB_WIRE_MEMBERS = /^(send|close)$/;
193
193
 
194
+ /** THE CALL-CLASSIFICATION NAME SETS — module scope, and exported for the R111 reason above.
195
+ *
196
+ * All four lived ~740 lines INSIDE scan.mjs's `visitCalls`, re-allocated on every classified call and,
197
+ * more to the point, unimportable: no test could identity-check one, so every assertion about them had
198
+ * to be a hand-copied spelling asserted through a scan — which is exactly how R109 and R110 drifted.
199
+ * They close over nothing. The PREDICATES that read them do (`isConnectingCtor` and `netEstablishing`
200
+ * close over `ctorRuleName`/`mod`), and those stay in scan.mjs; these are pure literal sets.
201
+ *
202
+ * They are deliberately NOT merged with scan.mjs's `NET_USE_VERBS`, which spells some of the same names
203
+ * while answering a DIFFERENT question — "is this call's argument 0 a PAYLOAD rather than a locator?" —
204
+ * and fails in the opposite direction. Two questions, two sets.
205
+ */
206
+
207
+ // The net cluster's documented public CONNECTING constructors. `new X()` normally synthesizes the member
208
+ // token "new" so a κ rule can exempt inert construction from a module-wide effect (`new http.Agent()`),
209
+ // BUT a connecting constructor is NOT inert: `new http.ClientRequest(url)` performs the network I/O on
210
+ // construction (it is what `http.request()` returns and dispatches), so the blanket `new`-exemption would
211
+ // convert a real Net source into pure — a cardinal-sin under-report. For such a ctor scan.mjs synthesizes
212
+ // the CLASS name instead of "new", so the net-cluster rule's `/^(?!new$)/` matcher keeps the effect.
213
+ // http2 connects via `connect()` (a function, not a ctor) so it needs no entry here. Inert ctors
214
+ // (Agent/Server/Socket/TLSSocket/Http2Server*/message shells) still synthesize "new" and stay pure.
215
+ // `CONNECTING_WEB_CTORS` above is the es-lib arm of the same question and is read beside this set.
216
+ export const CONNECTING_CTORS = new Set(["ClientRequest"]);
217
+
218
+ // SPEC §2.2's RESERVED TRAILING SEGMENTS — the family-wide set, and the ONE owner of it in this engine.
219
+ //
220
+ // §2.2 exists, in its own words, "because the engines were already drifting on it": three of the four
221
+ // excluded these by name and one discriminated by segment count, and the by-name lists DISAGREED. A
222
+ // consumer with the shorter list claims another engine's sidecar as a report.
223
+ //
224
+ // This engine had THREE spellings and they disagreed: `isReport`'s chained `endsWith` calls in
225
+ // query-core.mjs (six segments, MISSING `layerreach`), and two five-name arrays in query-core.mjs and
226
+ // scan.mjs. MEASURED 2026-09-13, and the missing one was live: candor-rust writes
227
+ // `<prefix>.<crate>.<kind>.layerreach.json`, `isReport` called it a REPORT, and a `map --json` over a
228
+ // perfectly good rust report flipped from `{"(root)": …}` to `{"incomplete": …, "modules": …}` with two
229
+ // "malformed report" diagnostics — fail-closed and disclosed, so not a cardinal sin, but it breaks the
230
+ // cross-engine premise this loader states in its own comment ("an agent queries a report from any
231
+ // language identically").
232
+ //
233
+ // `encountered-*` is NOT here: it is a FAMILY matched by prefix, not a fixed trailing segment, and the
234
+ // engine-local scan bookkeeping no query reads. It stays an explicit `.includes()` at the one predicate
235
+ // that needs it, rather than being smuggled into a list of exact segments.
236
+ export const RESERVED_SIDECAR_SEGMENTS =
237
+ ["callgraph", "hierarchy", "calibrated", "layerreach", "locs", "gate", "refused"];
238
+
239
+ // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
240
+ // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
241
+ // a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
242
+ // URL is the call arg (sweep [18]); `dgram.send(buf,port,host)` is added module-aware by
243
+ // scan.mjs's `netEstablishing` (UDP has no connect, so send carries the destination — sweep [12]).
244
+ // SOUNDNESS R410 — THE RESOLVER FAMILY WAS MISSING, and its absence is a GATE BYPASS, not a
245
+ // missed disclosure. `dns.resolve` classifies Net (see the κ table below), so a resolver call
246
+ // carries the effect while contributing NO host; with this list not naming it, nothing marked
247
+ // the surface incomplete and a benign sibling `fetch("https://api.stripe.com")` certified a
248
+ // caller-controlled DNS target — `allow Net api.stripe.com` exit 0, measured, with the
249
+ // sibling-free control correctly caught by AS-EFF-008. Written as the WHOLE family rather than
250
+ // the spelling in hand (R346): every node `dns` resolver, the `dns/promises` twins (same names)
251
+ // and the `Resolver` class methods, which share these member names. test.mjs pins that family by
252
+ // IDENTITY against this export — the assertion R410 shipped without, because the set was unreachable.
253
+ //
254
+ // NOTE THE SHAPE PROBLEM THIS DOES NOT FIX. This set is an INCLUSION list, so forgetting a
255
+ // member UNDER-reports — the opposite of `FS_USE_VERBS`/`EXEC_USE_VERBS` below, where
256
+ // forgetting over-charges and is safe. That asymmetry is the defect class itself: java's Net
257
+ // is sound precisely because it uses the general rule (any Net call contributing no visible
258
+ // host leaves the surface incomplete) rather than a list. The durable repair is to INVERT this
259
+ // into a use-verb denylist beside the other two; that is a wider change with its own
260
+ // over-charge bill to price, so it is filed rather than smuggled in here.
261
+
262
+ export const NET_ESTABLISHING = new Set(["request", "get", "post", "put", "patch", "delete", "head",
263
+ "options", "connect", "createConnection", "fetch",
264
+ "lookup", "lookupService", "reverse", "resolve", "resolve4", "resolve6", "resolveAny",
265
+ "resolveCname", "resolveCaa", "resolveMx", "resolveNaptr", "resolveNs", "resolvePtr",
266
+ "resolveSoa", "resolveSrv", "resolveTlsa", "resolveTxt"]);
267
+
268
+ // Fs/Exec USE-verbs whose LOCATOR was fixed earlier, not an arg of THIS call — so a missing literal
269
+ // here is the legitimate split-construct/use shape, never the masking signal (the establishing-
270
+ // allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
271
+ // ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
272
+ // (the command was fixed at spawn); the spawn fns are establishing.
273
+ // The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
274
+ // prior `open()` whose path this analysis already saw, so their invisible destination is not a
275
+ // gap and marking them `incomplete` charges every buffered write in a real tree.
276
+ //
277
+ // ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
278
+ // made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
279
+ // call and published as a path — a fabrication — so the set was never consulted for these four.
280
+ // Killing the fabrication moved them into the other wrong bucket. Found by generating a case
281
+ // per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
282
+ //
283
+ // Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
284
+ // UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
285
+ // the denylist rule that governs the classifier surface.
286
+ export const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
287
+ "fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
288
+ "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
289
+ "readv", "readvSync", "writev", "writevSync"]);
290
+ export const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
291
+
194
292
  // ---- κ — the curated classifier (CLASSIFIER §2: the dispatch/execution boundary, not builders) ----
195
293
  // Node builtins + a curated npm tier (the same under-report-and-say-so posture as the crate table:
196
294
  // an unlisted package contributes nothing — never a guess).
package/scan.mjs CHANGED
@@ -37,7 +37,8 @@ import { isTestPath, kappa, kappaKnows, nodeCoreUnreviewed, fsKind, commandHeadE
37
37
  tablesInSql, modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf,
38
38
  partnerFor, CLOCK_READING_PERFORMANCE_MEMBERS, CLOCK_READING_PROCESS_MEMBERS,
39
39
  CLOCK_READING_CONSOLE_MEMBERS, CONNECTING_WEB_CTORS,
40
- WEB_WIRE_MEMBERS } from "./scan-core.mjs";
40
+ WEB_WIRE_MEMBERS, CONNECTING_CTORS, NET_ESTABLISHING, FS_USE_VERBS,
41
+ EXEC_USE_VERBS, RESERVED_SIDECAR_SEGMENTS } from "./scan-core.mjs";
41
42
  import { emitSurface } from "./surface.mjs";
42
43
 
43
44
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -48,7 +49,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
48
49
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
49
50
  // Reused, never re-littered.
50
51
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
51
- const SPEC_VERSION = "0.36";
52
+ const SPEC_VERSION = "0.37";
52
53
 
53
54
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
54
55
  //
@@ -691,7 +692,14 @@ const outArmedSidecars = [];
691
692
  // destroying it from the report sink is precisely the cross-sink harm §3.3.1 measures, failing OPEN in
692
693
  // the way `armGateJsonFailClosed` refuses to. `encountered-*` is a prefix family rather than a segment
693
694
  // and belongs to no report's pair. Exclusion by argument, not by a shorter list.
694
- const REPORT_SIDECAR_SEGMENTS = ["callgraph", "hierarchy", "locs", "calibrated", "layerreach"];
695
+ // …and `refused` is the THIRD exclusion, which this list dropped by OMISSION while the paragraph above
696
+ // argued the other two. The ⟨0.32⟩ refusal MARKER has its own lifecycle — a run that completes its
697
+ // write phase removes it, and the rung guarantees a LOST marker fails OPEN while a STALE one fails
698
+ // CLOSED. Sweeping it from the report sink makes "lost" the common case, inverting exactly that. Named
699
+ // now, so all three exclusions are arguments rather than a shorter list, which is what the paragraph
700
+ // above already claimed of this line.
701
+ const REPORT_SIDECAR_EXCLUDED = new Set(["gate", "refused"]);
702
+ const REPORT_SIDECAR_SEGMENTS = RESERVED_SIDECAR_SEGMENTS.filter((s) => !REPORT_SIDECAR_EXCLUDED.has(s));
695
703
  const removeArmedReportSidecars = (report, prefix, inputs) => {
696
704
  const stem = report.replace(/\.json$/i, "");
697
705
  for (const seg of REPORT_SIDECAR_SEGMENTS) {
@@ -2925,9 +2933,62 @@ const FS_TWO_PATH_MEMBERS = new Set([
2925
2933
  "copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync",
2926
2934
  "link", "linkSync", "symlink", "symlinkSync",
2927
2935
  ]);
2936
+ // SOUNDNESS R416 — A LOCATOR THAT IS DETERMINED IS DETERMINED HOWEVER IT REACHES THE CALL. This
2937
+ // function read ONLY a syntactic string literal sitting in the path position, so a path bound one line
2938
+ // above was lost entirely. MEASURED on shipped 0.36.2:
2939
+ // export function f() { const p = "/tmp/benign"; fs.writeFileSync(p, ""); }
2940
+ // -> paths: null, incomplete: ["Fs"] => `allow Fs /tmp/benign` REFUSES a fully determined write
2941
+ // export function g() { fs.writeFileSync("/tmp/benign", ""); }
2942
+ // -> paths: ["/tmp/benign"] => certified, correctly
2943
+ // rust credits a plain local and a const; java and swift credit it; ts was the worst of the four
2944
+ // (conformance `gen_stat_locator.py`, arm `a4local`, which exists to pin exactly this).
2945
+ //
2946
+ // THIS FAILS CLOSED, so it is precision, not soundness — but it is SEQUENCING-CRITICAL. "Captured" has
2947
+ // to be a VALUE fact before any further rung marks a surface incomplete because "the locator was not
2948
+ // captured", or every such rung compounds the over-mask. Under-approximation stays permitted: what is
2949
+ // NOT resolved (a `let`, a parameter, a concatenation, a `path.join`) keeps today's behaviour and its
2950
+ // `incomplete` marker.
2951
+ //
2952
+ // THE RESOLVER IS `constStringValue`, unchanged and already trusted by the Net surface for the identical
2953
+ // question (`resolveConstUrlString` has read it since ⟨0.29⟩). Its bar is strict: EVERY value declaration
2954
+ // of the symbol must be an immutable `const` (or `readonly` field) whose initializer is a plain string
2955
+ // literal, and conflicting declarations resolve to null. Reusing it rather than writing a second path
2956
+ // resolver is the point — R288's fifteen-copies shape is what a private one would start.
2957
+ //
2958
+ // FAILURE DIRECTION, both ways, because this function feeds TWO consumers:
2959
+ // · `lits` is PUBLISHED as `paths`. Resolving a value wrongly would FABRICATE a locator — the cardinal
2960
+ // direction — which is why the resolver's bar is "every declaration is a const string literal" and
2961
+ // not "some declaration looks like one".
2962
+ // · `complete` SUPPRESSES the masking guard. Resolving more means marking `incomplete` less, so the
2963
+ // same wrong resolution would also un-mask. One resolver, one bar, both consumers.
2964
+ // Siblings NOT touched here: `programHeadLiteral` (Exec) and the SQL slot (Db) have the same gap, each
2965
+ // with its own over-charge bill to price. Filed, not smuggled in.
2966
+ //
2967
+ // `CANDOR_R416_HITS=1` prints one stderr line naming the members whose const-bound path was resolved —
2968
+ // the R103 hit-counter pattern, kept for the same reason (an A/B that comes back byte-identical says
2969
+ // nothing until the corpus is shown to REACH the branch). It prints ONLY when the count is non-zero,
2970
+ // and that is not a detail: the first run of this probe emitted its summary line unconditionally, so
2971
+ // `corpus-ab.py --mark R416PROBE` counted 7 hits across 7 entries over a corpus whose real hit count
2972
+ // was 0 — a flattering reach figure manufactured by the instrument, which is AGENT-CORPUS-BRIEF §E1
2973
+ // happening to the very measurement written to prevent it.
2974
+ const R416_HITS = process.env.CANDOR_R416_HITS ? new Map() : null;
2975
+ const r416Hit = (k) => { if (R416_HITS) R416_HITS.set(k, (R416_HITS.get(k) ?? 0) + 1); };
2976
+ if (R416_HITS) process.on("exit", () => {
2977
+ const rows = [...R416_HITS].sort();
2978
+ const total = rows.reduce((a, [, n]) => a + n, 0);
2979
+ if (total) process.stderr.write(`R416PROBE total=${total}`
2980
+ + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
2981
+ });
2928
2982
  function fsPathLiteral(node, member) {
2929
2983
  const args = node.arguments ?? [];
2930
- const at = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
2984
+ const at = (i) => {
2985
+ const a = args[i];
2986
+ if (!a) return null;
2987
+ if (ts.isStringLiteralLike(a)) return a.text;
2988
+ const c = constStringValue(a); // R416 — a const-bound path is a captured path
2989
+ if (c != null) r416Hit(member);
2990
+ return c;
2991
+ };
2931
2992
  const a0 = at(0);
2932
2993
  const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
2933
2994
  const a1 = needsTwo ? at(1) : null;
@@ -7145,69 +7206,12 @@ function visitCalls(node) {
7145
7206
  // The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
7146
7207
  // whose declaration is a Constructor (empty name) — synthesize "new" so a rule can exempt
7147
7208
  // inert construction from its module-wide effect (the net cluster: `new http.Agent()` etc.).
7148
- // BUT a CONNECTING constructor is NOT inert: `new http.ClientRequest(url)` performs the
7149
- // network I/O on construction (it is what `http.request()` returns and dispatches), so the
7150
- // blanket `new`-exemption would convert a real Net source into pure (a cardinal-sin under-
7151
- // report). For such a ctor we synthesize the CLASS name instead of "new", so the net-cluster
7152
- // rule's `/^(?!new$)/` matcher keeps the effect. The set is the net cluster's documented
7153
- // public connecting ctors; http2 connects via `connect()` (a function, not a ctor) so it
7154
- // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
7155
- // still synthesize "new" and stay pure.
7156
- const CONNECTING_CTORS = new Set(["ClientRequest"]);
7209
+ // A CONNECTING ctor is the exception to that exemption; the set, and why, is in scan-core.
7157
7210
  // R130 — `new WebSocket(url)` / `new EventSource(url)` are the same shape as `ClientRequest`:
7158
7211
  // the connection is opened BY the construction, so the blanket `new`-exemption would convert a
7159
7212
  // real Net source into pure. Read from the SHARED constant the es-lib arm reads, so the two
7160
7213
  // resolution paths cannot be widened separately.
7161
7214
  const isConnectingCtor = (n) => CONNECTING_CTORS.has(n) || CONNECTING_WEB_CTORS.test(n);
7162
- // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
7163
- // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
7164
- // a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
7165
- // URL is the call arg (sweep [18]); `dgram.send(buf,port,host)` is added module-aware below (UDP
7166
- // has no connect, so send carries the destination — sweep [12]).
7167
- // SOUNDNESS R410 — THE RESOLVER FAMILY WAS MISSING, and its absence is a GATE BYPASS, not a
7168
- // missed disclosure. `dns.resolve` classifies Net (see the κ table below), so a resolver call
7169
- // carries the effect while contributing NO host; with this list not naming it, nothing marked
7170
- // the surface incomplete and a benign sibling `fetch("https://api.stripe.com")` certified a
7171
- // caller-controlled DNS target — `allow Net api.stripe.com` exit 0, measured, with the
7172
- // sibling-free control correctly caught by AS-EFF-008. Written as the WHOLE family rather than
7173
- // the spelling in hand (R346): every node `dns` resolver, the `dns/promises` twins (same names)
7174
- // and the `Resolver` class methods, which share these member names.
7175
- //
7176
- // NOTE THE SHAPE PROBLEM THIS DOES NOT FIX. This set is an INCLUSION list, so forgetting a
7177
- // member UNDER-reports — the opposite of `FS_USE_VERBS`/`EXEC_USE_VERBS` below, where
7178
- // forgetting over-charges and is safe. That asymmetry is the defect class itself: java's Net
7179
- // is sound precisely because it uses the general rule (any Net call contributing no visible
7180
- // host leaves the surface incomplete) rather than a list. The durable repair is to INVERT this
7181
- // into a use-verb denylist beside the other two; that is a wider change with its own
7182
- // over-charge bill to price, so it is filed rather than smuggled in here.
7183
- const NET_ESTABLISHING = new Set(["request", "get", "post", "put", "patch", "delete", "head",
7184
- "options", "connect", "createConnection", "fetch",
7185
- "lookup", "lookupService", "reverse", "resolve", "resolve4", "resolve6", "resolveAny",
7186
- "resolveCname", "resolveCaa", "resolveMx", "resolveNaptr", "resolveNs", "resolvePtr",
7187
- "resolveSoa", "resolveSrv", "resolveTxt"]);
7188
- // Fs/Exec USE-verbs whose LOCATOR was fixed earlier, not an arg of THIS call — so a missing literal
7189
- // here is the legitimate split-construct/use shape, never the masking signal (the establishing-
7190
- // allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
7191
- // ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
7192
- // (the command was fixed at spawn); the spawn fns are establishing.
7193
- // The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
7194
- // prior `open()` whose path this analysis already saw, so their invisible destination is not a
7195
- // gap and marking them `incomplete` charges every buffered write in a real tree.
7196
- //
7197
- // ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
7198
- // made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
7199
- // call and published as a path — a fabrication — so the set was never consulted for these four.
7200
- // Killing the fabrication moved them into the other wrong bucket. Found by generating a case
7201
- // per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
7202
- //
7203
- // Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
7204
- // UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
7205
- // the denylist rule that governs the classifier surface.
7206
- const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
7207
- "fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
7208
- "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
7209
- "readv", "readvSync", "writev", "writevSync"]);
7210
- const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
7211
7215
  // `ctorRuleName` (below) rather than `ctorClassName`: a connecting ctor reached through a local
7212
7216
  // alias must still fail the surface closed on a runtime URL. Evaluated at call time, after it.
7213
7217
  const netEstablishing = (member) =>