candor-ts 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,7 +12,7 @@ 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 `<version>` (spec 0.23)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.24)."*
16
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
  >
@@ -80,8 +80,31 @@ A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) ca
80
80
  (a path list, or a directory of `*.json`); an unclassified call into a package with a loaded
81
81
  report inherits that function's recorded transitive effects and literal surfaces, joined by the
82
82
  report's `hash` (`package#LocalName`). A report produced by a different candor-ts version is
83
- downgraded to `Unknown` rather than silently trusted (spec §2.1). Caveat: a type-only boundary
84
- (`import type` …, the tRPC style) has no runtime calls to inherit through — nothing to join.
83
+ downgraded to `Unknown` rather than silently trusted, and grants that package **no coverage** — so a key it
84
+ does not answer falls back to the κ ledger's `invisible` hedge rather than reading pure (spec §2.1). **A report
85
+ that declares itself INCOMPLETE** — a non-empty ⟨0.21⟩ `unanalyzed` — grants **no coverage** either, for the
86
+ same reason read one step earlier: spec §2 rule 3 makes a report's silence a purity claim, and this one has
87
+ just said it never read some of its own source. Its entries are kept unchanged (they were derived from source
88
+ it *did* read); only its silence hedges, and an `import` backed only by such a report discloses
89
+ `Unknown[incomplete-dep:<pkg>]`. Chaining an incomplete report is therefore never worse than not chaining it.
90
+ **And a report that JUDGED NOTHING** — ⟨0.24⟩ `analyzed.count: 0`, or no manifest and no entries, or a manifest
91
+ too garbled to read — grants **no coverage** either: rule 3 turns a report's silence into a purity claim, and a
92
+ report that judged nothing is all silence, so its package stays in the κ ledger exactly as if it were never
93
+ chained. Keyed on the COUNT and never on `functions` being empty — `count: n > 0` with `functions: []` is a
94
+ legitimate all-pure claim rule 3 says to believe, and it is untouched. The same reading binds every route a
95
+ report arrives by (`gate --report`, MCP `candor_gate`, the LSP gate), each of which discloses the count-0 case
96
+ rather than answering "no violations" flat; the verdict itself does not move, because the report gives no
97
+ evidence of an effect and asserting one would be fabrication.
98
+ Caveat: a type-only boundary (`import type` …, the tRPC style) has no runtime calls to inherit through —
99
+ nothing to join.
100
+
101
+ **Changing a report KEY is a wire change: bump `package.json`'s version in the same commit.** `candor.version`
102
+ is the only discriminator §2.1 has, so two builds that disagree about a key while sharing a version string
103
+ disarm it — and note what the bump does and does not buy. It downgrades the entries a report *carries*; it
104
+ cannot conjure a key the report *lacks*, so an already-installed consumer that looks up a key this build no
105
+ longer emits simply misses, whatever the version says. That direction is unfixable from the producing side
106
+ (the consumer's code is frozen), which is why a stale report is denied coverage here: it is what stops the
107
+ NEXT key change reading as a purity claim.
85
108
 
86
109
  ## Query it (same names/shapes as the Rust and JVM engines — candor-spec §3.1)
87
110
 
@@ -101,6 +124,21 @@ Q whatif $P <fn> <Effect> [policy] # pre-edit gate verdict (exit 1 if it woul
101
124
  Q fix $P <fn> <Effect> <policy> # the boundary FIX: where the effect belongs + the hoist refactor
102
125
  Q unverified $P <policy> [--strict] # pure/deny layers that PASS but are Unknown (not PROVABLY clean)
103
126
  Q fix-gate $P <policy> # a fix for EVERY crossing — the loop's block-message remedy
127
+ # ⟨0.24⟩ BOTH, and `fix`: an advisory verb may be LESS certain than
128
+ # the gate, never MORE (SPEC §3.2). Where `gate --report` REFUSES a
129
+ # narrowed rule for want of evidence, `unverified` NAMES the function
130
+ # with the MISSING EVIDENCE as its reason (never a derived class),
131
+ # `fix-gate`/`fix` offer NO remedy premised on it, both carry the
132
+ # gate's `unevaluated:[{rule,why}]`, `ok` is OMITTED, `--strict` → 2.
133
+ Q gate --report $P --policy <file> [--json] [--gate-json <f>]
134
+ # ⟨0.24⟩ apply a policy to an EXISTING report, NO scan (exit 0/1/2).
135
+ # The supply-chain verb: gate a DEPENDENCY's published report. Reads
136
+ # the report and nothing else — no callgraph sidecar, no chained dep,
137
+ # no re-classification; an ABSENT entry stays absent (the ⟨0.21⟩
138
+ # purity claim). Its --gate-json is BYTE-EQUAL to `scan --policy`'s.
139
+ # `forbid`, `allow`, and a class-scoped `deny` whose scoping datum
140
+ # the report does not carry are REFUSED (exit 2), never evaluated on
141
+ # partial evidence — gate those at scan time instead.
104
142
  Q diff $P <baseline-prefix> 1 # per-function effect delta (exit 1 on a gained effect)
105
143
  Q gains $P <baseline-prefix> # supply-chain alarm: {gained, byFunction} — effects a surface grew
106
144
  Q reachable $P 1 # what the app DOES at runtime: effects over the entry points
@@ -159,9 +197,19 @@ want-JSON flag.
159
197
  `class PgStore implements Store`) resolves to the local implementors when the dispatch is narrow
160
198
  (≤12 classes) — the layered-DI pattern carries its real effects; only an interface with no
161
199
  visible implementor still reads `Unknown` (`dispatch:<Type>`).
