ambit-ts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +403 -0
  4. package/dist/checker/authority.d.ts +13 -0
  5. package/dist/checker/authority.js +87 -0
  6. package/dist/checker/backend/legacy-ts.d.ts +26 -0
  7. package/dist/checker/backend/legacy-ts.js +1936 -0
  8. package/dist/checker/config.d.ts +84 -0
  9. package/dist/checker/config.js +391 -0
  10. package/dist/checker/coverage.d.ts +78 -0
  11. package/dist/checker/coverage.js +84 -0
  12. package/dist/checker/diagnose.d.ts +89 -0
  13. package/dist/checker/diagnose.js +734 -0
  14. package/dist/checker/index.d.ts +8 -0
  15. package/dist/checker/index.js +8 -0
  16. package/dist/checker/init.d.ts +38 -0
  17. package/dist/checker/init.js +205 -0
  18. package/dist/checker/propagate.d.ts +69 -0
  19. package/dist/checker/propagate.js +259 -0
  20. package/dist/checker/summarize.d.ts +27 -0
  21. package/dist/checker/summarize.js +411 -0
  22. package/dist/cli/analyze.d.ts +33 -0
  23. package/dist/cli/analyze.js +98 -0
  24. package/dist/cli/approvals.d.ts +28 -0
  25. package/dist/cli/approvals.js +55 -0
  26. package/dist/cli/diff.d.ts +66 -0
  27. package/dist/cli/diff.js +235 -0
  28. package/dist/cli/github.d.ts +33 -0
  29. package/dist/cli/github.js +41 -0
  30. package/dist/cli/main.d.ts +8 -0
  31. package/dist/cli/main.js +385 -0
  32. package/dist/cli/worktree.d.ts +75 -0
  33. package/dist/cli/worktree.js +154 -0
  34. package/dist/config.d.ts +12 -0
  35. package/dist/config.js +10 -0
  36. package/dist/core/approvals.d.ts +82 -0
  37. package/dist/core/approvals.js +0 -0
  38. package/dist/core/authority-diff.d.ts +98 -0
  39. package/dist/core/authority-diff.js +209 -0
  40. package/dist/core/authority.d.ts +109 -0
  41. package/dist/core/authority.js +50 -0
  42. package/dist/core/backend.d.ts +355 -0
  43. package/dist/core/backend.js +1 -0
  44. package/dist/core/budget.d.ts +61 -0
  45. package/dist/core/budget.js +95 -0
  46. package/dist/core/capability.d.ts +53 -0
  47. package/dist/core/capability.js +117 -0
  48. package/dist/core/config.d.ts +59 -0
  49. package/dist/core/config.js +10 -0
  50. package/dist/core/diagnostic.d.ts +126 -0
  51. package/dist/core/diagnostic.js +13 -0
  52. package/dist/core/effects.d.ts +39 -0
  53. package/dist/core/effects.js +72 -0
  54. package/dist/core/index.d.ts +13 -0
  55. package/dist/core/index.js +13 -0
  56. package/dist/core/location.d.ts +15 -0
  57. package/dist/core/location.js +1 -0
  58. package/dist/core/sql.d.ts +22 -0
  59. package/dist/core/sql.js +38 -0
  60. package/dist/core/summary.d.ts +240 -0
  61. package/dist/core/summary.js +8 -0
  62. package/dist/core/symbol-id.d.ts +25 -0
  63. package/dist/core/symbol-id.js +23 -0
  64. package/dist/index.d.ts +14 -0
  65. package/dist/index.js +14 -0
  66. package/dist/runtime/child-process.d.ts +29 -0
  67. package/dist/runtime/child-process.js +124 -0
  68. package/dist/runtime/context.d.ts +37 -0
  69. package/dist/runtime/context.js +8 -0
  70. package/dist/runtime/enforce.d.ts +52 -0
  71. package/dist/runtime/enforce.js +95 -0
  72. package/dist/runtime/fs.d.ts +46 -0
  73. package/dist/runtime/fs.js +188 -0
  74. package/dist/runtime/hono.d.ts +55 -0
  75. package/dist/runtime/hono.js +68 -0
  76. package/dist/runtime/index.d.ts +71 -0
  77. package/dist/runtime/index.js +126 -0
  78. package/dist/runtime/next.d.ts +95 -0
  79. package/dist/runtime/next.js +60 -0
  80. package/dist/runtime/pg.d.ts +48 -0
  81. package/dist/runtime/pg.js +122 -0
  82. package/dist/stubs/constructors.d.ts +34 -0
  83. package/dist/stubs/constructors.js +111 -0
  84. package/dist/stubs/data-clients.d.ts +9 -0
  85. package/dist/stubs/data-clients.js +109 -0
  86. package/dist/stubs/http-capabilities.d.ts +15 -0
  87. package/dist/stubs/http-capabilities.js +70 -0
  88. package/dist/stubs/mutating-builtins.d.ts +1 -0
  89. package/dist/stubs/mutating-builtins.js +48 -0
  90. package/dist/stubs/node-builtins.d.ts +2 -0
  91. package/dist/stubs/node-builtins.js +77 -0
  92. package/dist/stubs/pure-builtins.d.ts +1 -0
  93. package/dist/stubs/pure-builtins.js +89 -0
  94. package/docs/diagnostics/README.md +519 -0
  95. package/docs/limitations.md +712 -0
  96. package/package.json +89 -0
