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
package/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ Notable changes to `ambit-ts`. The format is
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the versioning is
5
+ [semantic versioning](https://semver.org/spec/v2.0.0.html), with 0.x read as
6
+ semver defines it — while the major version is 0, a **minor** release may make a
7
+ breaking change (`docs/DESIGN.md` §9.3).
8
+
9
+ What a release must announce here is `docs/DESIGN.md` §9.2's **guaranteed
10
+ surface**. Changes outside that list — added stubs, added runtime hooks,
11
+ `unknown`-rate movement, caching — are deliberately absent. What is implemented
12
+ and what is not is [`docs/status.md`](docs/status.md); this file is not a status
13
+ report.
14
+
15
+ ## [Unreleased]
16
+
17
+ ## [0.1.0] — unreleased
18
+
19
+ The first release, so this records the surface it establishes rather than a
20
+ difference from an earlier one. The date is stamped at publish.
21
+
22
+ Two rules changed while nothing could yet depend on them. Governance takes
23
+ effect at 1.0 or the first external adopter, **not** at the first npm publish
24
+ (§9.1, [ADR-0010](docs/adr/0010-when-governance-takes-effect.md)). And §5.2's
25
+ "an `id` is never deleted or reused" now says from which version it holds: 1.0.
26
+
27
+ ### Added
28
+
29
+ - **Contract declarations as JSDoc tags on ordinary TypeScript**: `@effects`,
30
+ `@capabilities`, `@budget`, `@entrypoint`, and `@boundary reason="…"`. Nine
31
+ standard effects, plus user-defined effects composed from them. An undeclared
32
+ function is `unknown`, never `pure`.
33
+ - **`ambit check`** — static checking, with `--coverage`, `--strict`,
34
+ `--format json` and `--format github`. Exit 0 when clean, 1 on a violation,
35
+ 2 when analysis itself failed.
36
+ - **`ambit init`** — proposes `@effects` for the functions that have none;
37
+ `--config` proposes the same declarations as `ambit.config.ts` entries.
38
+ - **`ambit diff <ref> [dir]`** — compares the working tree's authority against a
39
+ base ref and exits 1 on an increase no approval covers. `--format github`
40
+ annotates each increase at its declaration. The full exit-code table is
41
+ `docs/DESIGN.md` §6.
42
+ - **`ambit.approvals.md`** — the ledger `ambit diff` reads on both sides of a
43
+ comparison. One `- ` line approves one authority gained by one symbol, and
44
+ counts only in the comparison that adds it.
45
+ - **NDJSON diagnostics** (`--format json`) — one record per diagnostic, plus one
46
+ `kind: "authority"` record per function, each carrying the analysis `engine`.
47
+ - **18 diagnostic ids**, each with its meaning, severity and category in
48
+ [`docs/diagnostics/`](docs/diagnostics/README.md).
49
+ - **Out-of-code contracts** — `ambit-ts/config`'s `defineConfig`, for code that
50
+ cannot carry a comment: all five tags, per-directory `strict`, and
51
+ user-defined effects. Where a symbol has both, the JSDoc contract is the one
52
+ in force.
53
+ - **Runtime enforcement** — `ambit-ts/runtime`'s `withAmbit`, with capability
54
+ hooks over `fetch`, `node:fs`, `node:fs/promises`, `node:child_process` and
55
+ `pg`, `timeMs` budget enforcement, and a per-context audit trail.
56
+ - **Framework adapters** — `ambit-ts/runtime/hono`'s `ambitHandler` and
57
+ `ambit-ts/runtime/next`'s `ambitRoute`. A literal `spec` passed to either, or
58
+ to `withAmbit`, is read as the handler's own `@capabilities` / `@budget`
59
+ declaration.
60
+
61
+ `@budget` takes three limits and enforces one. `timeMs` is enforced; `costUsd`
62
+ and `llmCalls` are parsed, carried and compared, and nothing increments them.
63
+ The rest of what Ambit does not do is
64
+ [`docs/limitations.md`](docs/limitations.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sano-suguru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,403 @@
1
+ # Ambit
2
+
3
+ **Declare what AI-written TypeScript is allowed to do, and check it mechanically.**
4
+
5
+ An agent can widen a function's authority faster than a human can review it.
6
+ Ambit — the range of one's authority — makes that range an explicit JSDoc
7
+ contract and verifies it.
8
+
9
+ ## The accident
10
+
11
+ Three files. `priceOrder` declares `pure`; `applyTax` and `currentRate` declare
12
+ nothing at all.
13
+
14
+ ```ts
15
+ // pricing.ts
16
+ /** @effects pure */
17
+ export function priceOrder(subtotal: number, region: string): number {
18
+ return applyTax(subtotal, region);
19
+ }
20
+
21
+ // tax.ts
22
+ export function applyTax(subtotal: number, region: string): number {
23
+ return Math.round(subtotal * (1 + currentRate(region)));
24
+ }
25
+
26
+ // rates.ts
27
+ const FALLBACK_RATE = 0.08;
28
+
29
+ export function currentRate(region: string): number {
30
+ void fetch(`https://rates.example.com/${region}`); // <- the agent's one added line
31
+ return FALLBACK_RATE;
32
+ }
33
+ ```
34
+
35
+ The added line is two calls away from the declaration it breaks. The next check
36
+ fails, and prints the way from one to the other:
37
+
38
+ ```console
39
+ $ node src/cli/main.ts check test/fixtures/accident; echo "exit=$?"
40
+ error: priceOrder declares pure but calls currentRate which has effects [network] (pricing.ts:4)
41
+ -> applyTax (tax.ts:3)
42
+ -> currentRate (rates.ts:3)
43
+ operation: fetch (rates.ts:4)
44
+ files=3 functions=3 declared=1
45
+ exit=1
46
+ ```
47
+
48
+ No file here contains both the declaration and the `fetch`. Every file is
49
+ locally unremarkable: `rates.ts` fetches a rate, which is what a rates module
50
+ does, and nothing in it mentions `pure`. The violation exists only in the path
51
+ between the three, which is why a rule that reads one node, one function, or one
52
+ file at a time has nothing to fire on.
53
+
54
+ TypeScript accepts that edit — the types still line up. It tells you whether a
55
+ value has the type you expect, not whether a function is allowed to do what it
56
+ does. Ambit moves that judgement out of convention and into an executable
57
+ contract.
58
+
59
+ ## Quick start
60
+
61
+ Requires Node.js 24.
62
+
63
+ ```sh
64
+ npm i -D ambit-ts
65
+ npx ambit init src # propose `@effects` for the functions that have none
66
+ npx ambit check src # check what they now declare
67
+ ```
68
+
69
+ `init` writes nothing on its own — it reports the declarations it would add, as
70
+ fix candidates. `check` exits 0 when it reported nothing, 1 on an error, and 2
71
+ when the analysis itself could not run; it never returns 0 for "could not
72
+ tell".
73
+
74
+ Backing out is `npm remove ambit-ts`. The `@effects` comments left behind are
75
+ JSDoc, so the code still type-checks and runs with Ambit gone.
76
+
77
+ See **[CLI and CI](#cli-and-ci)** below for the flags, the exit codes, and the
78
+ GitHub Actions output.
79
+
80
+ ## What Ambit controls
81
+
82
+ Contracts are JSDoc tags on ordinary TypeScript. Two of them can also be
83
+ declared by the runtime registration beside a handler instead — see *Static
84
+ check, runtime block* below.
85
+
86
+ | Tag | Declares | Checked |
87
+ |---|---|---|
88
+ | `@effects` | what side effects a function may perform | statically, propagated through the call graph |
89
+ | `@capabilities` | which resources it may reach | statically — may only narrow from caller to callee — and at run time by four hooks |
90
+ | `@budget` | how much an entrypoint may spend | parsed and validated; of its three limits only `timeMs` is enforced while the code runs |
91
+ | `@entrypoint` | where a request enters | warned when it declares no capability set (`AMB-W002`) |
92
+ | `@boundary reason="…"` | that a body is not analysed, and its declared contract is trusted in its place | counted separately in `--coverage` |
93
+
94
+ Which tag is enforced where, tag by tag, is in
95
+ [docs/status.md](docs/status.md#contract-tag-support-at-a-glance).
96
+
97
+ Effects are inferred from bundled tables covering `fetch`/`undici`, the
98
+ `node:fs`, `node:http`/`https`/`net`, and `node:child_process` builtins, and
99
+ five clients (`pg`, `mysql2`, `@prisma/client`, `openai`,
100
+ `@anthropic-ai/sdk`). Everything else resolves to `unknown` — never to `pure` —
101
+ and `--strict` turns those warnings into errors.
102
+
103
+ For code you cannot edit — third party, generated, or not yours yet — declare
104
+ the same contracts in `ambit.config.ts`:
105
+
106
+ ```ts
107
+ import { defineConfig } from "ambit-ts/config";
108
+
109
+ export default defineConfig({
110
+ effects: { payments: ["network", "db_write"] },
111
+ contracts: {
112
+ "src/legacy/billing.ts#charge": { effects: ["payments"] },
113
+ },
114
+ strict: ["src/app/**"],
115
+ });
116
+ ```
117
+
118
+ Where a symbol has both, the JSDoc contract is the one in force and the
119
+ difference is reported as a warning (`AMB-W005`). `ambit init --config`
120
+ proposes config entries for the declarations no comment can carry — accessors,
121
+ anonymous default exports, and a class with no constructor.
122
+
123
+ ## Static check, runtime block
124
+
125
+ `ambit check` reads the source and nothing that runs, so adopting the static
126
+ check means writing the declarations and nothing more. Runtime enforcement is
127
+ the opposite: it is adopted **per entrypoint**. Every entrypoint needs its own
128
+ `withAmbit` or adapter registration, and a JSDoc tag alone never turns it on.
129
+
130
+ ```ts
131
+ import { installFetchHook, withAmbit } from "ambit-ts/runtime";
132
+
133
+ installFetchHook();
134
+
135
+ /**
136
+ * @entrypoint
137
+ * @effects network
138
+ */
139
+ async function refreshRates(currency: string): Promise<void> {
140
+ await fetch(`https://api.example.com/rates?base=${currency}`);
141
+ // await fetch("https://elsewhere.example/steal"); // AMB-E009 if this line is added
142
+ }
143
+
144
+ export const refresh = withAmbit(
145
+ {
146
+ capabilities: ["http:get:api.example.com"],
147
+ budget: { timeMs: 500, costUsd: 0.01, onExceed: "throw" },
148
+ },
149
+ refreshRates,
150
+ );
151
+ ```
152
+
153
+ That file passes `ambit check` as written; uncommenting the second `fetch`
154
+ fails it.
155
+
156
+ **The capability list and the budget are written once, in the registration.**
157
+ A literal `spec` whose `handler` names a declaration in the same file *is* that
158
+ handler's `@capabilities` and `@budget`, so the checker reads the same values
159
+ the runtime will enforce. That keeps the contract a value in the module, which
160
+ survives a build that strips comments and a bundler that renames everything.
161
+ `@effects` and `@entrypoint` stay in the JSDoc, because the runtime never reads
162
+ them. Writing the tags as well is still allowed and still checked —
163
+ `AMB-E010` / `AMB-E011` fail the check if the two halves disagree.
164
+ (`docs/DESIGN.md` §4.1 "Where declarations live" has the full rule; §4.4 "The
165
+ range this does not reach" covers what happens when a `spec` cannot supply the
166
+ declaration.)
167
+
168
+ At run time `withAmbit` puts that capability set on the context, and four hooks
169
+ check operations against it — `installFetchHook()`, `installFsHook()`,
170
+ `installChildProcessHook()`, `installPgHook(pg)`. An ungranted operation throws
171
+ `AmbitCapabilityError` before the socket, the file, or the process is reached,
172
+ every decision is recorded on the context's audit trail, and `timeMs` is
173
+ measured against the wall clock. Each install returns the function that
174
+ restores the original, so removing Ambit is one call.
175
+
176
+ A grant names `http:<method>:<host>`, `fs:read:` / `fs:write:` with the path
177
+ resolved to an absolute path at the call, `proc:spawn:` with argv[0] as
178
+ written, or `db:read:` / `db:write:` with the database the connection names.
179
+
180
+ ### Framework adapters
181
+
182
+ On Hono, the adapter registers the same handler instead of a hand-written
183
+ `withAmbit`:
184
+
185
+ ```ts
186
+ import { Hono } from "hono";
187
+ import { ambitHandler } from "ambit-ts/runtime/hono";
188
+
189
+ const app = new Hono();
190
+
191
+ app.get("/rates", ambitHandler(
192
+ { capabilities: ["http:get:api.example.com"], budget: { timeMs: 500 } },
193
+ refreshRates,
194
+ (c) => [c.req.query("currency") ?? "USD"] as const,
195
+ ));
196
+ ```
197
+
198
+ Next.js App Router is supported for Node.js **Route Handlers** in
199
+ `app/**/route.ts`, through `ambitRoute`. Server Actions, `middleware.ts`, the
200
+ Pages Router, and any route on the Edge runtime are **not enforced** — see
201
+ [docs/integrations/nextjs.md](docs/integrations/nextjs.md) for the registration,
202
+ the `instrumentation.ts` hook install, and the coverage table.
203
+
204
+ Express, BullMQ and the rest have no adapter. A route registered without one
205
+ establishes no context, and `setUnscopedPolicy("allow" | "warn" | "deny")`
206
+ decides what its operations do — `allow` by default, so adopting the runtime
207
+ does not break code that has no contracts yet.
208
+
209
+ ## CLI and CI
210
+
211
+ | Command | What it does |
212
+ |---|---|
213
+ | `ambit check <dir>` | Static check. `--coverage`, `--strict`, `--format json`, `--format github` |
214
+ | `ambit init <dir>` | Proposes `@effects` for undeclared functions. `--config` for the ones no comment can carry |
215
+ | `ambit diff <ref> [dir]` | Compares the working tree's authority against a base ref and fails on an increase no approval covers |
216
+
217
+ Exit codes: **0** when nothing was reported, **1** on an error, **2** when the
218
+ analysis itself could not run. That exit code is the whole CI integration:
219
+
220
+ ```yaml
221
+ - run: npx ambit check src --strict
222
+ ```
223
+
224
+ `--format github` turns each diagnostic into a GitHub Actions annotation on the
225
+ declaration that broke, carrying the whole call path into the pull request:
226
+
227
+ ```console
228
+ $ node src/cli/main.ts check test/fixtures/accident --format github; echo "exit=$?"
229
+ ::error file=test/fixtures/accident/pricing.ts,line=4,col=17,title=AMB-E001::priceOrder declares pure but calls currentRate which has effects [network]%0A-> applyTax (tax.ts:3)%0A-> currentRate (rates.ts:3)%0Aoperation: fetch (rates.ts:4)
230
+ files=3 functions=3 declared=1
231
+ exit=1
232
+ ```
233
+
234
+ The second gate is `ambit diff`. It compares the authority of the working tree
235
+ against a base ref, and an increase that no line in `ambit.approvals.md`
236
+ approves fails the build — with the line to add, so approving it is a copy and
237
+ a reason:
238
+
239
+ ```console
240
+ $ node src/cli/main.ts diff HEAD src; echo "exit=$?"
241
+ base HEAD (fa633b1) vs the working tree, over src
242
+
243
+ 2 authorities increased without approval:
244
+
245
+ core/authority-diff.ts#leakedHelper (core/authority-diff.ts:342) [new symbol]
246
+ + network
247
+ operation: fetch (core/authority-diff.ts:343)
248
+ - `core/authority-diff.ts#leakedHelper` `effect:network` — <why this increase is correct>
249
+
250
+ core/authority-diff.ts#leakedHelper (core/authority-diff.ts:342) [new symbol]
251
+ + capability http:get:exfil.example.com
252
+ - `core/authority-diff.ts#leakedHelper` `capability:http:get:exfil.example.com` — <why this increase is correct>
253
+
254
+ Add each line above to ambit.approvals.md, with the reason, and
255
+ commit it in the same change (DESIGN.md §6.3). An approval already in the base
256
+ grants nothing.
257
+
258
+ 302 symbols unchanged, out of 303 symbols compared.
259
+ exit=1
260
+ ```
261
+
262
+ That is a real run against this repository, with one function added to
263
+ `src/core/authority-diff.ts` that fetches from `exfil.example.com`. Only
264
+ increases fail: tightening a contract is never taxed.
265
+
266
+ ## For coding agents
267
+
268
+ `check --format json` emits NDJSON — one diagnostic per line, then a summary
269
+ line — meant to be piped into an agent loop. Where a diagnostic carries a
270
+ patch, the agent applies the edits and re-checks without a human in the loop;
271
+ `AMB-E001` is the one that carries a patch today.
272
+
273
+ ```console
274
+ $ node src/cli/main.ts check src --format json
275
+ {"id":"AMB-E001","severity":"error","contract":{"declared":["pure"],"observed":["network"]},"fixes":[{"kind":"widen","consistentWithContract":false,"edits":[{"file":"tax.ts","range":[[0,4],[0,17]],"replacement":"@effects network"}]}], ...}
276
+ {"kind":"summary","filesAnalyzed":1,"functionsExtracted":1,"functionsDeclared":1}
277
+ # the agent applies fixes[0].edits — ranges are 0-based, end-exclusive
278
+ $ node src/cli/main.ts check src --format json # re-check
279
+ ```
280
+
281
+ The patch Ambit offers widens the contract to what the code actually does. It
282
+ is marked `consistentWithContract: false` and carries the callers it would
283
+ affect, so the agent — or the human reading its output — can tell "the contract
284
+ was wrong" from "the code was wrong". Ambit does not invent the other patch,
285
+ the one that keeps the contract and rewrites the code.
286
+
287
+ ## Why not ESLint / Effect-TS / dependency-cruiser
288
+
289
+ | Tool | Primary abstraction |
290
+ |---|---|
291
+ | ESLint | code-level lint rules |
292
+ | dependency-cruiser | module dependency edges |
293
+ | Effect-TS | effects represented in program values and types |
294
+ | **Ambit** | **authority propagated across function calls** |
295
+
296
+ Ambit's abstraction is the authority a function holds after propagation, which
297
+ is why a `pure` function calling an undeclared helper that calls `fetch` is an
298
+ error on the pure function, with the path reported — no single file contains the
299
+ violation. A module graph that is entirely legal can still contain a `pure`
300
+ helper that opens a socket. And where Effect-TS puts effects in the types of the
301
+ values you construct — so the code is written in that style throughout — Ambit's
302
+ static contracts are JSDoc comments on ordinary TypeScript: adding them changes
303
+ no runtime behavior, and removing Ambit is a small diff.
304
+
305
+ ## What Ambit does not guarantee
306
+
307
+ Ambit stops the violations it can detect and states the rest. It does **not**
308
+ claim:
309
+
310
+ - **Whole-program soundness.** No alias analysis is performed: a locally created
311
+ value handed elsewhere and then mutated (`sink(out); out.push(x)`) still reads
312
+ as local mutation. Property and method calls resolve from the receiver's
313
+ value, which `const` does not freeze.
314
+ - **That `unknown` is safe.** A call Ambit cannot resolve is reported and
315
+ counted, never folded into `pure`. `--strict` makes it an error.
316
+ - **Enforcement on the Edge runtime.** Every hook Ambit installs is a Node.js
317
+ one, so an Edge route has no capability checked at all.
318
+ - **Interception beyond four hooks.** `fetch`, `node:fs`, `node:child_process`
319
+ and `pg`. `mysql2`, Prisma and the LLM SDKs have static effects but no hook,
320
+ so calling them is neither blocked nor recorded. Native addons, child
321
+ processes, and other `worker_threads` workers are outside every hook.
322
+ - **That the declaration cannot simply be widened.** `check` validates code
323
+ against the contract currently written, so changing the contract can make it
324
+ green again. `ambit diff <ref>` is what reviews increases in authority, and it
325
+ has documented blind spots of its own
326
+ ([limitations](docs/limitations.md#what-ambit-diff-can-and-cannot-see)).
327
+ - **That an approved increase is a safe one.** An approval line in
328
+ `ambit.approvals.md` records that an increase was put in front of a reviewer,
329
+ in the same pull request, where it can be read. It does not record that the
330
+ reviewer was right, and Ambit cannot check that a person wrote the line at
331
+ all — branch protection and a `CODEOWNERS` entry on the file are what make
332
+ that true.
333
+ - **Targets finer than the resource.** A database target names the database, not
334
+ the table — Ambit does not read table names out of SQL — and a shell spawn
335
+ names the shell, not the program inside the command string.
336
+ - **That `costUsd` and `llmCalls` are enforced.** They are parsed and validated.
337
+ Nothing increments them.
338
+
339
+ [docs/limitations.md](docs/limitations.md) has all of this in detail.
340
+
341
+ ## Status
342
+
343
+ Ambit is experimental and not production-ready. It is versioned `0.x`, and
344
+ semver's 0.x rule is in force: **a minor release may make a breaking change** —
345
+ diagnostic ids, the NDJSON field shape, and everything else on the guaranteed
346
+ surface can still move. What that surface is, and what is explicitly not on it,
347
+ is [DESIGN.md §9.2](docs/DESIGN.md#92-the-guaranteed-surface); every change to
348
+ it is announced in [CHANGELOG.md](CHANGELOG.md). `check src` over Ambit's own source — 39 files,
349
+ 302 functions — takes 1.07–1.11 s across five runs; `diff HEAD src`, which
350
+ analyzes two trees, takes 1.86–1.98 s across five runs. Nothing is cached, so a
351
+ re-check costs the same. The analysis backend has been measured on a
352
+ 300-file project (458 ms, 348 MiB peak) as part of choosing it; the CLI on top
353
+ of it has not. What is implemented and what is not, milestone by milestone with
354
+ the measured numbers behind it, is in [docs/status.md](docs/status.md).
355
+
356
+ The analysis runs on the TypeScript Compiler API (`typescript` 6.0.3, the
357
+ JavaScript implementation). That is a decision, not an accident: native
358
+ TypeScript 7 (the Go implementation) is three to four times faster and was still
359
+ not adopted, because its API is published entirely under `unstable/` and,
360
+ unless it is told which files changed, it answers from a stale snapshot without
361
+ saying so.
362
+ [ADR-0001](docs/adr/0001-analysis-backend.md) records the decision and what
363
+ would reopen it.
364
+
365
+ ## Working on Ambit itself
366
+
367
+ There is no build step during development: `.ts` runs directly under Node's
368
+ type stripping.
369
+
370
+ ```sh
371
+ git clone https://github.com/sano-suguru/ambit.git && cd ambit && pnpm install
372
+ node src/cli/main.ts check src --coverage
373
+ ```
374
+
375
+ That last command needs nothing prepared — it checks Ambit's own source, and
376
+ exit 0 is the fastest evidence a change did what it claimed:
377
+
378
+ ```console
379
+ warning: extractProject declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:55)
380
+ warning: loadProjectConfig declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:165)
381
+ ...
382
+ files=39 functions=302 declared=14
383
+ declared-by: jsdoc=14 config=0
384
+ unknown-rate=62.9% (190/302 functions) boundary-rate=0.0% (0/302 functions)
385
+ ```
386
+
387
+ `pnpm test`, `pnpm exec tsc --noEmit` and `biome ci .` are the rest of the
388
+ gate; [AGENTS.md](AGENTS.md) is the working agreement, including what belongs
389
+ in which document.
390
+
391
+ ## Docs / License
392
+
393
+ - [docs/DESIGN.md](docs/DESIGN.md) — the product specification.
394
+ - [docs/adr/](docs/adr/README.md) — why each design is the one in the spec.
395
+ - [docs/diagnostics/](docs/diagnostics/README.md) — the diagnostic code ledger.
396
+ - [CHANGELOG.md](CHANGELOG.md) — every breaking change to the guaranteed surface.
397
+ - [docs/limitations.md](docs/limitations.md) — where the analysis is narrower
398
+ than the model suggests.
399
+ - [docs/status.md](docs/status.md) — what is implemented, with measured numbers.
400
+ - [ROADMAP.md](ROADMAP.md) — milestones and the Phase 1 exit criterion.
401
+
402
+ MIT licensed; see [LICENSE](LICENSE). Ambit is one person's experiment:
403
+ no support commitment, no release schedule yet.
@@ -0,0 +1,13 @@
1
+ import type { AuthorityRecord, SymbolId } from "../core/index.ts";
2
+ import { type PropagatedFunction } from "./propagate.ts";
3
+ /**
4
+ * Every analyzed function's authority, in the order `ambit check --format
5
+ * json` emits it and `ambit diff` compares it (DESIGN.md §5.1).
6
+ *
7
+ * Built from the propagated state alone, so it says the same thing the
8
+ * diagnostics say — a function's record and the diagnostic about it can never
9
+ * disagree about what its effects are. Records are sorted by symbol id, and
10
+ * every list inside one is sorted too, so two runs over the same tree produce
11
+ * byte-identical output and a diff never reports ordering as change.
12
+ */
13
+ export declare function buildAuthorityRecords(state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly AuthorityRecord[];
@@ -0,0 +1,87 @@
1
+ import { formatCapability, KNOWN_EFFECTS } from "../core/index.js";
2
+ import { operationSite } from "./diagnose.js";
3
+ import { capabilityWitnessChain, chainToVia, witnessChain, } from "./propagate.js";
4
+ /**
5
+ * Every analyzed function's authority, in the order `ambit check --format
6
+ * json` emits it and `ambit diff` compares it (DESIGN.md §5.1).
7
+ *
8
+ * Built from the propagated state alone, so it says the same thing the
9
+ * diagnostics say — a function's record and the diagnostic about it can never
10
+ * disagree about what its effects are. Records are sorted by symbol id, and
11
+ * every list inside one is sorted too, so two runs over the same tree produce
12
+ * byte-identical output and a diff never reports ordering as change.
13
+ */
14
+ export function buildAuthorityRecords(state) {
15
+ return [...state.values()]
16
+ .map((propagated) => buildRecord(propagated, state))
17
+ .toSorted((a, b) => (a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0));
18
+ }
19
+ function buildRecord(propagated, state) {
20
+ const { summary } = propagated;
21
+ const declaredEffects = summary.declared;
22
+ const declaredCapabilities = summary.capabilities;
23
+ const observed = KNOWN_EFFECTS.filter((effect) => propagated.observed.effects.has(effect));
24
+ const required = propagated.required.capabilities.map(formatCapability).toSorted();
25
+ return {
26
+ kind: "authority",
27
+ symbol: summary.id,
28
+ location: summary.location,
29
+ entrypoint: summary.entrypoint,
30
+ effects: {
31
+ // An `@effects` tag that did not parse is no declaration at all, the
32
+ // same reading `AMB-E002` and propagation give it: a broken tag must
33
+ // never be read as a narrower grant than the author wrote.
34
+ declared: declaredEffects.kind === "declared"
35
+ ? KNOWN_EFFECTS.filter((effect) => declaredEffects.effects.effects.has(effect))
36
+ : null,
37
+ observed,
38
+ unknown: propagated.observed.unknown,
39
+ },
40
+ capabilities: {
41
+ declared: declaredCapabilities.kind === "declared"
42
+ ? declaredCapabilities.capabilities.capabilities.map(formatCapability).toSorted()
43
+ : null,
44
+ required,
45
+ unknown: propagated.required.unknown,
46
+ },
47
+ paths: [
48
+ ...effectPaths(propagated, observed, state),
49
+ ...capabilityPaths(summary.id, required, state),
50
+ ],
51
+ };
52
+ }
53
+ /**
54
+ * The call path for each effect the function reaches, plus the operation site
55
+ * inside the function at the end of that path — exactly what `AMB-E001` puts
56
+ * in `contract.via` / `contract.operation`, computed the same way so a
57
+ * diff-rendered path and a check-rendered path are the same path.
58
+ *
59
+ * An effect the function performs itself has an empty `via` and still carries
60
+ * an operation, so the reader is sent to the `fetch(...)` line either way.
61
+ */
62
+ function effectPaths(propagated, observed, state) {
63
+ const owner = propagated.summary.id;
64
+ return observed.map((effect) => {
65
+ const chain = witnessChain(owner, effect, state);
66
+ const operation = operationSite(effect, chain[chain.length - 1] ?? owner, state);
67
+ return {
68
+ authority: effect,
69
+ kind: "effect",
70
+ via: chainToVia(chain, state),
71
+ ...(operation ? { operation } : {}),
72
+ };
73
+ });
74
+ }
75
+ /**
76
+ * The call path for each capability the function requires. No operation site:
77
+ * a capability requirement is established by a declaration or by an
78
+ * operation's target, and the latter is already the effect's operation — a
79
+ * second, differently-derived site would be a guess (DESIGN.md §5.3).
80
+ */
81
+ function capabilityPaths(owner, required, state) {
82
+ return required.map((capability) => ({
83
+ authority: capability,
84
+ kind: "capability",
85
+ via: chainToVia(capabilityWitnessChain(owner, capability, state), state),
86
+ }));
87
+ }
@@ -0,0 +1,26 @@
1
+ import type { TsBackend, UnresolvedReason } from "../../core/index.ts";
2
+ /**
3
+ * `TsBackend` implementation on the TypeScript Compiler API (DESIGN.md §3.4).
4
+ *
5
+ * **This is the adopted backend**, not a placeholder. M0.5's comparison ran and
6
+ * chose it — DESIGN.md §3.5 and `docs/adr/0001-analysis-backend.md`, with the measurements
7
+ * in `docs/status.md`. The native TypeScript 7 engine (Go) was faster on every
8
+ * corpus and was still not adopted: its API is published entirely under
9
+ * `unstable/`, it answers from a stale snapshot unless told which files
10
+ * changed, and none of its speed was needed to meet a threshold. §3.5 also
11
+ * records what would reopen the decision; changing the default now requires an
12
+ * RFC (§9).
13
+ *
14
+ * The name `typescript-legacy` is the engine id in diagnostics and predates
15
+ * that decision. It distinguishes the JavaScript implementation from the Go
16
+ * one; it does not mean unmaintained. The version tracks the JS line's newest
17
+ * stable release (6.0.3), by the rule in AGENTS.md.
18
+ *
19
+ * The separation this file sits behind is unchanged and still the point: this
20
+ * is the ONLY file allowed to import `typescript`, and no `ts.Node`,
21
+ * `ts.Symbol`, or `ts.Type` may be returned from `extractProject` — see
22
+ * `src/core/backend.ts`. Adoption makes the boundary more useful, not less: it
23
+ * is what will let §3.5's review happen without touching the contract layer.
24
+ */
25
+ export declare const legacyTsBackend: TsBackend;
26
+ export type { UnresolvedReason };