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