ambit-ts 0.1.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +403 -0
  4. package/dist/checker/authority.d.ts +13 -0
  5. package/dist/checker/authority.js +87 -0
  6. package/dist/checker/backend/legacy-ts.d.ts +26 -0
  7. package/dist/checker/backend/legacy-ts.js +1936 -0
  8. package/dist/checker/config.d.ts +84 -0
  9. package/dist/checker/config.js +391 -0
  10. package/dist/checker/coverage.d.ts +78 -0
  11. package/dist/checker/coverage.js +84 -0
  12. package/dist/checker/diagnose.d.ts +89 -0
  13. package/dist/checker/diagnose.js +734 -0
  14. package/dist/checker/index.d.ts +8 -0
  15. package/dist/checker/index.js +8 -0
  16. package/dist/checker/init.d.ts +38 -0
  17. package/dist/checker/init.js +205 -0
  18. package/dist/checker/propagate.d.ts +69 -0
  19. package/dist/checker/propagate.js +259 -0
  20. package/dist/checker/summarize.d.ts +27 -0
  21. package/dist/checker/summarize.js +411 -0
  22. package/dist/cli/analyze.d.ts +33 -0
  23. package/dist/cli/analyze.js +98 -0
  24. package/dist/cli/approvals.d.ts +28 -0
  25. package/dist/cli/approvals.js +55 -0
  26. package/dist/cli/diff.d.ts +66 -0
  27. package/dist/cli/diff.js +235 -0
  28. package/dist/cli/github.d.ts +33 -0
  29. package/dist/cli/github.js +41 -0
  30. package/dist/cli/main.d.ts +8 -0
  31. package/dist/cli/main.js +385 -0
  32. package/dist/cli/worktree.d.ts +75 -0
  33. package/dist/cli/worktree.js +154 -0
  34. package/dist/config.d.ts +12 -0
  35. package/dist/config.js +10 -0
  36. package/dist/core/approvals.d.ts +82 -0
  37. package/dist/core/approvals.js +0 -0
  38. package/dist/core/authority-diff.d.ts +98 -0
  39. package/dist/core/authority-diff.js +209 -0
  40. package/dist/core/authority.d.ts +109 -0
  41. package/dist/core/authority.js +50 -0
  42. package/dist/core/backend.d.ts +355 -0
  43. package/dist/core/backend.js +1 -0
  44. package/dist/core/budget.d.ts +61 -0
  45. package/dist/core/budget.js +95 -0
  46. package/dist/core/capability.d.ts +53 -0
  47. package/dist/core/capability.js +117 -0
  48. package/dist/core/config.d.ts +59 -0
  49. package/dist/core/config.js +10 -0
  50. package/dist/core/diagnostic.d.ts +126 -0
  51. package/dist/core/diagnostic.js +13 -0
  52. package/dist/core/effects.d.ts +39 -0
  53. package/dist/core/effects.js +72 -0
  54. package/dist/core/index.d.ts +13 -0
  55. package/dist/core/index.js +13 -0
  56. package/dist/core/location.d.ts +15 -0
  57. package/dist/core/location.js +1 -0
  58. package/dist/core/sql.d.ts +22 -0
  59. package/dist/core/sql.js +38 -0
  60. package/dist/core/summary.d.ts +240 -0
  61. package/dist/core/summary.js +8 -0
  62. package/dist/core/symbol-id.d.ts +25 -0
  63. package/dist/core/symbol-id.js +23 -0
  64. package/dist/index.d.ts +14 -0
  65. package/dist/index.js +14 -0
  66. package/dist/runtime/child-process.d.ts +29 -0
  67. package/dist/runtime/child-process.js +124 -0
  68. package/dist/runtime/context.d.ts +37 -0
  69. package/dist/runtime/context.js +8 -0
  70. package/dist/runtime/enforce.d.ts +52 -0
  71. package/dist/runtime/enforce.js +95 -0
  72. package/dist/runtime/fs.d.ts +46 -0
  73. package/dist/runtime/fs.js +188 -0
  74. package/dist/runtime/hono.d.ts +55 -0
  75. package/dist/runtime/hono.js +68 -0
  76. package/dist/runtime/index.d.ts +71 -0
  77. package/dist/runtime/index.js +126 -0
  78. package/dist/runtime/next.d.ts +95 -0
  79. package/dist/runtime/next.js +60 -0
  80. package/dist/runtime/pg.d.ts +48 -0
  81. package/dist/runtime/pg.js +122 -0
  82. package/dist/stubs/constructors.d.ts +34 -0
  83. package/dist/stubs/constructors.js +111 -0
  84. package/dist/stubs/data-clients.d.ts +9 -0
  85. package/dist/stubs/data-clients.js +109 -0
  86. package/dist/stubs/http-capabilities.d.ts +15 -0
  87. package/dist/stubs/http-capabilities.js +70 -0
  88. package/dist/stubs/mutating-builtins.d.ts +1 -0
  89. package/dist/stubs/mutating-builtins.js +48 -0
  90. package/dist/stubs/node-builtins.d.ts +2 -0
  91. package/dist/stubs/node-builtins.js +77 -0
  92. package/dist/stubs/pure-builtins.d.ts +1 -0
  93. package/dist/stubs/pure-builtins.js +89 -0
  94. package/docs/diagnostics/README.md +519 -0
  95. package/docs/limitations.md +712 -0
  96. package/package.json +89 -0
@@ -0,0 +1,712 @@
1
+ # Known limitations
2
+
3
+ Implementation status of `ambit check` — what the analysis actually sees
4
+ today, and where it stops. This file records current behavior, not design
5
+ intent; the specification is [DESIGN.md](DESIGN.md), and design-level open
6
+ questions live in its §12 (Open Questions).
7
+
8
+ Ambit is experimental. Expect this file to shrink as the analysis grows.
9
+
10
+ ## Contract tags
11
+
12
+ `@effects`, `@capabilities`, `@budget`, `@entrypoint`, and `@boundary` are all
13
+ parsed. `@effects` and `@capabilities` are checked statically; `@budget` is
14
+ validated but only `timeMs` is enforced, at runtime, through `withAmbit` or an
15
+ adapter's `ambitHandler` / `ambitRoute`.
16
+
17
+ The `@capabilities` check has two halves (DESIGN.md §4.4's dual enforcement):
18
+
19
+ - **caller → callee narrowing**, across undeclared functions;
20
+ - **a literal target** — an `http:<method>:<host>` read from a literal URL, or
21
+ from a template literal whose static head already ends the authority — which
22
+ must fall inside what the function was granted (`AMB-E009`).
23
+
24
+ A URL the source does not fix produces no comparable requirement. It is
25
+ reported as `AMB-W003` naming the runtime as the place it is matched, never
26
+ passed over. No target is derived for any other operation: DESIGN.md §4.4 is
27
+ explicit that hooking a database client does not amount to deciding
28
+ table-level permission for arbitrary SQL, so no `db:` capability is read out
29
+ of a statement.
30
+
31
+ A `withAmbit(spec, handler)`, or an adapter's `ambitHandler(spec, handler,
32
+ decode)` / `ambitRoute(spec, handler, decode)`, whose `capabilities` is a
33
+ literal array and whose `handler` names a
34
+ declaration in the same file *is* that handler's `@capabilities` (DESIGN.md
35
+ §4.4). `spec.budget` is the handler's `@budget` under the same conditions,
36
+ independently of the capability half. Writing the tag as well is still
37
+ allowed and still checked: the two disagreeing is `AMB-E010` / `AMB-E011`,
38
+ an error.
39
+
40
+ Two cases fall outside that, and in both the JSDoc tag is still required:
41
+
42
+ - **a spec Ambit cannot read** — a capability list built at runtime, a budget
43
+ that is not an object literal of literal limits;
44
+ - **a handler from another module** — the registration names no declaration in
45
+ the file, so there is no summary to attach the declaration to. This is
46
+ DESIGN.md §12's "Mapping contracts to handlers" (3), still open.
47
+
48
+ Either is reported as `AMB-W004`, whose message says that the handler's own
49
+ JSDoc is the only declaration there. Neither is silently treated as unknown:
50
+ an entrypoint left with no capability set is `AMB-W002` as well. Ambit has no
51
+ measurement of how often either case occurs in general code — in
52
+ `test/fixtures/realistic-api` every registration names a same-file handler.
53
+
54
+ Runtime enforcement covers `globalThis.fetch`, `node:fs`/`node:fs/promises`,
55
+ `node:child_process`, `pg`, and `@budget timeMs`. `costUsd` and `llmCalls` are
56
+ parsed and carried on the context, and nothing increments them.
57
+
58
+ ## Commands and flags
59
+
60
+ `ambit check`, `ambit init` and `ambit diff` are implemented, with `--format
61
+ json`, `--format github`, `--coverage`, `--strict`, and `init --config`.
62
+ `--format github` renders the same diagnostics as GitHub Actions workflow
63
+ commands, with the call path folded into the annotation body; on `check` it
64
+ changes no exit code. `ambit run`, `ambit agent`, `ambit stubs`, and `ambit
65
+ sbom` are planned, not built.
66
+
67
+ `ambit init` proposes `@effects` JSDoc. `ambit init --config` proposes an
68
+ `ambit.config.ts` entry instead, for the declarations no comment can carry —
69
+ and only by appending to an existing `contracts: {` block. It creates no
70
+ config file: the `defineConfig` import specifier depends on how the consumer
71
+ installed Ambit, and DESIGN.md §5.3 forbids emitting a patch that may not
72
+ apply.
73
+
74
+ ### What `ambit diff` can and cannot see
75
+
76
+ `ambit diff <ref>` compares the working tree's authority against a base ref
77
+ and fails on an increase that no approval covers (DESIGN.md §6.3).
78
+
79
+ It exists because `check` alone cannot catch a widened declaration. `check`
80
+ validates the code against whatever contract is currently written, so editing
81
+ the tag along with the code — including by applying the `widen` fix Ambit itself
82
+ offers — makes it green again. Measured on `test/fixtures/accident`, changing
83
+ `priceOrder` from `@effects pure` to `@effects network` takes
84
+ `check test/fixtures/accident` from exit 1 to exit 0, while
85
+ `diff HEAD test/fixtures/accident` exits 1 and names the hop that carried the
86
+ authority:
87
+
88
+ ```text
89
+ Authority increased in 1 symbol:
90
+
91
+ pricing.ts#priceOrder (pricing.ts:4)
92
+ + network
93
+ -> applyTax (tax.ts:3)
94
+ -> currentRate (rates.ts:3)
95
+ operation: fetch (rates.ts:4)
96
+ ```
97
+
98
+ Four things `diff` does not see, or sees differently from how a reader might
99
+ expect:
100
+
101
+ - **A function whose file git does not report as renamed reads as a deletion
102
+ plus a new symbol.** A symbol id is `<path relative to the checked
103
+ directory>#<declaration path>` (DESIGN.md §5.3), so
104
+ `src/tax.ts#calculateTax` and `src/pricing/tax.ts#calculateTax` are two
105
+ different symbols. `ambit diff` re-expresses the base side's ids under the
106
+ head side's paths for every rename `git diff --find-renames` reports, so an
107
+ ordinary file move is compared against itself and needs no approval. Two
108
+ cases are left, and each costs one approval line: a function renamed
109
+ *within* a file (git reports no rename, and matching two declaration paths
110
+ inside one file would be a guess about identity), and a move git does not
111
+ detect — because the edit that came with it fell under its similarity
112
+ threshold, or because the new path is not tracked yet, since rename
113
+ detection compares the index and the working tree against the base commit.
114
+ Both over-report rather than under-report, which is the direction §3.4
115
+ requires.
116
+ - **Gaining `unknown` is not an increase, because unknown is not authority.**
117
+ A call the analysis cannot resolve means the effect set may be incomplete
118
+ (DESIGN.md §4.3) — it does not mean the function acquired anything. `ambit
119
+ diff` reports the symbols that newly reach an unresolved call in their own
120
+ section and exits 0 on them alone. A range that stopped being analyzable is
121
+ never reported as "nothing increased here", but it does not fail a build
122
+ either. If that matters for a directory, `check --strict` is the tool that
123
+ makes an unresolved call an error.
124
+ - **A symbol with no declaration path never appears at all.** A function
125
+ Ambit could not extract — the `skipped` count in `--coverage`, and the
126
+ symbols `AMB-E003` names as having nowhere to hang a contract — has no
127
+ record on either side, so no comparison is made for it. Whatever authority
128
+ such a function gains, `ambit diff` is silent about it. The `skipped`
129
+ breakdown in `check --coverage` is the number to read alongside a green
130
+ diff.
131
+ - **It compares two trees, so it runs the analysis twice.** There is no cache
132
+ and no resident path (DESIGN.md §6.2 is a separate open question), and the
133
+ base side is a fresh `git worktree`. Measured on this repository, five runs
134
+ each: `check src` 1.07–1.11 s, `diff HEAD src` 1.86–1.98 s.
135
+
136
+ ### What an approval means, and what it does not
137
+
138
+ An increase passes when `ambit.approvals.md` gains a line naming it, in the
139
+ same change (DESIGN.md §6.3). Three limits of that are worth stating plainly:
140
+
141
+ - **Ambit does not know a person wrote the line.** An agent can write one as
142
+ easily as a reviewer can. What the mechanism supplies is the record and its
143
+ visibility in the pull request's diff; what supplies the person is the
144
+ repository's branch protection, and a `CODEOWNERS` entry naming the file so
145
+ that changing it needs an approver. Neither is something Ambit can check.
146
+ - **An approval says nothing about whether the increase is safe.** It says one
147
+ named increase was shown to whoever read the diff.
148
+ - **A malformed line grants nothing and does not fail on its own.** A `- ` line
149
+ under the `Approvals` heading that does not parse is reported with its line
150
+ number; the increase it was meant to approve stays unapproved, and that is
151
+ what fails. A line written *above* that heading is prose and is not reported
152
+ at all — which is what lets the file explain itself in a bullet list, and
153
+ also means an approval written in the wrong place is silently inert. The
154
+ increase still fails, so the failure is visible; the reason for it is one
155
+ line further away.
156
+
157
+ The working tree's `node_modules` is symlinked into the base checkout before
158
+ the base side is analyzed. Without it the two sides differ by their
159
+ environment rather than by their contracts: re-measured on 2026-09-10 against
160
+ commit `42addc9`, a checkout without `node_modules` reports 498 unresolved call
161
+ sites against 479 with it, and an `any-typed` reason (77 sites) that the side
162
+ with `node_modules` does not have at all — `external-module` 64 against 331,
163
+ `unresolved-symbol` 236 against 17.
164
+
165
+ ## `ambit.config.ts`
166
+
167
+ Out-of-code contracts (DESIGN.md §4.1) are implemented for all five tags. Two
168
+ things the specification mentions are not:
169
+
170
+ - **No `stubs` key.** A package's effect definitions still come only from the
171
+ bundled tables in `src/stubs/`; neither config nor a package-provided
172
+ `ambit.stubs.json` (§4.2) is read.
173
+ - **No price table**, so `@budget costUsd` is still never enforced (§4.5).
174
+
175
+ ### Symbols a config key cannot name
176
+
177
+ A `contracts` key is `"<file>#<symbol>"`, where `<symbol>` is the checker's
178
+ own declaration path. Anything with no stable declaration path cannot be named
179
+ — by a config key or by anything else — and stays reported as `AMB-E003` when
180
+ a contract is written on it, and counted under `--coverage`'s "skipped":
181
+
182
+ - an object-literal member with a **computed, string, or numeric key**
183
+ (`{ [KEY]: … }`, `{ "a.b": … }`, `{ 0: … }`) — the path is `"."`-joined, so
184
+ `{ "a.b": … }` would be indistinguishable from nesting
185
+ - any member of an object literal the notation cannot reach at all: a nested
186
+ literal, one bound by `let`, one carrying a spread, one declared inside a
187
+ function body, or one passed inline as an argument
188
+ - a **callback passed inline as an argument** (`xs.map((x) => …)`)
189
+ - a **function declared inside another function**
190
+ - a **named `export default`** is *not* in this list — it has its identifier
191
+ name and is named that way; only the anonymous form uses `default`
192
+
193
+ The reverse case is a config-only namespace: three kinds of declaration have a
194
+ path but nowhere to write a comment, so a key is the *only* way to declare
195
+ them.
196
+
197
+ | Declaration | Key | JSDoc |
198
+ |---|---|---|
199
+ | `get x()` / `set x()`, on a class or a module-scope `const` literal | `Cls.get x` / `Cls.set x` | inert — `AMB-E003` |
200
+ | anonymous `export default` | `default` | inert — `AMB-E003` |
201
+ | a class with no constructor | `Cls.constructor` | no declaration site at all |
202
+
203
+ Keeping JSDoc closed on the first two is a decision, not a limit of the
204
+ analysis: the comment is syntactically attachable, and DESIGN.md §4.1 (a)
205
+ records why it is refused and §12 records that the asymmetry is open.
206
+
207
+ ### Matching
208
+
209
+ `<file>` accepts `*` (within one path segment) and `**` (across directories);
210
+ `<symbol>` accepts neither. An exact key always beats a glob; two globs
211
+ matching one symbol stop the run with exit 2 rather than picking one. An exact
212
+ key that matches nothing is `AMB-W006`; a glob that matches nothing is silent,
213
+ because a glob covering a directory this run did not check is normal.
214
+
215
+ The config is found by walking up from the directory passed to `check` /
216
+ `init`, stopping after the first directory holding a `package.json` or `.git`.
217
+ It is **loaded by importing it**, not by parsing it: a config that throws on
218
+ import is exit 2, and a `contracts` object built by an expression rather than
219
+ written literally works for matching but has no line a diagnostic can point
220
+ at (`AMB-W006` then falls back to the file's first character, and `init
221
+ --config` finds no `contracts: {` line to append to).
222
+
223
+ ## Effect inference
224
+
225
+ Effects are inferred from four bundled tables.
226
+
227
+ ### The stub table (`src/stubs/node-builtins.ts`)
228
+
229
+ 52 entries — `fetch`, `undici`'s `fetch`, plus Node.js builtins — producing
230
+ `network`, `fs_read`, `fs_write`, and `process`.
231
+
232
+ Matching is import-shape sensitive. Lookup keys are built from the *module
233
+ specifier text* plus the imported property/export name, so:
234
+
235
+ - `import * as fs from "node:fs"; fs.writeFileSync(...)` is recognized
236
+ - `import fs from "node:fs"; fs.writeFileSync(...)` is recognized
237
+ - `import { writeFileSync } from "node:fs"; writeFileSync(...)` is recognized
238
+ (a local `as` alias doesn't affect matching — the imported name is used)
239
+ - a binding re-exported through one or more barrel files is followed to the
240
+ module that owns it, so `import { readFileSync } from "./lib/index.ts"` is
241
+ still `node:fs.readFileSync`
242
+
243
+ The re-export walk takes the deepest **bare** specifier it passes through, not
244
+ simply the deepest one: a package's own types re-export internally
245
+ (`export { helper } from "./internal.js"`), and a path inside a package means
246
+ nothing outside it. A binding reached by destructuring a *value*
247
+ (`const { readFile } = fs`) is not followed at all.
248
+
249
+ ### The database and LLM client table (`src/stubs/data-clients.ts`)
250
+
251
+ 35 rules covering `pg`, `mysql2`, `@prisma/client`, `openai`, and
252
+ `@anthropic-ai/sdk`, producing `db_read`, `db_write`, and `llm`. Keys are the
253
+ module specifier the client's class was imported from, the class name, and the
254
+ property path written at the call site — `pg.Pool.query`,
255
+ `@prisma/client.PrismaClient.user.findMany`,
256
+ `openai.OpenAI.chat.completions.create`. Every part comes from the project's
257
+ own source, so a locally written `declare module "pg"` and an installed `pg`
258
+ produce the same key.
259
+
260
+ Two limits follow from that:
261
+
262
+ - The receiver must be a `const` whose initializer is `new <ImportedClass>(…)`,
263
+ followed through imports and re-exports. A client held in a class field,
264
+ bound with `let`, or returned by a factory is not matched and reports
265
+ `unknown`.
266
+ - Only these five packages are covered. Drizzle, MongoDB, Redis, an S3 client,
267
+ a queue client — all `unknown`.
268
+
269
+ `pg`'s and `mysql2`'s `query`/`execute` take a statement whose direction is not
270
+ always fixed by the source. A literal statement (or a template literal whose
271
+ static head reaches the first keyword) is classified by that keyword; anything
272
+ else contributes **both** `db_read` and `db_write`. The cost is real: a
273
+ read-only function that builds its statement dynamically has to declare
274
+ `db_write` too. The alternative would let a generated `UPDATE` pass a
275
+ `@effects db_read` contract. DESIGN.md §4.2 records the decision.
276
+
277
+ ### The pure built-ins allowlist (`src/stubs/pure-builtins.ts`)
278
+
279
+ A separate, smaller table allowlists default-lib methods reached through a
280
+ local value (`set.has(...)`, `arr.map(...)`). These have no import binding for
281
+ the stub table to key on, so they are named by their default-lib type and
282
+ method (`Set.has`, `Array.map`) in a namespace kept separate from the
283
+ module-specifier one.
284
+
285
+ It is deliberately narrow:
286
+
287
+ - Anything that mutates is excluded — `Array.push`, `Array.sort`, `Map.set`,
288
+ `Set.add`. Those live in a separate table, `src/stubs/mutating-builtins.ts`,
289
+ because a name alone does not decide their effect: mutating a value the
290
+ function itself allocated carries none, and mutating anything reachable from
291
+ outside is `state_write` (DESIGN.md §4.2, "Local mutation and `pure`"). What
292
+ counts as "allocated here" is deliberately narrow — a `const` bound to an
293
+ array literal, object literal, or `new` expression inside the function — and
294
+ every other receiver, including a `let` binding nothing reassigns, is
295
+ over-approximated to `state_write`. A `this` is local in two cases only: a
296
+ constructor of a class with no `extends` clause (with `erasableSyntaxOnly`
297
+ there are no parameter properties, so `this.x = x` is the only way to write
298
+ a field), and a function that is the direct operand of `new`. There is no
299
+ alias analysis: a fresh value handed to something else and mutated
300
+ afterwards still reads as local.
301
+ - A method that can take a callback (`map`, `filter`, `reduce`, …) is trusted
302
+ only when that callback is written inline. `arr.map(x => ...)` is walked and
303
+ its effects attributed to the enclosing function; `arr.map(namedFn)` passes
304
+ a callback Ambit never sees, so the call stays `unknown` even though
305
+ `Array.map` itself is allowlisted.
306
+
307
+ ### Call resolution
308
+
309
+ A call is followed to its target only when the callee's declaration is one the
310
+ backend extracts. `handlers.read()` is resolved through the receiver's *value*
311
+ rather than its static type, so a type annotation on `handlers` does not change
312
+ the outcome — what matters is whether one object literal certainly stands
313
+ behind the receiver. These are followed:
314
+
315
+ - `const handlers = { read() { … } }` and `{ read: () => … }` — the member is
316
+ extracted and has its own contract
317
+ - `const handlers = { read: readIt }` and `{ readIt }` — the member names an
318
+ already-extracted function, and the call resolves to that function
319
+ - the same with `satisfies` or `as const`, which assert a type without changing
320
+ the value
321
+ - a class instance method (`client.read()`)
322
+
323
+ These are not:
324
+
325
+ - a receiver with no single literal behind it — a parameter (`function f(d: D)
326
+ { d.run() }`), a class property, an import of a value built elsewhere. Any
327
+ object satisfying the type could arrive at runtime.
328
+ - a `let` or `var` receiver, which may hold a different object by the time the
329
+ call runs
330
+ - a literal containing a spread, which can carry members this analysis cannot
331
+ enumerate
332
+ - a member with a computed, string, or numeric name (`{ ["a-b"]: … }`,
333
+ `{ "x y"() { … } }`) — there is no declaration path for it, see Function
334
+ extraction below
335
+ - a literal that is not a module-scope `const`'s own initializer — one nested in
336
+ another literal, declared inside a function body, or passed inline as an
337
+ argument
338
+ - a nested function declaration — one declared inside another function's body
339
+
340
+ A method on a database or LLM client is a separate path: it is never followed
341
+ to a declaration (the declaration is in a `.d.ts`), it is *named* from the
342
+ receiver's origin and matched against the client table above. A method on any
343
+ other value reached the same way — `(await fetch(url)).json()`, an SDK type
344
+ Ambit ships no rules for — gets no name and stays `unknown`. Where that
345
+ declaration lives is reported as the unresolved reason: `builtin-method` for the
346
+ compiler's own lib, `external-module` for an installed package, and
347
+ `ambient-declaration` for a `.d.ts` the project wrote itself.
348
+
349
+ **This is not soundness.** `const` freezes the binding, not the properties, so
350
+ `handlers.read = other` still defeats it. Resolving a class instance method
351
+ rests on exactly the same assumption; following the value adds no new one, and
352
+ neither is a guarantee.
353
+
354
+ An unfollowed call keeps the "unknown stays unknown" property: it is reported as
355
+ unresolved, becomes `unknown` in the enclosing function, and raises `AMB-W001`
356
+ if that function declares a contract. It is counted under `unresolved-symbol` in
357
+ `--coverage` — or under the more specific reason the callee's own declaration
358
+ gives (`builtin-method`, `external-module`, `ambient-declaration`). It is
359
+ counted *without a name* unless one could be built, and a name is only built
360
+ for a bare identifier, or a property access whose receiver traces back to an
361
+ import or to a `const` constructed from an imported class. A call through a
362
+ parameter, a class field, or a `let` gets no name and appears nowhere but the
363
+ reason counts. See Reading `--coverage` below.
364
+
365
+ ### Higher-order functions
366
+
367
+ Inferring a callback's effects from the argument passed at the call site is
368
+ not implemented. A call through a callback parameter falls back to `unknown`.
369
+
370
+ ### `new X(...)`
371
+
372
+ Construction is part of the call graph. Every class is indexed under the
373
+ declaration path `Class.constructor`, and a construction resolves to it:
374
+
375
+ - `new X(...)` on a project class, whether or not the class writes a
376
+ constructor
377
+ - `super(...)`, and the implicit base call a derived class makes when it
378
+ writes no constructor of its own
379
+ - a class's property initializers and its constructor's parameter defaults,
380
+ which run as part of the same construction and are attributed to the same
381
+ `Class.constructor` entry
382
+
383
+ A construction of a class Ambit cannot name — an anonymous class expression,
384
+ or a class declared inside a function body — stays `unresolved`, so the
385
+ enclosing function becomes `unknown` rather than silently effect-free.
386
+
387
+ `new Function(...)` is reported as an `eval`-like unresolved call, as before.
388
+
389
+ An *external* construction is matched against a bundled constructor table
390
+ (`src/stubs/constructors.ts`), keyed in its own namespace so that `URL(...)`
391
+ and `new URL(...)` never share an entry. The table is small and split three
392
+ ways:
393
+
394
+ - always effectful: `node:net.Socket`, `node:tls.TLSSocket`,
395
+ `node:http.Agent`, `node:https.Agent`, `WebSocket` (`network`);
396
+ `node:worker_threads.Worker` (`process`)
397
+ - effectful only with no arguments: `new Date()` reads the clock (`env`),
398
+ while `new Date(2020, 0, 1)` only converts its arguments
399
+ - known effect-free: the standard collections, typed arrays, error types,
400
+ `Promise`, `RegExp`, `URL`, `AbortController`, and similar
401
+
402
+ Anything not in that table — `new PrismaClient()`, for one — is `unknown`,
403
+ not effect-free. Constructing a client is a different question from calling
404
+ one: the client table names `prisma.user.findMany()`, and says nothing about
405
+ whether `new PrismaClient()` opens a connection. In practice clients are
406
+ constructed at module scope, outside any function, where there is no call site
407
+ to attribute; construct one inside a function and that function is `unknown`.
408
+
409
+ `new Promise(namedExecutor)` is also `unknown` rather than effect-free: the
410
+ executor runs immediately and its body was never walked, the same rule that
411
+ applies to `arr.forEach(handler)`.
412
+
413
+ A class's own JSDoc is never read as its implicit constructor's contract. A
414
+ contract belongs on a declaration, and an implicit constructor has none;
415
+ `/** @effects pure */ class C {}` documents the class. Writing a contract tag
416
+ there is reported as `AMB-E003` rather than ignored, and `ambit init` reports
417
+ such a class with no patch attached — the effects are real, but only writing
418
+ an explicit constructor gives them somewhere to be declared.
419
+
420
+ ## `@boundary` and the coverage numbers
421
+
422
+ A `@boundary` function's body is excluded from propagation, so it leaves the
423
+ `unknown` numerator without ever having been checked. `--coverage` therefore
424
+ prints `boundary-rate` on the same line as `unknown-rate`, over the same
425
+ denominator: the two together are the fraction of functions whose contract is
426
+ not backed by an analyzed body. Reading `unknown-rate` alone would show
427
+ "declare more boundaries" as an improvement.
428
+
429
+ For the same reason a boundary's own call sites are left out of the
430
+ `call-sites:` and `unresolved-by-reason:` lines. Those measure how well
431
+ analysis resolves what it looks at, and a boundary is code it deliberately
432
+ does not look at; including it would also pad `top-unresolved-names`, the
433
+ "what to stub next" signal, with names no stub would help.
434
+
435
+ ## Function extraction
436
+
437
+ The set of function-like nodes that can carry their own `@effects` contract
438
+ covers:
439
+
440
+ - named function declarations
441
+ - class methods
442
+ - variable-bound function and arrow expressions
443
+ - a class's construction, under `Class.constructor` — a written constructor
444
+ carries the contract; a class with no constructor has no declaration site
445
+ for one
446
+ - members of a module-scope `const` object literal, when the member has an
447
+ identifier name — `const handlers = { read() { … } }` gives `read` the id
448
+ `handlers.read`, the same declaration-path notation a class method uses
449
+
450
+ Two more shapes are extracted and propagate, but can only be *declared* from
451
+ `ambit.config.ts` (DESIGN.md §4.1 (a)): a `get`/`set` accessor, under
452
+ `Cls.get x` / `Cls.set x`, and an anonymous `export default`, under `default`.
453
+ The accessor case follows the same reachability rule as the bullet above it —
454
+ a class member, or a member of a module-scope `const` object literal — so an
455
+ accessor in a literal that rule does not reach is skipped, not extracted. A
456
+ contract comment on any of these is still `AMB-E003`; see "`ambit.config.ts`"
457
+ above.
458
+
459
+ A call inside any other function-like node — an object-literal member the
460
+ notation cannot name, a nested function declaration, an inline callback
461
+ argument, or anything else with no extracted ancestor — is still walked, and
462
+ its effects are attributed to the nearest enclosing *extracted* function. Such
463
+ a call is invisible only when no extracted ancestor exists.
464
+
465
+ The identifier-name restriction is not arbitrary. A declaration path is
466
+ `"."`-joined, so a computed, string, or numeric key has no spelling that
467
+ survives it: `{ "a.b": … }` would be indistinguishable from nesting. The same
468
+ rule already limits class-method extraction.
469
+
470
+ `ambit check --coverage` reports these nodes as "skipped", broken down by
471
+ kind: `getter-setter`, `object-literal-method`, `anonymous-default-export`,
472
+ `callback-argument`, `nested-function`, `bodyless-declaration`, and a residual
473
+ `other`. `bodyless-declaration` is a signature with no code — an overload
474
+ signature, an `abstract` member, or a `declare function` in a `.ts` file. An
475
+ overload set is one function and it is the implementation, so the signatures
476
+ are skipped and the implementation is extracted; a contract on a signature is
477
+ `AMB-E003` naming the implementation. The first and
478
+ third are narrower than they were: an accessor is extracted when it is a class
479
+ member or a member of a module-scope `const` object literal, so `getter-setter`
480
+ now counts only accessors in the literals that notation cannot reach (a `let`
481
+ binding, a spread, a nested literal, one inside a function body, one passed
482
+ inline). `anonymous-default-export` likewise counts only the forms `default`
483
+ does not cover. "Skipped"
484
+ means the node cannot declare a contract of its own — not that its effects go
485
+ unseen. `object-literal-method` is now narrower than the kind's name suggests:
486
+ it covers members of the literals Call resolution above rules out — a
487
+ non-identifier key, a `let` binding, a spread, a nested literal, or a literal
488
+ declared inside a function body or passed inline as an argument.
489
+
490
+ Writing a contract on a skipped node is reported as `AMB-E003` rather than
491
+ ignored — see `docs/diagnostics/README.md`.
492
+
493
+ Carrying a contract and being reachable as a call target are gated by the same
494
+ rule for object-literal members, so those two lists coincide there. They still
495
+ differ elsewhere: a call through a parameter-typed receiver cannot be followed
496
+ even when the member it would reach is extracted and declares a contract, and
497
+ an inline callback argument's calls are attributed to its enclosing function
498
+ even though the callback itself can declare nothing.
499
+
500
+ ## Reading `--coverage`
501
+
502
+ Ambit run against its own `src/` (2026-09-09, four functions declaring
503
+ `@effects`):
504
+
505
+ ```console
506
+ $ node src/cli/main.ts check src --coverage
507
+ warning: extractProject declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:43)
508
+ warning: loadProjectConfig declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:152)
509
+ warning: collectTsFiles declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:199)
510
+ warning: main declares fs_read but calls something that could not be resolved (cli/main.ts:37)
511
+ files=29 functions=238 declared=4
512
+ declared-by: jsdoc=4 config=0
513
+ unknown-rate=66.0% (157/238 functions) boundary-rate=0.0% (0/238 functions)
514
+ entrypoints=0 (without-capabilities=0)
515
+ skipped=109 (callback-argument=91, object-literal-method=3, nested-function=15)
516
+ call-sites: total=1287 resolved=406 stub=8 pure=327 mutation=110 unresolved=436
517
+ unresolved-by-reason: builtin-method=102, external-module=314, dynamic-import=1, unresolved-symbol=15, callback-parameter=4
518
+ top-unresolved-names: typescript.isIdentifier=22, ReadonlyArray.map=13, ...
519
+ ```
520
+
521
+ No `bodyless-declaration` appears because `src/` contains no overload
522
+ signature, `abstract` member, or `.ts`-file `declare`.
523
+
524
+ - `unknown-rate` — the share of extracted functions whose effects could not be
525
+ fully determined.
526
+ - `mutation` — in-place mutation sites, counted apart from `pure`: a local one
527
+ carries no effect but is not the same evidence as a call proven pure, and an
528
+ escaping one is a `state_write` no stub table produced.
529
+ - `skipped` — function-like nodes that cannot carry a contract, by kind (see
530
+ above).
531
+ - `unresolved-by-reason` and `top-unresolved-names` are the signal for what to
532
+ stub next. `Array.push` and `Map.set` dominating the list here reflects the
533
+ mutating-method exclusion described above.
534
+ - `top-unresolved-names` lists only calls a textual name could be built for,
535
+ and only the ten most frequent. An unresolved call with no name raises the
536
+ `unresolved-symbol` count and appears nowhere else, so this list is not a
537
+ complete picture of what is unresolved.
538
+
539
+ This number is not a target that has been met, and it is dominated by
540
+ `external-module` — almost entirely calls into the TypeScript compiler API from
541
+ the one file meant to be replaceable. `ROADMAP.md`'s goal of 30% is for an
542
+ *adopting team*. `docs/status.md` records that figure separately, measured
543
+ against `test/fixtures/realistic-api`, and the two must not be mixed.
544
+
545
+ The summary line (`files= functions= declared=`) is printed on every run, with
546
+ or without `--coverage`, so a check that analyzed nothing is never
547
+ indistinguishable from a check that found no violations.
548
+
549
+ ## Runtime hooks
550
+
551
+ Four hooks enforce `@capabilities` at run time: `installFetchHook()`,
552
+ `installFsHook()`, `installChildProcessHook()` and `installPgHook(pg)`. Each
553
+ returns the function that restores what it replaced. Everything below is what
554
+ they do **not** cover.
555
+
556
+ ### What is hooked, and at which version
557
+
558
+ | Hook | Target | Versions |
559
+ |---|---|---|
560
+ | `installFetchHook` | `globalThis.fetch` | the Node.js runtime Ambit supports (`engines.node`) |
561
+ | `installFsHook` | `node:fs`, `node:fs/promises` | same |
562
+ | `installChildProcessHook` | `node:child_process` | same |
563
+ | `installPgHook` | `pg`'s `Pool.prototype.query`, `Client.prototype.query` | `pg` 8.x — verified against `pg@8` in `test/e2e.runtime.test.ts` |
564
+
565
+ Not hooked at all, and therefore neither blocked nor recorded: `mysql2`,
566
+ `@prisma/client`, `drizzle-orm`, `mongodb`, `openai`, `@anthropic-ai/sdk`,
567
+ the Vercel AI SDK, `node:http`/`https`/`net` (their effects are inferred
568
+ statically, but no runtime hook replaces them), and every other client. Ambit
569
+ has no way to notice that an upstream release moved a patch point; the `pg`
570
+ row above is a claim about the version tested, not about future ones
571
+ (DESIGN.md §12).
572
+
573
+ ### Install order decides what a builtin hook covers
574
+
575
+ A builtin's ESM namespace is a snapshot of its properties taken when that
576
+ builtin is first `import`ed anywhere in the process.
577
+
578
+ - Installed from a preload (`node --import ./ambit-hooks.mjs app.js`), before
579
+ the application's module graph is linked, the hooks cover every form:
580
+ `import { readFileSync } from "node:fs"`, `import fs from "node:fs"`, and
581
+ `require("fs")`.
582
+ - Installed from inside the module graph — a call at the top of the entry
583
+ module — they cover `fs.readFileSync()` through the default export and
584
+ through `require("fs")`, but **not** a named import
585
+ (`import { readFileSync } from "node:fs"`) or `import * as fs`, which are
586
+ already bound to the original function.
587
+
588
+ Neither mode covers a native addon, code inside a child process, or another
589
+ `worker_threads` worker: a worker needs its own install.
590
+
591
+ ### Node's own module loader reads through the hook
592
+
593
+ Node reads module sources with the public `fs.readFileSync`. After
594
+ `installFsHook()`, a `require()` or a dynamic `import()` therefore goes
595
+ through the capability check like any other read. Under
596
+ `setUnscopedPolicy("deny")`, or inside a `withAmbit` context that grants no
597
+ `fs:read`, a lazily loaded module is denied. Load what you need before
598
+ installing the hook, or grant `fs:read:` for the directories that hold the
599
+ code.
600
+
601
+ ### What a target can and cannot say
602
+
603
+ - **Paths** are resolved to absolute at the call (`path.resolve`,
604
+ `fileURLToPath`, `Buffer` decoded), so a grant is written as an absolute
605
+ path glob. `*` crosses `/`: `fs:read:/srv/app/*` also covers
606
+ `/srv/app/a/b.txt`. There is no way to grant exactly one directory level.
607
+ - **File descriptors** carry no path. `fs.readSync(fd)` is not checked; the
608
+ check happened at `open`, from the flags. A descriptor obtained before the
609
+ hook was installed is never checked.
610
+ - **A shell spawn names the shell.** `exec`, `execSync` and `shell: true`
611
+ give `proc:spawn:/bin/sh` (or `options.shell`). Which program the command
612
+ string runs is not decidable without a shell parser, so a grant for the
613
+ shell permits any program the shell can start — the exception message says
614
+ this.
615
+ - **A `pg` target is the database, never a table.** Ambit does not read table
616
+ names out of SQL, so `db:read:users` — which reads like a table grant — is
617
+ not what the `pg` hook matches; it matches `db:read:<database>`.
618
+ Table-level `db:` targets have meaning only in the static narrowing rule
619
+ (`AMB-E005`). When the connection names no database, the requirement
620
+ becomes `db:read:unknown`, which only a target-agnostic grant
621
+ (`db:read:*`) covers.
622
+ - **An opaque statement requires both directions.** A `query` whose leading
623
+ SQL keyword is not readable — a `Submittable`, a config object without
624
+ `text` — requires `db:read:` *and* `db:write:`.
625
+
626
+ ### `costUsd` and `llmCalls` are still not enforced
627
+
628
+ No hook increments them, and none of the four hooks changes that. They are
629
+ parsed, validated, and carried on the context for an adapter to use.
630
+
631
+ ## Framework adapters
632
+
633
+ Two adapters exist: `ambitHandler` from `ambit-ts/runtime/hono` and `ambitRoute`
634
+ from `ambit-ts/runtime/next`. Express, BullMQ, `worker_threads` and the rest have
635
+ none, and a handler they register establishes no Ambit context.
636
+
637
+ Both are declared the same way: a devDependency here and a type-only import,
638
+ so the published package depends on neither `hono` nor `next`. Both enforce the
639
+ same thing — the capability set and `@budget` of the route they register, for
640
+ the handler and its `decode`.
641
+
642
+ | Adapter | Verified against | By |
643
+ |---|---|---|
644
+ | `ambit-ts/runtime/hono` — `ambitHandler` | `hono@4`, `@hono/node-server@1` | `test/runtime.hono.test.ts` in process, `test/e2e.runtime.test.ts` through a real server and a real socket, `test/e2e.install.test.ts` through the installed package |
645
+ | `ambit-ts/runtime/next` — `ambitRoute` | `next@16`, Node.js runtime only | `test/runtime.next.test.ts` — the exported Route Handler called directly with a real `NextRequest`, which is what Next.js does with it; `test/e2e.next-app.test.ts` for an `app/**/route.ts` project through `ambit check`; `test/e2e.install.test.ts` type-checks README's route and `instrumentation.ts` snippets against the installed package |
646
+
647
+ What the Next.js row does **not** claim: no test starts a `next` server
648
+ process, so the adapter is verified as a Route Handler function, not as a
649
+ running Next.js application. Nothing is verified on the Edge runtime.
650
+
651
+ Limits of what the adapters guarantee:
652
+
653
+ - **Only the route it registers.** `app.get(path, handler)` written without
654
+ `ambitHandler` establishes no context, so operations inside it are decided by
655
+ `setUnscopedPolicy` (`allow` by default) — not by the handler's JSDoc, which
656
+ the runtime never reads. The adapter does not scan the app for unwrapped
657
+ routes, and nothing reports one.
658
+ - **Middleware ordering.** The context exists only inside the wrapped handler.
659
+ Middleware registered with `app.use` runs *outside* it — before and after —
660
+ so anything a middleware does is unscoped even when the route it fronts is
661
+ wrapped. Middleware that runs `next()` and then touches a hooked API is
662
+ therefore not covered by that route's capabilities.
663
+ - **A contract that is not found is not a denial.** There is no lookup that can
664
+ fail: the contract is the `spec` argument. A missing contract means a missing
665
+ registration, which the adapter treats as "no context", never as "no
666
+ capabilities" — an empty grant would make a forgotten route look like a
667
+ policy decision.
668
+ - **Errors are not HTTP statuses.** `AmbitCapabilityError` and
669
+ `AmbitBudgetError` reach the framework's error handler (Hono's default: a
670
+ bare 500). Nothing maps them to 403 or 504, and the message — which names the
671
+ granted set — is not put in a response body by Ambit.
672
+ - **The wrapped route returns a plain `Response`**, so Hono's RPC type
673
+ inference (`hc`) sees `Response` rather than the handler's return shape.
674
+ - **`timeMs` includes `decode`**, which runs inside the context: the time spent
675
+ reading a request body counts against the budget.
676
+ - **Only `app/**/route.ts`, registered through `ambitRoute`.** Next.js runs
677
+ code down several paths, and the adapter reaches one of them. Server Actions
678
+ (`"use server"`) are not route modules and have no registration call a `spec`
679
+ could ride on; `middleware.ts` runs on the Edge runtime and outside every
680
+ route module; the Pages Router (`pages/api/*`) has a different handler shape.
681
+ None of the three has an adapter, none establishes an Ambit context, and
682
+ `setUnscopedPolicy` decides what operations inside them do — `allow` by
683
+ default. Nothing reports a handler on those paths as unregistered, the same
684
+ way nothing reports an unwrapped Hono route.
685
+ - **The Edge runtime is not enforced.** A Next.js route that sets
686
+ `export const runtime = "edge"` leaves the Node.js runtime, and every hook
687
+ Ambit installs is a Node.js hook: `installFsHook` and
688
+ `installChildProcessHook` wrap `node:fs` and `node:child_process`, which do
689
+ not exist there, and the `register()` README documents installs nothing
690
+ unless `process.env.NEXT_RUNTIME === "nodejs"`. **No capability is checked on
691
+ an Edge route.** Nothing further about `ambitRoute` there is claimed either —
692
+ no test runs on the Edge runtime, so whether the context is established at
693
+ all is unverified. DESIGN.md §12 "Edge runtimes" guarantees the Node.js
694
+ runtime only in Phase 1. Leaving an Edge route unwrapped is the honest form:
695
+ a registration that reads as enforced and is not would be worse than none.
696
+ - **A file that imports `ambit-ts/runtime/<framework>` does not type-check after
697
+ `npm remove ambit-ts`.** P5 (DESIGN.md §2, "allow backing out at any time")
698
+ guarantees that the JSDoc contracts survive removal — they are comments on
699
+ ordinary TypeScript, and nothing reads them at run time. The adapter call is not
700
+ covered by that: `ambitHandler(spec, handler, decode)` is a value imported
701
+ from Ambit, so removing the package leaves an unresolved import and a route
702
+ registration with no replacement. Backing out of an adapted route means
703
+ editing the source — replacing each `ambitHandler(...)` with the framework's
704
+ own handler — not only deleting a dependency.
705
+
706
+ ## Backend
707
+
708
+ The analysis backend (`src/checker/backend/legacy-ts.ts`) is a connection layer
709
+ over the TypeScript compiler API. It is the adopted default (DESIGN.md §3.5,
710
+ ADR-0001), and it stays replaceable: the conditions that would reopen the choice
711
+ are written down in §3.5, and no performance number is claimed for a backend
712
+ that has not been run.