candor-ts 0.28.2 → 0.29.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/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
@@ -2214,13 +2335,17 @@ function packageManifestEffects(file) {
2214
2335
  }
2215
2336
 
2216
2337
  // ---- 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
- }
2338
+ // Read ONLY from string literals at a classified call — informative, never complete, never inferred,
2339
+ // and ALWAYS from the argument POSITION that names the locator.
2340
+ //
2341
+ // ⟨0.29⟩ `firstStringLiteral` — "the first string literal anywhere in the args" — is DELETED. It was the
2342
+ // reader for every locator surface except the `Exec` head, and it produced the same defect in each one it
2343
+ // touched: `write(userPath, "/tmp/lit")` published the bytes as the path, `send(msg, …, addr)` published
2344
+ // the payload as the host, `query(userSql, "SELECT * FROM audit_log")` published a parameter as the
2345
+ // table — each a fabricated locator that ALSO suppressed the masking guard, so a benign-looking literal
2346
+ // certified a destination nobody could see. Removed rather than left unused: a "first literal anywhere"
2347
+ // in scope is how this class comes back at the next call site somebody adds. Use
2348
+ // `programHeadLiteral` / `urlArgLiteral` / `fsPathLiteral` / argument 0 for SQL.
2224
2349
 
2225
2350
  // Is this property/element access a SETTER target reached through a destructuring assignment —
2226
2351
  // `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
@@ -2242,13 +2367,13 @@ function isDestructuringAssignTarget(node) {
2242
2367
  }
2243
2368
 
2244
2369
  // 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
2370
+ // Unlike a "first literal ANYWHERE in the args" reader, this refuses to refine when
2246
2371
  // the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
2247
2372
  // table: `spawn(toolVar, "curl")` must NOT fabricate Net — the literal is an argument, not the
2248
2373
  // program (spec §4 ⟨0.5⟩: the head is argv[0]). Mirrors candor-java programHeadLiteral and the Rust
2249
2374
  // 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.