162
- - **`unknownWhy` names each direct Unknown's origin** (`call:jwt.sign`, `callback:param#0`,
163
- `dispatch:<Type>`) — triage starts at the named site. Inheritors carry `Unknown` with no why;
164
- follow the callgraph down to the root.
200
+ - **`unknownWhy` names each direct Unknown's origin** as `kind:detail` — triage starts at the named
201
+ site. Inheritors carry `Unknown` with no why; follow the callgraph down to the root. The `kind` is
202
+ drawn from §4's closed **five**-kind vocabulary — `reflect:` / `native:` / `dispatch:` /
203
+ `callback:` / `ambiguous:` — of which candor-ts's language model produces three:
204
+ `reflect:eval`, `reflect:defineProperty:dynamic-key` (TypeScript folds its native boundary into
205
+ `reflect:`, so no bare `native:`), `callback:param#0`, and `dispatch:<pkg>.<Type>.<member>` —
206
+ the one **normative** detail shape, and what the `callers --include-unknown` frontier resolves
207
+ overrides against. It also carries `no-node_modules:<pkg>` for the *setup* class (a declared dep
208
+ that isn't installed — a mis-configuration, not a blind spot). A kind candor-ts never emits can
209
+ still appear in its output: a chained dependency's reasons are relayed verbatim, and every query
210
+ verb reads other engines' reports — `ambiguous:` (name resolution was ambiguous, so no owner type
211
+ was formed at all) is candor-rust's and arrives that way. Any kind outside the five round-trips
212
+ untouched and classifies through the conservative catch-all (§2 forward-compatibility).
165
213
  - **`entryPoint: true` marks runtime-invoked roots** (Nest `@Get/@Post/…` handler methods, Next
166
214
  `route.ts` HTTP exports and `middleware`) — their effects are never orphaned; `reachable` unions
167
215
  over them. Pure entry points stay visible in the report.
package/README.md CHANGED
@@ -34,6 +34,14 @@ node query.mjs blindspots .candor/report # the Unknown SOURCES, ranked
34
34
  node query.mjs whatif .candor/report db.save Net policy # pre-edit gate verdict (exit 1)
35
35
  node query.mjs diff .candor/report baseline 1 # per-function effect delta (exit 1 on a gain;
36
36
  # a baseline from a DIFFERENT build ⇒ disclosed ⚠ + exit 0)
37
+ node query.mjs gate --report dep/.candor/report.json --policy arch.policy --gate-json -
38
+ # ⟨0.24⟩ apply a policy to an EXISTING report, NO scan
39
+ # (exit 0/1/2) — the supply-chain verb: gate a
40
+ # DEPENDENCY's published report without its source.
41
+ # Reads that report and nothing else; a rule the wire
42
+ # cannot answer (`forbid`, `allow`, a class-scoped
43
+ # `deny` with the scoping field absent) is REFUSED
44
+ # with exit 2, never evaluated on partial evidence.
37
45
  ```
38
46
 
39
47
  A checked-in **`.candor/config`** (spec §3.4) replaces the env wiring — `policy arch.policy` /
@@ -184,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
184
192
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
185
193
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
186
194
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
187
- | `{ candor: { version, toolchain, spec: "0.23" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
195
+ | `{ candor: { version, toolchain, spec: "0.24" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
188
196
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
189
197
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
190
198
 
@@ -202,9 +210,10 @@ read the Rust source".
202
210
 
203
211
  ## Status
204
212
 
205
- 0.19.x, speaking candor-spec 0.23: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.19.x, speaking candor-spec 0.24: the analysis core, the gate (`--policy` / `--gate-json` /
206
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
207
- `--include-unknown` dispatch frontier), the MCP server, the LSP server, and the watch loop are
215
+ `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
+ report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
208
217
  real, behaviorally tested (`npm test` — the behavioral suite across six harnesses), **soundness-fuzzed
209
218
  with verified teeth** (`node fuzz.mjs` — spec §7.13: generated effect chains through every encoded
210
219
  call form, any silent-pure = red), and conformance-held against the Rust/JVM/Swift engines. The
package/lsp.mjs CHANGED
@@ -45,7 +45,7 @@ import { createRequire } from "node:module";
45
45
  import nodePath from "node:path";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
47
47
  import * as Q from "./query-core.mjs";
48
- import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
48
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses, parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText, unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } from "./policy.mjs";
49
49
 
50
50
  // Version: from the sibling package.json when running inside the npm package; a single-file BUNDLE of
51
51
  // this server (the IDE-plugin embedding) has no sibling package.json — fall back rather than crash.
@@ -280,24 +280,91 @@ function hoverAt(docPath, line) {
280
280
  // ---- Diagnostics (the live gate) ---------------------------------------------------------------------
281
281
  const warned = new Set();
282
282
  function warnOnce(message) { if (!warned.has(message)) { warned.add(message); logMessage(message); } }
283
+ // ⟨0.24⟩ the file `activePolicy()` last read, so the vocabulary anchor can be the POLICY FILE's dir.
284
+ let activePolicyPath = null;
283
285
  function activePolicy() {
284
286
  const env = process.env.CANDOR_POLICY;
285
287
  if (env) {
286
- if (fs.existsSync(env)) return fs.readFileSync(env, "utf8");
288
+ if (fs.existsSync(env)) { activePolicyPath = env; return fs.readFileSync(env, "utf8"); }
287
289
  // Set-but-missing must be LOUD (the family's configured-but-unusable posture — scan exits 2 here).
288
290
  // This is an advisory surface, so: disclose the policy-source swap, then fall through to discovery.
289
291
  warnOnce(`candor-lsp: CANDOR_POLICY is set but ${env} does not exist — falling back to .candor/config discovery (diagnostics may reflect a different policy than you configured)`);
290
292
  }
291
293
  const from = reportPrefix ? nodePath.dirname(nodePath.resolve(reportPrefix)) : rootPath;
292
294
  const cfg = from ? discoverConfigPolicy(from) : null;
293
- if (cfg && fs.existsSync(cfg.policyPath)) return fs.readFileSync(cfg.policyPath, "utf8");
295
+ if (cfg && fs.existsSync(cfg.policyPath)) { activePolicyPath = cfg.policyPath; return fs.readFileSync(cfg.policyPath, "utf8"); }
296
+ activePolicyPath = null;
297
+ return null;
298
+ }
299
+
300
+ /**
301
+ * ⟨0.24⟩ ONE policy PARSE for this surface — the LSP twin of query.mjs's `loadPolicyOrDie` and mcp.mjs's
302
+ * `policyOrThrow`, and it closes the same drift. Every call site here parsed with NO alias map, so a rule
303
+ * written against a checked-in `unknown-alias` was silently WIDENED to the bare effect: the editor drew
304
+ * squiggles for a class the operator had deliberately excluded, and drew none for the one they had named.
305
+ * The vocabulary anchors at the POLICY FILE, as it does on both CLI routes, so the same policy means the
306
+ * same thing in the editor and in the gate that judges the edit.
307
+ *
308
+ * On a policy this engine CANNOT HONOUR AS WRITTEN (§6.2 `be0b9a9`), it returns null and says so ONCE.
309
+ * This surface has no exit code, so the enforcement posture cannot be borrowed wholesale — but evaluating
310
+ * a policy the engine has silently rewritten is the fail-open the ruling exists to close, and it is worse
311
+ * here than elsewhere because a squiggle that does not appear is invisible. What the disclosure buys is
312
+ * that the empty editor is EXPLAINED rather than read as a clean bill of health, which is the same
313
+ * argument this file already makes about a report that judged nothing.
314
+ */
315
+ function activePolicyParsed(text) {
316
+ const errs = [];
317
+ const aliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(activePolicyPath, rootPath || process.cwd())), errs);
318
+ const pol = parsePolicy(text, aliases);
319
+ errs.push(...pol.errors);
320
+ // ⟨0.24⟩ FATAL only: `errors` also carries every LINE the parser dropped whole (SPEC §3.1
321
+ // `195d45a`) — additive to the `parsepolicy` witness, deliberately silent about the gate.
322
+ const fatal = fatalPolicyErrors(errs);
323
+ if (!fatal.length) return pol;
324
+ warnOnce(`candor-lsp: ${policyErrorText(activePolicyPath ?? "(policy)", fatal)}\n No gate diagnostics are produced from it — their ABSENCE here is the refusal, not an all-clear.`);
294
325
  return null;
295
326
  }
