npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ambit-ts

v0.2.0

Published

Review changes in what AI-written TypeScript is allowed to do

Downloads

393

Readme

Ambit

Review changes in what AI-written TypeScript is allowed to do.

A code diff says what changed. An authority diff says what became possible.

An agent can widen a function's authority faster than a human can review it. Ambit makes authority explicit in the source, checks it, and puts increases in front of a reviewer.

  • Contracts declare a function's authority, as JSDoc on ordinary TypeScript.
  • ambit check fails code that exceeds the contract written today.
  • ambit diff fails a change that grants authority the base commit did not, unless an approval in the same change covers it.

The third exists because the second can be satisfied by editing the contract rather than the code, which is what the demonstration below does.

Experimental, 0.x, and not a sandbox. Known blind spots are documented in What Ambit does not guarantee.

Quick start

Requires Node.js 24 and a git repository. Ambit reads the nearest tsconfig.json at or above the directory it is given, and analyzes with the TypeScript it installs rather than the project's; a tsconfig written for TypeScript 5 needs no change.

1. Declare what the code does today. init writes nothing; it prints what each undeclared function was observed to do.

$ npm i -D ambit-ts
$ npx ambit init src
info: priceOrder has no @effects; its observed effects are [none] (pricing.ts:3)
info: applyTax has no @effects; its observed effects are [none] (tax.ts:3)
info: loadRates has no @effects; its observed effects are [fs_read] (tax.ts:7)
files=2 functions=3 declared=0

Copy each set into a JSDoc tag above its function — [none] is written pure — then check and commit. Declaring a contract is not an increase, so this commit passes ambit diff too. The effect names are the table in DESIGN.md §4.2.

