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,240 @@
1
+ import type { UnresolvedReason } from "./backend.ts";
2
+ import type { Budget } from "./budget.ts";
3
+ import type { Capability, CapabilitySet } from "./capability.ts";
4
+ import type { EffectSet, KnownEffect } from "./effects.ts";
5
+ import type { SourceLocation } from "./location.ts";
6
+ import type { SymbolId } from "./symbol-id.ts";
7
+ /** A call resolved to another function Ambit has a summary for. */
8
+ export interface ResolvedCall {
9
+ readonly kind: "resolved";
10
+ readonly location: SourceLocation;
11
+ readonly callee: SymbolId;
12
+ }
13
+ /**
14
+ * A call matched against a known stub (`src/stubs/`); its effects are known
15
+ * directly.
16
+ *
17
+ * `effects` is a set, not one effect, because an operation's direction is not
18
+ * always decidable from the source: `pool.query(sql)` with a non-literal
19
+ * statement may read or write, and the stub table answers with both rather
20
+ * than picking one (DESIGN.md §4.2, "Operations whose read/write direction is
21
+ * not statically determined"). One call site produces one
22
+ * `StubCall` whatever the size of that set — never one per effect, which
23
+ * would double-count it in `--coverage`.
24
+ */
25
+ export interface StubCall {
26
+ readonly kind: "stub";
27
+ readonly location: SourceLocation;
28
+ readonly effects: readonly KnownEffect[];
29
+ readonly qualifiedName: string;
30
+ /**
31
+ * The capability this call requires, when the stub tables know of a target
32
+ * and the source fixes it — a literal URL's host (DESIGN.md §4.4's static
33
+ * half). Absent when the operation has no target this layer claims to know.
34
+ */
35
+ readonly requiredCapability?: Capability;
36
+ /**
37
+ * Set when the operation *has* a target the runtime will match but the
38
+ * source does not fix it (a URL built at runtime). Distinct from having no
39
+ * requirement at all: the caller's capability requirement is then not fully
40
+ * known, which is `AMB-W003`, not silence.
41
+ */
42
+ readonly capabilityTargetUnknown?: true;
43
+ }
44
+ /**
45
+ * A call matched against the pure-builtins allowlist (`src/stubs/
46
+ * pure-builtins.ts`) — a default-lib method (e.g. `Set.has`, `Array.map`)
47
+ * known to have no effect. `qualifiedName` is in that allowlist's own
48
+ * namespace (`checker.getFullyQualifiedName()` form), a different space from
49
+ * `StubCall.qualifiedName`'s module-specifier form — never compare the two.
50
+ */
51
+ export interface KnownPureCall {
52
+ readonly kind: "known-pure";
53
+ readonly location: SourceLocation;
54
+ readonly qualifiedName: string;
55
+ }
56
+ /**
57
+ * A call whose target or effects could not be determined (DESIGN.md §4.2
58
+ * rule 6). `qualifiedName` is carried through from a stub-lookup miss (see
59
+ * `summarize.ts`'s `toCall`) so `src/checker/coverage.ts` can report which
60
+ * unresolved names recur most — the signal for "what to stub next".
61
+ */
62
+ export interface UnresolvedCall {
63
+ readonly kind: "unresolved";
64
+ readonly location: SourceLocation;
65
+ readonly reason: UnresolvedReason;
66
+ readonly qualifiedName?: string;
67
+ }
68
+ /**
69
+ * A site that changes a value in place (DESIGN.md §4.2, "Local mutation and
70
+ * `pure`") — a mutating builtin method (`src/stubs/mutating-builtins.ts`)
71
+ * or an assignment / `++` / `delete` targeting a property.
72
+ *
73
+ * `escaping` is the whole decision: `false` means the mutated value was
74
+ * allocated inside the function, so no caller can observe the change and the
75
+ * site carries no effect; `true` means the root is a parameter, `this`, a
76
+ * module-scope binding, or something the analysis could not pin down, and the
77
+ * site carries `state_write`. The undecidable case is over-approximated to
78
+ * `true`, matching the `db_read`/`db_write` rule in §4.2.
79
+ *
80
+ * `unknownCallback` is a mutator handed a callback by reference
81
+ * (`xs.sort(cmp)`): the mutation is known, the callback's own effects are not
82
+ * (§4.2 rule 4), so the site is both `state_write` and `unknown`.
83
+ */
84
+ export interface MutationCall {
85
+ readonly kind: "mutation";
86
+ readonly location: SourceLocation;
87
+ readonly escaping: boolean;
88
+ readonly qualifiedName?: string;
89
+ readonly unknownCallback?: true;
90
+ }
91
+ export type Call = ResolvedCall | StubCall | KnownPureCall | UnresolvedCall | MutationCall;
92
+ /**
93
+ * Whether a call site leaves the caller's effect set incomplete — an
94
+ * unresolved call, or a mutator whose callback was passed by reference.
95
+ * Both mean "there may be more effects here than are listed".
96
+ */
97
+ export declare function callLeavesUnknown(call: Call): boolean;
98
+ /**
99
+ * Whether a function declared `@effects`, and what.
100
+ *
101
+ * `{ kind: "none" }` is distinct from a declared empty set: it means no tag
102
+ * was present at all (undeclared), which this slice treats as a coverage
103
+ * concern rather than as `unknown` in propagation (DESIGN.md §4.2:
104
+ * "Undeclared" and "`unknown`" are not the same thing).
105
+ *
106
+ * `{ kind: "invalid" }` means a tag was present but contained a token that is
107
+ * neither `pure` nor a known effect (a typo, e.g. `@effects netwrok`) — see
108
+ * `AMB-E002` in `diagnose.ts`. Treated the same as `"none"` everywhere except
109
+ * diagnosis: a broken declaration must not silently collapse to `pure` (an
110
+ * empty set), and its caller must not trust it as a boundary either.
111
+ */
112
+ export type DeclaredEffects = {
113
+ readonly kind: "none";
114
+ } | {
115
+ readonly kind: "invalid";
116
+ readonly raw: string;
117
+ } | {
118
+ readonly kind: "declared";
119
+ readonly effects: EffectSet;
120
+ };
121
+ /**
122
+ * Whether a function declared `@capabilities`, and what. Mirrors
123
+ * {@link DeclaredEffects}: `"invalid"` is a tag that was present but did not
124
+ * parse, and is treated as undeclared everywhere except diagnosis, so a broken
125
+ * declaration never reads as a narrower grant than was written.
126
+ */
127
+ export type DeclaredCapabilities = {
128
+ readonly kind: "none";
129
+ } | {
130
+ readonly kind: "invalid";
131
+ readonly raw: string;
132
+ } | {
133
+ readonly kind: "declared";
134
+ readonly capabilities: CapabilitySet;
135
+ };
136
+ /** Whether a function declared `@budget`, and what. */
137
+ export type DeclaredBudget = {
138
+ readonly kind: "none";
139
+ } | {
140
+ readonly kind: "invalid";
141
+ readonly raw: string;
142
+ } | {
143
+ readonly kind: "declared";
144
+ readonly budget: Budget;
145
+ };
146
+ /**
147
+ * Whether a function declared `@boundary` (DESIGN.md §4.6) — an explicit
148
+ * statement that its body is not statically checked and that the contract it
149
+ * declares to the outside is to be trusted instead.
150
+ *
151
+ * `reason` is required by §4.6, so a tag without one is `"invalid"`: an
152
+ * unexplained hole in the analysis is the thing the tag exists to make
153
+ * visible.
154
+ */
155
+ export type DeclaredBoundary = {
156
+ readonly kind: "none";
157
+ } | {
158
+ readonly kind: "invalid";
159
+ readonly raw: string;
160
+ } | {
161
+ readonly kind: "declared";
162
+ readonly reason: string;
163
+ };
164
+ /**
165
+ * One tag where JSDoc and `ambit.config.ts` both declared something and the
166
+ * two did not agree (DESIGN.md §4.1: "If a symbol has both JSDoc and config,
167
+ * JSDoc wins and the difference is warned about"). Reported as `AMB-W005`.
168
+ *
169
+ * Both sides are held as their formatted text, not as parsed objects: the
170
+ * comparison has already happened, and what the message needs is the two
171
+ * declarations as a reader would recognize them.
172
+ */
173
+ export interface ContractDivergence {
174
+ /** The contract tag: `effects`, `capabilities`, `budget`, `entrypoint` or `boundary`. */
175
+ readonly tag: string;
176
+ readonly jsDoc: string;
177
+ readonly config: string;
178
+ }
179
+ /**
180
+ * Where one declaration came from.
181
+ *
182
+ * `"spec"` is a `withAmbit` / `ambitHandler` registration in the same file as
183
+ * the handler it names, whose `capabilities` / `budget` the source fixes as
184
+ * literals (DESIGN.md §4.4). It is a declaration, not an observation: the
185
+ * runtime establishes exactly that set, so the source has already said what
186
+ * the contract is and the JSDoc tag beside it would only repeat it.
187
+ *
188
+ * Only `"jsdoc"` and `"config"` can ever appear on {@link
189
+ * ContractOrigins.effects}: `@effects` is never delivered to the runtime and
190
+ * so has no place in a spec.
191
+ */
192
+ export type DeclarationOrigin = "jsdoc" | "config" | "spec";
193
+ /**
194
+ * Which side supplied each contract tag, for the tags anything supplied.
195
+ *
196
+ * Per tag rather than per function because the sides fill different tags: a
197
+ * function can take `@effects` from its JSDoc and its capability set from the
198
+ * registration beside it, and `--coverage`'s `declared-by` breakdown counts
199
+ * the `effects` half (DESIGN.md §4.1, "Out-of-code declarations").
200
+ */
201
+ export interface ContractOrigins {
202
+ readonly effects?: DeclarationOrigin;
203
+ readonly capabilities?: DeclarationOrigin;
204
+ readonly budget?: DeclarationOrigin;
205
+ }
206
+ /** Ambit's own representation of one function, independent of any backend. */
207
+ export interface FunctionSummary {
208
+ readonly id: SymbolId;
209
+ readonly location: SourceLocation;
210
+ /**
211
+ * Where each contract tag was written, carried through from
212
+ * {@link RawJsDoc.tagLocations} so a fix can rewrite the tag in place
213
+ * instead of guessing at a line (DESIGN.md §5.3).
214
+ */
215
+ readonly tagLocations: ReadonlyMap<string, SourceLocation>;
216
+ /** Where a new contract comment would go (see {@link ExtractedFunction.declarationStart}). */
217
+ readonly declarationStart: SourceLocation;
218
+ /** The existing JSDoc block, when there is exactly one — a tag can be added to it. */
219
+ readonly jsDocRange?: SourceLocation;
220
+ /** A class's construction with no constructor written: real, but with nowhere to hang a contract. */
221
+ readonly implicitConstructor?: true;
222
+ /** Only `ambit.config.ts` can declare this symbol — see {@link ExtractedFunction.configOnly}. */
223
+ readonly configOnly?: true;
224
+ /**
225
+ * Which side supplied each declared tag, when anything did. Absent for a
226
+ * function nothing declared anything for. `--coverage` counts the `effects`
227
+ * half apart so a codebase can see how much of its contract surface lives
228
+ * outside the code (DESIGN.md §4.1, "Out-of-code declarations").
229
+ */
230
+ readonly declaredBy?: ContractOrigins;
231
+ /** Tags JSDoc and config both declared and disagreed on. JSDoc is what {@link FunctionSummary} carries. */
232
+ readonly divergences?: readonly ContractDivergence[];
233
+ readonly declared: DeclaredEffects;
234
+ readonly capabilities: DeclaredCapabilities;
235
+ readonly budget: DeclaredBudget;
236
+ readonly boundary: DeclaredBoundary;
237
+ /** `@entrypoint` (DESIGN.md §4.1): where the runtime establishes a context. */
238
+ readonly entrypoint: boolean;
239
+ readonly calls: readonly Call[];
240
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether a call site leaves the caller's effect set incomplete — an
3
+ * unresolved call, or a mutator whose callback was passed by reference.
4
+ * Both mean "there may be more effects here than are listed".
5
+ */
6
+ export function callLeavesUnknown(call) {
7
+ return call.kind === "unresolved" || (call.kind === "mutation" && call.unknownCallback === true);
8
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A stable identifier for a function or method declaration.
3
+ *
4
+ * Built from the file path (relative to the project root passed to
5
+ * `ambit check`) and a "."-joined declaration path (e.g. `["Foo",
6
+ * "bar"]` for method `bar` on class `Foo`), joined with `#`
7
+ * (`src/tax.ts#calculateTax`, matching the form used in DESIGN.md §5.1's
8
+ * `contract.via[].symbol`).
9
+ *
10
+ * Never derived from a compiler-internal id or snapshot offset (DESIGN.md
11
+ * §5.3), so it stays valid across re-analysis and across backends.
12
+ */
13
+ export type SymbolId = string & {
14
+ readonly __brand: "SymbolId";
15
+ };
16
+ export declare function symbolId(relativeFilePath: string, declarationPath: readonly string[]): SymbolId;
17
+ /**
18
+ * The short, human-facing name for a symbol id: the last segment of the
19
+ * declaration path (`src/tax.ts#Foo.bar` → `bar`). Used by diagnostic
20
+ * messages and by the CLI's human-readable rendering, which is why it lives
21
+ * here rather than beside either one — DESIGN.md §5 makes the text output a
22
+ * rendering of the structured diagnostic, so both sides must name a symbol
23
+ * the same way.
24
+ */
25
+ export declare function displayName(id: SymbolId): string;
@@ -0,0 +1,23 @@
1
+ export function symbolId(relativeFilePath, declarationPath) {
2
+ return `${relativeFilePath}#${declarationPath.join(".")}`;
3
+ }
4
+ /**
5
+ * The short, human-facing name for a symbol id: the last segment of the
6
+ * declaration path (`src/tax.ts#Foo.bar` → `bar`). Used by diagnostic
7
+ * messages and by the CLI's human-readable rendering, which is why it lives
8
+ * here rather than beside either one — DESIGN.md §5 makes the text output a
9
+ * rendering of the structured diagnostic, so both sides must name a symbol
10
+ * the same way.
11
+ */
12
+ export function displayName(id) {
13
+ const afterHash = id.split("#")[1] ?? id;
14
+ const parts = afterHash.split(".");
15
+ const last = parts[parts.length - 1];
16
+ if (last === undefined)
17
+ return id;
18
+ // A bare "constructor" names nothing — keep the class with it
19
+ // (`Client.constructor`), since every class contributes one.
20
+ if (last === "constructor" && parts.length >= 2)
21
+ return `${parts[parts.length - 2]}.${last}`;
22
+ return last;
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Package entry point. Re-exports the contract model and diagnostic types so a
3
+ * consumer can type NDJSON output from `ambit check` without depending on the
4
+ * checker or on `typescript`.
5
+ *
6
+ * Runtime enforcement is a separate entry (`ambit-ts/runtime`) so a production
7
+ * process imports only what it needs (DESIGN.md §6: "Production uses
8
+ * `ambit-ts/runtime` and the necessary adapters and contract data. The compiler
9
+ * and the development CLI are not to be required production dependencies").
10
+ * The single package does not yet meet that: `typescript` is a `dependencies`
11
+ * entry, so importing only the runtime still installs the compiler. §6 records
12
+ * the decision to keep one package until a production adopter exists.
13
+ */
14
+ export * from "./core/index.ts";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Package entry point. Re-exports the contract model and diagnostic types so a
3
+ * consumer can type NDJSON output from `ambit check` without depending on the
4
+ * checker or on `typescript`.
5
+ *
6
+ * Runtime enforcement is a separate entry (`ambit-ts/runtime`) so a production
7
+ * process imports only what it needs (DESIGN.md §6: "Production uses
8
+ * `ambit-ts/runtime` and the necessary adapters and contract data. The compiler
9
+ * and the development CLI are not to be required production dependencies").
10
+ * The single package does not yet meet that: `typescript` is a `dependencies`
11
+ * entry, so importing only the runtime still installs the compiler. §6 records
12
+ * the decision to keep one package until a production adopter exists.
13
+ */
14
+ export * from "./core/index.js";
@@ -0,0 +1,29 @@
1
+ export interface SpawnRequirement {
2
+ readonly capability: string;
3
+ readonly detail?: string;
4
+ }
5
+ /**
6
+ * The capability one `child_process` call requires.
7
+ *
8
+ * `spawn`, `execFile` and their `Sync` forms name their program in argv[0],
9
+ * and it is taken **as written** — no `PATH` lookup, because resolving it
10
+ * would mean reading the filesystem to answer a question about a string, and
11
+ * the answer would depend on the environment rather than on the call.
12
+ *
13
+ * `exec`, `execSync`, and any form with `shell: true` run a shell. Which
14
+ * program the shell then runs is inside a shell command string, and naming it
15
+ * would need a shell parser — the same claim §4.4 forbids for table names
16
+ * inside arbitrary SQL. So the capability names the shell, and the exception
17
+ * says exactly that.
18
+ */
19
+ export declare function spawnCapability(kind: "spawn" | "exec" | "fork", args: readonly unknown[]): SpawnRequirement;
20
+ /**
21
+ * Replace the checked members of `node:child_process`, returning the function
22
+ * that puts them back — the shape `installFetchHook` established.
23
+ *
24
+ * `spawn` and `fork` have no callback to hand an error to and return a
25
+ * `ChildProcess` rather than a result, so a denial throws; `exec` and
26
+ * `execFile` deliver through their callback when one is present, which is
27
+ * §4.4 (a)'s rule for the callback family.
28
+ */
29
+ export declare function installChildProcessHook(): () => void;
@@ -0,0 +1,124 @@
1
+ import { createRequire } from "node:module";
2
+ import { checkCapability } from "./enforce.js";
3
+ /** `createRequire` rather than `import`, for the reason `fs.ts` documents. */
4
+ const requireBuiltin = createRequire(import.meta.url);
5
+ const childProcess = requireBuiltin("node:child_process");
6
+ /**
7
+ * Runtime enforcement for `node:child_process` (DESIGN.md §4.4 (a), (b)).
8
+ *
9
+ * Same monkeypatch mechanism and the same install-order caveat as
10
+ * {@link installFsHook}: installed from a preload it covers named ESM imports
11
+ * too; installed from inside the module graph it covers the default export and
12
+ * `requireBuiltin("node:child_process")`, not a named import already bound. It never
13
+ * covers what happens *inside* the child.
14
+ */
15
+ /** The default shell `exec` uses when `options.shell` does not name one. */
16
+ const DEFAULT_SHELL = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
17
+ /**
18
+ * Why a shell spawn cannot name the program that runs. Carried into the
19
+ * exception text because DESIGN.md §4.4 requires an operation Ambit cannot
20
+ * decide to say so where the user reads it, not only in the docs.
21
+ */
22
+ const SHELL_DETAIL = "this spawns a shell, and the granted shell can run any program: the capability names the shell, not the program in the command string";
23
+ /**
24
+ * The capability one `child_process` call requires.
25
+ *
26
+ * `spawn`, `execFile` and their `Sync` forms name their program in argv[0],
27
+ * and it is taken **as written** — no `PATH` lookup, because resolving it
28
+ * would mean reading the filesystem to answer a question about a string, and
29
+ * the answer would depend on the environment rather than on the call.
30
+ *
31
+ * `exec`, `execSync`, and any form with `shell: true` run a shell. Which
32
+ * program the shell then runs is inside a shell command string, and naming it
33
+ * would need a shell parser — the same claim §4.4 forbids for table names
34
+ * inside arbitrary SQL. So the capability names the shell, and the exception
35
+ * says exactly that.
36
+ */
37
+ export function spawnCapability(kind, args) {
38
+ if (kind === "fork") {
39
+ // A fork always runs this Node binary; the module path is an argument to it.
40
+ return { capability: `proc:spawn:${process.execPath}` };
41
+ }
42
+ const shell = shellOption(args);
43
+ if (kind === "exec" || shell !== undefined) {
44
+ return { capability: `proc:spawn:${shell ?? DEFAULT_SHELL}`, detail: SHELL_DETAIL };
45
+ }
46
+ const command = typeof args[0] === "string" ? args[0] : undefined;
47
+ if (command === undefined) {
48
+ return {
49
+ capability: "proc:spawn:unknown",
50
+ detail: "the command is not a string in this call, so it cannot be named",
51
+ };
52
+ }
53
+ return { capability: `proc:spawn:${command}` };
54
+ }
55
+ /**
56
+ * `options.shell` when the call asks for a shell, `undefined` when it does
57
+ * not. `shell: true` means the platform default.
58
+ */
59
+ function shellOption(args) {
60
+ for (const argument of args.slice(1)) {
61
+ if (argument === null || typeof argument !== "object" || Array.isArray(argument))
62
+ continue;
63
+ const shell = argument.shell;
64
+ if (typeof shell === "string")
65
+ return shell;
66
+ if (shell === true)
67
+ return DEFAULT_SHELL;
68
+ return undefined;
69
+ }
70
+ return undefined;
71
+ }
72
+ /** Which member takes which shape of argument, and how a denial is delivered. */
73
+ const OPERATIONS = [
74
+ { name: "spawn", kind: "spawn", family: "sync" },
75
+ { name: "spawnSync", kind: "spawn", family: "sync" },
76
+ { name: "execFile", kind: "spawn", family: "callback" },
77
+ { name: "execFileSync", kind: "spawn", family: "sync" },
78
+ { name: "exec", kind: "exec", family: "callback" },
79
+ { name: "execSync", kind: "exec", family: "sync" },
80
+ { name: "fork", kind: "fork", family: "sync" },
81
+ ];
82
+ /**
83
+ * Replace the checked members of `node:child_process`, returning the function
84
+ * that puts them back — the shape `installFetchHook` established.
85
+ *
86
+ * `spawn` and `fork` have no callback to hand an error to and return a
87
+ * `ChildProcess` rather than a result, so a denial throws; `exec` and
88
+ * `execFile` deliver through their callback when one is present, which is
89
+ * §4.4 (a)'s rule for the callback family.
90
+ */
91
+ export function installChildProcessHook() {
92
+ const host = childProcess;
93
+ const restores = [];
94
+ for (const { name, kind, family } of OPERATIONS) {
95
+ const original = host[name];
96
+ if (typeof original !== "function")
97
+ continue;
98
+ const target = original;
99
+ const hooked = function (...args) {
100
+ const { capability, detail } = spawnCapability(kind, args);
101
+ const error = checkCapability(capability, detail);
102
+ if (!error)
103
+ return target.apply(this, args);
104
+ if (family === "callback") {
105
+ const callback = args[args.length - 1];
106
+ if (typeof callback === "function") {
107
+ process.nextTick(callback, error);
108
+ return undefined;
109
+ }
110
+ }
111
+ throw error;
112
+ };
113
+ Object.defineProperty(hooked, "name", { value: name });
114
+ host[name] = hooked;
115
+ restores.push(() => {
116
+ if (host[name] === hooked)
117
+ host[name] = original;
118
+ });
119
+ }
120
+ return () => {
121
+ for (const restore of restores)
122
+ restore();
123
+ };
124
+ }
@@ -0,0 +1,37 @@
1
+ import type { Budget, Capability } from "../core/index.ts";
2
+ /**
3
+ * The capability and budget context an entrypoint establishes (DESIGN.md
4
+ * §4.4). `AsyncLocalStorage` holds the context; the blocking itself is the
5
+ * hooks' job — §4.4 is explicit that the two are separate
6
+ * ("`AsyncLocalStorage` is responsible for holding the context; the blocking
7
+ * itself is implemented by the adapter").
8
+ *
9
+ * Nothing here imports `typescript` or anything under `src/checker/`:
10
+ * §3.4 keeps runtime enforcement independent of the analysis engine, so
11
+ * production never depends on a compiler. `test/architecture.test.ts`
12
+ * enforces that.
13
+ */
14
+ export interface AmbitContext {
15
+ readonly capabilities: readonly Capability[];
16
+ readonly budget?: Budget;
17
+ /** Wall-clock start, for `timeMs`. */
18
+ readonly startedAt: number;
19
+ /** Aborted when a `timeMs` budget with `onExceed: "abort"` runs out. */
20
+ readonly signal?: AbortSignal;
21
+ /** Every capability check made in this context, for auditing. */
22
+ readonly audit: AuditEntry[];
23
+ }
24
+ export interface AuditEntry {
25
+ readonly capability: string;
26
+ readonly allowed: boolean;
27
+ readonly reason: "granted" | "denied" | "unscoped";
28
+ }
29
+ /**
30
+ * What a capability check does when no entrypoint context is active
31
+ * (DESIGN.md §4.4 `runtime.unscoped`). `allow` is the default so that
32
+ * adopting the runtime does not break code that has no entrypoints declared
33
+ * yet (P3: incremental adoption).
34
+ */
35
+ export type UnscopedPolicy = "allow" | "warn" | "deny";
36
+ export declare function currentContext(): AmbitContext | undefined;
37
+ export declare function runInContext<T>(context: AmbitContext, fn: () => T): T;
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const storage = new AsyncLocalStorage();
3
+ export function currentContext() {
4
+ return storage.getStore();
5
+ }
6
+ export function runInContext(context, fn) {
7
+ return storage.run(context, fn);
8
+ }
@@ -0,0 +1,52 @@
1
+ import type { Capability } from "../core/index.ts";
2
+ import type { UnscopedPolicy } from "./context.ts";
3
+ /**
4
+ * The capability decision every hook shares (DESIGN.md §4.4).
5
+ *
6
+ * Separate from `index.ts` so that `fs.ts`, `child-process.ts` and `pg.ts` can
7
+ * import the decision without importing the module that installs them, and so
8
+ * that the `runtime.unscoped` policy is one variable rather than one per hook.
9
+ */
10
+ export declare class AmbitCapabilityError extends Error {
11
+ readonly capability: string;
12
+ constructor(capability: string, granted: readonly Capability[], detail?: string);
13
+ }
14
+ /**
15
+ * Set the process-wide `runtime.unscoped` policy (DESIGN.md §4.4).
16
+ *
17
+ * Process-wide and not per-entrypoint on purpose. The policy only applies
18
+ * where there is *no* context, so a per-entrypoint override would be read
19
+ * only by concurrent work running outside every entrypoint — which makes it a
20
+ * mutable global that unrelated calls observe changing mid-flight, not a
21
+ * setting scoped to anything. Set it once at startup.
22
+ */
23
+ export declare function setUnscopedPolicy(policy: UnscopedPolicy): void;
24
+ /**
25
+ * Decide one capability without throwing, so a callback-style API can deliver
26
+ * the denial the way its caller expects (DESIGN.md §4.4: "synchronous APIs
27
+ * `throw`, callback APIs use `process.nextTick(callback, error)`, and Promise
28
+ * APIs reject").
29
+ *
30
+ * Returns the error to deliver, or `undefined` when the operation is allowed.
31
+ * Every decision inside a context — allowed or denied — is appended to the
32
+ * context's {@link AuditEntry} list.
33
+ */
34
+ export declare function checkCapability(required: string, detail?: string): AmbitCapabilityError | undefined;
35
+ /**
36
+ * Decide several capabilities at once, all of which are required. Used where
37
+ * one operation touches two resources (`fs.rename`) or where the source does
38
+ * not fix the direction, so both directions are required (an opaque SQL
39
+ * statement — DESIGN.md §4.4 (c)).
40
+ *
41
+ * Every capability is checked, so the audit records all of them, and the
42
+ * first denial is the one reported.
43
+ */
44
+ export declare function checkCapabilities(required: readonly string[], detail?: string): AmbitCapabilityError | undefined;
45
+ /**
46
+ * Check one capability against the active entrypoint context, throwing
47
+ * {@link AmbitCapabilityError} when it is not granted. Exported so an adapter
48
+ * for an unhooked client can perform the same check.
49
+ *
50
+ * With no active context the `runtime.unscoped` policy decides (§4.4).
51
+ */
52
+ export declare function requireCapability(required: string, detail?: string): void;