296
327
  function diagnosticsFor(docPath) {
297
328
  const text = activePolicy();
298
329
  if (text === null || !hasReport(reportPrefix)) return [];
299
330
  const fns = Q.loadReport(reportPrefix);
300
- const violations = evaluatePolicy(parsePolicy(text), fns, Q.loadCallgraph(reportPrefix));
331
+ // ⟨0.24⟩ A report that JUDGED NOTHING is not a clean bill of health, and this surface is where that is
332
+ // hardest to notice: the live gate's whole vocabulary is squiggles, and a report with `analyzed.count: 0`
333
+ // has no entries, so it produces none — an empty editor reads as "the gate is green" when the truth is
334
+ // that nothing was ever judged. SPEC §2's three-row table, bound here by §3.1 ⟨0.24⟩ ("the obligation is
335
+ // on the reading, not on the route by which the report arrived"); this route takes a `report` locator,
336
+ // so a FOREIGN report arrives by it. The channel is the LOG and `warnOnce` rather than a diagnostic on
337
+ // every publish, because there is no line to pin it to and a per-keystroke popup is how an advisory gets
338
+ // turned off. The verdict is untouched: no effect is asserted that the report does not carry.
339
+ if (Q.reportJudgedNothing(reportPrefix))
340
+ warnOnce(`candor-lsp: the report at ${reportPrefix} judged NOTHING (⟨0.24⟩ \`analyzed.count\` is 0, absent with no `
341
+ + `entries, or unreadable) — no gate diagnostics can come from it, and their absence is NOT an all-clear: `
342
+ + `absence from \`functions\` licenses no purity claim about any unit. Re-scan the sources you meant to gate `
343
+ + `(candor-ts <src> --out ${reportPrefix}).`);
344
+ // ⟨0.24⟩ `netClass` VERBATIM off the wire — the same report-route rule as the MCP `candor_gate` tool and
345
+ // `gate --report`; re-deriving it here would answer with the CONSUMER's `net-partner` evidence about the
346
+ // producer's project, in both the fabricating and the fail-open direction (see reportNetClasses).
347
+ const dpol = activePolicyParsed(text);
348
+ if (dpol === null) return [];
349
+ // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
350
+ // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
351
+ // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
352
+ // where `gate --report` exits 2 (a squiggle that does not appear is the least visible false all-clear this
353
+ // project has), and `deny Net[unknown-host]` drew one whose message ASSERTS a destination class the report
354
+ // never carried. `authoritative: true` for the same reason the MCP gate takes it: an entry without
355
+ // `netClass` gets the EMPTY set rather than a derivation from `hosts`, so the report is the only source of
356
+ // the class and nothing is re-classified with this machine's evidence about the producer's project.
357
+ const dnet = reportNetClasses(fns, { authoritative: true });
358
+ const { unevaluated: dunevaluated, withhold: dwithhold } =
359
+ unanswerableScoped(dpol, fns, resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix)), dnet);
360
+ // The surface has no exit code, so the refusal is carried the way this file already carries its other two
361
+ // (the unhonourable policy, the judged-nothing report): ONE log line naming the rules, so the missing
362
+ // squiggle is EXPLAINED rather than read as a clean bill of health. Per rule, not per keystroke — warnOnce
363
+ // keys on the message, and the message is a function of the policy and the report, not of the edit.
364
+ for (const u of dunevaluated)
365
+ warnOnce(`candor-lsp: ${u.why}\n NO diagnostics are drawn for that rule — their ABSENCE here is the refusal, not an all-clear.`);
366
+ const violations = evaluatePolicy(dpol, fns, Q.loadCallgraph(reportPrefix),
367
+ new Map(), new Set(), dnet, dwithhold);
301
368
  const locByFn = new Map(fns.filter((e) => e.loc).map((e) => [e.fn, locParts(e.loc)]));