/** @effects pure */
export function applyTax(subtotal: number, region: string): number {
$ npx ambit check src; echo "exit=$?"
files=2 functions=3 declared=3
exit=0
$ git commit -am "declare effects"

2. Make a change that adds authority. Add void fetch(`https://rates.example.com/${region}`); to applyTax. The check fails, because the code now exceeds its contract:

$ npx ambit check src; echo "exit=$?"
error: applyTax declares pure but performs [network] directly (tax.ts:4)
  operation: fetch (tax.ts:5)
files=2 functions=3 declared=3
exit=1

3. Widen the contract, and see what diff asks for. Change both pure tags on the path — applyTax and its caller priceOrder — to @effects network. check is green again; diff is not:

$ npx ambit diff HEAD src; echo "exit=$?"
base HEAD (79fead0) vs the working tree, over src

4 authorities increased without approval:

  pricing.ts#priceOrder (pricing.ts:4)
    + network
      -> applyTax (tax.ts:4)
      operation: fetch (tax.ts:5)
    - `pricing.ts#priceOrder` `effect:network` — <why this increase is correct>
  ...
exit=1

4. Approve it in the same change. Put each - line diff printed into ambit.approvals.md at the repository root, with the placeholder replaced by the reason, and commit it with the code. diff then exits 0 and still lists each increase with the reason given.

The contracts are JSDoc, so npm remove ambit-ts leaves ordinary TypeScript that still type-checks and runs. The flags, the exit codes, and the GitHub Actions workflow are in CLI and CI.

The accident, and the fix that is not one

Three files. priceOrder declares pure; applyTax and currentRate declare nothing at all.

// pricing.ts
/** @effects pure */
export function priceOrder(subtotal: number, region: string): number {
  return applyTax(subtotal, region);
}

// tax.ts
export function applyTax(subtotal: number, region: string): number {
  return Math.round(subtotal * (1 + currentRate(region)));
}

// rates.ts
const FALLBACK_RATE = 0.08;

export function currentRate(region: string): number {
  void fetch(`https://rates.example.com/${region}`);  // <- the agent's one added line
  return FALLBACK_RATE;
}

The added line is two calls away from the declaration it breaks. The next check fails, and prints the way from one to the other:

$ node src/cli/main.ts check test/fixtures/accident; echo "exit=$?"
error: priceOrder declares pure but calls currentRate which has effects [network] (pricing.ts:4)
  -> applyTax (tax.ts:3)
  -> currentRate (rates.ts:3)
  operation: fetch (rates.ts:4)
files=3 functions=3 declared=1
exit=1

No file here contains both the declaration and the fetch, and each is locally unremarkable — a rates module fetching a rate. The violation exists only in the path between the three, which is why a rule that reads one file at a time has nothing to fire on.

Now the second edit — the one Ambit itself offers as a fix candidate, in the --format json output an agent reads ("kind":"widen"):

-/** @effects pure */
+/** @effects network */
 export function priceOrder(subtotal: number, region: string): number {
$ node src/cli/main.ts check test/fixtures/accident; echo "exit=$?"
files=3 functions=3 declared=1
exit=0

Green. Nothing about the code moved — priceOrder still reaches the same fetch through the same two calls. What changed is that it is now allowed to. check validates code against the contract currently written, so widening the contract is always a way to pass it. That is what the second gate reads:

$ node src/cli/main.ts diff HEAD test/fixtures/accident; echo "exit=$?"
base HEAD (81ea225) vs the working tree, over test/fixtures/accident

1 authority increased without approval:

  pricing.ts#priceOrder (pricing.ts:4)
    + network
      -> applyTax (tax.ts:3)
      -> currentRate (rates.ts:3)
      operation: fetch (rates.ts:4)
    - `pricing.ts#priceOrder` `effect:network` — <why this increase is correct>

Add each line above to ambit.approvals.md, with the reason, and
commit it in the same change (DESIGN.md §6.3). An approval already in the base
grants nothing.

2 symbols unchanged, out of 3 symbols compared.
exit=1

That is a real run, with test/fixtures/accident's declaration widened in the working tree. The path is the one check printed, and the last line is what to paste into ambit.approvals.md if the increase is the correct change. Only increases are gated — narrowing is never taxed — and an approval that was already in the base grants nothing, so the record is made in the change that makes the increase.

TypeScript accepts both edits: the types line up either way. It tells you whether a value has the type you expect, not whether a function is allowed to do what it does.

Where this sits

| Tool | Primary abstraction | |---|---| | TypeScript | the types of values | | ESLint | code-level lint rules | | dependency-cruiser | module dependency edges | | Effect-TS | effects represented in program values and types | | a runtime sandbox | isolation of the running process | | Ambit | authority propagated across function calls, and the change in it |

Ambit's abstraction is the authority a function holds after propagation, which is why a pure function calling an undeclared helper that calls fetch is an error on the pure function, with the path reported — no single file contains the violation. A module graph that is entirely legal can still contain a pure helper that opens a socket. And where Effect-TS puts effects in the types of the values you construct — so the code is written in that style throughout — Ambit's static contracts are JSDoc comments on ordinary TypeScript: adding them changes no runtime behavior, and removing Ambit is a small diff.

A sandbox is the other axis: it decides what a process may do while it runs, and knows nothing about which function asked. Ambit's runtime hooks are the narrow overlap, opt-in per entrypoint — Static check, runtime block, below.

What Ambit controls

Contracts are JSDoc tags on ordinary TypeScript. Two of them can also be declared by the runtime registration beside a handler instead — see Static check, runtime block below.

| Tag | Declares | Checked | |---|---|---| | @effects | what side effects a function may perform | statically, propagated through the call graph | | @capabilities | which resources it may reach | statically — may only narrow from caller to callee — and at run time by four hooks | | @budget | how much an entrypoint may spend | parsed and validated; of its three limits only timeMs is enforced while the code runs | | @entrypoint | where a request enters | warned when it declares no capability set (AMB-W002) | | @boundary reason="…" | that a body is not analysed, and its declared contract is trusted in its place | counted separately in --coverage |

Which tag is enforced where, tag by tag, is in docs/status.md.

Effects are inferred from bundled tables covering fetch/undici/ky, the node:fs, node:http/https/net, and node:child_process builtins (with or without the node: prefix), and six clients (pg, mysql2, knex, @prisma/client, openai, @anthropic-ai/sdk). Everything else resolves to unknown — never to pure — and --strict turns those warnings into errors.

For code you cannot edit — third party, generated, or not yours yet — declare the same contracts in ambit.config.ts:

import { defineConfig } from "ambit-ts/config";

export default defineConfig({
  effects: { payments: ["network", "db_write"] },
  contracts: {
    "src/legacy/billing.ts#charge": { effects: ["payments"] },
  },
  strict: ["src/app/**"],
});

Where a symbol has both, the JSDoc contract is the one in force and the difference is reported as a warning (AMB-W005). ambit init --config proposes config entries for the declarations no comment can carry — accessors, anonymous default exports, and a class with no constructor.

Static check, runtime block

ambit check reads the source and nothing that runs, so adopting the static check means writing the declarations and nothing more. Runtime enforcement is the opposite: it is adopted per entrypoint. Every entrypoint needs its own withAmbit or adapter registration, and a JSDoc tag alone never turns it on.

import { installFetchHook, withAmbit } from "ambit-ts/runtime";

installFetchHook();

/**
 * @entrypoint
 * @effects network
 */
async function refreshRates(currency: string): Promise<void> {
  await fetch(`https://api.example.com/rates?base=${currency}`);
  // await fetch("https://elsewhere.example/steal"); // AMB-E009 if this line is added
}

export const refresh = withAmbit(
  {
    capabilities: ["http:get:api.example.com"],
    budget: { timeMs: 500, costUsd: 0.01, onExceed: "throw" },
  },
  refreshRates,
);

That file passes ambit check as written; uncommenting the second fetch fails it.

The capability list and the budget are written once, in the registration. A literal spec whose handler names a declaration in the same file is that handler's @capabilities and @budget, so the checker reads the values the runtime will enforce, and the contract survives a build that strips comments. @effects and @entrypoint stay in the JSDoc, because the runtime never reads them. Writing the tags as well is allowed and still checked — AMB-E010 / AMB-E011 fail on a disagreement. docs/DESIGN.md §4.1 has the rule, and §4.4 the two registrations it cannot read.

At run time withAmbit puts that capability set on the context, and four hooks check operations against it — installFetchHook(), installFsHook(), installChildProcessHook(), installPgHook(pg). An ungranted operation throws AmbitCapabilityError before the socket, the file, or the process is reached, every decision is recorded on the context's audit trail, and timeMs is measured against the wall clock. Each install returns the function that restores the original, so removing Ambit is one call.

A grant names http:<method>:<host>, fs:read: / fs:write:, proc:spawn:, or db:read: / db:write:. What each target is taken from at the call, and why a shell spawn names the shell rather than the program inside the command string, is docs/DESIGN.md §4.4 "Target formats".

Framework adapters

Two exist, and both carry the contract in the registration instead of a hand-written withAmbit: ambitHandler on Hono (docs/integrations/hono.md), and ambitRoute on Next.js App Router, for Node.js Route Handlers in app/**/route.ts only — Server Actions, middleware.ts, the Pages Router and the Edge runtime are not enforced (docs/integrations/nextjs.md).

Express, BullMQ and the rest have no adapter. A route registered without one establishes no context, and setUnscopedPolicy("allow" | "warn" | "deny") decides what its operations do — allow by default, so adopting the runtime does not break code that has no contracts yet.

CLI and CI

| Command | What it does | |---|---| | ambit check <dir> | Static check. --coverage, --strict, --format json, --format github | | ambit init <dir> | Proposes @effects for undeclared functions, writing nothing. --config for the ones no comment can carry | | ambit diff <ref> [dir] | Compares the working tree's authority against a base ref and fails on an increase no approval covers. --strict also fails where the analysis reached less than it did |

Exit codes: 0 when nothing was reported, 1 on an error, 2 when the analysis itself could not run — never 0 for "could not tell". That exit code is the whole CI integration. Both gates, as a pull-request workflow:

# .github/workflows/ambit.yml
name: Ambit
on: pull_request
permissions:
  contents: read
jobs:
  ambit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 2 # `diff` needs the base commit; the default of 1 exits 2
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npx ambit check src --format github
      - run: npx ambit diff HEAD~1 src --format github

On a pull_request event the checkout is GitHub's merge of the branch into its base, so HEAD~1 is the base branch's tip and diff compares exactly the authority the pull request adds. That holds for the event, not for a push: running the same step on push compares only against the previous commit, which is the base only when each pull request lands as one commit — the comment above the diff step in this repository's own .github/workflows/ci.yml spells that out.

Leave --strict off both commands to begin with. It fails on any unknown — a pure function that calls a package no bundled table covers (zod, for one) is an error under it — and for a third-party package the only way to close that is @boundary (limitations). The warnings are printed at exit 0 either way.

--format github turns each diagnostic into a GitHub Actions annotation on the declaration that broke, carrying the whole call path into the pull request:

$ node src/cli/main.ts check test/fixtures/accident --format github; echo "exit=$?"
::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)
files=3 functions=3 declared=1
exit=1

diff annotates the same way, and an increase that was approved stays visible as a ::notice carrying the reason that was given, rather than disappearing — the point of the ledger is that no increase passes unseen.

Three rules decide what counts. A new symbol has no base to compare against, so the authority it holds is an increase in full — added code is not exempt for having no history. A capability is compared by containment, not by text: http:get:* narrowing to http:get:api.example.com is not an increase, and the reverse is. And each authority is approved separately, so a function that gains both an effect and a capability needs two lines.

A change can also make Ambit see less than it did — a call through a client no stub table covers, added to a function that was already unknown. That is not authority and is not approved by a line; diff reports it in its own section and exits 0, and diff --strict is what turns it into a failure. Leave the flag off until the packages you call are covered by stubs — docs/limitations.md says why.

For coding agents

check --format json emits NDJSON — one diagnostic per line, then a summary line — meant to be piped into an agent loop. Where a diagnostic carries a patch, the agent applies the edits and re-checks without a human in the loop; AMB-E001 is the one that carries a patch today.

$ node src/cli/main.ts check src --format json
{"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"}]}], ...}
{"kind":"summary","filesAnalyzed":1,"functionsExtracted":1,"functionsDeclared":1}
# the agent applies fixes[0].edits — ranges are 0-based, end-exclusive
$ node src/cli/main.ts check src --format json    # re-check