@@ -0,0 +1,95 @@
1
+ import type { NextRequest } from "next/server.js";
2
+ import type { AmbitSpec } from "./index.ts";
3
+ /**
4
+ * The Next.js App Router adapter (DESIGN.md §4.4, "Mapping contracts to
5
+ * handlers").
6
+ *
7
+ * `next` is imported for **types only** and is a devDependency here, never a
8
+ * dependency of the published package — the same treatment `hono` gets in
9
+ * `./hono.ts`, and the same reason `installPgHook(pg)` takes the module from
10
+ * the caller instead of importing `pg` (§4.4 (c)). Nothing in this file
11
+ * imports `typescript` or anything under `src/checker/`.
12
+ *
13
+ * **What this adapter does not reach.** It covers one thing: a Route Handler
14
+ * exported from `app/**\/route.ts` and registered through {@link ambitRoute},
15
+ * running on the Node.js runtime. It establishes no context for, and enforces
16
+ * nothing in:
17
+ *
18
+ * - a route that sets `export const runtime = "edge"` — the hooks
19
+ * (`installFetchHook` and friends) are Node.js hooks and are not installed
20
+ * there, so no capability is checked on such a route. Nothing else about
21
+ * `ambitRoute` on Edge is claimed either: no test runs there. DESIGN.md §12
22
+ * "Edge runtimes": Phase 1 guarantees Node.js only.
23
+ * - Server Actions (`"use server"`), which are not route modules and have no
24
+ * registration call to attach a spec to.
25
+ * - `middleware.ts`, which runs on the Edge runtime and outside any route
26
+ * module.
27
+ * - the Pages Router (`pages/api/*`), which has a different handler shape.
28
+ *
29
+ * Reaching any of those is out of scope for this adapter, not something it
30
+ * silently half-covers: with no `ambitRoute` registration there is no context
31
+ * at all, and what an operation does then is `setUnscopedPolicy`'s decision
32
+ * (`allow` by default).
33
+ */
34
+ /**
35
+ * The second argument Next.js passes a Route Handler: the matched dynamic
36
+ * segments. Declared here rather than imported because Next.js generates the
37
+ * per-route version into `.next/types/`, which does not exist in a repository
38
+ * that never runs `next build`.
39
+ */
40
+ export interface RouteContext<Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>> {
41
+ /** A Promise since Next.js 15; `await`ed inside the context by `decode`. */
42
+ readonly params: Promise<Params>;
43
+ }
44
+ /**
45
+ * The shape Next.js expects `export const GET = …` in `app/**\/route.ts` to
46
+ * have.
47
+ */
48
+ export type RouteHandler<Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>> = (request: NextRequest, context: RouteContext<Params>) => Promise<Response>;
49
+ /**
50
+ * Register a contract-bearing handler as a Next.js Route Handler,
51
+ * establishing the entrypoint's capability set and budget for the whole
52
+ * request.
53
+ *
54
+ * ```ts
55
+ * // app/orders/route.ts
56
+ * export const POST = ambitRoute(
57
+ * { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
58
+ * createOrder,
59
+ * async (request) => [await request.json() as CreateOrderInput] as const,
60
+ * );
61
+ * ```
62
+ *
63
+ * `spec` and `handler` are in the **same first two positions** as
64
+ * {@link ambitHandler}'s, and that is load-bearing rather than cosmetic:
65
+ * `RUNTIME_WRAPPER_NAMES` in `src/checker/backend/legacy-ts.ts` reads
66
+ * `arguments[0]` as the spec and `arguments[1]` as the handler for every
67
+ * registered wrapper, so one extraction serves all of them. Changing the order
68
+ * here would cost the check without saying so.
69
+ *
70
+ * Per §4.4, "Removing the double declaration", a literal `spec` beside a
71
+ * handler declared in the same file *is* that handler's `@capabilities` and
72
+ * `@budget`: the JSDoc tags need not repeat it. Writing both stays legal, and
73
+ * a disagreement is still an error — the capability list against
74
+ * `@capabilities` (`AMB-E010`) and `spec.budget` against `@budget`
75
+ * (`AMB-E011`).
76
+ *
77
+ * `decode` keeps the framework out of `handler`, exactly as in the Hono
78
+ * adapter: `NextRequest` is in no stub table, so a `request.json()` inside a
79
+ * contract-bearing function would make that function's requirement partly
80
+ * `unknown` (`AMB-W003`). It runs **inside** the context, so reading the
81
+ * request body and awaiting `context.params` count toward `timeMs`.
82
+ *
83
+ * `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
84
+ * HTTP statuses. A denied capability is this server's own code exceeding its
85
+ * grant, which is not what 403 says, and the message names the granted set —
86
+ * so it propagates out of the handler to Next.js's error handling, not to the
87
+ * client (§4.4).
88
+ *
89
+ * The hooks this enforcement depends on are installed once per process, in
90
+ * `instrumentation.ts`'s `register()` — see `docs/integrations/nextjs.md`.
91
+ * Without them, `ambitRoute` still establishes the context and applies
92
+ * `timeMs`, but no capability is checked, because nothing is intercepting the
93
+ * operations.
94
+ */
95
+ export declare function ambitRoute<Args extends readonly unknown[], Result, Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>>(spec: AmbitSpec, handler: (...args: Args) => Result | Promise<Result>, decode: (request: NextRequest, context: RouteContext<Params>) => readonly [...Args] | Promise<readonly [...Args]>): RouteHandler<Params>;
@@ -0,0 +1,60 @@
1
+ import { withAmbit } from "./index.js";
2
+ /**
3
+ * Register a contract-bearing handler as a Next.js Route Handler,
4
+ * establishing the entrypoint's capability set and budget for the whole
5
+ * request.
6
+ *
7
+ * ```ts
8
+ * // app/orders/route.ts
9
+ * export const POST = ambitRoute(
10
+ * { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
11
+ * createOrder,
12
+ * async (request) => [await request.json() as CreateOrderInput] as const,
13
+ * );
14
+ * ```
15
+ *
16
+ * `spec` and `handler` are in the **same first two positions** as
17
+ * {@link ambitHandler}'s, and that is load-bearing rather than cosmetic:
18
+ * `RUNTIME_WRAPPER_NAMES` in `src/checker/backend/legacy-ts.ts` reads
19
+ * `arguments[0]` as the spec and `arguments[1]` as the handler for every
20
+ * registered wrapper, so one extraction serves all of them. Changing the order
21
+ * here would cost the check without saying so.
22
+ *
23
+ * Per §4.4, "Removing the double declaration", a literal `spec` beside a
24
+ * handler declared in the same file *is* that handler's `@capabilities` and
25
+ * `@budget`: the JSDoc tags need not repeat it. Writing both stays legal, and
26
+ * a disagreement is still an error — the capability list against
27
+ * `@capabilities` (`AMB-E010`) and `spec.budget` against `@budget`
28
+ * (`AMB-E011`).
29
+ *
30
+ * `decode` keeps the framework out of `handler`, exactly as in the Hono
31
+ * adapter: `NextRequest` is in no stub table, so a `request.json()` inside a
32
+ * contract-bearing function would make that function's requirement partly
33
+ * `unknown` (`AMB-W003`). It runs **inside** the context, so reading the
34
+ * request body and awaiting `context.params` count toward `timeMs`.
35
+ *
36
+ * `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
37
+ * HTTP statuses. A denied capability is this server's own code exceeding its
38
+ * grant, which is not what 403 says, and the message names the granted set —
39
+ * so it propagates out of the handler to Next.js's error handling, not to the
40
+ * client (§4.4).
41
+ *
42
+ * The hooks this enforcement depends on are installed once per process, in
43
+ * `instrumentation.ts`'s `register()` — see `docs/integrations/nextjs.md`.
44
+ * Without them, `ambitRoute` still establishes the context and applies
45
+ * `timeMs`, but no capability is checked, because nothing is intercepting the
46
+ * operations.
47
+ */
48
+ export function ambitRoute(spec, handler, decode) {
49
+ const scoped = withAmbit(spec, async (request, context) =>
50
+ // `decode` may return a `readonly` tuple — `[x] as const` is what a caller
51
+ // naturally writes — and a readonly tuple spreads into rest parameters.
52
+ handler(...(await decode(request, context))));
53
+ return async (request, context) => {
54
+ const result = await scoped(request, context);
55
+ // `Response.json` rather than `NextResponse.json`: the handler's return
56
+ // type is the domain's, and `NextResponse` adds nothing a Route Handler
57
+ // needs here. Next.js accepts any `Response`.
58
+ return result instanceof Response ? result : Response.json(result ?? null);
59
+ };
60
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Runtime enforcement for the `pg` client (DESIGN.md §4.4 (a), (c)).
3
+ *
4
+ * Unlike the `node:` builtins this is a **client wrap**, not a global
5
+ * monkeypatch: Ambit does not depend on `pg`, so the caller hands the module
6
+ * in — `installPgHook(await import("pg"))` — and Ambit replaces
7
+ * `Pool.prototype.query` and `Client.prototype.query`. `pg` was chosen over
8
+ * `@prisma/client` because those prototypes give install and restore the same
9
+ * symmetry `installFetchHook` has, while Prisma's only stable extension point
10
+ * (`$extends`) returns a *new* client and so cannot be undone (P5).
11
+ *
12
+ * What this does **not** do: derive a table from the statement. §4.4 is
13
+ * explicit — "the mere existence of a hook into a DB client is not taken to
14
+ * mean that table-level permissions can be decided for arbitrary SQL" — so the
15
+ * target is the database the
16
+ * connection names, and the exception says so rather than leaving the reader
17
+ * to assume table granularity.
18
+ */
19
+ /** The `pg` surface this hook needs. Structural, so a test double satisfies it. */
20
+ export interface PgModule {
21
+ readonly Pool?: {
22
+ readonly prototype: object;
23
+ };
24
+ readonly Client?: {
25
+ readonly prototype: object;
26
+ };
27
+ }
28
+ /**
29
+ * The capabilities one `query` requires, given the client it was called on and
30
+ * the arguments it was called with.
31
+ *
32
+ * Direction comes from `sqlStatementDirection` in `src/core/`, the same rule
33
+ * the static effect table uses — §4.4 (c) puts it in one place so the checker
34
+ * and the running process cannot answer differently about one statement.
35
+ */
36
+ export declare function pgCapabilities(client: unknown, args: readonly unknown[]): {
37
+ readonly capabilities: readonly string[];
38
+ readonly detail: string;
39
+ };
40
+ /**
41
+ * Wrap `Pool.prototype.query` and `Client.prototype.query` on the `pg` module
42
+ * handed in, returning the function that restores both.
43
+ *
44
+ * `query` is promise-returning unless a callback is passed, so a denial
45
+ * rejects, or reaches the callback through `process.nextTick` when there is
46
+ * one — §4.4 (a)'s rule for each family.
47
+ */
48
+ export declare function installPgHook(pg: PgModule): () => void;
@@ -0,0 +1,122 @@
1
+ import { sqlStatementDirection } from "../core/index.js";
2
+ import { checkCapabilities } from "./enforce.js";
3
+ const TARGET_DETAIL = "the target is the database this connection names, not a table: Ambit does not read table names out of arbitrary SQL";
4
+ const UNKNOWN_DATABASE_DETAIL = "the connection does not name a database, so the target could not be determined; only a grant that does not name one (db:read:*) can cover it";
5
+ const OPAQUE_STATEMENT_DETAIL = "the statement's direction is not decidable from this call, so both db:read and db:write are required";
6
+ /**
7
+ * The capabilities one `query` requires, given the client it was called on and
8
+ * the arguments it was called with.
9
+ *
10
+ * Direction comes from `sqlStatementDirection` in `src/core/`, the same rule
11
+ * the static effect table uses — §4.4 (c) puts it in one place so the checker
12
+ * and the running process cannot answer differently about one statement.
13
+ */
14
+ export function pgCapabilities(client, args) {
15
+ const database = databaseOf(client);
16
+ const target = database ?? "unknown";
17
+ const direction = sqlStatementDirection(statementText(args[0]));
18
+ const capabilities = direction === "read"
19
+ ? [`db:read:${target}`]
20
+ : direction === "write"
21
+ ? [`db:write:${target}`]
22
+ : [`db:read:${target}`, `db:write:${target}`];
23
+ const notes = [TARGET_DETAIL];
24
+ if (database === undefined)
25
+ notes.push(UNKNOWN_DATABASE_DETAIL);
26
+ if (direction === "both")
27
+ notes.push(OPAQUE_STATEMENT_DETAIL);
28
+ return { capabilities, detail: notes.join("; ") };
29
+ }
30
+ /**
31
+ * `undefined` when the statement is not a string this call fixes — a
32
+ * `Submittable` (a cursor, say), or a config object with no `text`. That is
33
+ * not "no statement": it is "the direction is not decidable", which
34
+ * {@link pgCapabilities} turns into both directions.
35
+ */
36
+ function statementText(value) {
37
+ if (typeof value === "string")
38
+ return value;
39
+ if (value !== null && typeof value === "object") {
40
+ const text = value.text;
41
+ if (typeof text === "string")
42
+ return text;
43
+ }
44
+ return undefined;
45
+ }
46
+ /**
47
+ * The database a `pg` client is connected to, or `undefined` when the client
48
+ * does not name one — an environment-only configuration, or a
49
+ * `connectionString` with no path. Each of the shapes below is somewhere `pg`
50
+ * actually keeps it (`Pool#options`, `Client#connectionParameters`, and the
51
+ * `database` a connected client exposes).
52
+ */
53
+ function databaseOf(client) {
54
+ if (client === null || typeof client !== "object")
55
+ return undefined;
56
+ const record = client;
57
+ for (const source of [record, record.options, record.connectionParameters]) {
58
+ if (source === null || typeof source !== "object")
59
+ continue;
60
+ const config = source;
61
+ const database = config.database;
62
+ if (typeof database === "string" && database.length > 0)
63
+ return database;
64
+ const fromUrl = databaseFromConnectionString(config.connectionString);
65
+ if (fromUrl !== undefined)
66
+ return fromUrl;
67
+ }
68
+ return undefined;
69
+ }
70
+ function databaseFromConnectionString(value) {
71
+ if (typeof value !== "string")
72
+ return undefined;
73
+ try {
74
+ const name = new URL(value).pathname.replace(/^\//, "");
75
+ return name.length > 0 ? decodeURIComponent(name) : undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Wrap `Pool.prototype.query` and `Client.prototype.query` on the `pg` module
83
+ * handed in, returning the function that restores both.
84
+ *
85
+ * `query` is promise-returning unless a callback is passed, so a denial
86
+ * rejects, or reaches the callback through `process.nextTick` when there is
87
+ * one — §4.4 (a)'s rule for each family.
88
+ */
89
+ export function installPgHook(pg) {
90
+ const restores = [];
91
+ for (const clientClass of [pg.Pool, pg.Client]) {
92
+ const prototype = clientClass?.prototype;
93
+ if (!prototype)
94
+ continue;
95
+ const original = prototype.query;
96
+ if (typeof original !== "function")
97
+ continue;
98
+ const target = original;
99
+ const hooked = function (...args) {
100
+ const { capabilities, detail } = pgCapabilities(this, args);
101
+ const error = checkCapabilities(capabilities, detail);
102
+ if (!error)
103
+ return target.apply(this, args);
104
+ const callback = args[args.length - 1];
105
+ if (typeof callback === "function") {
106
+ process.nextTick(callback, error);
107
+ return undefined;
108
+ }
109
+ return Promise.reject(error);
110
+ };
111
+ Object.defineProperty(hooked, "name", { value: "query" });
112
+ prototype.query = hooked;
113
+ restores.push(() => {
114
+ if (prototype.query === hooked)
115
+ prototype.query = original;
116
+ });
117
+ }
118
+ return () => {
119
+ for (const restore of restores)
120
+ restore();
121
+ };
122
+ }
@@ -0,0 +1,34 @@
1
+ import type { KnownEffect } from "../core/index.ts";
2
+ /**
3
+ * Effect table for `new X(...)`, keyed in its own namespace: every key is the
4
+ * `calleeQualifiedName` of a call site, prefixed by {@link CONSTRUCTOR_KEY_PREFIX}
5
+ * (`"new Date"`, `"new @prisma/client.PrismaClient"`). The prefix keeps
6
+ * construction and plain calls apart — `URL(...)` and `new URL(...)` are not
7
+ * the same operation, and a space cannot occur in the qualified name a call
8
+ * produces, so the two key spaces can never collide.
9
+ *
10
+ * This table exists because before it, `new X(...)` was dropped from the call
11
+ * graph entirely: a function declared `@effects pure` that constructed a
12
+ * database client reported no call at all, not even `unknown`. DESIGN.md §3.4
13
+ * forbids turning an unanalyzed path into "no violation".
14
+ *
15
+ * Trust level: bundled with Ambit — the highest level in DESIGN.md §8.
16
+ */
17
+ export declare const CONSTRUCTOR_KEY_PREFIX = "new ";
18
+ export declare function constructorStubKey(qualifiedName: string): string;
19
+ /**
20
+ * The effect of constructing `qualifiedName` (a {@link constructorStubKey}),
21
+ * or `undefined` when this table says nothing about it — which leaves the
22
+ * call `unknown`, never "no effect".
23
+ *
24
+ * `withoutArguments` distinguishes `new Date()` from `new Date(x)`; pass
25
+ * `true` when the construction has no arguments.
26
+ */
27
+ export declare function lookupConstructorEffect(qualifiedName: string, withoutArguments: boolean): KnownEffect | undefined;
28
+ /**
29
+ * True when constructing `qualifiedName` is known to have no `KnownEffect`.
30
+ * A nullary-only effect entry (`new Date()`) is excluded when the
31
+ * construction actually is nullary.
32
+ */
33
+ export declare function isKnownPureConstructor(qualifiedName: string, withoutArguments: boolean): boolean;
34
+ export declare function isConstructorKey(qualifiedName: string): boolean;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Effect table for `new X(...)`, keyed in its own namespace: every key is the
3
+ * `calleeQualifiedName` of a call site, prefixed by {@link CONSTRUCTOR_KEY_PREFIX}
4
+ * (`"new Date"`, `"new @prisma/client.PrismaClient"`). The prefix keeps
5
+ * construction and plain calls apart — `URL(...)` and `new URL(...)` are not
6
+ * the same operation, and a space cannot occur in the qualified name a call
7
+ * produces, so the two key spaces can never collide.
8
+ *
9
+ * This table exists because before it, `new X(...)` was dropped from the call
10
+ * graph entirely: a function declared `@effects pure` that constructed a
11
+ * database client reported no call at all, not even `unknown`. DESIGN.md §3.4
12
+ * forbids turning an unanalyzed path into "no violation".
13
+ *
14
+ * Trust level: bundled with Ambit — the highest level in DESIGN.md §8.
15
+ */
16
+ export const CONSTRUCTOR_KEY_PREFIX = "new ";
17
+ export function constructorStubKey(qualifiedName) {
18
+ return `${CONSTRUCTOR_KEY_PREFIX}${qualifiedName}`;
19
+ }
20
+ const CONSTRUCTOR_EFFECTS = new Map([
21
+ ["new node:net.Socket", "network"],
22
+ ["new node:tls.TLSSocket", "network"],
23
+ ["new node:http.Agent", "network"],
24
+ ["new node:https.Agent", "network"],
25
+ ["new WebSocket", "network"],
26
+ ["new node:worker_threads.Worker", "process"],
27
+ ]);
28
+ /**
29
+ * Constructors that read non-deterministic input only when called with no
30
+ * arguments. `new Date()` reads the clock (DESIGN.md §4.2 lists the clock
31
+ * under `env`); `new Date(2020, 0, 1)` is a pure conversion of its arguments.
32
+ */
33
+ const NULLARY_ONLY_EFFECTS = new Map([["new Date", "env"]]);
34
+ /**
35
+ * Constructors known to perform none of Ambit's `KnownEffect`s. As with
36
+ * `src/stubs/pure-builtins.ts`, this claims "no `KnownEffect`", not purity in
37
+ * a stricter sense — `new Map()` allocates mutable state, which is outside
38
+ * the effect model (DESIGN.md §12).
39
+ *
40
+ * A constructor that takes a callback (`Promise`) is listed here, but the
41
+ * connector layer marks `new Promise(namedExecutor)` `callbackByReference`
42
+ * and `summarize.ts` then refuses the pure verdict — the executor's body was
43
+ * never walked (DESIGN.md §4.2 rule 4).
44
+ */
45
+ const PURE_CONSTRUCTORS = new Set([
46
+ "new Map",
47
+ "new Set",
48
+ "new WeakMap",
49
+ "new WeakSet",
50
+ "new Array",
51
+ "new Object",
52
+ "new Promise",
53
+ "new RegExp",
54
+ "new Error",
55
+ "new TypeError",
56
+ "new RangeError",
57
+ "new SyntaxError",
58
+ "new EvalError",
59
+ "new ReferenceError",
60
+ "new AggregateError",
61
+ "new URL",
62
+ "new URLSearchParams",
63
+ "new TextEncoder",
64
+ "new TextDecoder",
65
+ "new AbortController",
66
+ "new Number",
67
+ "new String",
68
+ "new Boolean",
69
+ "new Int8Array",
70
+ "new Uint8Array",
71
+ "new Uint8ClampedArray",
72
+ "new Int16Array",
73
+ "new Uint16Array",
74
+ "new Int32Array",
75
+ "new Uint32Array",
76
+ "new Float32Array",
77
+ "new Float64Array",
78
+ "new BigInt64Array",
79
+ "new BigUint64Array",
80
+ "new ArrayBuffer",
81
+ "new DataView",
82
+ ]);
83
+ /**
84
+ * The effect of constructing `qualifiedName` (a {@link constructorStubKey}),
85
+ * or `undefined` when this table says nothing about it — which leaves the
86
+ * call `unknown`, never "no effect".
87
+ *
88
+ * `withoutArguments` distinguishes `new Date()` from `new Date(x)`; pass
89
+ * `true` when the construction has no arguments.
90
+ */
91
+ export function lookupConstructorEffect(qualifiedName, withoutArguments) {
92
+ const always = CONSTRUCTOR_EFFECTS.get(qualifiedName);
93
+ if (always)
94
+ return always;
95
+ return withoutArguments ? NULLARY_ONLY_EFFECTS.get(qualifiedName) : undefined;
96
+ }
97
+ /**
98
+ * True when constructing `qualifiedName` is known to have no `KnownEffect`.
99
+ * A nullary-only effect entry (`new Date()`) is excluded when the
100
+ * construction actually is nullary.
101
+ */
102
+ export function isKnownPureConstructor(qualifiedName, withoutArguments) {
103
+ if (lookupConstructorEffect(qualifiedName, withoutArguments) !== undefined)
104
+ return false;
105
+ if (NULLARY_ONLY_EFFECTS.has(qualifiedName))
106
+ return true;
107
+ return PURE_CONSTRUCTORS.has(qualifiedName);
108
+ }
109
+ export function isConstructorKey(qualifiedName) {
110
+ return qualifiedName.startsWith(CONSTRUCTOR_KEY_PREFIX);
111
+ }
@@ -0,0 +1,9 @@
1
+ import type { KnownEffect, LiteralArgument } from "../core/index.ts";
2
+ /**
3
+ * The effects of a database or LLM client call, or `undefined` when this table
4
+ * says nothing about it — which leaves the call `unknown`, never "no effect".
5
+ *
6
+ * `literalArguments` is what the connector layer could read at the call site;
7
+ * it decides the direction for the SQL-statement methods above.
8
+ */
9
+ export declare function lookupClientEffects(qualifiedName: string, literalArguments: readonly (LiteralArgument | undefined)[] | undefined): readonly KnownEffect[] | undefined;
@@ -0,0 +1,109 @@
1
+ import { sqlStatementDirection } from "../core/index.js";
2
+ /**
3
+ * Both directions, for an operation whose direction the source does not fix.
4
+ *
5
+ * This is the conservative reading, and it is a choice with a cost: a function
6
+ * that only ever reads, but builds its statement dynamically, has to declare
7
+ * `db_write` too. The alternative — picking `db_read` — would let a generated
8
+ * `UPDATE` pass a `@effects db_read` contract, which is the failure this whole
9
+ * layer exists to prevent (DESIGN.md §3.4).
10
+ */
11
+ const READ_AND_WRITE = ["db_read", "db_write"];
12
+ /**
13
+ * Methods whose effect is decided by a SQL statement argument, and which
14
+ * argument holds it. A literal statement (or a template literal whose static
15
+ * head reaches the first keyword) is classified by that keyword; anything else
16
+ * is {@link READ_AND_WRITE}.
17
+ */
18
+ const SQL_STATEMENT_ARGUMENT = new Map([
19
+ ["pg.Pool.query", 0],
20
+ ["pg.Client.query", 0],
21
+ ["pg.PoolClient.query", 0],
22
+ ["mysql2.Pool.query", 0],
23
+ ["mysql2.Pool.execute", 0],
24
+ ["mysql2.Connection.query", 0],
25
+ ["mysql2.Connection.execute", 0],
26
+ ]);
27
+ const CLIENT_METHOD_RULES = [
28
+ // @prisma/client — the delegate methods are the documented public API; the
29
+ // model segment is whatever the user's schema declares.
30
+ { pattern: "@prisma/client.PrismaClient.*.findMany", effects: ["db_read"] },
31
+ { pattern: "@prisma/client.PrismaClient.*.findFirst", effects: ["db_read"] },
32
+ { pattern: "@prisma/client.PrismaClient.*.findFirstOrThrow", effects: ["db_read"] },
33
+ { pattern: "@prisma/client.PrismaClient.*.findUnique", effects: ["db_read"] },
34
+ { pattern: "@prisma/client.PrismaClient.*.findUniqueOrThrow", effects: ["db_read"] },
35
+ { pattern: "@prisma/client.PrismaClient.*.count", effects: ["db_read"] },
36
+ { pattern: "@prisma/client.PrismaClient.*.aggregate", effects: ["db_read"] },
37
+ { pattern: "@prisma/client.PrismaClient.*.groupBy", effects: ["db_read"] },
38
+ { pattern: "@prisma/client.PrismaClient.*.create", effects: ["db_write"] },
39
+ { pattern: "@prisma/client.PrismaClient.*.createMany", effects: ["db_write"] },
40
+ { pattern: "@prisma/client.PrismaClient.*.update", effects: ["db_write"] },
41
+ { pattern: "@prisma/client.PrismaClient.*.updateMany", effects: ["db_write"] },
42
+ { pattern: "@prisma/client.PrismaClient.*.upsert", effects: ["db_write"] },
43
+ { pattern: "@prisma/client.PrismaClient.*.delete", effects: ["db_write"] },
44
+ { pattern: "@prisma/client.PrismaClient.*.deleteMany", effects: ["db_write"] },
45
+ // Prisma splits raw access by direction itself, so no statement inspection
46
+ // is needed: `$queryRaw` is for `SELECT`, `$executeRaw` for everything else.
47
+ { pattern: "@prisma/client.PrismaClient.$queryRaw", effects: ["db_read"] },
48
+ { pattern: "@prisma/client.PrismaClient.$queryRawUnsafe", effects: ["db_read"] },
49
+ { pattern: "@prisma/client.PrismaClient.$executeRaw", effects: ["db_write"] },
50
+ { pattern: "@prisma/client.PrismaClient.$executeRawUnsafe", effects: ["db_write"] },
51
+ // A transaction's callback is not walked from here, and its statements are
52
+ // whatever the caller put in it.
53
+ { pattern: "@prisma/client.PrismaClient.$transaction", effects: READ_AND_WRITE },
54
+ // openai — both the named and the default export are in use in the wild, so
55
+ // both spellings of the class key are listed.
56
+ { pattern: "openai.OpenAI.chat.completions.create", effects: ["llm"] },
57
+ { pattern: "openai.default.chat.completions.create", effects: ["llm"] },
58
+ { pattern: "openai.OpenAI.responses.create", effects: ["llm"] },
59
+ { pattern: "openai.default.responses.create", effects: ["llm"] },
60
+ { pattern: "openai.OpenAI.embeddings.create", effects: ["llm"] },
61
+ { pattern: "openai.default.embeddings.create", effects: ["llm"] },
62
+ // @anthropic-ai/sdk
63
+ { pattern: "@anthropic-ai/sdk.Anthropic.messages.create", effects: ["llm"] },
64
+ { pattern: "@anthropic-ai/sdk.default.messages.create", effects: ["llm"] },
65
+ ];
66
+ /**
67
+ * The effects of a database or LLM client call, or `undefined` when this table
68
+ * says nothing about it — which leaves the call `unknown`, never "no effect".
69
+ *
70
+ * `literalArguments` is what the connector layer could read at the call site;
71
+ * it decides the direction for the SQL-statement methods above.
72
+ */
73
+ export function lookupClientEffects(qualifiedName, literalArguments) {
74
+ const statementArgument = SQL_STATEMENT_ARGUMENT.get(qualifiedName);
75
+ if (statementArgument !== undefined) {
76
+ return sqlStatementEffects(literalArguments?.[statementArgument]);
77
+ }
78
+ for (const rule of CLIENT_METHOD_RULES) {
79
+ if (patternMatches(rule.pattern, qualifiedName))
80
+ return rule.effects;
81
+ }
82
+ return undefined;
83
+ }
84
+ /**
85
+ * The direction of one SQL statement, as effects. The keyword rule itself is
86
+ * `src/core/sql.ts`, shared with the runtime `pg` hook so the checker and the
87
+ * running process cannot disagree about the same statement (DESIGN.md §4.4).
88
+ */
89
+ function sqlStatementEffects(argument) {
90
+ switch (sqlStatementDirection(argument?.text)) {
91
+ case "read":
92
+ return ["db_read"];
93
+ case "write":
94
+ return ["db_write"];
95
+ default:
96
+ return READ_AND_WRITE;
97
+ }
98
+ }
99
+ function patternMatches(pattern, qualifiedName) {
100
+ if (pattern === qualifiedName)
101
+ return true;
102
+ if (!pattern.includes("*"))
103
+ return false;
104
+ const patternSegments = pattern.split(".");
105
+ const nameSegments = qualifiedName.split(".");
106
+ if (patternSegments.length !== nameSegments.length)
107
+ return false;
108
+ return patternSegments.every((segment, index) => segment === "*" || segment === nameSegments[index]);
109
+ }
@@ -0,0 +1,15 @@
1
+ import type { Capability, LiteralArgument } from "../core/index.ts";
2
+ export interface HttpCapabilityRequirement {
3
+ /** The capability the call requires, when the host could be read from the source. */
4
+ readonly capability?: Capability;
5
+ /** Set when this operation has a target but the source does not fix it. */
6
+ readonly targetUnknown?: true;
7
+ }
8
+ /**
9
+ * The capability requirement of one call, or `undefined` when this table knows
10
+ * of no target for it — which is not the same as "requires nothing": every
11
+ * other operation simply has no rule here yet (`node:fs`, DB clients, LLM
12
+ * SDKs), and §4.4's caveat forbids inferring one for a DB client from a SQL
13
+ * string.
14
+ */
15
+ export declare function lookupHttpCapability(qualifiedName: string, literalArguments: readonly (LiteralArgument | undefined)[] | undefined): HttpCapabilityRequirement | undefined;