302
369
  const out = [];
303
370
  for (const v of violations) {
@@ -360,7 +427,8 @@ function codeActions(docPath, uri, range) {
360
427
  // function that actually violates the boundary. Same policy source as the diagnostics + the whatif action.
361
428
  const policyText = activePolicy();
362
429
  if (policyText !== null && hasReport(reportPrefix)) {
363
- const pol = parsePolicy(policyText);
430
+ const pol = activePolicyParsed(policyText);
431
+ if (pol === null) return out;
364
432
  const cg = Q.loadCallgraph(reportPrefix);
365
433
  const fns = Q.loadReport(reportPrefix);
366
434
  for (const eff of Q.CONTAINED) {
@@ -420,23 +488,36 @@ function runWhatif(a) {
420
488
  }
421
489
  const policyText = activePolicy();
422
490
  const r = Q.whatif(Q.loadCallgraph(reportPrefix), a.fn, a.effect,
423
- policyText === null ? null : parsePolicy(policyText), scopeMatches);
491
+ policyText === null ? null : activePolicyParsed(policyText), scopeMatches);
424
492
  if (r === null) {
425
493
  showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
426
494
  return null;
427
495
  }
428
496
  const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
429
497
  const rules = [...new Set(r.violations.map((v) => v.rule))];
498
+ // ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
499
+ // surface becomes the one place the defect still lives — and it is the sharpest place for it, because
500
+ // `rules[0]` is now the operator's RAW line. Without the condition the squiggle would read
501
+ // `✗ deny Net[unknown-host] app would fire`: a narrowed rule beside an unconditional verdict, which SPEC
502
+ // §3.1 calls WORSE than printing the rule stripped of its filter, since it reads as a narrowing candor
503
+ // evaluated and did not.
504
+ const condOf = new Map(r.violations.filter((v) => v.conditional).map((v) => [v.rule, v.conditional]));
430
505
  const verdict = policyText === null
431
506
  ? `candor: no policy discovered — blast radius only: ${callers.length} caller(s) would inherit ${a.effect}`
432
507
  : rules.length
433
- ? `✗ ${rules[0]} would fire — ${callers.length} caller(s) inherit ${a.effect}`
508
+ ? `✗ ${rules[0]} would fire${condOf.has(rules[0]) ? ` IF ${condOf.get(rules[0])}` : ""} — ${callers.length} caller(s) inherit ${a.effect}`
434
509
  : `✓ no policy rule fires — ${callers.length} caller(s) would inherit ${a.effect}`;
435
510
  showMessage(rules.length ? 2 : 3, verdict); // warning when a rule fires, info otherwise
436
511
  if (typeof a.uri === "string" && Number.isInteger(a.line)) { // the detail, pinned at the fn's line
437
512
  const head = callers.slice(0, 10);
438
513
  const lines = [`what if ${r.of.join(", ")} performed ${a.effect}? ${verdict}`];
439
514
  if (rules.length > 1) lines.push(`rules: ${rules.join("; ")}`);
515
+ // The one-liner has room for the condition but not for WHY there is one; the pinned detail has room for
516
+ // both, and the reason is the half that stops an operator reading a fail-closed hedge as a false alarm.
517
+ for (const [rule, c] of condOf)
518
+ lines.push(`conditional — \`${rule}\` fires IF ${c}. That rule NARROWS, and the effect you have not `
519
+ + "written yet has no class to match, so candor charges it fail-closed rather than guessing "
520
+ + "which class your edit would land in.");
440
521
  lines.push(head.length
441
522
  ? `callers: ${head.join(", ")}${callers.length > head.length ? ` +${callers.length - head.length} more` : ""}`
442
523
  : "no callers — the blast radius is the function itself");
@@ -466,12 +547,26 @@ function runFix(a) {
466
547
  showMessage(2, "candor: no policy discovered — a fix is defined relative to a boundary; set CANDOR_POLICY or check one into .candor/config");
467
548
  return null;
468
549
  }
550
+ const fpol = activePolicyParsed(policyText);
551
+ if (fpol === null) return null;
469
552
  const r = Q.fix(Q.loadCallgraph(reportPrefix), Q.loadReport(reportPrefix), a.fn, a.effect,
470
- parsePolicy(policyText), scopeMatches);
553
+ fpol, scopeMatches);
471
554
  if (r === null) {
472
555
  showMessage(2, `candor: no function matching \`${a.fn}\` in the call graph — the report may be stale`);
473
556
  return null;
474
557
  }
558
+ // ⟨0.24⟩ SPEC §3.2 `4fd140c` — THE REFUSAL, BEFORE the `crossing` test and not folded into it. `Q.fix`
559
+ // now returns `{refused:true, unevaluated}` with NO `crossing` key where the gate could not adjudicate the
560
+ // boundary, and a falsy-`crossing` reading of that lands on "isn't forbidden here; no boundary fix
561
+ // needed" — the derived second opinion, delivered as a clean bill of health, on the surface that ASKED
562
+ // for a fix. Same disclosure the diagnostics path already makes (dunevaluated), repeated here because a
563
+ // code action can be invoked on a file whose diagnostics were never published.
564
+ if (r.refused) {
565
+ showMessage(2, `candor: \`${a.fn}\` — no fix computed: the gate could NOT judge ${a.effect} here (this report does not carry the evidence the policy narrows on), and a hoist plan for a boundary the gate could not adjudicate would rest on a guess`);
566
+ for (const u of r.unevaluated ?? [])
567
+ logMessage(`candor-lsp: ${u.why}\n NO fix is offered for that rule — the ABSENCE of a remedy here is the refusal, not an all-clear.`);
568
+ return r;
569
+ }
475
570
  if (!r.crossing) {
476
571
  showMessage(3, `candor: \`${a.fn}\` — ${a.effect} isn't forbidden here; no boundary fix needed`);
477
572
  return r;
package/mcp.mjs CHANGED
@@ -19,7 +19,9 @@ import fs from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import nodePath from "node:path";
21
21
  import * as Q from "./query-core.mjs";
22
- import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches } from "./policy.mjs";
22
+ import { discoverConfigPolicy, evaluatePolicy, parsePolicy, scopeMatches, reportNetClasses,
23
+ parseUnknownAliases, discoverConfigText, policyVocabularyAnchor, policyErrorText,
24
+ unanswerableScoped, resolveReasonClasses, fatalPolicyErrors } from "./policy.mjs";
23
25
 
24
26
  const VERSION = createRequire(import.meta.url)("./package.json").version; // single-sourced, like scan.mjs
25
27
 
@@ -68,10 +70,19 @@ const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n
68
70
  // corrupt report — the §4 cardinal sin, exactly what the CLI's loadReportOrDie exits 2 on. The throw
69
71
  // surfaces as the same isError result shape every other tool failure uses. EVERY tool that loads a
70
72
  // report (main prefix or baseline) goes through this — never bare Q.loadReport.
71
- function loadReportLoud(p) {
73
+ // `partialIsFatal` raises the bar from "NOTHING parsed" to "not EVERYTHING parsed", and `candor_gate`
74
+ // passes it because that tool emits a VERDICT: `{ok: true, violations: []}` over a multi-report prefix
75
+ // with one clean sibling and one truncated one is a green document over a package half of whose signature
76
+ // never loaded, and the disclosure Q.loadReport writes goes to the SERVER's stderr — a channel the calling
77
+ // agent never reads. Same rule, same argument as the CLI `gate --report` (see query.mjs). The read-only
78
+ // tools keep the looser bar deliberately: they return what they found rather than asserting a clean bill
79
+ // of health, so the partial answer is a smaller claim than a green gate.
80
+ function loadReportLoud(p, { partialIsFatal = false } = {}) {
72
81
  const fns = Q.loadReport(p);
73
- if (fns.length === 0 && fns.hardFail)
74
- throw new Error(`every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`);
82
+ if (fns.hardFail && (partialIsFatal || fns.length === 0))
83
+ throw new Error(fns.length === 0
84
+ ? `every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`
85
+ : `a report found at prefix \`${clip(p)}\` failed to load — refusing to gate over a report that did not load cleanly; a partial signature makes a green verdict meaningless (the effects of the report that did not load are exactly the ones a violation would come from). Re-run the scan`);
75
86
  return fns;
76
87
  }
77
88
  // The confinement root for a caller-supplied policy path: the repo the report belongs to — the
@@ -98,6 +109,33 @@ function confinedPolicyRead(policyPath, prefix, root = policyRoot(prefix)) {
98
109
  try { return fs.readFileSync(abs, "utf8"); }
99
110
  catch { throw new Error(`policy \`${clip(policyPath)}\` could not be read — NOT evaluated (a missing gate source must be loud, never a clean verdict)`); }
100
111
  }
112
+ /**
113
+ * ⟨0.24⟩ ONE policy PARSE for every tool here that consults one — the MCP twin of query.mjs's
114
+ * `loadPolicyOrDie`, and it closes the same two defects on the agent-facing surface.
115
+ *
116
+ * These tools called `parsePolicy(text)` with NO alias map, so a rule written against a checked-in
117
+ * `unknown-alias` was silently REWRITTEN — widened to the bare effect — in the very tools an agent
118
+ * consults before and after an edit, while the CLI gate honoured it. And with ⟨0.24⟩ making an
119
+ * unrecognised value token a POLICY ERROR (§6.2 `382a7e0`/`be0b9a9`), `candor_gate` would otherwise
120
+ * have kept enforcing a policy it cannot honour as written — the narrowing case stops gating what the
121
+ * operator spelled while the gate still looks armed, which is the fail-open the ruling exists to
122
+ * close, arriving here through the surface an agent trusts most.
123
+ *
124
+ * The vocabulary anchors at the POLICY FILE (§3.1 `99eb4e9`), as it does on both CLI routes, so the
125
+ * same policy means the same thing however it is reached. A policy error throws, which the tool layer
126
+ * renders as `isError` — the MCP shape of the CLI's exit 2.
127
+ */
128
+ function policyOrThrow(text, policyPath) {
129
+ const errs = [];
130
+ const aliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, process.cwd())), errs);
131
+ const pol = parsePolicy(text, aliases);
132
+ errs.push(...pol.errors);
133
+ // ⟨0.24⟩ FATAL only: `errors` also carries every LINE the parser dropped whole (SPEC §3.1
134
+ // `195d45a`) — additive to the `parsepolicy` witness, deliberately silent about the gate.
135
+ const fatal = fatalPolicyErrors(errs);
136
+ if (fatal.length) throw new Error(policyErrorText(policyPath ?? "(policy)", fatal));
137
+ return pol;
138
+ }
101
139
  // The repo's .candor/config (spec §3.4), from the report's directory upward — shared impl in policy.mjs.
102
140
  function configPolicy(prefix) {
103
141
  return discoverConfigPolicy(nodePath.dirname(nodePath.resolve(prefix)) || ".");
@@ -195,22 +233,23 @@ const TOOLS = {
195
233
  // typo'd/missing path silently evaluate with NO policy → `ok:true, violations:[]`, a false green
196
234
  // on the agent-facing pre-edit gate (exactly what the CLI whatif exits 2 to prevent). The read's
197
235
  // throw lands as the tool-level isError, mirroring the CLI's fail-closed posture.
198
- const pol = a.policy ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
236
+ const pol = a.policy ? policyOrThrow(confinedPolicyRead(a.policy, p), a.policy) : null;
199
237
  const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
200
238
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
201
239
  return r;
202
240
  },
203
241
  },
204
242
  candor_fix: {
205
- description: "THE BOUNDARY FIX: when `fn` performs `effect` in a layer the policy forbids (a violation candor_whatif/candor_gate reports), compute the architectural REMEDY — not just 'the domain can't do Net', but WHERE the effect belongs and the refactor to put it there: the direct call site to hoist, the forbidden-layer functions that become pure and thread the value as a parameter, and the nearest allowed-layer caller to perform the effect ({ crossing, site, deniedSpan, hoistTo, policyAlternative }). The remedial inverse of candor_whatif. Call this INSTEAD OF guessing a fix (adding `allow` to the domain, moving the I/O one call up, threading a handle the wrong way). Advisory: it names the structure, you write the code; the gate re-scan verifies. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4).",
243
+ description: "THE BOUNDARY FIX: when `fn` performs `effect` in a layer the policy forbids (a violation candor_whatif/candor_gate reports), compute the architectural REMEDY — not just 'the domain can't do Net', but WHERE the effect belongs and the refactor to put it there: the direct call site to hoist, the forbidden-layer functions that become pure and thread the value as a parameter, and the nearest allowed-layer caller to perform the effect ({ crossing, site, deniedSpan, hoistTo, policyAlternative }). The remedial inverse of candor_whatif. Call this INSTEAD OF guessing a fix (adding `allow` to the domain, moving the I/O one call up, threading a handle the wrong way). Advisory: it names the structure, you write the code; the gate re-scan verifies. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `refused` BEFORE `crossing`: where the policy narrows on evidence this report does not carry, candor_gate REFUSES and no remedy is computed — the result is `{ fn, effect, refused:true, unevaluated:[{rule, why}] }` WITH NO `crossing` KEY, an absent key rather than `crossing:false`, because 'no boundary fix needed' is a claim and that is the one thing the tool cannot claim here (spec §3.2: an advisory verb may be LESS certain than the gate, never MORE). Re-scan so the report carries the narrowing evidence, or widen the rule; do not read the missing plan as an all-clear.",
206
244
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: ["fn", "effect"] },
207
245
  run: (a, p) => {
208
246
  // The fix is defined relative to a boundary — a policy is required. Given → confined fail-closed read;
209
247
  // else the repo's checked-in policy (same resolution as candor_gate), so it works zero-config.
210
- let text;
248
+ let text, polPath = a.policy ?? null;
211
249
  if (a.policy) text = confinedPolicyRead(a.policy, p);
212
250
  else {
213
251
  const cfg = configPolicy(p);
252
+ polPath = cfg?.policyPath ?? null;
214
253
  if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4) — the fix is defined relative to the boundary it crosses");
215
254
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
216
255
  }
@@ -218,24 +257,94 @@ const TOOLS = {
218
257
  // The sidecar is the only graph a candor-ts report carries — fail loud (tool error) when it's absent,
219
258
  // never a degenerate empty-graph remedy. (/code-review.)
220
259
  if (!cg || Object.keys(cg).length === 0) throw new Error(`no call-graph sidecar for the report — fix needs it (re-scan with --out)`);
221
- const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, parsePolicy(text), scopeMatches);
260
+ const r = Q.fix(cg, loadReportLoud(p), a.fn, a.effect, policyOrThrow(text, polPath), scopeMatches);
222
261
  if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
223
262
  return r;
224
263
  },