The patch widens the contract to what the code actually does — the second edit in The accident, above. It is marked consistentWithContract: false and carries the callers it would affect, so the reader can tell "the contract was wrong" from "the code was wrong". Ambit does not invent the other patch, the one that keeps the contract and rewrites the code; ambit diff is what keeps the widening one from being applied in silence.

What Ambit does not guarantee

Ambit stops the violations it can detect and states the rest. It does not claim:

  • Whole-program soundness. No alias analysis is performed: a locally created value handed elsewhere and then mutated (sink(out); out.push(x)) still reads as local mutation. Property and method calls resolve from the receiver's value, which const does not freeze.
  • That unknown is safe. A call Ambit cannot resolve is reported and counted, never folded into pure. --strict makes it an error.
  • Enforcement on the Edge runtime. Every hook Ambit installs is a Node.js one, so an Edge route has no capability checked at all.
  • Interception beyond four hooks. fetch, node:fs, node:child_process and pg. mysql2, Prisma and the LLM SDKs have static effects but no hook, so calling them is neither blocked nor recorded. Native addons, child processes, and other worker_threads workers are outside every hook.
  • That a green check means the authority did not change. check validates code against the contract currently written, so widening the contract makes it green again — The accident, above. Reviewing the increase is ambit diff's job, and the next bullet is what that misses.
  • That ambit diff names the function every increase came from. It compares the symbols both sides extracted, and a handler written inline in argument position — router.post("/x", async (ctx) => { … }) — has no name to be one. Every such handler in a file is compared together, under routes.ts#<inline callbacks>, counting how many of them hold each authority — so an increase inside one is reported, but against the file, and the call path may point at a sibling that already held it. Authority moving between two of them changes no count and is not an increase; it is reported as authority the analysis cannot attribute, which --strict fails on. Binding the handler to a name makes it an ordinary symbol again. A function renamed within a file, or moved in a way git did not report as a rename, reads as a deletion plus a new symbol instead — an over-report, which is the direction the comparison is built to fail in (limitations). check --coverage's unknown-rate is what says how much was visible in the first place; a green diff on its own does not.
  • That an approved increase is a safe one. An approval line in ambit.approvals.md records that an increase was put in front of a reviewer, in the same pull request, where it can be read. It does not record that the reviewer was right, and Ambit cannot check that a person wrote the line at all — branch protection and a CODEOWNERS entry on the file are what make that true.
  • Targets finer than the resource. A database target names the database, not the table — Ambit does not read table names out of SQL — and a shell spawn names the shell, not the program inside the command string.
  • That costUsd and llmCalls are enforced. They are parsed and validated. Nothing increments them.

