# Using candor-ts (instructions for an AI coding agent) You are working in a TypeScript project. **candor-ts** tells you, for every function, which side effects it can reach — network, calls to LLM/model providers (`Llm`, refines network), filesystem, database, subprocess, env, clock, IPC (`Ipc`), logging (`Log`), randomness (`Rand`), clipboard (`Clipboard`) — *including effects inherited transitively through any chain of calls across files*. Use it instead of tracing call chains by hand. The language-agnostic consumption contract is [candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is the TypeScript-specific production + query surface. > **If the repository is not TypeScript-only, start at the umbrella:** > [candor/AGENTS.md](https://github.com/tombaldwin/candor/blob/main/AGENTS.md). `candor` is one > command in front of every engine (TypeScript, JVM, Rust, Swift, agent fleets) — it picks the right > one per target, `candor update` installs and upgrades them, and `candor doctor` checks that every > installed engine agrees on a spec version. A polyglot repo scanned with this engine alone gets an > answer about its TypeScript and nothing that says so. > **This document ships inside the package.** `npx -y candor-ts --agents` prints the contract for > the *installed* version — always prefer that over a vendored or fetched copy, which can describe > a different candor-ts than the one you are running. > **Already installed? Report the version and ask before upgrading — before you scan.** If this > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user > plainly which version they're on** — e.g. *"This project is on candor-ts `` (spec 0.42)."* > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing > `.candor/report*.json`, or `npm ls -g candor-ts`. > > **Staying current is your job, not candor's.** candor never phones home — it audits and denies the > Net effect, so it will not reach the network to check itself. `candor-ts --version` prints the > installed build, the spec contract it speaks, and the upgrade line (`npm install -g > candor-ts@latest`) — fully offline. **You** have the network: compare the installed version against > npm. If it's behind, **ask the user before upgrading** — e.g. *"candor-ts `` is available > (you're on ``) — upgrade before I scan?"* — and run `npm install -g candor-ts@latest` (or `npx > -y candor-ts@latest`) only if they agree. Never upgrade silently: an analysis tool's version is > part of its result's provenance, so the user decides when it changes. If it's already current (or > the user declines), just proceed; if candor isn't installed at all, install it normally. The language-agnostic consumption contract is [candor-spec/AGENTS.md](https://github.com/tombaldwin/candor-spec/blob/main/AGENTS.md); this file is the TypeScript-specific production + query surface. ## Produce a report On npm (needs node ≥ 20): ```sh npx -y candor-ts # tsconfig.json honored; tests/node_modules excluded npx -y candor-ts --allow-js # also analyze .js/.mjs sources (walks the tree) ``` This writes `/.candor/report.json` and `.candor/report.callgraph.json` (override with `--out `). **Install the TARGET's dependencies first** (`npm install` in the project) — without node_modules, imports don't resolve and most functions read `Unknown` (disclosed; the scanner warns loudly). Add `--policy ` (or set `CANDOR_POLICY`) to enforce a §6.2 policy over the scan: exit 1 on violation, exit 2 LOUDLY if the policy file is unreadable. `--gate-json ` additionally writes the structured verdict `{spec, ok, violations:[{rule,fn,effects,detail}]}` (spec §3.3) — the machine-readable form CI/SARIF converters consume, from the SAME violations that set the exit code. A checked-in `.candor/config` (spec §3.4; `policy ` / `baseline ` / `deps `, one key per line, discovered walking UP from the scan target, relative values anchored to the config's repo) is the no-env-wiring floor; flag → env → config → default. **The AS-EFF-005 baseline guard** (spec §7): set `CANDOR_BASELINE=` (or the config `baseline` key) and the scan compares per function — a function that gained an effect versus the baseline fails the run (exit 1, `[AS-EFF-005]` lines, records join `--gate-json` with `origin`); ⟨0.40⟩ a function ABSENT from the baseline is compared against nothing (a new effectful function fails, `origin:"new"`; a new pure one passes; a new `Unknown`-only one is named in a note) — keys are `Module.fn`, so a renamed file reads as absent: run `candor diff ` before re-recording. Fail-closed: an unparseable baseline, or one from a different engine build, is invalid gate input — exit 2 WITHOUT evaluating (never a silent skip); an absent file is a note and the guard is inactive. `query diff` is the read-only twin: it DISCLOSES a build mismatch (⚠, exit 0) instead of failing — use the scan-time guard, not `diff`, as the CI gate. Semantics match the reference engine (candor-java). **Report shape:** the file is `{ "candor": {version, toolchain, spec}, "functions": [...] }`; `functions` is an **array** of entries (not a map — don't index it by name), each carrying **`fn`** — module-qualified, `.`-separated (`src.db.save` for `save()` in `src/db.ts`; class methods are `src.api.Client.send`; a function declared inside a TS `namespace` carries the namespace segments too — `src.util.Ns.helper` — in `fn` AND the callgraph/hierarchy keys, while its `hash` keeps the bare local name for cross-package joining; builds before 0.8.7 omitted the namespace segments, so an engine upgrade across that line is baseline-invalidating — regenerate saved reports) — with `inferred` (the full transitive set) / `direct` / `unresolved` / optional `hosts`/`cmds`/`paths`/ `tables` (the literal surfaces). **Only effectful-or-unresolved functions appear in the report; pure functions are omitted** — a function present in the callgraph sidecar but absent from `.functions[]` is pure (as far as the engine resolved). In *neither* file = never analyzed (a test file? an unexported arrow inside an object literal?) — conclude nothing. **A report with `analyzed.count: 0` is a run that FAILED, not a clean codebase.** A scan that exits 2 leaves that fail-closed shape (`functions: []`, `analyzed.count: 0`, a non-empty `unanalyzed`) at the `--out` report, and it DELETES that report's `.callgraph`/`.hierarchy`/`.locs` sidecars with it — so `callers`/`whatif` answer from an absent call graph rather than from the last successful run. Read the two together: **a sidecar whose report is one of these empties tells you nothing, whatever it says.** Re-scan; do not conclude from either half. ⟨0.28⟩ **And `callers`, `impact` and `path` SAY so in the machine channel over such a pair** — a document carrying `"unanswerable": ""` and NO answer keys, at **exit 2**, never an empty `direct` / `affectedCount: 0` / `path: []` at exit 0. All three resolve their target over the call graph, and each of those empties is the reassurance you asked the verb for: *nothing calls this*, *safe to change*, *it does not reach that effect*. With no call graph the engine knows none of it — so do not read `d.direct ?? []`, `d.affectedCount ?? 0` or `d.path ?? []` without checking `unanswerable` first. Over a REAL graph the negatives stand: a function with genuinely no callers still answers `direct: []`, one that affects nothing still answers `affectedCount: 0`, and one that does not reach an effect still answers `path: []`, all at exit 0 — those are determined negatives, and they are correct. ⟨0.28⟩ **A target NAME that does not resolve is a THIRD answer, and it is exit 2 as well** — `callers`, `impact` and `path` print `no function matching ''` on stderr instead of answering `direct: []` / `affectedCount: 0` / `path: []`. So a query gets one of three, and a typo gets the third, not the second: *there is no call graph* (an `unanswerable` document), *the graph says no* (a real empty answer, exit 0), and *there is no such function* (stderr, and NO `unanswerable` key — a graph WAS read). If you generate query targets, read exit 2 + `no function matching` as **fix the name**, never as a finding. ⟨0.28⟩ **And the REPORT-ONLY verbs — `where`, `map`, `blindspots`, `reachable`, `containment`, `tour` — carry the report's own ⟨0.21⟩ manifest instead**, because they have no call graph to be missing and their empty answer is the whole of the harm: `{"directly":[],"inherited":[]}`, `{}`, `{"sources":[],"totalUnknown":0}`, `{"entryPoints":0,"effects":{}}`, `{"contained":[],"ambient":{}}`, `{"reaches":[]}`. Over a report declaring a non-empty `unanalyzed` **or** `analyzed.count: 0` they add `"incomplete": true` (plus `unanalyzed` / `"judgedNothing": true`, naming which cause — the two want different repairs) to the SAME document, and the human arm withdraws its ✓. **The exit code does NOT move**: this rung is a caveat, not a refusal, so `if (!doc.incomplete)` is the guard and the exit is not. Same keys on the MCP tools (`candor_where`, `candor_map`, `candor_blindspots`, `candor_reachable`, `candor_containment`) and on the advisory verbs (`unverified`, `fix-gate`, `whatif`), which additionally OMIT `ok`. Over a COMPLETE report the answer is byte-identical to a pre-⟨0.28⟩ one — no key, no note — and `analyzed.count > 0` with `functions: []` is deliberately NOT hedged: that is a genuine all-pure claim (§2 rule 3) you should believe. A dist-CJS export unit (a `module.exports` surface scanned with `--allow-js`) carries `unitKind: "export"` (spec 0.8, informative); ordinary functions omit the field. **Multi-package (monorepos / private deps):** point `CANDOR_DEPS` at the dependencies' reports (a path list, or a directory of `*.json`); an unclassified call into a package with a loaded report inherits that function's recorded transitive effects and literal surfaces, joined by the report's `hash` (`package#LocalName`). A report produced by a different candor-ts version is downgraded to `Unknown` rather than silently trusted, and grants that package **no coverage** — so a key it does not answer falls back to the κ ledger's `invisible` hedge rather than reading pure (spec §2.1). **A report that declares itself INCOMPLETE** — a non-empty ⟨0.21⟩ `unanalyzed` — grants **no coverage** either, for the same reason read one step earlier: spec §2 rule 3 makes a report's silence a purity claim, and this one has just said it never read some of its own source. Its entries are kept unchanged (they were derived from source it *did* read); only its silence hedges, and an `import` backed only by such a report discloses `Unknown[incomplete-dep:]`. Chaining an incomplete report is therefore never worse than not chaining it. **And a report that JUDGED NOTHING** — ⟨0.24⟩ `analyzed.count: 0`, or no manifest and no entries, or a manifest too garbled to read — grants **no coverage** either: rule 3 turns a report's silence into a purity claim, and a report that judged nothing is all silence, so its package stays in the κ ledger exactly as if it were never chained. Keyed on the COUNT and never on `functions` being empty — `count: n > 0` with `functions: []` is a legitimate all-pure claim rule 3 says to believe, and it is untouched. The same reading binds every route a report arrives by (`gate --report`, MCP `candor_gate`, the LSP gate), each of which discloses the count-0 case rather than answering "no violations" flat; the verdict itself does not move, because the report gives no evidence of an effect and asserting one would be fabrication. Caveat: a type-only boundary (`import type` …, the tRPC style) has no runtime calls to inherit through — nothing to join. **Changing a report KEY is a wire change: bump `package.json`'s version in the same commit.** `candor.version` is the only discriminator §2.1 has, so two builds that disagree about a key while sharing a version string disarm it — and note what the bump does and does not buy. It downgrades the entries a report *carries*; it cannot conjure a key the report *lacks*, so an already-installed consumer that looks up a key this build no longer emits simply misses, whatever the version says. That direction is unfixable from the producing side (the consumer's code is frozen), which is why a stale report is denied coverage here: it is what stops the NEXT key change reading as a purity claim. ## Query it (same names/shapes as the Rust and JVM engines — candor-spec §3.1) ```sh Q() { npx -y -p candor-ts candor-ts-query "$@"; }; P=".candor/report" # a function — works in bash AND zsh Q show $P 1 # a function's effects (+ hosts/tables when visible) Q where $P 1 # {effect, directly, inherited} Q impact $P # THE BLAST RADIUS: {fn, affectedCount, affected, entryPoints} Q callers $P 1 # the lower-level form: {of, direct, transitive} — works for pure fns Q callers $P --include-unknown 1 # + possibleViaUnknownDispatch: the unresolved-dispatch frontier Q path $P # how a fn reaches an effect: the chain to the nearest source Q tour [N] --report $P # the N (default 10) most surprising transitive reaches Q map $P 1 # {module: {effects, functions}} Q containment $P [baseline-prefix] # §6.1 boundary-effect dispersion; with a baseline = AS-EFF-010 ratchet (exit 1 on a leak) Q blindspots $P # the Unknown SOURCES (fns with unknownWhy), ranked by Unknown blast radius Q whatif $P [policy] # pre-edit gate verdict (exit 1 if it would violate) Q fix $P # the boundary FIX: where the effect belongs + the hoist refactor Q unverified $P [--strict] # pure/deny layers that PASS but are Unknown (not PROVABLY clean) Q fix-gate $P # a fix for EVERY crossing — the loop's block-message remedy # ⟨0.24⟩ BOTH, and `fix`: an advisory verb may be LESS certain than # the gate, never MORE (SPEC §3.2). Where `gate --report` REFUSES a # narrowed rule for want of evidence, `unverified` NAMES the function # with the MISSING EVIDENCE as its reason (never a derived class), # `fix-gate`/`fix` offer NO remedy premised on it, both carry the # gate's `unevaluated:[{rule,why}]`, `ok` is OMITTED, `--strict` → 2. Q gate --report $P --policy [--json] [--gate-json ] # ⟨0.24⟩ apply a policy to an EXISTING report, NO scan (exit 0/1/2). # The supply-chain verb: gate a DEPENDENCY's published report. Reads # the report and nothing else — no callgraph sidecar, no chained dep, # no re-classification; an ABSENT entry stays absent (the ⟨0.21⟩ # purity claim). Its --gate-json is BYTE-EQUAL to `scan --policy`'s. # `forbid`, `allow`, and a class-scoped `deny` whose scoping datum # the report does not carry are REFUSED (exit 2), never evaluated on # partial evidence — gate those at scan time instead. Q diff $P 1 # per-function effect delta (exit 1 on a gained effect) Q gains $P # supply-chain alarm: {gained, byFunction} — effects a surface grew Q reachable $P 1 # what the app DOES at runtime: effects over the entry points Q parsepolicy # the canonical §6.2 parse (what the gate will enforce) ``` And as an MCP server, so an agent pulls these as tools instead of shelling out: `CANDOR_REPORT=$P npx -y candor-ts-mcp` (tools `candor_impact`/`candor_reachable`/`candor_where`/…, plus `candor_gate`/`candor_whatif`/`candor_fix` — a given-but-unreadable `policy` is a loud tool error, never a clean verdict — and `candor_activity`: what the edit-time gate CAUGHT, measured from `.candor/activity.jsonl` — edits checked, verdicts, violations by AS-EFF code, effects introduced, largest blast radius, deepest propagation — so you can self-inspect the loop without shelling out; a missing log is an empty result, not an error). `npx -y candor-ts-watch ` keeps the report fresh as you edit (and reports the edit-delta); `candor-lsp` serves the same report as CodeLens/hover/diagnostics in any LSP editor (and TAILS `.candor/activity.jsonl`: a new blocked gate record pushes the delta — gained effects, blast radius, deepest propagation, the AS-EFF cause — as a showMessage + a transient diagnostic on the edited files, cleared by the next clean record; `CANDOR_LSP_ACTIVITY=off` disables), plus two code actions (plain LSP — helix/neovim/VS Code/JetBrains-via-LSP4IJ all get them without client code): the pre-edit whatif (`candor: what if performed ?` → the `candor.whatif` command) and, when the cursor sits in a function that actually violates the policy, the boundary FIX (`candor fix: hoist out of ` → the `candor.fix` command: where the effect belongs + the hoist refactor, as a showMessage and a transient diagnostic, cleared on the file's next open/save). CAVEAT — the MCP/LSP gate verdicts are computed FROM THE REPORT: the engine's own `--policy` / `--gate-json` run additionally fails an allow rule whose literal surface is incomplete (a masked endpoint — internal state, not a report field), so treat a report-side green as advisory and the scan-time gate as the CI truth. ⟨0.21⟩/⟨0.30⟩/⟨0.32⟩ — where the REPORT cannot support a green verdict at all (a declared `unanalyzed`, an out-of-scope denied effect the producer's peek named, or a class the producing scan never OPENED), every route says so: `gate --report` exits 2, `candor_gate` and `candor_unverified` withhold `ok` and name the cause, and the LSP — which has neither an exit code nor a verdict document — discloses on `window/logMessage` + `window/showMessage`. **An empty editor is not an all-clear**: code the scan never read has no line to squiggle, so its absence from the diagnostics IS the incompleteness. Re-scan those sources WITH the policy. Name queries resolve exact > segment-suffix (`db.save` matches `src.db.save`, never `src.db.save_all`) > substring — the same ladder as the other engines. The trailing `1` is the want-JSON flag. - **Blast radius of editing a function** → `impact ` (the `affected` list + downstream `entryPoints`; NOT its `inferred`, which is what the function itself does). Works pre-edit for a still-pure function. `callers ` is the lower-level raw-callers form. - **Decide BEFORE you edit** → `whatif [policy]` — every transitive caller gains the effect, crossed with the policy. - **After you change code** → `diff` against a baseline report; a gained `Net`/`Db`/`Exec`/`Fs` you didn't intend is a regression in your change. ## TypeScript-specific things to know - **Arrow-const functions are first-class**: `export const f = async () => …` is analyzed and named like a declaration; calls to it are edges. An arrow assigned inside a function body becomes its own unit (`src.x.helper`) — effects still propagate to the enclosing caller through the edge. - **The classifier is curated** — the node builtins plus a growing npm tier; the README's "classifier" paragraph is the ONE current list (this file deliberately doesn't duplicate it — a vendored copy here drifted a full generation once). An unlisted npm package contributes nothing — an effect through it is invisible, not `Unknown`. The scanner **names these per scan**: the receipt's coverage-ledger line (marker: `classifier doesn't cover`) lists every npm package the code demonstrably calls that candor's classifier neither classifies nor has reviewed-pure — read it before concluding "no effect" through anything it names. ⟨0.32⟩ **NODE CORE is the exception, and it fails closed.** Node's builtins are a finite set, every module's surface has been reviewed, and a core member that is neither classified nor reviewed effect-free reads `Unknown[native:.]` rather than pure. `repl.start()`, `process.dlopen()`, `process.chdir()`, `v8.setFlagsFromString()` and `new worker_threads.Worker(file)` are holes you can see and `deny Unknown[native]` can select — and an API node adds next year is a hole too, until someone reviews it. - **`process.env.X` reads are `Env`** (a property read, not a call); `Date.now()` is `Clock`. - **DI-style code reads `Unknown` a lot, by design**: a function-typed parameter or field being called is genuinely indeterminate (rimraf's injected-fs style yields many `Unknown`s — that's the §4 disclosure contract, not noise). When every visible call site passes a *named* function, the callback resolves instead. And a method call on a **local-interface-typed value** (`store.save()` where `class PgStore implements Store`) resolves to the local implementors when the dispatch is narrow (≤12 classes) — the layered-DI pattern carries its real effects; only an interface with no visible implementor still reads `Unknown` (`dispatch:`). - **`unknownWhy` names each direct Unknown's origin** as `kind:detail` — triage starts at the named site. Inheritors carry `Unknown` with no why; follow the callgraph down to the root. The `kind` is drawn from §4's closed **five**-kind vocabulary — `reflect:` / `native:` / `dispatch:` / `callback:` / `ambiguous:` — of which candor-ts's language model produces three: `reflect:eval`, `reflect:defineProperty:dynamic-key` (TypeScript folds its native boundary into `reflect:`, so no bare `native:`), `callback:param#0`, and `dispatch:..` — the one **normative** detail shape, and what the `callers --include-unknown` frontier resolves overrides against. It also carries `no-node_modules:` for the *setup* class (a declared dep that isn't installed — a mis-configuration, not a blind spot). A kind candor-ts never emits can still appear in its output: a chained dependency's reasons are relayed verbatim, and every query verb reads other engines' reports — `ambiguous:` (name resolution was ambiguous, so no owner type was formed at all) is candor-rust's and arrives that way. Any kind outside the five round-trips untouched and classifies through the conservative catch-all (§2 forward-compatibility). - **`entryPoint: true` marks runtime-invoked roots** (Nest `@Get/@Post/…` handler methods, Next `route.ts` HTTP exports and `middleware`) — their effects are never orphaned; `reachable` unions over them. Pure entry points stay visible in the report. A worked policy (§6.2 — one rule per line, `#` comments): ```text deny Net domain # the domain module reaches no network, transitively pure parse allow Db in db orders ledger.* # the db module touches ONLY these tables forbid domain -> infra ``` Note the `pure` semantics: it forbids every *effect* but NOT `Unknown` — the §4 trust marker is uncertainty, not an effect (matching the reference engine, candor-java). Where a boundary must also exclude the unverifiable case, say so explicitly: `deny Unknown ` is the knob. ## The trust rule — do not skip this `inferred` is authoritative for what candor-ts resolved. When `unresolved` is true (or `Unknown` is present — a callback value, an `any`-typed callee, resolution landing on a type rather than a body), the set may be incomplete: read the source for *that* function before relying on it. Never conclude a function is pure while it is marked unresolved. The literal surfaces (`hosts`/`tables`/ `cmds`/`paths`) are the decidable subset only — absence is never a claim of absence. **And the curated-classifier caveat cuts the other way:** a call into an npm package the classifier doesn't cover contributes NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (the coverage ledger, marker: `classifier doesn't cover`), so the blind spots are per-scan evidence, not a doc footnote: never conclude "no effect" through a package that line names (the documented weaker edge of the never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO carries an `invisible` list — the uncovered packages it (transitively) reaches — so `inferred` is never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its `package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to the declared set instead of contributing nothing. - **⟨0.30⟩ A GREEN GATE CAN NOW EXIT 2 — read `outOfScope` before you trust a pass.** When a policy is configured, candor also reads the files the scan EXCLUDED (test files, build scripts, archives under the root, files outside the build's program) and reports any that perform an effect the policy DENIES, under the report's `outOfScope` key. A non-empty block makes the verdict `ok:false`, `incomplete:true`, exit 2 — *"I could not see enough of this tree to certify it"*, which is NOT the same as "your code violates": those functions are never in `violations`, because the gate did not judge them. Branch on `incomplete` to tell the two apart. An absent key means the producer was never asked (no policy at scan time), and an empty one means asked-and-clear.