225
264
  },
226
265
  candor_gate: {
227
- description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
266
+ description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `{ incomplete:true, unanalyzed }` means the report declares code candor could not analyze, so the gate cannot be green. Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
228
267
  schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
229
268
  run: (a, p) => {
230
- let text;
269
+ let text, polPath = a.policy ?? null;
231
270
  if (a.policy) text = confinedPolicyRead(a.policy, p);
232
271
  else {
233
272
  const cfg = configPolicy(p);
273
+ polPath = cfg?.policyPath ?? null;
234
274
  if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
235
275
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
236
276
  }
237
- const v = evaluatePolicy(parsePolicy(text), loadReportLoud(p), Q.loadCallgraph(p));
238
- return { ok: v.length === 0, violations: v };
277
+ // ⟨0.24⟩ THE SAME THREE-PIECE GATE THE CLI RUNS, because this tool is a REPORT route exactly as
278
+ // `gate --report` is, and the two pieces it was missing were BOTH live harms on the surface an
279
+ // agent trusts and no human reads. Measured, same report, same policy, against the CLI:
280
+ //
281
+ // deny Unknown[reflect] app -> CLI exit 2 (refused); here {"ok":true,"violations":[]}
282
+ // deny Net[unknown-host] app -> CLI exit 2 (refused); here FIRES, asserting
283
+ // "netClass":["unknown-host"] the report never carried
284
+ //
285
+ // (1) `authoritative` netClass. The DEFAULT mode maps only entries that CARRY the field and lets the
286
+ // rest fall back to `netClassesOf`, which floors an empty surface at `unknown-host`. That was chosen
287
+ // as a hedge because "neither surface can refuse a question" — but a hedge that ASSERTS a destination
288
+ // class in the violation record is not a hedge, it is the re-derivation §3.1 ⟨0.24⟩ forbids, in a
289
+ // field a consumer reads as the PRODUCER's judgment. Now that the tool discloses (3), it can refuse,
290
+ // so the authoritative mode is the correct one and the two routes read one report identically.
291
+ // (2) `withhold` (unanswerableScoped). A scoped `deny` whose narrowing evidence the wire does not
292
+ // carry is NOT EVALUATED, per (rule, function, effect) — never scored as a filter that succeeded for
293
+ // lack of evidence, and never fired on `reasonClassesMatch`'s empty-set floor, which is right for a
294
+ // MATCHER and wrong for a FIRING.
295
+ // (3) the disclosure, which is what makes (1) and (2) safe here: an unevaluated rule rides the tool
296
+ // result, so the agent sees WHICH part of its policy went unenforced rather than an empty list.
297
+ //
298
+ // ONE PASS over the report files (`loadGateReport`), the same reader `gate --report` uses: it yields
299
+ // the entries AND the ⟨0.21⟩/⟨0.15⟩ envelope out of the same bytes. Three separate reads (functions,
300
+ // judged-nothing, and — newly — `unanalyzed`) could disagree with each other on a file another
301
+ // process rewrites between them.
302
+ const pol = policyOrThrow(text, polPath);
303
+ const g = Q.loadGateReport(p);
304
+ if (g.hardFail)
305
+ throw new Error((g.corrupt.length
306
+ ? `the report at prefix \`${clip(p)}\` has ${g.corrupt.length} present-but-unparseable §2 key(s) — a key that is THERE but of the wrong shape is corrupt input, not an empty one (SPEC §2 ⟨0.24⟩); coercing it to its empty value would turn corruption into a purity claim: ${g.corrupt.join("; ")}. `
307
+ : "")
308
+ + (g.functions.length === 0
309
+ ? `every report found at prefix \`${clip(p)}\` failed to load — refusing to report an empty (all-clear) answer over a corrupt report; re-run the scan`
310
+ : `a report found at prefix \`${clip(p)}\` failed to load — refusing to gate over a report that did not load cleanly; a partial signature makes a green verdict meaningless (the effects of the report that did not load are exactly the ones a violation would come from). Re-run the scan`));
311
+ const gfns = g.functions;
312
+ const cg = Q.loadCallgraph(p);
313
+ const gnet = reportNetClasses(gfns, { authoritative: true });
314
+ const { unevaluated, withhold } = unanswerableScoped(pol, gfns, resolveReasonClasses(gfns, cg), gnet);
315
+ const v = evaluatePolicy(pol, gfns, cg, new Map(), new Set(), gnet, withhold);
316
+ // ⟨0.21⟩ COMPLETENESS MANIFEST — this tool implemented no incompleteness rule at all: it answered
317
+ // `{ok:true, violations:[]}` over a report DECLARING `unanalyzed`, where the CLI exits 2. A gate
318
+ // cannot be green over code candor never analyzed, and the manifest travels ON the report, so the
319
+ // same verdict follows from it here. Additive to the pinned `{ok, violations}` shape.
320
+ const incomplete = g.unanalyzed.length > 0;
321
+ // ⟨0.24⟩ …and a report that JUDGED NOTHING is not an all-clear (SPEC §2's three-row table, bound to
322
+ // every report-reading route by §3.1: "the obligation is on the reading, not on the route by which
323
+ // the report arrived"). This tool is exactly such a route — it gates whatever `report` points at,
324
+ // which is how a FOREIGN report arrives here — and `{ok: true, violations: []}` over a report with
325
+ // `analyzed.count: 0` tells an agent the code is clean when nothing in it was ever judged. The
326
+ // caveat is ADDITIVE (the two existing keys keep their shape and meaning, and the field is absent
327
+ // on every ordinary report) because the verdict itself must not move: the report asserts no effect,
328
+ // so asserting one here would be the fabrication mirror of the silence being disclosed.
329
+ const judged = g.judgedNothing ? { judgedNothing: true,
330
+ caveat: "⟨0.24⟩ this report judged NOTHING (`analyzed.count` is 0, absent with no entries, or unreadable) — "
331
+ + "a green verdict here certifies nothing: absence from `functions` licenses no purity claim about any "
332
+ + "unit. Re-scan the sources you meant to gate, or point `report` at the package that has them." } : {};
333
+ const inc = incomplete ? { incomplete: true, unanalyzed: g.unanalyzed } : {};
334
+ // ⟨0.24⟩ PRECEDENCE (SPEC §3.1 `7271c69`/`4c79958`): violation (1) > refusal (2) > incomplete (2), and
335
+ // the REFUSAL SHAPE is the one the CLI writes — `ok:false`, `refused:true`, and NO `violations` KEY AT
336
+ // ALL, because the gate is making no claim about violations and `[]` is precisely the claim it cannot
337
+ // make. THE TOOL-RESULT SHAPE DECISION, stated: a refusal is returned as this structured document and
338
+ // NOT as `isError`. `isError` flattens to a text blob, and the machine-actionable half of a refusal is
339
+ // `unevaluated` — WHICH rules went unenforced and why — which is exactly what an agent needs to fix it.
340
+ // The document is still fail-closed to the naivest possible reader (`ok` is false), and it is the same
341
+ // shape the agent would get from `--gate-json`, so one consumer parses both routes.
342
+ if (v.length) return { ok: false, violations: v, ...(unevaluated.length ? { unevaluated } : {}), ...inc, ...judged };
343
+ if (unevaluated.length)
344
+ return { ok: false, refused: true,
345
+ reason: `${unevaluated.length} policy rule(s) could not be evaluated against this report`,
346
+ unevaluated, ...inc, ...judged };
347
+ return { ok: !incomplete, violations: v, ...inc, ...judged };
239
348
  },
240
349
  },
241
350
  candor_unverified: {
@@ -245,17 +354,34 @@ const TOOLS = {
245
354
  + "case is a fn/closure-injected 'port' — the domain reads as Unknown, so `deny Net domain`/`pure "
246
355
  + "domain` clear it though it may reach Net at runtime. Returns each such function + the `deny <E> "
247
356
  + "Unknown <scope>` upgrade that makes the layer PROVABLY clean. Uses `policy` if given, else the "
248
- + "repo's checked-in .candor/config policy.",
357
+ + "repo's checked-in .candor/config policy. ALWAYS CHECK `ok`, never the length of `unverified`: "
358
+ + "over a report declaring code candor could NOT analyze, `ok` IS ABSENT and `{incomplete:true, "
359
+ + "unanalyzed}` takes its place — a function in an unanalyzed file is missing from the report "
360
+ + "entirely, so it cannot be enumerated as an unverified pass, and an empty array there is not "
361
+ + "an all-clear (spec §3.2). `ok` is ABSENT for a second reason too, and the entries that come "
362
+ + "with it are the sharpest ones: where the policy narrows on evidence this report does not carry, "
363
+ + "candor_gate REFUSES — and this verb then NAMES each function the gate could not judge, as "
364
+ + "`{fn, rule, why}` where `why` is THE MISSING EVIDENCE and never a derived class, plus the gate's "
365
+ + "own `unevaluated:[{rule, why}]`. Those entries carry no `upgrade`: whether they pass at all is "
366
+ + "the open question, so there is nothing to upgrade yet — re-scan so the report carries the "
367
+ + "evidence, or widen the rule (spec §3.2: an advisory verb may be LESS certain than the gate, "
368
+ + "never MORE).",
249
369
  schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
250
370
  run: (a, p) => {
251
- let text;
371
+ let text, polPath = a.policy ?? null;
252
372
  if (a.policy) text = confinedPolicyRead(a.policy, p);
253
373
  else {
254
374
  const cfg = configPolicy(p);
375
+ polPath = cfg?.policyPath ?? null;
255
376
  if (!cfg) throw new Error("no policy: pass `policy`, or check one into the repo's .candor/config (spec §3.4)");
256
377
  text = confinedPolicyRead(cfg.policyPath, p, cfg.repoRoot);
257
378
  }
258
- return Q.unverified(loadReportLoud(p), parsePolicy(text), scopeMatches);
379
+ // ⟨0.24⟩ SPEC §3.2 — the agent surface is a CHANNEL this verb answers on, and the rule binds every
380
+ // one of them. `candor_gate` already refuses to read green over `unanalyzed`; this returned
381
+ // `ok:true` with an empty array over the identical bytes, on the surface an agent trusts and no
382
+ // human reads. Same `advisoryAnswer` the CLI applies, so the two cannot drift.
383
+ return Q.advisoryAnswer(Q.unverified(loadReportLoud(p), policyOrThrow(text, polPath), scopeMatches),
384
+ Q.reportUnanalyzed(p));
259
385
  },
260
386
  },
261
387
  candor_containment: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.23.0",
4
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.23)",
3
+ "version": "0.24.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.24)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
@@ -15,14 +15,16 @@
15
15
  "candor-mcp": "./mcp.mjs",