docs/limitations.md has all of this in detail.

Status

Ambit is experimental and not production-ready. It is versioned 0.x, and semver's 0.x rule is in force: a minor release may make a breaking change — diagnostic ids, the NDJSON field shape, and everything else on the guaranteed surface can still move. What that surface is, and what is explicitly not on it, is DESIGN.md §9.2; every change to it is announced in CHANGELOG.md. check src over Ambit's own source — 40 files, 340 functions — takes 1.17–1.38 s across five runs; diff HEAD src, which analyzes two trees, takes 2.01–2.55 s across five runs. Nothing is cached, so a re-check costs the same. The analysis backend has been measured on a 300-file project (458 ms, 348 MiB peak) as part of choosing it; the CLI on top of it has not. What is implemented and what is not, milestone by milestone with the measured numbers behind it, is in docs/status.md.

The analysis runs on the TypeScript Compiler API (typescript 6.0.3, the JavaScript implementation) rather than the faster native TypeScript 7, for reasons ADR-0001 records along with what would reopen the decision.

Working on Ambit itself

There is no build step during development: .ts runs directly under Node's type stripping.

git clone https://github.com/sano-suguru/ambit.git && cd ambit && pnpm install
node src/cli/main.ts check src --coverage

That last command needs nothing prepared — it checks Ambit's own source, and exit 0 is the fastest evidence a change did what it claimed:

warning: extractProject declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:55)
warning: loadProjectConfig declares fs_read but calls something that could not be resolved (checker/backend/legacy-ts.ts:195)
...
files=40 functions=347 declared=14
declared-by: jsdoc=14 config=0
unknown-rate=38.9% (135/347 functions) boundary-rate=0.0% (0/345 functions)

pnpm test, pnpm exec tsc --noEmit and biome ci . are the rest of the gate; AGENTS.md is the working agreement, including what belongs in which document.

Docs / License

MIT licensed; see LICENSE. An ambit is the range of one's authority, which is the thing this tracks. Ambit is one person's experiment: no support commitment, no release schedule yet.