2375
+ // direction. ⟨0.29⟩ the `cmds` SURFACE reads this too: it was documented as cosmetic, but `cmds` is what
2376
+ // `allow Exec <cmd>` gates on (AS-EFF-008).
2252
2377
  function programHeadLiteral(node) {
2253
2378
  const a0 = (node.arguments ?? [])[0];
2254
2379
  return a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
@@ -2262,6 +2387,54 @@ function programHeadLiteral(node) {
2262
2387
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
2263
2388
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
2264
2389
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
2390
+ // ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
2391
+ // Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
2392
+ // `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
2393
+ // `incomplete`, so `allow Net telemetry.example` answered `policy ✓` at exit 0 over a UDP send to a
2394
+ // runtime-controlled address. A fabricated destination masking a real one — the `Fs` content-literal
2395
+ // defect of this same rung, one effect over, in the one API whose locator is neither first nor second.
2396
+ //
2397
+ // Node documents exactly two overloads:
2398
+ // send(msg, port, address[, cb]) → address at 2, a numeric port at 1
2399
+ // send(msg, offset, length, port, address[, cb]) → address at 4, a numeric port at 3
2400
+ // so the position is recovered from the NUMERIC PORT that must precede it rather than guessed from the
2401
+ // argument count (a `cb` or an options object shifts nothing, and anything that does not match both
2402
+ // shapes returns null — the destination is then not statically visible and the caller fails closed).
2403
+ const isNumericLit = (a) => a && (ts.isNumericLiteral(a)
2404
+ || (ts.isPrefixUnaryExpression(a) && ts.isNumericLiteral(a.operand)));
2405
+ function dgramSendAddressIndex(args) {
2406
+ if (isNumericLit(args[3])) return 4;
2407
+ if (isNumericLit(args[1])) return 2;
2408
+ return -1;
2409
+ }
2410
+ // ⟨0.29⟩ THE Fs PATH LITERAL, read from the PATH ARGUMENT POSITION — the third application of the
2411
+ // `programHeadLiteral` discipline, and the one that never got it. The comment above says that rule was
2412
+ // "generalized from Exec to Net"; it stopped there, and `Fs` went on reading the first literal ANYWHERE
2413
+ // in the call. MEASURED: `fs.writeFileSync(userPath, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the
2414
+ // BYTES BEING WRITTEN — so `allow Fs /tmp/lit` certified a write to a runtime-controlled destination at
2415
+ // exit 0, while candor-java and candor-swift failed closed on identical code.
2416
+ //
2417
+ // Returns { lit, complete }: `lit` is the path literal at position 0 when there is one, and `complete` is
2418
+ // false when ANY path position is not a literal. Two-path operations are why that is not one boolean:
2419
+ // `fs.copyFile("/safe", userPath)` has a literal at position 0 and still writes somewhere nobody can see.
2420
+ const FS_TWO_PATH_MEMBERS = new Set([
2421
+ "copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync",
2422
+ "link", "linkSync", "symlink", "symlinkSync",
2423
+ ]);
2424
+ function fsPathLiteral(node, member) {
2425
+ const args = node.arguments ?? [];
2426
+ const at = (i) => (args[i] && ts.isStringLiteralLike(args[i]) ? args[i].text : null);
2427
+ const a0 = at(0);
2428
+ const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
2429
+ const a1 = needsTwo ? at(1) : null;
2430
+ const complete = a0 !== null && (!needsTwo || a1 !== null);
2431
+ // EVERY literal path position, not just the first. Publishing position 0 and calling a
2432
+ // both-literal two-path call COMPLETE certified the position it never published:
2433
+ // `copyFileSync("/tmp/lit", "/tmp/dst")` under `allow Fs /tmp/lit` answered `policy ✓` at exit 0
2434
+ // while writing `/tmp/dst`. candor-java and candor-swift publish both. Found by generating a case
2435
+ // per node `fs` export and comparing against the four engines, not by re-reading this function.
2436
+ return { lits: [a0, a1].filter((x) => x !== null), complete };
2437
+ }
2265
2438
  // CONST-STRING PROPAGATION (java constant-inlining parity): resolve a bare identifier that references a
2266
2439
  // `const NAME = "literal"` string to its literal value, and ONLY then. Returns the string, or null. The
2267
2440
  // soundness rule is strict: resolve ONLY when EVERY value-declaration of the symbol is an immutable
@@ -2368,7 +2541,7 @@ function literalHeadHostUrl(expr) {
2368
2541
  }
2369
2542
  return null;
2370
2543
  }
2371
- function urlArgLiteral(node, member) {
2544
+ function urlArgLiteral(node, member, mod) {
2372
2545
  const args = node.arguments ?? [];
2373
2546
  const litAt = (i) => {
2374
2547
  const a = args[i];
@@ -2379,6 +2552,12 @@ function urlArgLiteral(node, member) {
2379
2552
  return resolveConstUrlString(a) ?? literalHeadHostUrl(a);
2380
2553
  };
2381
2554
  if (member && NET_URL_ARG1_MEMBERS.has(member)) return litAt(0) ?? litAt(1); // (port, host) or (path)
2555
+ // ⟨0.29⟩ dgram `send` — keyed on the MODULE, because a bare `send` elsewhere legitimately takes its
2556
+ // URL first and reading position 2/4 there would invent a host out of an unrelated argument.
2557
+ if (member === "send" && /^(node:)?dgram$/.test(mod ?? "")) {
2558
+ const i = dgramSendAddressIndex(args);
2559
+ return i < 0 ? null : litAt(i);
2560
+ }
2382
2561
  return litAt(0);
2383
2562
  }
2384
2563
  // Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
@@ -3315,6 +3494,7 @@ function applyDepHit(rec, hit) {
3315
3494
  // layer down. Additive: it can only ADD `unknown-host`, never remove a class, so it never turns a firing
3316
3495
  // gate green.
3317
3496
  if (hit.netIncomplete) rec.incomplete.add("Net");
3497
+ for (const eff of hit.incomplete ?? []) rec.incomplete.add(eff); // ⟨0.29⟩ see the sibling join site
3318
3498
  }
3319
3499
  function unanswerableKey(decl) {
3320
3500
  if (!decl) return null;
@@ -4416,14 +4596,48 @@ function visitCalls(node) {
4416
4596
  // key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
4417
4597
  // `EventSource`/`WebSocket` constructors open a connection on construction. Net. (Found by a
4418
4598
  // 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");
4599
+ // ⟨0.29⟩ …AND THE HOST SURFACE, which these branches did not touch. Adding `Net` here bypasses
4600
+ // the `eff === "Net"` block that captures the endpoint and — when it is a runtime value — marks
4601
+ // the surface `incomplete`. So an invisible endpoint reached this way was recorded as Net with
4602
+ // NO hedge, and any benign literal in the same function certified it. MEASURED, `policy ✓` at
4603
+ // exit 0 under `allow Net ok.example`:
4604
+ // await fetch("https://ok.example/a"); // captures ok.example
4605
+ // new WebSocket(u); // Net, no host, no `incomplete` ← goes anywhere
4606
+ // That is the masking evasion the `incomplete` machinery exists to prevent, reached through the
4607
+ // two Net APIs whose branches skip it. Same shape as the EventKit/`privacyKind` and
4608
+ // `FS_USE_VERBS` gaps: a refinement the general path performs and a carve-out does not.
4609
+ //
4610
+ // POSITION, per the DOM signatures: `xhr.open(method, url)` puts the URL at argument 1 — the
4611
+ // one API in this file whose locator is neither 0 nor a labelled argument. `send` carries NO
4612
+ // url (the endpoint was fixed at `open`), so it is a USE-VERB: it must not mark `incomplete`,
4613
+ // or every XHR with a perfectly visible `open` literal would fail closed.
4614
+ if (parent === "XMLHttpRequest" && (name === "send" || name === "open")) {
4615
+ rec.direct.add("Net");
4616
+ if (name === "open") {
4617
+ const u = (node.arguments ?? [])[1];
4618
+ const lit = u && ts.isStringLiteralLike(u)
4619
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
4620
+ const h = lit ? hostLiteral(lit) : null;
4621
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
4622
+ else rec.incomplete.add("Net");
4623
+ }
4624
+ }
4420
4625
  // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
4421
4626
  // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
4422
4627
  // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
4423
4628
  // so the constructed identifier is the real browser global.
4424
4629
  if (ts.isNewExpression(node)) {
4425
4630
  const ctorName = node.expression.getText();
4426
- if (ctorName === "EventSource" || ctorName === "WebSocket") rec.direct.add("Net");
4631
+ if (ctorName === "EventSource" || ctorName === "WebSocket") {
4632
+ rec.direct.add("Net");
4633
+ // The URL is argument 0 of both constructors — see the XHR note above for the measurement.
4634
+ const u = (node.arguments ?? [])[0];
4635
+ const lit = u && ts.isStringLiteralLike(u)
4636
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
4637
+ const h = lit ? hostLiteral(lit) : null;
4638
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
4639
+ else rec.incomplete.add("Net");
4640
+ }
4427
4641
  }
4428
4642
  } else {
4429
4643
  // The member token κ matches: the resolved declaration's name, EXCEPT a `new X()` call,
@@ -4450,9 +4664,23 @@ function visitCalls(node) {
4450
4664
  // allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
4451
4665
  // ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
4452
4666
  // (the command was fixed at spawn); the spawn fns are establishing.
4667
+ // The node `fs` verbs whose FIRST argument is a DESCRIPTOR, not a path — the fd came from a
4668
+ // prior `open()` whose path this analysis already saw, so their invisible destination is not a
4669
+ // gap and marking them `incomplete` charges every buffered write in a real tree.
4670
+ //
4671
+ // ⟨0.29⟩ `readv`/`writev` (+Sync) were MISSING, and the ⟨0.29⟩ positional-literal fix is what
4672
+ // made it visible: before it, `writev(fd, "/tmp/lit")` had its literal found ANYWHERE in the
4673
+ // call and published as a path — a fabrication — so the set was never consulted for these four.
4674
+ // Killing the fabrication moved them into the other wrong bucket. Found by generating a case
4675
+ // per node `fs` export rather than reasoning about the list (24 fd verbs in node, 20 here).
4676
+ //
4677
+ // Forgetting a member here OVER-charges (safe); adding a path-taking verb by mistake
4678
+ // UNDER-reports. An allowlist is the right shape for exactly that reason — the inverse of
4679
+ // the denylist rule that governs the classifier surface.
4453
4680
  const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
4454
4681
  "fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
4455
- "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"]);
4682
+ "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
4683
+ "readv", "readvSync", "writev", "writevSync"]);
4456
4684
  const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
4457
4685
  const netEstablishing = (member) =>
4458
4686
  CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
@@ -4516,7 +4744,7 @@ function visitCalls(node) {
4516
4744
  // fetch/axios/the HTTP verbs), NEVER the first literal anywhere in the args: a trailing literal
4517
4745
  // in headers/body/options must not be read as the host (FINDING 6). Ollama's model decision runs
4518
4746
  // through the parsed host too, never a raw string that merely contains ":11434" (FINDING 1/9).
4519
- const urlLit = urlArgLiteral(node, member);
4747
+ const urlLit = urlArgLiteral(node, member, mod);
4520
4748
  const ollama = ollamaFromUrlArg(urlLit);
4521
4749
  if (ollama === "capture-model" || ollama === "capture-plain") {
4522
4750
  const h = hostLiteral(urlLit);
@@ -4544,7 +4772,16 @@ function visitCalls(node) {
4544
4772
  // parity #1) — any call into these single-purpose clients is a model dispatch. Additive.
4545
4773
  if (isModelSdkPackage(mod)) rec.direct.add("Llm");
4546
4774
  if (eff === "Db") {
4547
- const lit = firstStringLiteral(node);
4775
+ // ⟨0.29⟩ THE SQL SLOT, never the first literal anywhere. Every string-SQL client puts the
4776
+ // query at argument 0 (`query(text, values)`, `execute(sql, params)`, `raw(sql, bindings)`,
4777
+ // `prepare(sql)`), so a literal in a LATER position is a parameter, a fallback query, a
4778
+ // health-check string — data, not the statement being run. MEASURED:
4779
+ // `db.query(userSql, "SELECT * FROM audit_log")` published `tables: ["audit_log"]` and, because
4780
+ // a table HAD been captured, the masking guard below never fired — so `allow Db audit_log`
4781
+ // certified a query whose SQL is a runtime value. The `Fs` and `Net` defects of this rung, in
4782
+ // the fourth and last locator surface.
4783
+ const a0 = (node.arguments ?? [])[0];
4784
+ const lit = a0 && ts.isStringLiteralLike(a0) ? a0.text : null;
4548
4785
  const before = rec.tables.size;
4549
4786
  for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
4550
4787
  // ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
@@ -4564,12 +4801,27 @@ function visitCalls(node) {
4564
4801
  if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
4565
4802
  }
4566
4803
  if (eff === "Exec") {
4567
- const lit = firstStringLiteral(node);
4568
- if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]); // cosmetic cmds surface (any literal)
4804
+ // ⟨0.29⟩ `cmds` reads argv[0] too. It was documented as "the cosmetic cmds surface (any
4805
+ // literal)", but `cmds` is precisely what `allow Exec <cmd>` gates on (AS-EFF-008) — nothing
4806
+ // cosmetic about it. No node API places a bare string after the head (args are an array,
4807
+ // options an object), so this changes no measured behaviour today; it removes the hazard for
4808
+ // the next exec-like wrapper whose second argument is a string, which is how the identical
4809
+ // defect reached `Fs`, `Net` and `Db` in this same rung.
4810
+ // ⟨0.29⟩ A USE-VERB NAMES NO PROGRAM, and its argument 0 is not a head. `EXEC_USE_VERBS`
4811
+ // already says so — it is consulted for the `incomplete` branch below and was NOT consulted
4812
+ // here, so `child.send(msg)` (IPC to an ALREADY-spawned child) read its MESSAGE as argv[0].
4813
+ // MEASURED: `ch.send("ls")` published `cmds: ["ls"]` and certified under `allow Exec ls`
4814
+ // though the function executes nothing, and `ch.send("curl")` FABRICATED `Net` — a function
4815
+ // that makes no network call reported as performing one, which `deny Net` then fires on.
4816
+ // That is exactly what the doc comment above forbids ("`spawn(toolVar, "curl")` must NOT
4817
+ // fabricate Net — the literal is an argument, not the program"); the rule was written for the
4818
+ // argument POSITION and never covered the same hazard reached through the RECEIVER.
4819
+ const lit = EXEC_USE_VERBS.has(member) ? null : programHeadLiteral(node);
4820
+ if (lit) rec.cmds.add(lit.trim().split(/\s+/)[0]);
4569
4821
  // a known literal head refines the cliff (curl→Net, candor→Fs/Env); Exec stays. The head
4570
4822
  // MUST be argv[0] (programHeadLiteral), NOT any literal arg: `spawn(toolVar, "curl")`
4571
4823
  // names no static program, so its trailing literal must not fabricate Net (spec §4).
4572
- const head = programHeadLiteral(node);
4824
+ const head = lit;
4573
4825
  if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
4574
4826
  // masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
4575
4827
  // command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
@@ -4577,12 +4829,16 @@ function visitCalls(node) {
4577
4829
  else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
4578
4830
  }
4579
4831
  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);
4832
+ // ⟨0.29⟩ the PATH POSITION, never the first literal anywhere — see fsPathLiteral.
4833
+ const { lits, complete } = fsPathLiteral(node, member);
4834
+ const captured = lits.filter((l) => /[/\\]|^[.~]/.test(l)); // path-shaped literals only
4835
+ const pathCaptured = captured.length > 0 && captured.length === lits.length;
4836
+ for (const l of captured) rec.paths.add(l);
4583
4837
  // masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
4584
4838
  // 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");
4839
+ // ⟨0.29⟩ `complete` also covers a two-path op whose SECOND path is runtime, which a captured
4840
+ // position-0 literal would otherwise certify.
4841
+ if (!(pathCaptured && complete) && !FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
4586
4842
  }
4587
4843
  // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
4588
4844
  // that function's recorded transitive effects (+ literal surfaces) by `hash`.
@@ -5285,7 +5541,7 @@ function depInitCell(pkg, subpath) {
5285
5541
  let cell = null;
5286
5542
  for (const [h, c] of crossDeps) {
5287
5543
  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 };
5544
+ cell ??= { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
5289
5545
  for (const e of c.inferred) cell.inferred.add(e);
5290
5546
  for (const b of c.invisible) cell.invisible.add(b);
5291
5547
  for (const w of c.why ?? []) cell.why.add(w);
@@ -5398,6 +5654,9 @@ function resolveDepEntryKey(pkg, subpath) {
5398
5654
  // arm copies no `hosts` (a module unit's literals are not this caller's), so today it can only turn a
5399
5655
  // hostless Net that a sibling literal in THIS body would have certified back into `unknown-host`.
5400
5656
  if (cell.netIncomplete) rec.incomplete.add("Net");
5657
+ // ⟨0.29⟩ every OTHER effect the dependency could not locate, carried the same way. Without this the
5658
+ // join copies the dep's Fs and drops its "I could not see where", which is the fail-open above.
5659
+ for (const eff of cell.incomplete ?? []) rec.incomplete.add(eff);
5401
5660
  }
5402
5661
  for (let changed = true; changed; ) {
5403
5662
  changed = false;
@@ -5557,7 +5816,11 @@ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsK
5557
5816
  // ---- emit: the §2 envelope (effect-free items omitted) + the §2.2 sidecar (EVERY fn a key) --------
5558
5817
  // ⟨0.20⟩ Net destination-class partners from `.candor/config` — read ONCE here, used by the report's per-fn
5559
5818
  // `netClass` field (below) and the gate (deny Net[unknown-host]); the SAME set both surfaces resolve.
5560
- const netPartners = parseNetPartners(discoverConfigText(target));
5819
+ // ⟨0.29⟩ …and a malformed `net-partner` line is DISCLOSED rather than kept as a junk host that matches
5820
+ // nothing for the rest of the run. Printed once, here, beside the other config diagnostics.
5821
+ const netPartnerErrs = [];
5822
+ const netPartners = parseNetPartners(discoverConfigText(target), netPartnerErrs);
5823
+ for (const e of netPartnerErrs) console.error(`candor-ts: ${e.why} — ${e.raw}`);
5561
5824
  const functions = [];
5562
5825
  for (const [name, rec] of fns) {
5563
5826
  const inf = [...inferred.get(name)].sort();
@@ -5592,6 +5855,19 @@ for (const [name, rec] of fns) {
5592
5855
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
5593
5856
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
5594
5857
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
5858
+ // ⟨0.29⟩ SPEC §2 `incomplete` — the effects whose LOCATOR this fn could not determine. Omitted when
5859
+ // empty, so a scan that determined everything is byte-identical to a pre-rung report.
5860
+ //
5861
+ // THIS ENGINE USED TO KEEP IT INTERNAL, on a premise about the family that was FALSE. The comment at the
5862
+ // dep-join said `rec.incomplete` is "deliberately INTERNAL family-wide (java/rust keep it out of the
5863
+ // report too)" — candor-rust has emitted it all along, and SPEC §2 says so in the clause right beside
5864
+ // the one this engine was following. MEASURED, and the cost is a false all-clear on a configured gate:
5865
+ // a dependency whose `Fs` path is a runtime value published NOTHING to say so, so a consumer that also
5866
+ // writes ONE allowed literal joined `paths: ['/tmp/lit']` with no incompleteness marker and
5867
+ // `allow Fs /tmp/lit` answered `policy ✓`. candor-rust, over the identical shape, charges AS-EFF-008.
5868
+ // An absent `paths` is overloaded between "reaches no path" and "reaches a path I could not see", and
5869
+ // this field is the only thing that separates them.
5870
+ if (rec.incomplete.size) entry.incomplete = [...rec.incomplete].sort();
5595
5871
  // SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
5596
5872
  // (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
5597
5873
  //
@@ -6051,7 +6327,10 @@ function fnv1aHex(sortedQuals) {
6051
6327
  // so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
6052
6328
  // false "undetermined", which is the inversion the field exists to prevent.
6053
6329
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
6054
- resolves: ["fs"], package: pkgName, functions };
6330
+ // ⟨0.29⟩ `incomplete` joins `resolves`: an optional per-function refinement surface
6331
+ // whose absence is overloaded the way `fs`'s was — "does not compute undetermined
6332
+ // locators" vs "computed them and found none". This engine computes it, so it says so.
6333
+ resolves: ["fs", "incomplete"], package: pkgName, functions };
6055
6334
  // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
6056
6335
  // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
6057
6336
  // the --gate-json advisory, so the three can never tell different stories.
@@ -6093,9 +6372,153 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
6093
6372
  // ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
6094
6373
  // the operator points rather than replaced. Reports have the same layout exposure as verdicts.
6095
6374
  const writeAtomic = (file, text) => writeSinkAtomic(file, text);
6375
+ // ── ⟨0.29⟩ THE PEEK ───────────────────────────────────────────────────────────────────────────────
6376
+ // Read the files this run deliberately did NOT judge, and say so when they hold an effect the policy
6377
+ // DENIES. The verdict does not move: `outOfScope` is its own kind and never a violation, because a file
6378
+ // the gate declined to judge must not decide an exit code.
6379
+ //
6380
+ // A CHILD `scan.mjs`, not a second analysis path. candor-rust buys this by recursing into `scan_one`;
6381
+ // this engine is a script rather than a callable function, so the same guarantee comes from the same
6382
+ // BINARY over a different file set. That identity is the design constraint, not a convenience: a bespoke
6383
+ // second pass would be a SECOND OPINION, and a drifted second opinion reported as a warning is worse
6384
+ // than no warning — the reader cannot tell a real finding from two code paths disagreeing. The child is
6385
+ // given no policy, so it cannot gate and cannot recurse.
6386
+ //
6387
+ // PLACED HERE, ABOVE THE WRITE, on purpose. The gate block runs AFTER the envelope is serialised, so
6388
+ // computing this there would have put the finding on stderr and left it out of the artifact — the split
6389
+ // ⟨0.26⟩ calls worse than saying nothing. It reads the policy itself rather than borrowing the gate's
6390
+ // parse: one extra read of the same bytes through the same `parsePolicy`, not a second interpretation.
6391
+ //
6392
+ // POLICY-SCOPED AND POLICY-BOUNDED, which is the whole reason it stays quiet. No policy ⇒ the key is
6393
+ // ABSENT, because nothing was asked and `[]` would be a claim. With a policy, only effects that policy
6394
+ // DENIES are reported — otherwise the noise floor is "everything you excluded".
6395
+ let outOfScopeFindings = null;
6396
+ // ⟨0.29⟩ DID THE PEEK ACTUALLY READ ANYTHING? `peeked` was a constant of the exclusion CLASS, so a peek
6397
+ // that never ran, could not spawn, or produced unparseable output still published `peeked: true` beside
6398
+ // `outOfScope: []` — byte-identical to a clean peek, and the ⟨0.26⟩ partial-manifest failure inside the
6399
+ // rung built to prevent it. It is an outcome now.
6400
+ let peekRead = false;
6401
+ // ⟨0.29⟩ …AND DID IT READ THEM ALL? A child report the parent could PARSE is a different fact from every
6402
+ // excluded file having been opened. The child publishes its own ⟨0.21⟩ `unanalyzed` manifest and this
6403
+ // code read only `functions`, so an excluded file that failed to parse INSIDE the peek produced
6404
+ // `peeked: true` beside `outOfScope: []` — the same overclaim one comment up, one level down. `peeked` is
6405
+ // per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
6406
+ const peekUnread = new Set();
6407
+ let peekUnattributed = false;
6408
+ if (policyPath && excludedFiles.length) {
6409
+ let denied = new Set();
6410
+ try {
6411
+ const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
6412
+ // ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
6413
+ // so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
6414
+ // claims a look taken against rules that never stood, and the `denied` set it would look for is the
6415
+ // parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
6416
+ // candor-java already withheld here; this engine, candor-rust and candor-swift did not.
6417
+ if (!fatalPolicyErrors(pol.errors).length) {
6418
+ denied = new Set((pol.deny ?? []).flatMap((r) => r.effects ?? []));
6419
+ }
6420
+ } catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
6421
+ if (denied.size) {
6422
+ outOfScopeFindings = [];
6423
+ const peekDir = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-peek-"));
6424
+ try {
6425
+ fs.writeFileSync(path.join(peekDir, "tsconfig.json"), JSON.stringify({
6426
+ compilerOptions: { target: "es2022", module: "esnext", allowJs: true, noEmit: true },
6427
+ files: excludedFiles.map((e) => path.resolve(rootDir, e.path)),
6428
+ }));
6429
+ const out = execFileSync(process.execPath,
6430
+ [fileURLToPath(import.meta.url), path.join(peekDir, "tsconfig.json"), "--json",
6431
+ "--peek-excluded"],
6432
+ // ⟨0.29⟩ `timeout` beside `maxBuffer`: the peek re-parses exactly the files this engine has never
6433
+ // parsed — vendored trees, generated code, declaration bundles — i.e. the inputs least likely to
6434
+ // have been exercised. Without a deadline one pathological file turns into a hung scan and a hung
6435
+ // CI job, which contradicts this feature's own rule that a peek which cannot run must not fail
6436
+ // the gate: hanging is the one failure that stops the gate completing at all. On timeout
6437
+ // `execFileSync` throws, the catch below leaves the findings as far as they got, and `peekRead`
6438
+ // stays false so no class claims to have been read.
6439
+ { encoding: "utf8", maxBuffer: 1 << 28, timeout: 120_000,
6440
+ stdio: ["ignore", "pipe", "ignore"] });
6441
+ const doc = JSON.parse(out);
6442
+ peekRead = true; // the child returned a report this run could read — see `peekRead`
6443
+ for (const u of Array.isArray(doc.unanalyzed) ? doc.unanalyzed : []) {
6444
+ const where = String(u?.path ?? "");
6445
+ const hit = excludedFiles.find((e) => where.endsWith(e.path)
6446
+ || where.endsWith(path.basename(e.path)));
6447
+ // The peek walks ONLY excluded files, so an unread path matching no exclusion is one this code
6448
+ // cannot attribute — fail closed across every class rather than let one unattributable file leave
6449
+ // all of them claiming completeness.
6450
+ if (hit) peekUnread.add(hit.cls); else peekUnattributed = true;
6451
+ }
6452
+ for (const f of doc.functions ?? []) {
6453
+ const hits = (f.inferred ?? []).filter((e) => denied.has(e));
6454
+ if (!hits.length) continue;
6455
+ // NAME IT FROM THE PROJECT, NOT FROM THE CHILD'S TEMP ROOT. The child's tsconfig lives in a
6456
+ // temp directory, so it derives module qualifiers from THAT root and the fn came out as a
6457
+ // dotted absolute path — accurate, unreadable, and pointing at a directory that no longer
6458
+ // exists by the time anyone reads it. The project-relative path is already known here (it is
6459
+ // what was excluded), so match on basename and report the path we already trust.
6460
+ const childLoc = (f.loc ?? "").split(":")[0] ?? "";
6461
+ const hit = excludedFiles.find((e) => childLoc.endsWith(e.path)
6462
+ || childLoc.endsWith(path.basename(e.path)));
6463
+ const where = hit?.path ?? childLoc;
6464
+ const cls = hit?.cls ?? "excluded";
6465
+ outOfScopeFindings.push({
6466
+ fn: f.fn.split(".").pop() ?? f.fn, path: where, effects: hits, class: cls,
6467
+ reason: `OUTSIDE this scan's scope (${cls}) — the gate did NOT judge it. `
6468
+ + "The effect is real; the verdict does not account for it.",
6469
+ });
6470
+ }
6471
+ outOfScopeFindings.sort((a, b) => (a.path + a.fn).localeCompare(b.path + b.fn));
6472
+ } catch {
6473
+ // A PEEK THAT CANNOT RUN MUST NOT FAIL THE GATE — it is advisory by construction, and turning a
6474
+ // child-process failure into a red gate would make the safest thing an operator can do (add a
6475
+ // policy) the thing that breaks their build. It stays `[]`: a policy WAS configured, so the key is
6476
+ // a real answer, and "we looked and found nothing" is what an empty list says.
6477
+ } finally {
6478
+ fs.rmSync(peekDir, { recursive: true, force: true });
6479
+ }
6480
+ }
6481
+ }
6482
+ if (outOfScopeFindings) {
6483
+ envelope.outOfScope = outOfScopeFindings;
6484
+ // SAY IT ON STDERR TOO. The report block is for machines; an operator reading `policy ✓` needs to know
6485
+ // in the same breath that a file this scan did not judge holds the effect they denied.
6486
+ for (const f of outOfScopeFindings) {
6487
+ console.error(`candor-ts: ⚠ ${f.fn} performs ${f.effects.join("+")} — OUTSIDE this scan's scope `
6488
+ + `(${f.class}), so the gate did NOT judge it.`);
6489
+ if (f.path) console.error(` ${f.path}`);
6490
+ }
6491
+ if (outOfScopeFindings.length) {
6492
+ console.error(" The verdict does not account for "
6493
+ + (outOfScopeFindings.length === 1 ? "it." : `these ${outOfScopeFindings.length}.`));
6494
+ }
6495
+ }
6496
+
6497
+ // ⟨0.29⟩ THE SCOPE, BUILT AFTER THE PEEK — `peeked` is an OUTCOME, so this block cannot be assembled
6498
+ // until the peek has run. It sat above the peek and read `peekRead` before anything could set it, which
6499
+ // is the ordering bug the flag's own purpose makes fatal: a field that exists to say whether a read
6500
+ // happened, computed before the read. Emitted even when EMPTY — ⟨0.27⟩ makes zero-match a positive
6501
+ // statement, and ⟨0.26⟩ makes an ABSENT key mean "this producer cannot answer".
6502
+ {
6503
+ const byClass = new Map();
6504
+ for (const e of excludedFiles) byClass.set(e.cls, (byClass.get(e.cls) ?? 0) + 1);
6505
+ envelope.excluded = [...byClass.entries()].sort((a, b) => a[0].localeCompare(b[0]))
6506
+ .map(([cls, count]) => ({ class: cls, count,
6507
+ // `peekUnread` subtracts the classes the peek RAN over and could not read:
6508
+ // parse failures are per file, so the claim is per class.
6509
+ peeked: peekRead && !peekUnattributed && !peekUnread.has(cls),
6510
+ reason: EXCLUDED_REASON[cls] ?? `excluded (${cls})` }));
6511
+ }
6512
+
6096
6513
  // --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
6097
6514
  if (wantJson) {
6098
- console.log(JSON.stringify(envelope, null, 1));
6515
+ // writeStdoutSync, NOT console.log. MEASURED over a 400-file fixture with a violating policy: 95281
6516
+ // bytes to a FILE and valid JSON, **65536 bytes through a PIPE and a JSONDecodeError** — exactly the
6517
+ // pipe buffer, exit 1 either way, nothing on stderr. console.log is asynchronous on a pipe and the
6518
+ // process.exit() sites below discard what is still buffered. This is the machine-consumer path
6519
+ // (`candor-ts src --json --policy p | jq`), so the loss is silent and the document is invalid, which
6520
+ // is worse than the `--agents` truncation that got this class fixed in the first place.
6521
+ writeStdoutSync(JSON.stringify(envelope, null, 1) + "\n", "--json");
6099
6522
  // ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
6100
6523
  // corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
6101
6524
  // placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
@@ -6509,7 +6932,10 @@ if (policyPath !== null) {
6509
6932
  // `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
6510
6933
  // so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
6511
6934
  policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
6512
- } else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length) {
6935
+ // ⟨0.29⟩ …counting `only` too: an `only`-only policy is a LIVE gate, and refusing it here would
6936
+ // be the ⟨0.28⟩ fail-closed guard turned into a false refusal by the rung that added the kind.
6937
+ } else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length
6938
+ && !(gatePolicy.only ?? []).length) {
6513
6939
  // ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
6514
6940
  // refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
6515
6941
  // FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
@@ -6560,6 +6986,33 @@ if (policyPath !== null) {
6560
6986
  // code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
6561
6987
  // repositories and a layer exists in only some of them, so refusal would make a shared policy
6562
6988
  // unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
6989
+ // ⟨0.29⟩ THE NAME RULES STOP AT THE SCAN BOUNDARY, AND NOW SAY SO. `forbid A -> B` and
6990
+ // `only A -> B …` are matched over the call graph; a chained dependency contributes EFFECTS, not
6991
+ // EDGES, so a function that calls into a dep has an EMPTY adjacency and the crossing is invisible.
6992
+ // MEASURED: with a dep chained, `only model -> util` answered `policy ✓` over
6993
+ // `model.viaDep() -> deplib.infra.dbRead()` while a LOCAL unpermitted scope in the same run fired
6994
+ // AS-EFF-011 — so the rule was armed and the boundary, not the rule, was the gap.
6995
+ //
6996
+ // WORSE FOR `only` THAN FOR `forbid`, which is why this is disclosed rather than left implicit:
6997
+ // `forbid` asks whether ONE named crossing is present, so missing a dep crossing under-reports one
6998
+ // prohibition; `only` asserts A reaches the listed scopes AND NOTHING ELSE — a COMPLETENESS claim —
6999
+ // and it exists precisely because `forbid` fails open. A package that calls a third-party library is
7000
+ // not a leaf, and without this line the gate called it one.
7001
+ //
7002
+ // DISCLOSURE, NOT A VERDICT CHANGE, and not a re-scoping either: making the rules cross would need
7003
+ // dep-report EDGES, and it would force operators to enumerate third-party scopes in an `only` list —
7004
+ // the enumeration-that-rots that form was designed to escape. The ⟨0.29⟩ `outOfScope` posture
7005
+ // exactly: say what was not judged, leave the exit code alone.
7006
+ if (depChained()) {
7007
+ const named = [...(gatePolicy.forbid ?? []), ...(gatePolicy.only ?? [])];
7008
+ if (named.length) {
7009
+ console.error(`candor-ts: ⚠ ${named.length} name-matching rule(s) (\`forbid\`/\`only\`) were `
7010
+ + "matched over THIS scan's call graph only — a chained dependency contributes effects, not "
7011
+ + "call edges, so a crossing INTO a dependency is invisible to them. `deny`/`allow` still "
7012
+ + "cross (effects propagate); an `only` rule cannot certify that a package is a leaf when it "
7013
+ + "calls into one of its dependencies.");
7014
+ }
7015
+ }
6563
7016
  for (const raw of gateOut.zeroMatch ?? []) {
6564
7017
  console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
6565
7018
  + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `