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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- 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.
|