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
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 };
|