16
16
  "candor-lsp": "./lsp.mjs",
17
17
  "candor-ts-verify": "./verify.mjs",
18
- "candor-ts-sensitivity": "./sensitivity.mjs"
18
+ "candor-ts-sensitivity": "./sensitivity.mjs",
19
+ "candor-ts-transitive-recall": "./transitive-recall.mjs"
19
20
  },
20
21
  "scripts": {
21
22
  "lint": "eslint *.mjs",
22
23
  "test": "npm run lint && node --test test-unit.mjs && node test.mjs && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
23
24
  "test:unit": "node --test test-unit.mjs",
24
25
  "test:probe": "node fabrication_probe.mjs",
25
- "test:fuzz": "node fuzz.mjs"
26
+ "test:fuzz": "node fuzz.mjs",
27
+ "test:transitive-recall": "node transitive-recall.mjs"
26
28
  },
27
29
  "license": "(MIT OR Apache-2.0)",
28
30
  "repository": {
@@ -64,7 +66,8 @@
64
66
  "verify-emit.mjs",
65
67
  "verify-loader.mjs",
66
68
  "verify-syscall.mjs",
67
- "sensitivity.mjs"
69
+ "sensitivity.mjs",
70
+ "transitive-recall.mjs"
68
71
  ],
69
72
  "devDependencies": {
70
73
  "@eslint/js": "^9.39.4",