candor-ts 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,16 +12,16 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
14
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
- > plainly which version they're on** — e.g. *"This project is on candor-ts 0.7.1 (spec 0.7)."* On a
16
- > build too old for the flag, read `candor.version` / `candor.spec` from an existing
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.8)."*
16
+ > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
19
19
  > **Staying current is your job, not candor's.** candor never phones home — it audits and denies the
20
20
  > Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the
21
21
  > installed build, the spec contract it speaks, and the upgrade line (`npm install -g
22
22
  > candor-ts@latest`) — fully offline. **You** have the network: compare the installed version against
23
- > npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts 0.7.2 is available
24
- > (you're on 0.7.1) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx
23
+ > npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts `<latest>` is available
24
+ > (you're on `<installed>`) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx
25
25
  > -y candor-ts@latest`) only if they agree. Never upgrade silently: an analysis tool's version is
26
26
  > part of its result's provenance, so the user decides when it changes. If it's already current (or
27
27
  > the user declines), just proceed; if candor isn't installed at all, install it normally.
@@ -41,19 +41,32 @@ npx -y candor-ts <dir> --allow-js # also analyze .js/.mjs sources (walks t
41
41
 
42
42
  This writes `<project-dir>/.candor/report.json` and `.candor/report.callgraph.json` (override
43
43
  with `--out <prefix>`). **Install the TARGET's dependencies first** (`npm install` in the project)
44
- — without node_modules, imports don't resolve and most functions honestly read `Unknown` (the
44
+ — without node_modules, imports don't resolve and most functions read `Unknown` (disclosed; the
45
45
  scanner warns loudly). Add `--policy <file>` (or set `CANDOR_POLICY`) to enforce a §6.2 policy over the
46
46
  scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable. `--gate-json <file|->`
47
47
  additionally writes the structured verdict `{spec, ok, violations:[{rule,fn,effects,detail}]}`
48
48
  (spec §3.3) — the machine-readable form CI/SARIF converters consume, from the SAME violations that
49
- set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `deps <paths>`, one
50
- key per line, discovered walking UP from the scan target, relative values anchored to the config's
51
- repo) is the no-env-wiring floor; flag → env → config → default.
49
+ set the exit code. A checked-in `.candor/config` (spec §3.4; `policy <file>` / `baseline <report>` /
50
+ `deps <paths>`, one key per line, discovered walking UP from the scan target, relative values
51
+ anchored to the config's repo) is the no-env-wiring floor; flag → env → config → default.
52
+
53
+ **The AS-EFF-005 baseline guard** (spec §7): set `CANDOR_BASELINE=<saved report.json>` (or the
54
+ config `baseline` key) and the scan compares per function — an EXISTING function that gained an
55
+ effect versus the baseline fails the run (exit 1, `[AS-EFF-005]` lines, records join `--gate-json`);
56
+ new functions are exempt. Fail-closed: an unparseable baseline, or one from a different engine
57
+ build, is invalid gate input — exit 2 WITHOUT evaluating (never a silent skip); an absent file is a
58
+ note and the guard is inactive. `query diff` is the read-only twin: it DISCLOSES a build mismatch
59
+ (⚠, exit 0) instead of failing — use the scan-time guard, not `diff`, as the CI gate. Semantics
60
+ match the reference engine (candor-java).
52
61
 
53
62
  **Report shape:** the file is `{ "candor": {version, toolchain, spec}, "functions": [...] }`;
54
63
  `functions` is an **array** of entries (not a map — don't index it by name), each carrying **`fn`**
55
64
  — module-qualified, `.`-separated
56
- (`src.db.save` for `save()` in `src/db.ts`; class methods are `src.api.Client.send`) — with
65
+ (`src.db.save` for `save()` in `src/db.ts`; class methods are `src.api.Client.send`; a function
66
+ declared inside a TS `namespace` carries the namespace segments too — `src.util.Ns.helper` — in
67
+ `fn` AND the callgraph/hierarchy keys, while its `hash` keeps the bare local name for cross-package
68
+ joining; builds before 0.8.7 omitted the namespace segments, so an engine upgrade across that line
69
+ is baseline-invalidating — regenerate saved reports) — with
57
70
  `inferred` (the full transitive set) / `direct` / `unresolved` / optional `hosts`/`cmds`/`paths`/
58
71
  `tables` (the literal surfaces). **Only effectful-or-unresolved functions appear in the report;
59
72
  pure functions are omitted** — a function present in the callgraph sidecar but absent from
@@ -61,7 +74,7 @@ pure functions are omitted** — a function present in the callgraph sidecar but
61
74
  (a test file? an unexported arrow inside an object literal?) — conclude nothing.
62
75
 
63
76
  A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries
64
- `unitKind: "export"` (spec 0.7, informative); ordinary functions omit the field.
77
+ `unitKind: "export"` (spec 0.8, informative); ordinary functions omit the field.
65
78
 
66
79
  **Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports
67
80
  (a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
@@ -117,16 +130,17 @@ want-JSON flag.
117
130
  - **Arrow-const functions are first-class**: `export const f = async () => …` is analyzed and named
118
131
  like a declaration; calls to it are edges. An arrow assigned inside a function body becomes its
119
132
  own unit (`src.x.helper`) — effects still propagate to the enclosing caller through the edge.
120
- - **The classifier is curated** (node builtins + a small npm tier: axios/got/node-fetch/undici/ws,
121
- pg/mysql2/mongodb/redis/knex, execa/cross-spawn, fs-extra/rimraf/glob, dotenv, winston/pino).
133
+ - **The classifier is curated** — the node builtins plus a growing npm tier; the README's
134
+ "classifier" paragraph is the ONE current list (this file deliberately doesn't duplicate it — a
135
+ vendored copy here drifted a full generation once).
122
136
  An unlisted package contributes nothing — an effect through it is invisible, not `Unknown`. The
123
137
  scanner **names these per scan**: the receipt's `κ doesn't know N packages…` line lists every npm
124
138
  package the code demonstrably calls that κ neither classifies nor has reviewed-pure — read it
125
139
  before concluding "no effect" through anything it names.
126
140
  - **`process.env.X` reads are `Env`** (a property read, not a call); `Date.now()` is `Clock`.
127
- - **DI-style code reads `Unknown` a lot, honestly**: a function-typed parameter or field being
141
+ - **DI-style code reads `Unknown` a lot, by design**: a function-typed parameter or field being
128
142
  called is genuinely indeterminate (rimraf's injected-fs style yields many `Unknown`s — that's the
129
- §4 contract, not noise). When every visible call site passes a *named* function, the callback
143
+ §4 disclosure contract, not noise). When every visible call site passes a *named* function, the callback
130
144
  resolves instead. And a method call on a **local-interface-typed value** (`store.save()` where
131
145
  `class PgStore implements Store`) resolves to the local implementors when the dispatch is narrow
132
146
  (≤12 classes) — the layered-DI pattern carries its real effects; only an interface with no
@@ -147,6 +161,10 @@ allow Db in db orders ledger.* # the db module touches ONLY these tables
147
161
  forbid domain -> infra
148
162
  ```
149
163
 
164
+ Note the `pure` semantics: it forbids every *effect* but NOT `Unknown` — the §4 trust marker is
165
+ uncertainty, not an effect (matching the reference engine, candor-java). Where a boundary must also
166
+ exclude the unverifiable case, say so explicitly: `deny Unknown <scope>` is the knob.
167
+
150
168
  ## The trust rule — do not skip this
151
169
 
152
170
  `inferred` is authoritative for what candor-ts resolved. When `unresolved` is true (or `Unknown` is
package/Cases.ts ADDED
@@ -0,0 +1,77 @@
1
+ // The candor-spec conformance cases in TypeScript — paired by bare function name with the Rust and
2
+ // Java fixtures; the expected effect sets are conformance/expected.json (the SAME oracle).
3
+ import * as fsm from "node:fs";
4
+ import * as netm from "node:net";
5
+ import * as cp from "node:child_process";
6
+ import * as cryptom from "node:crypto";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import * as winstonm from "winston";
9
+
10
+ // --- one function per std-only effect ---
11
+ export function fs_read(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
12
+ export function net_connect(): void { try { netm.connect(1, "h"); } catch {} }
13
+ export function exec_spawn(): void { try { cp.spawn("x"); } catch {} }
14
+ // Exec-cliff refinement (spec §4 ⟨0.5⟩): a known literal head adds its effect; all engines must agree.
15
+ export function exec_curl(): void { try { cp.spawn("curl"); } catch {} }
16
+ // Exec-refinement reads the HEAD (argv[0]) only: a dynamic program with a literal ARGUMENT keeps the
17
+ // bare cliff — "curl" in the args array must NOT fabricate Net (spec §4 ⟨0.5⟩: the head is argv[0]).
18
+ export function exec_dyn_head(tool: string): void { try { cp.spawn(tool, ["curl"]); } catch {} }
19
+ export function env_read(): void { void process.env.X; }
20
+ export function clock_now(): void { void Date.now(); }
21
+ // Rand: node:crypto's CSPRNG. candor-ts is syntactic (AST), so the builtin need not resolve at scan time.
22
+ export function rand_gen(): void { void cryptom.randomBytes(16); }
23
+ // Db: node:sqlite's DatabaseSync.exec is the store round-trip (named import — candor-ts tracks the symbol).
24
+ export function db_query(): void { void new DatabaseSync(":memory:").exec("SELECT 1"); }
25
+ export function log_msg(): void { winstonm.info("m"); }
26
+
27
+ // --- purity (negative) ---
28
+ export function pure_fn(): number { return 1 + 2; }
29
+
30
+ // --- the Unknown trust contract: a function-valued field the engine cannot see through ---
31
+ class Holder { cb: () => void = () => {}; }
32
+ const h = new Holder();
33
+ export function unknown_dyn(): void { const cb = h.cb; cb(); }
34
+
35
+ // --- multi-effect union in one body ---
36
+ export function combined(): void { try { fsm.readFileSync("/tmp/x"); netm.connect(1, "h"); } catch {} }
37
+
38
+ // --- transitive propagation across a call ---
39
+ export function transitive_leaf(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
40
+ export function transitive_caller(): void { transitive_leaf(); }
41
+
42
+ // --- an effect inside a closure attributes to the enclosing function (SEMANTICS §2) ---
43
+ export function closure_effect(): void { const f = () => { try { fsm.readFileSync("/tmp/x"); } catch {} }; f(); }
44
+
45
+ // --- Unknown propagates like an effect ---
46
+ export function unknown_propagates(): void { unknown_dyn(); }
47
+
48
+ // --- mixed: a concrete effect AND an Unknown in one transitive set ---
49
+ export function mixed_unknown(): void { try { fsm.readFileSync("/tmp/x"); } catch {} unknown_dyn(); }
50
+
51
+ // --- a 3-hop chain a -> b -> c(Net) ---
52
+ export function hop_c(): void { try { netm.connect(1, "h"); } catch {} }
53
+ export function hop_b(): void { hop_c(); }
54
+ export function hop_a(): void { hop_b(); }
55
+
56
+ // --- a caller unions the effects of two distinct callees ---
57
+ export function union_b(): void { try { fsm.readFileSync("/tmp/x"); } catch {} }
58
+ export function union_c(): void { try { netm.connect(1, "h"); } catch {} }
59
+ export function union_a(): void { union_b(); union_c(); }
60
+
61
+ // --- recursion: the fixpoint must terminate AND keep the effect ---
62
+ export function recurse(n: number): void { if (n > 0) { void process.env.X; recurse(n - 1); } }
63
+
64
+ // --- an effect in one branch only is still inferred (over-approximation) ---
65
+ export function conditional(b: boolean): void { if (b) { try { cp.spawn("x"); } catch {} } }
66
+
67
+ // --- transitive purity: a -> b -> c, all pure, stays pure (negative) ---
68
+ export function pure_c(): number { return 3; }
69
+ export function pure_b(): number { return pure_c(); }
70
+ export function pure_a(): number { return pure_b(); }
71
+
72
+ // --- a method call on a concrete LOCAL-type receiver propagates the method's effect ---
73
+ export class Svc { act(): void { try { fsm.readFileSync("/tmp/x"); } catch {} } }
74
+ export function method_call(s: Svc): void { s.act(); }
75
+
76
+ // --- scheduler attribution: an effect inside a scheduled task attributes to the SCHEDULER ---
77
+ export function sched(): void { setTimeout(() => { try { fsm.readFileSync("/tmp/x"); } catch {} }, 0); }
package/PROVE-IT.md CHANGED
@@ -30,7 +30,9 @@ TRANSITIVE caller, across all files?" Work as you normally would (grep, read). W
30
30
  list to ./candor-manual-<target>.txt in the repo root (NOT a fixed /tmp name — repeated runs must
31
31
  not cross-contaminate) — one function per line, named the way the callgraph keys them:
32
32
  module-qualified with "." segments (src.db.save for save() in src/db.ts; class members
33
- src.api.Client.send, constructors src.api.Client.constructor; a NESTED named function is keyed flat
33
+ src.api.Client.send, constructors src.api.Client.constructor; a function declared inside a TS
34
+ namespace carries the namespace segments — src.util.Ns.helper for helper() in namespace Ns — only
35
+ the report's `hash` field keeps the bare local name; a NESTED named function is keyed flat
34
36
  under its module, while an anonymous arrow — including one wrapped in a cast — folds into its
35
37
  enclosing function). Also note roughly how
36
38
  many file-reads/searches it took you.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  **candor for TypeScript: per-function side effects, transitively, with a deterministic policy
6
6
  gate.** candor-ts resolves every call through the TypeScript compiler API and reports, for each
7
7
  function in your project, which effects it can reach — `Net`, `Fs`, `Db`, `Exec`, `Env`, `Clock`,
8
- … — **including effects inherited through any chain of calls across files**, with an honest
8
+ … — **including effects inherited through any chain of calls across files**, with a disclosed
9
9
  `Unknown` wherever resolution fails (a callback value, an `any`-typed callee — never silently
10
10
  pure). A [candor-spec](https://github.com/tombaldwin/candor-spec) implementation, sibling of the
11
11
  [Rust](https://github.com/tombaldwin/candor-rust) and
@@ -20,6 +20,8 @@ node scan.mjs <project-dir> # tsconfig.json honored; tests exclu
20
20
  # <dir>/.candor/report.json + .callgraph.json
21
21
  node scan.mjs . --policy .candor/policy # the §6.2 gate: exit 1 on violation, 2 if unreadable
22
22
  node scan.mjs . --gate-json gate.json # + the structured verdict {spec, ok, violations} (§3.3)
23
+ CANDOR_BASELINE=saved.json node scan.mjs . # AS-EFF-005 guard: exit 1 if an existing fn GAINED an
24
+ # effect vs the saved report; 2 if it can't evaluate
23
25
 
24
26
  node scan.mjs --version # installed build + spec contract (offline), + upgrade line
25
27
 
@@ -35,14 +37,31 @@ node query.mjs diff .candor/report baseline 1 # per-function effect delta (
35
37
  ```
36
38
 
37
39
  A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
38
- `deps <report paths>` one per line, discovered by walking up from the scan target; relative values
39
- resolve against the config's repo, so CI is "point at the repo". A configured-but-unusable
40
- config/policy fails loud (exit 2), never silently gateless.
40
+ `baseline <report.json>` / `deps <report paths>` one per line, discovered by walking up from the
41
+ scan target; relative values resolve against the config's repo, so CI is "point at the repo". A
42
+ configured-but-unusable config/policy/baseline fails loud (exit 2), never silently gateless.
43
+
44
+ The scan-time **baseline guard** (AS-EFF-005, spec §7) makes effect *regressions* un-shippable:
45
+ point `CANDOR_BASELINE` (or the config's `baseline` key) at a saved report, and any existing
46
+ function that **gained** an effect fails the scan — exit 1, the records join the `--gate-json`
47
+ verdict. New functions are exempt (reviewed as new code, not a regression). The guard is
48
+ fail-closed like the policy gate: a present-but-unparseable baseline, or one produced by a
49
+ different engine build (§2.1 — an engine upgrade is baseline-invalidating), exits 2 **without
50
+ evaluating**; only a genuinely absent file is a one-line note (guard not active). Keep the two
51
+ surfaces straight: `query diff` is the read-only comparison — it *discloses* a producing-build
52
+ mismatch (⚠, exit 0) and informs; the scan-time guard is the gate-grade fail-closed surface, the
53
+ one CI should hold. Semantics mirror the reference engine (candor-java) exactly.
41
54
 
42
55
  **Staying current:** check your installed version and upgrade — [candor/AGENTS.md §2a](https://github.com/tombaldwin/candor/blob/main/AGENTS.md#2a-staying-current--check-the-version-upgrade). `npx -y candor-ts --version` prints the build, the spec, and the upgrade one-liner (offline; candor never phones home).
43
56
 
44
57
  Function names are module-qualified with `.` segments (`src.db.save`), so policy scopes read
45
- naturally:
58
+ naturally. A function declared inside a TS `namespace` carries the namespace segments in `fn` and
59
+ the callgraph keys (`src.util.Ns.helper`) — so layer policies on namespaces bite — while the §2
60
+ `hash` join key keeps the bare local name; builds before 0.8.7 omitted the segments, so crossing
61
+ that line invalidates saved baselines (regenerate them). A `pure <scope>` rule forbids every
62
+ *effect* but not `Unknown` — the §4 trust marker is uncertainty, not an effect (matching the
63
+ reference engine, candor-java); `deny Unknown <scope>` is the explicit knob for boundaries that
64
+ must also exclude the unverifiable case.
46
65
 
47
66
  ```text
48
67
  # .candor/policy
@@ -132,16 +151,17 @@ a κ-ledger blind spot. A name outside the §1 vocabulary voids the declaration
132
151
  silently narrow a surface). And `candor-ts-query gains <cur> <base>` flags the **supply-chain**
133
152
  delta — the effects a surface *gained* between two reports.
134
153
  Real-world consequence, measured on [rimraf](https://github.com/isaacs/rimraf) (50 files, 55
135
- functions analyzed): its DI-style fs injection means many functions honestly read `Unknown` —
154
+ functions analyzed): its DI-style fs injection means many functions read `Unknown`, disclosed —
136
155
  that's the contract working, not noise. The report says "can reach", never "does"; an absent
137
156
  literal is never a claim of absence.
138
157
 
139
158
  ## Cross-engine consistency — machine-checked
140
159
 
141
- candor-ts runs live in the spec's conformance CI as the third engine in **three differentials**:
142
- the effect-set oracle (20 shared cases), the §6.2 policy-grammar battery (including `allow Db`),
143
- and the §3.1 query-shape and match-ladder checks — all three engines must answer identically, on
144
- every push to the spec.
160
+ candor-ts is one of the **four code engines** (with the reference engine candor-java, the Rust
161
+ engines, and candor-swift) held together by the spec's **16-part conformance suite**: the shared
162
+ effect-set oracle, the §6.2 policy-grammar battery (including `allow Db`), the §3.1 query-shape
163
+ and match-ladder checks, the gate exit-code contracts, and the newer parts up through the
164
+ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on every push to the spec.
145
165
 
146
166
  ## What the analysis core implements (and where the spec told it how)
147
167
 
@@ -176,13 +196,14 @@ read the Rust source".
176
196
  0.8.x, speaking candor-spec 0.8: the analysis core, the gate (`--policy` / `--gate-json` /
177
197
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
178
198
  `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
179
- real, behaviorally tested (`npm test` — ~360 assertions across six suites), **soundness-fuzzed
199
+ real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
180
200
  with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
181
201
  call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
182
202
  npm classifier tier is deliberately curated and keeps growing case-by-case. Entry points
183
203
  (Nest/Next populations), `unknownWhy` origins, `reachable`, cross-package inheritance
184
204
  (`CANDOR_DEPS` + the spec §2 `hash`, version-trusted per §2.1), and `--allow-js` are all in.
185
- On npm: `npx -y candor-ts <dir>`.
205
+ On npm: `npx -y candor-ts <dir>`. Per-release detail (⚠ marks report/verdict-affecting changes):
206
+ [CHANGELOG.md](CHANGELOG.md).
186
207
 
187
208
  ## Development
188
209
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.8.6",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
3
+ "version": "0.8.8",
4
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.8)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -40,6 +40,7 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "files": [
43
+ "Cases.ts",
43
44
  "scan.mjs",
44
45
  "query.mjs",
45
46
  "policy.mjs",
package/policy.mjs CHANGED
@@ -120,7 +120,13 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
120
120
  for (const f of functions) {
121
121
  for (const r of pol.deny) {
122
122
  if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
123
- const hits = r.effects.length === 0 ? f.inferred : f.inferred.filter((e) => r.effects.includes(e));
123
+ // `pure` (empty forbidden set) forbids every EFFECT — not `Unknown`, which is the §4 trust
124
+ // marker, not an effect (AS-EFF-003's concern; `deny Unknown <scope>` is the explicit knob).
125
+ // The reference engine (candor-java) and the rust deep engine exclude it identically; candor-ts
126
+ // wrongly counted an Unknown-only fn as a `pure` violation until 2026-07-09.
127
+ const hits = r.effects.length === 0
128
+ ? f.inferred.filter((e) => e !== "Unknown")
129
+ : f.inferred.filter((e) => r.effects.includes(e));
124
130
  if (hits.length) push("AS-EFF-006", f.fn, hits, `\`${f.fn}\` performs { ${hits.join(", ")} }, forbidden by policy: \`${r.raw}\``);
125
131
  }
126
132
  for (const r of pol.allow) {
package/scan-core.mjs CHANGED
@@ -36,11 +36,11 @@ export const KAPPA_RULES = [
36
36
  // 0/4/6 (or a boolean) with no socket, no fd, no syscall — pure functions. The whole-module Net rule
37
37
  // once fabricated Net onto them; a real-world sweep on node-fetch caught it (its trustworthy URL
38
38
  // predicates isOriginPotentiallyTrustworthy/isUrlPotentiallyTrustworthy call isIP() and inherited a
39
- // FABRICATED Net — the cardinal sin — purely from this classification, with no local Net edge). Only
39
+ // FABRICATED Net — the precision failure — purely from this classification, with no local Net edge). Only
40
40
  // these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
41
41
  // stays Net (the matcher excludes ONLY new + the three validators, nothing else).
42
42
  // ALSO exempt the pure CONFIG/METADATA members the whole-module rule fabricated Net on (sweep [9], the
43
- // cardinal sin — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
43
+ // precision failure — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
44
44
  // (cipher-list + cert helpers), http.validateHeaderName/validateHeaderValue (string validators, like
45
45
  // isIP), and a Socket/Server's setKeepAlive/setNoDelay/ref/unref/address (TCP-option + bound-address
46
46
  // metadata — no I/O). Every genuine verb still classifies; only these proven-pure names are freed.
@@ -51,7 +51,7 @@ export const KAPPA_RULES = [
51
51
  // reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
52
52
  // Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
53
53
  // inert, and the SERVER-CONFIG accessors getServers/setServers/get|setDefaultResultOrder touch no
54
- // network (in-process config) — classifying them Net would FABRICATE the cardinal sin. Every genuine
54
+ // network (in-process config) — classifying them Net would be a FABRICATION (the precision failure). Every genuine
55
55
  // resolver verb (lookup/resolve4/resolveMx/reverse/…) stays Net. Covers node:dns/promises too.
56
56
  [/^(node:)?dns(\/promises)?$/,
57
57
  /^(?!(new|getServers|setServers|getDefaultResultOrder|setDefaultResultOrder)$)/, "Net"],
package/scan.mjs CHANGED
@@ -13,7 +13,9 @@
13
13
  * `any`-typed callee or a function-valued parameter/field IS the "could not resolve" case), and
14
14
  * emit the §2 report envelope + the §2.2 call-graph sidecar (every analyzed function a key). With
15
15
  * --policy (or CANDOR_POLICY), evaluate the §6.2 gate (AS-EFF-006/008/009) over the result: exit 1
16
- * on violation, exit 2 LOUDLY on an unreadable policy.
16
+ * on violation, exit 2 LOUDLY on an unreadable policy. With CANDOR_BASELINE (or a config `baseline`
17
+ * key), run the AS-EFF-005 regression guard against a saved report: an existing fn gaining an effect
18
+ * is a violation (exit 1); an unparseable or different-build baseline is invalid gate input (exit 2).
17
19
  *
18
20
  * Usage: node scan.mjs <dir | file.ts | tsconfig.json> [--out <prefix>] [--policy <file>]
19
21
  * node scan.mjs <file.ts> <out-prefix> (legacy positional form)
@@ -65,6 +67,10 @@ USAGE: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--po
65
67
  -V, --version print the build and spec version (offline)
66
68
  -h, --help show this help
67
69
 
70
+ CANDOR_BASELINE=<report.json> (or a .candor/config \`baseline\` key) runs the AS-EFF-005 regression
71
+ guard against a saved same-build report: exit 1 when an existing function gained an effect, exit 2
72
+ on an unparseable or different-build baseline (never evaluated), a stderr note when absent.
73
+
68
74
  See https://github.com/tombaldwin/candor`);
69
75
  process.exit(0);
70
76
  }
@@ -168,6 +174,11 @@ const candorConfig = loadCandorConfig(target);
168
174
  // precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
169
175
  // A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
170
176
  if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
177
+ // baseline (the AS-EFF-005 regression guard, SPEC §7 item 5): CANDOR_BASELINE env → config `baseline`
178
+ // (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
179
+ // candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
180
+ let baselinePath = process.env.CANDOR_BASELINE ?? null;
181
+ if (baselinePath === null && candorConfig.baseline !== undefined) baselinePath = candorConfig.baseline;
171
182
 
172
183
  // ---- project discovery (a dir, a single file, or a tsconfig) --------------------------------------
173
184
  let rootDir, fileNames, compilerOptions = {
@@ -498,6 +509,27 @@ function moduleOf(sf) {
498
509
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
499
510
  return rel.split(path.sep).join(".");
500
511
  }
512
+ // Enclosing `namespace`/`module` blocks are NAME SEGMENTS (the family ruling: §6.2 scope segments
513
+ // split on the same boundaries as the §3.1 query name ladder, and a namespace is a segment — rust
514
+ // modules and swift enum-namespaces already qualify this way). A unit declared in
515
+ // `export namespace app { … }` is `mod.app.fn`, so a layer policy authored against namespace layers
516
+ // (`forbid app -> repo`, `deny Db app`) bites in TS instead of being silently inert. Returns the
517
+ // dotted prefix ("app." / "a.b.") or "". Dotted (`namespace a.b`) and nested forms both contribute
518
+ // each identifier segment; ambient string-named modules (`declare module "x"`) and `declare global`
519
+ // augmentations contribute nothing (not lexical layers of THIS module).
520
+ function namespacePrefixOf(node) {
521
+ const segs = [];
522
+ for (let p = node.parent; p && !ts.isSourceFile(p); p = p.parent) {
523
+ if (!ts.isModuleBlock(p)) continue;
524
+ // `namespace a.b { … }` nests ModuleDeclarations (a -> b -> block); walk the chain so every
525
+ // dotted segment lands, innermost-first up.
526
+ for (let d = p.parent; d && ts.isModuleDeclaration(d); d = ts.isModuleDeclaration(d.parent) ? d.parent : null) {
527
+ if (d.name && ts.isIdentifier(d.name) && !(d.flags & ts.NodeFlags.GlobalAugmentation))
528
+ segs.unshift(d.name.text);
529
+ }
530
+ }
531
+ return segs.length ? `${segs.join(".")}.` : "";
532
+ }
501
533
  // Is `node` (a function-expression / method-declaration / arrow) the `get` or `set` member of an
502
534
  // accessor DESCRIPTOR object passed to `Object.defineProperty(target, key, desc)` /
503
535
  // `Object.defineProperties(target, { key: desc, … })` / `Object.create(proto, { key: desc, … })`?
@@ -698,7 +730,7 @@ for (const sf of sources) {
698
730
  }
699
731
  }
700
732
  }
701
- const ctorQual = `${mod}.${node.name.text}.constructor`;
733
+ const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
702
734
  if (!fns.has(ctorQual)) {
703
735
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
704
736
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
@@ -719,7 +751,11 @@ for (const sf of sources) {
719
751
  // shared entry — FABRICATING them onto a pure caller. `nodeName` is keyed by NODE identity, so a
720
752
  // per-node-unique key keeps resolution exact; only TOP-LEVEL units need the stable bare name a
721
753
  // consumer's hash-join targets (a function-scoped local is never an export, so nothing joins to it).
722
- const qual = isFunctionScoped(node) ? `${mod}.${n}#${line + 1}:${character + 1}` : `${mod}.${n}`;
754
+ // Namespace segments go in the QUAL only; `local` (and so the §2 hash `pkg#local`) stays the
755
+ // bare name — a consumer's cross-package join resolves the callee's own name, never the
756
+ // producer's namespace nesting, so widening the hash would break report chaining.
757
+ const nsp = namespacePrefixOf(node);
758
+ const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
723
759
  fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
724
760
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
725
761
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
@@ -988,7 +1024,7 @@ function enclosing(node) {
988
1024
  // the decorated declaration's body. The parent chain of a decorator's expression is
989
1025
  // CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
990
1026
  // lands on the decorated unit and FABRICATES the factory's effects onto that method/class/param and
991
- // every transitive caller (a cardinal sin — @Entity/@Injectable factories that touch I/O would
1027
+ // every transitive caller (a fabrication — @Entity/@Injectable factories that touch I/O would
992
1028
  // poison every decorated handler). Stop at the Decorator: the factory's own effects live in its own
993
1029
  // function unit; the application site attributes to nothing (load-time, like a no-arg decorator).
994
1030
  if (ts.isDecorator(p)) return null;
@@ -1195,7 +1231,7 @@ function noteOpaqueIteration(node, iterExpr, localResolved) {
1195
1231
  // Callee names that INVOKE a function/method argument (so a fn-reference passed to one is reachable
1196
1232
  // through it). Array/iterable HOFs, the timer/microtask schedulers, and Promise continuations. A
1197
1233
  // STORE/compare/log sink (`set`/`push`/`add`/`includes`/`indexOf`/`concat`/`log`/`stringify`/…) is
1198
- // deliberately ABSENT — edging there would fabricate the fn's effects on a pure path (the cardinal sin).
1234
+ // deliberately ABSENT — edging there would fabricate the fn's effects on a pure path (the precision failure).
1199
1235
  const HOF_INVOKERS = new Set([
1200
1236
  "map", "forEach", "filter", "reduce", "reduceRight", "find", "findIndex", "findLast", "findLastIndex",
1201
1237
  "some", "every", "flatMap", "sort", "group", "groupBy", "partition", "mapValues", "flatMapDeep",
@@ -1277,7 +1313,7 @@ function visitCalls(node) {
1277
1313
  // on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
1278
1314
  // ONLY a callee that actually INVOKES its fn argument makes the reference reachable here. The
1279
1315
  // earlier version edged for ANY non-local callee — fabricating the fn's effects onto a pure path
1280
- // (the cardinal sin) for STORE/compare/log sinks that never call it (`map.set(k, fn)`,
1316
+ // (the precision failure) for STORE/compare/log sinks that never call it (`map.set(k, fn)`,
1281
1317
  // `arr.push(fn)`, `arr.includes(fn)`, `console.log(fn)`, `[fn]`). Gate on a known INVOKING HOF by
1282
1318
  // callee name; a custom non-local HOF that invokes its arg is an honest under-report (sound),
1283
1319
  // never a fabrication. (A LOCAL callee keeps its precise callback-flow below.)
@@ -1486,7 +1522,7 @@ function visitCalls(node) {
1486
1522
  // dispatch-frontier (callers --include-unknown) can resolve overrides against the
1487
1523
  // hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
1488
1524
  const tn = decl.parent?.name
1489
- ? `${moduleOf(decl.parent.getSourceFile())}.${decl.parent.name.getText()}`
1525
+ ? `${moduleOf(decl.parent.getSourceFile())}.${namespacePrefixOf(decl.parent)}${decl.parent.name.getText()}`
1490
1526
  : "type";
1491
1527
  const mn = decl.name?.getText?.() ?? "member";
1492
1528
  rec.why.add(`dispatch:${tn}.${mn}`); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
@@ -1592,7 +1628,7 @@ function visitCalls(node) {
1592
1628
  // "Console" effect in §1, so it must be PURE. Suppress the fabricated effect for these receivers
1593
1629
  // (a real `net.Socket` you constructed and `.write()` to still classifies Net — only the three
1594
1630
  // std streams are freed). Real-world sweep: nanoid/commander(×43)/bunyan/pino fabricated Net
1595
- // purely from a `process.stdout.write` — the cardinal sin.
1631
+ // purely from a `process.stdout.write` — the precision failure.
1596
1632
  if (eff && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
1597
1633
  && rootsAtStdStream(node.expression.expression))
1598
1634
  eff = null;
@@ -2112,10 +2148,10 @@ for (const sf of sources) {
2112
2148
  let sym = checker.getSymbolAtLocation(t.expression);
2113
2149
  if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
2114
2150
  const d = (sym?.declarations ?? []).find((x) => ts.isClassDeclaration(x) || ts.isInterfaceDeclaration(x));
2115
- supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${d.name.getText()}` : t.expression.getText());
2151
+ supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : t.expression.getText());
2116
2152
  }
2117
2153
  }
2118
- if (supers.length) hierarchy[`${mod}.${node.name.getText()}`] = supers;
2154
+ if (supers.length) hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
2119
2155
  }
2120
2156
  ts.forEachChild(node, walk);
2121
2157
  })(sf);
@@ -2143,12 +2179,74 @@ if (unlistedSeen.size > 0) {
2143
2179
  + `effects through ${top.length === 1 ? "it are" : "them are"} INVISIBLE (not Unknown): ${shown}${more}`);
2144
2180
  }
2145
2181
 
2182
+ // ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
2183
+ // When stdout carries a JSON document — the §2 envelope (--json) OR the streamed gate verdict
2184
+ // (--gate-json -) — it must stay pure JSON: route the gate's [AS-EFF-…] violation lines to stderr so
2185
+ // a `… | jq` / `… | candor-sarif` pipe never breaks.
2186
+ const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
2187
+ let gateViolations = [];
2188
+
2189
+ // ---- the AS-EFF-005 baseline guard (CANDOR_BASELINE / config `baseline`; SPEC §7 item 5) -----------
2190
+ // Semantics mirror the reference engine (candor-java Policy.checkBaseline) exactly:
2191
+ // · ABSENT file → one stderr note, guard inactive (ratchet not adopted; exit unchanged).
2192
+ // · PRESENT but unparseable (corrupt/truncated/not-a-report) → exit 2 WITHOUT evaluating — the guard
2193
+ // must never silently pass on unreadable gate input (the unreadable-policy class, §6.2).
2194
+ // · A missing provenance header (legacy bare array) OR a producing `candor.version` ≠ this build →
2195
+ // exit 2 WITHOUT evaluating (§2.1: a baseline is comparable only to its OWN producing version —
2196
+ // evaluating a stale one yields a bogus AS-EFF-005 wave; skipping is an unbounded fail-open window).
2197
+ // The read-only `diff`/`gains` QUERIES disclose a mismatch instead of failing — a comparison the
2198
+ // user explicitly asked for should inform; this scan-time guard is the gate and fails closed.
2199
+ // · Valid + same build → per-fn compare: an EXISTING fn gaining an effect is an [AS-EFF-005]
2200
+ // violation (exit 1, joins --gate-json); a fn absent from the baseline is NEW code, reviewed as
2201
+ // such, not a regression. Baselines omit pure fns (spec §2), so absent-prior means no prior claim.
2202
+ if (baselinePath !== null) {
2203
+ const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
2204
+ if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
2205
+ console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
2206
+ + `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
2207
+ } else {
2208
+ let root = null;
2209
+ try { root = JSON.parse(fs.readFileSync(baselinePath, "utf8")); } catch { /* root stays null → exit 2 */ }
2210
+ const arr = Array.isArray(root) ? root : (root && typeof root === "object" ? root.functions : null);
2211
+ if (!Array.isArray(arr)) {
2212
+ console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
2213
+ + `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
2214
+ process.exit(2);
2215
+ }
2216
+ const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
2217
+ && typeof root.candor.version === "string" ? root.candor.version : null;
2218
+ if (baseVersion === null) {
2219
+ console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
2220
+ + `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
2221
+ process.exit(2);
2222
+ }
2223
+ if (baseVersion !== ENGINE_VERSION) {
2224
+ console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
2225
+ + `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
2226
+ + `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
2227
+ process.exit(2);
2228
+ }
2229
+ const base = new Map();
2230
+ for (const e of arr) {
2231
+ if (e && typeof e.fn === "string" && e.fn) base.set(e.fn, new Set(Array.isArray(e.inferred) ? e.inferred : []));
2232
+ }
2233
+ for (const name of [...inferred.keys()].sort()) {
2234
+ const prior = base.get(name);
2235
+ if (prior === undefined) continue; // new function — not a regression
2236
+ const gained = [...inferred.get(name)].filter((x) => !prior.has(x)).sort();
2237
+ if (gained.length) {
2238
+ gateViolations.push({ rule: "AS-EFF-005", fn: name, effects: gained,
2239
+ detail: `\`${name}\` gained effect { ${gained.join(", ")} } not present in the baseline` });
2240
+ }
2241
+ }
2242
+ }
2243
+ }
2244
+
2146
2245
  // ---- the standing §6.2 gate (--policy / CANDOR_POLICY) --------------------------------------------
2147
2246
  // `!== null`, not truthiness: a CONFIGURED-but-EMPTY policy (a bare `policy` config line, a set-but-
2148
2247
  // empty CANDOR_POLICY) is "" — falsy, so a truthy check silently skipped the gate, the exact quiet
2149
2248
  // drop the config comment above promises fails loud. "" now reaches the read, which fails → exit 2
2150
2249
  // (the Rust engine's behavior on the same input).
2151
- let gateViolations = [];
2152
2250
  if (policyPath !== null) {
2153
2251
  let text;
2154
2252
  try {
@@ -2162,13 +2260,9 @@ if (policyPath !== null) {
2162
2260
  // java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
2163
2261
  const incompleteMap = new Map();
2164
2262
  for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
2165
- gateViolations = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
2166
- // When stdout carries a JSON document — the §2 envelope (--json) OR the streamed gate verdict
2167
- // (--gate-json -) — it must stay pure JSON: route the gate's [AS-EFF-…] violation lines to stderr so
2168
- // a `… | jq` / `… | candor-sarif` pipe never breaks.
2169
- const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
2170
- for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2263
+ gateViolations = gateViolations.concat(evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap));
2171
2264
  }
2265
+ for (const x of gateViolations) emitViolation(`[${x.rule}] ${x.detail}`);
2172
2266
  // --gate-json ⟨0.8⟩: the structured gate verdict { spec, ok, violations:[{rule,fn,effects,detail}] }, from
2173
2267
  // the SAME gateViolations that set the exit code (so it can't disagree). Written whenever the flag is set —
2174
2268
  // ok:true,[] when no gate is configured. Must precede the exit(1) below.
@@ -2182,8 +2276,10 @@ if (gateJsonPath) {
2182
2276
  catch (e) { console.error(`candor-ts: could not write --gate-json ${gateJsonPath}: ${e.message}`); }
2183
2277
  }
2184
2278
  }
2185
- if (policyPath !== null && gateViolations.length) {
2279
+ // gateViolations is non-empty only when a gate surface (policy / baseline) was active and fired.
2280
+ if (gateViolations.length) {
2186
2281
  console.error(`candor-ts: ${gateViolations.length} policy violation(s)`);
2187
2282
  process.exit(1);
2188
2283
  }
2189
2284
  if (policyPath !== null) console.error("candor-ts: policy ✓");
2285
+ if (baselinePath !== null && fs.existsSync(baselinePath)) console.error("candor-ts: baseline ✓"); // absent = inactive (noted above)
package/watch.mjs CHANGED
@@ -121,6 +121,14 @@ async function main() {
121
121
  // NO .unref() — the interval is the ONLY thing keeping the process alive; unref'ing it made Node exit
122
122
  // ~0.6s after the startup scan, so the watcher did ONE scan and died while printing "Watching…" (the
123
123
  // whole feature was silently broken, and test-watch.mjs only tests the helpers, never the live loop).
124
+
125
+ // GRACEFUL stop: the documented quit is Ctrl-C, but the default SIGINT/SIGTERM handler TERMINATES —
126
+ // exit hooks never run, the stop reads as a signal death (no exit code) to a supervisor, and a child
127
+ // instrumented with NODE_V8_COVERAGE discards its coverage (the TESTING.md §6 flush rule — this made
128
+ // the live loop measure 0% while actually exercised). Handle both: announce, exit 0.
129
+ for (const sig of ["SIGINT", "SIGTERM"]) {
130
+ process.on(sig, () => { console.error(`candor-ts-watch: ${sig} — stopping`); process.exit(0); });
131
+ }
124
132
  }
125
133
 
126
134
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) main();