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,385 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import { displayName, isEffectsContract } from "../core/index.js";
5
+ import { analyze } from "./analyze.js";
6
+ import { formatDiffGithub, formatDiffText, hasUnapprovedIncrease, runDiff } from "./diff.js";
7
+ import { githubAnnotation, workspacePath } from "./github.js";
8
+ /**
9
+ * Exit codes (plan step 9): distinguish "checked, no error-level violation"
10
+ * from "the check itself could not run" (DESIGN.md §3.4 — never turn an
11
+ * analysis failure into "no violations").
12
+ */
13
+ const USAGE = `Usage: ambit check <dir> [--format json|github] [--coverage] [--strict]
14
+ ambit init <dir> [--format json] [--config] propose @effects for undeclared functions
15
+ ambit diff <ref> [dir] [--format github] report authority the working tree gained over <ref>
16
+ `;
17
+ const EXIT_OK = 0;
18
+ const EXIT_VIOLATIONS = 1;
19
+ const EXIT_ANALYSIS_FAILED = 2;
20
+ /**
21
+ * `diff` shells out to git and writes a worktree, so the entry point's own
22
+ * contract is wider than `check`'s alone (DESIGN.md §6).
23
+ *
24
+ * @effects fs_read, fs_write, process
25
+ */
26
+ export async function main(argv) {
27
+ const args = parseArgs(argv);
28
+ if (args.error) {
29
+ process.stderr.write(`ambit: ${args.error}\n${USAGE}`);
30
+ return EXIT_ANALYSIS_FAILED;
31
+ }
32
+ if (args.command === "diff")
33
+ return await diffCommand(args);
34
+ if (args.command !== "check" && args.command !== "init") {
35
+ process.stderr.write(`Unknown command: ${args.command}\n${USAGE}`);
36
+ return EXIT_ANALYSIS_FAILED;
37
+ }
38
+ let analysis;
39
+ try {
40
+ analysis = await analyze(args.dir, {
41
+ propose: args.command === "init",
42
+ proposeConfig: args.config,
43
+ strict: args.strict,
44
+ });
45
+ }
46
+ catch (error) {
47
+ process.stderr.write(`ambit: analysis failed: ${errorMessage(error)}\n`);
48
+ return EXIT_ANALYSIS_FAILED;
49
+ }
50
+ const { diagnostics, authority, coverage } = analysis;
51
+ for (const diagnostic of diagnostics) {
52
+ process.stdout.write(formatDiagnostic(diagnostic, args));
53
+ }
54
+ // The per-function authority records (DESIGN.md §5.1). Emitted after the
55
+ // diagnostics and before the trailing `summary` line, so a consumer that
56
+ // reads the last record as the summary keeps working, and only for `check`:
57
+ // `init` reports proposals about contracts that do not exist yet.
58
+ if (args.format === "json" && args.command === "check") {
59
+ for (const record of authority) {
60
+ process.stdout.write(`${JSON.stringify(record)}\n`);
61
+ }
62
+ }
63
+ // Always report what was analyzed — a silent, empty result must never
64
+ // read the same as "checked and found nothing" (DESIGN.md §3.4). The
65
+ // detailed unresolved-reason/name breakdown is opt-in via --coverage.
66
+ process.stdout.write(args.format === "json" ? formatSummaryJson(coverage) : formatSummaryText(coverage));
67
+ if (args.coverage) {
68
+ process.stdout.write(args.format === "json" ? formatCoverageJson(coverage) : formatCoverageText(coverage));
69
+ }
70
+ // `init` reports proposals, not violations: a codebase with contracts left
71
+ // to write has not failed a check, so it must not exit non-zero.
72
+ if (args.command === "init")
73
+ return EXIT_OK;
74
+ const hasError = diagnostics.some((d) => d.severity === "error");
75
+ return hasError ? EXIT_VIOLATIONS : EXIT_OK;
76
+ }
77
+ /**
78
+ * `ambit diff <ref>`: what authority the working tree gained over `ref`
79
+ * (DESIGN.md §6).
80
+ *
81
+ * @effects process, fs_read, fs_write
82
+ */
83
+ async function diffCommand(args) {
84
+ let result;
85
+ try {
86
+ result = await runDiff(args.ref, args.dir);
87
+ }
88
+ catch (error) {
89
+ // Either side failing to analyze is exit 2, never 0: a comparison that
90
+ // could not be made must not read as "nothing increased" (DESIGN.md §3.4).
91
+ process.stderr.write(`ambit: diff failed: ${errorMessage(error)}\n`);
92
+ return EXIT_ANALYSIS_FAILED;
93
+ }
94
+ process.stdout.write(args.format === "github" ? formatDiffGithub(result) : formatDiffText(result));
95
+ // An increase with no approval in force fails (DESIGN.md §6.3). An increase
96
+ // carrying an approval added in this same comparison is reported and passes;
97
+ // so are a decrease and a deletion, because taking authority away is not the
98
+ // thing this command watches for, and failing on it would give an author a
99
+ // reason to leave a contract alone.
100
+ return hasUnapprovedIncrease(result) ? EXIT_VIOLATIONS : EXIT_OK;
101
+ }
102
+ const KNOWN_FLAGS = new Set(["--format", "--coverage", "--strict", "--config"]);
103
+ function parseArgs(argv) {
104
+ const [command = "check", ...rest] = argv;
105
+ let dir = ".";
106
+ // `diff` takes the base ref first and the directory second; every other
107
+ // command's first positional is the directory.
108
+ let ref = "";
109
+ const positionals = [];
110
+ let format = "text";
111
+ let coverage = false;
112
+ let strict = false;
113
+ let config = false;
114
+ for (let i = 0; i < rest.length; i++) {
115
+ const arg = rest[i];
116
+ if (arg === "--format") {
117
+ const value = rest[i + 1];
118
+ if (!isOutputFormat(value)) {
119
+ return {
120
+ command,
121
+ dir,
122
+ ref,
123
+ format,
124
+ coverage,
125
+ strict,
126
+ config,
127
+ error: `--format expects ${OUTPUT_FORMATS.map((f) => JSON.stringify(f)).join(", ")}, got ${value === undefined ? "nothing" : JSON.stringify(value)}`,
128
+ };
129
+ }
130
+ format = value;
131
+ i++;
132
+ }
133
+ else if (arg === "--coverage") {
134
+ coverage = true;
135
+ }
136
+ else if (arg === "--strict") {
137
+ strict = true;
138
+ }
139
+ else if (arg === "--config") {
140
+ config = true;
141
+ }
142
+ else if (arg?.startsWith("--")) {
143
+ if (!KNOWN_FLAGS.has(arg)) {
144
+ return {
145
+ command,
146
+ dir,
147
+ ref,
148
+ format,
149
+ coverage,
150
+ strict,
151
+ config,
152
+ error: `unknown option: ${arg}`,
153
+ };
154
+ }
155
+ }
156
+ else if (arg) {
157
+ positionals.push(arg);
158
+ }
159
+ }
160
+ if (command === "diff") {
161
+ const [first, second] = positionals;
162
+ if (first === undefined) {
163
+ return { command, dir, ref, format, coverage, strict, config, error: "diff expects a ref" };
164
+ }
165
+ ref = first;
166
+ if (second !== undefined)
167
+ dir = second;
168
+ // A flag `diff` does not act on is an error, not something to drop
169
+ // quietly: an author who wrote `--strict` and got a green diff would
170
+ // read it as "strict found nothing" (DESIGN.md §3.4, the same reason an
171
+ // unknown option exits 2 rather than running).
172
+ const inert = [
173
+ ...(coverage ? ["--coverage"] : []),
174
+ ...(strict ? ["--strict"] : []),
175
+ ...(config ? ["--config"] : []),
176
+ ...(format === "json" ? ['--format "json"'] : []),
177
+ ];
178
+ if (inert.length > 0) {
179
+ return {
180
+ command,
181
+ dir,
182
+ ref,
183
+ format,
184
+ coverage,
185
+ strict,
186
+ config,
187
+ error: `diff does not support ${inert.join(", ")} (diff takes --format text or github)`,
188
+ };
189
+ }
190
+ }
191
+ else {
192
+ const [first] = positionals;
193
+ if (first !== undefined)
194
+ dir = first;
195
+ }
196
+ return { command, dir, ref, format, coverage, strict, config };
197
+ }
198
+ /**
199
+ * `--format` values. `text` is for a human at a terminal, `json` is the NDJSON
200
+ * of DESIGN.md §5.1 for agents and tools, and `github` renders the same
201
+ * structured diagnostic as GitHub Actions workflow commands so a CI run
202
+ * annotates the offending lines — §6: "CI integrates via the exit code and
203
+ * the structured output. A dedicated CI plugin is not required".
204
+ */
205
+ const OUTPUT_FORMATS = ["text", "json", "github"];
206
+ function isOutputFormat(value) {
207
+ return OUTPUT_FORMATS.some((format) => format === value);
208
+ }
209
+ function formatDiagnostic(diagnostic, args) {
210
+ if (args.format === "json")
211
+ return formatJson(diagnostic);
212
+ if (args.format === "github")
213
+ return formatGithub(diagnostic, args.dir);
214
+ return formatText(diagnostic);
215
+ }
216
+ const GITHUB_COMMAND = {
217
+ error: "error",
218
+ warning: "warning",
219
+ info: "notice",
220
+ };
221
+ /**
222
+ * One GitHub Actions workflow command per diagnostic
223
+ * (`::error file=...,line=...::message`), which is what makes a failing check
224
+ * annotate the offending line in a pull request without installing anything —
225
+ * DESIGN.md §6's "A dedicated CI plugin is not required".
226
+ *
227
+ * The call path and the operation site are folded into the message with `%0A`
228
+ * so the annotation is self-sufficient: a reader on the diff sees every hop
229
+ * without opening the job log.
230
+ *
231
+ * `location.file` is relative to the directory that was checked, while an
232
+ * annotation is resolved from the workspace root, so the path is re-expressed
233
+ * relative to the working directory.
234
+ */
235
+ function formatGithub(diagnostic, rootDir) {
236
+ const { location } = diagnostic;
237
+ return githubAnnotation({
238
+ severity: GITHUB_COMMAND[diagnostic.severity],
239
+ file: workspacePath(rootDir, location.file),
240
+ line: location.line,
241
+ col: location.col,
242
+ title: diagnostic.id,
243
+ body: [diagnostic.message, ...viaPath(diagnostic)],
244
+ });
245
+ }
246
+ function formatJson(diagnostic) {
247
+ return `${JSON.stringify(diagnostic)}\n`;
248
+ }
249
+ /**
250
+ * Human-readable form, rendered from the structured diagnostic (DESIGN.md §5:
251
+ * "Human-facing display is implemented as a rendering of the structured
252
+ * diagnostics").
253
+ *
254
+ * The header line reports the function that declared the contract, at its own
255
+ * `file:line`. `contract.via` — the call path from there to the function that
256
+ * carries what was observed — follows as one indented line per hop, each with
257
+ * its own `file:line`, so the middle of the path is readable without
258
+ * re-running the check with `--format json`.
259
+ */
260
+ function formatText(diagnostic) {
261
+ const { severity, message, location } = diagnostic;
262
+ const header = `${severity}: ${message} (${location.file}:${location.line})\n`;
263
+ return (header +
264
+ viaPath(diagnostic)
265
+ .map((hop) => ` ${hop}\n`)
266
+ .join(""));
267
+ }
268
+ /**
269
+ * One entry per hop in `contract.via`, as `-> name (file:line)`.
270
+ *
271
+ * Empty when the diagnostic has no hops: the effect is performed in the
272
+ * reported function's own body, so there is no call path, and a path that does
273
+ * not exist is not synthesized (DESIGN.md §5.3 — the same rule that forbids
274
+ * fabricating a fix candidate).
275
+ */
276
+ function viaPath(diagnostic) {
277
+ const contract = diagnostic.contract;
278
+ const via = contract?.via ?? [];
279
+ // The operation site is not a hop — it is where, inside the last function of
280
+ // the path, the effect is performed — so it is labelled rather than arrowed,
281
+ // and it appears for a direct diagnostic that has no path at all.
282
+ const operation = contract && isEffectsContract(contract) ? contract.operation : undefined;
283
+ return [
284
+ ...via.map((hop) => `-> ${displayName(hop.symbol)} (${hop.file}:${hop.line})`),
285
+ ...(operation
286
+ ? [`operation: ${operation.qualifiedName} (${operation.file}:${operation.line})`]
287
+ : []),
288
+ ];
289
+ }
290
+ function formatSummaryText(coverage) {
291
+ return `files=${coverage.filesAnalyzed} functions=${coverage.functionsExtracted} declared=${coverage.functionsDeclared}\n`;
292
+ }
293
+ function formatSummaryJson(coverage) {
294
+ return `${JSON.stringify({
295
+ kind: "summary",
296
+ filesAnalyzed: coverage.filesAnalyzed,
297
+ functionsExtracted: coverage.functionsExtracted,
298
+ functionsDeclared: coverage.functionsDeclared,
299
+ })}\n`;
300
+ }
301
+ function formatCoverageText(coverage) {
302
+ const unknownPct = (coverage.functionUnknownRate * 100).toFixed(1);
303
+ // Reported beside the unknown rate on purpose: a boundary leaves the
304
+ // unknown numerator without the body ever being checked, so reading one
305
+ // number without the other would show declaring boundaries as progress.
306
+ const boundaryPct = (coverage.functionBoundaryRate * 100).toFixed(1);
307
+ const lines = [
308
+ `declared-by: jsdoc=${coverage.functionsDeclaredByJsDoc} config=${coverage.functionsDeclaredByConfig}`,
309
+ `unknown-rate=${unknownPct}% (${Math.round(coverage.functionUnknownRate * coverage.functionsExtracted)}/${coverage.functionsExtracted} functions) boundary-rate=${boundaryPct}% (${coverage.functionsBoundary}/${coverage.functionsExtracted} functions)`,
310
+ `entrypoints=${coverage.functionsEntrypoint} (without-capabilities=${coverage.entrypointsWithoutCapabilities})`,
311
+ `skipped=${coverage.functionsSkipped} (${mapEntries(coverage.skippedByKind)})`,
312
+ `call-sites: total=${coverage.callSitesTotal} resolved=${coverage.callSitesResolved} stub=${coverage.callSitesStub} pure=${coverage.callSitesPure} mutation=${coverage.callSitesMutation} unresolved=${coverage.callSitesUnresolved}`,
313
+ `unresolved-by-reason: ${mapEntries(coverage.unresolvedByReason)}`,
314
+ ];
315
+ if (coverage.topUnresolvedNames.length > 0) {
316
+ lines.push(`top-unresolved-names: ${coverage.topUnresolvedNames.map((n) => `${n.name}=${n.count}`).join(", ")}`);
317
+ }
318
+ return `${lines.join("\n")}\n`;
319
+ }
320
+ function formatCoverageJson(coverage) {
321
+ return `${JSON.stringify({
322
+ kind: "coverage",
323
+ functionsDeclaredByJsDoc: coverage.functionsDeclaredByJsDoc,
324
+ functionsDeclaredByConfig: coverage.functionsDeclaredByConfig,
325
+ functionUnknownRate: coverage.functionUnknownRate,
326
+ functionBoundaryRate: coverage.functionBoundaryRate,
327
+ functionsBoundary: coverage.functionsBoundary,
328
+ functionsEntrypoint: coverage.functionsEntrypoint,
329
+ entrypointsWithoutCapabilities: coverage.entrypointsWithoutCapabilities,
330
+ functionsSkipped: coverage.functionsSkipped,
331
+ skippedByKind: Object.fromEntries(coverage.skippedByKind),
332
+ callSitesTotal: coverage.callSitesTotal,
333
+ callSitesResolved: coverage.callSitesResolved,
334
+ callSitesStub: coverage.callSitesStub,
335
+ callSitesPure: coverage.callSitesPure,
336
+ callSitesMutation: coverage.callSitesMutation,
337
+ callSitesUnresolved: coverage.callSitesUnresolved,
338
+ unresolvedByReason: Object.fromEntries(coverage.unresolvedByReason),
339
+ topUnresolvedNames: coverage.topUnresolvedNames,
340
+ })}\n`;
341
+ }
342
+ function mapEntries(map) {
343
+ if (map.size === 0)
344
+ return "none";
345
+ return [...map.entries()].map(([k, v]) => `${k}=${v}`).join(", ");
346
+ }
347
+ function errorMessage(error) {
348
+ return error instanceof Error ? error.message : String(error);
349
+ }
350
+ /**
351
+ * True when this file is the process entry point (`node src/cli/main.ts ...`,
352
+ * or the installed `ambit` bin), false when a test or another module imports
353
+ * it.
354
+ *
355
+ * npm installs `bin` as a symlink (`node_modules/.bin/ambit ->
356
+ * ../ambit-ts/dist/cli/main.js`), and `process.argv[1]` is then the *symlink*
357
+ * path while `import.meta.url` is the resolved target — so comparing the two
358
+ * directly makes the installed CLI silently do nothing and exit 0, which reads
359
+ * exactly like "checked, no violations" (DESIGN.md §3.4 forbids that). The
360
+ * symlink is resolved before comparing. `pathToFileURL` (rather than a plain
361
+ * `file://` template) also handles a path containing spaces.
362
+ */
363
+ function isProcessEntryPoint() {
364
+ const entry = process.argv[1];
365
+ if (!entry)
366
+ return false;
367
+ let resolved;
368
+ try {
369
+ resolved = realpathSync(entry);
370
+ }
371
+ catch {
372
+ resolved = entry;
373
+ }
374
+ return import.meta.url === pathToFileURL(resolved).href;
375
+ }
376
+ if (isProcessEntryPoint()) {
377
+ main(process.argv.slice(2))
378
+ .then((code) => {
379
+ process.exitCode = code;
380
+ })
381
+ .catch((error) => {
382
+ process.stderr.write(`ambit: unexpected failure: ${errorMessage(error)}\n`);
383
+ process.exitCode = EXIT_ANALYSIS_FAILED;
384
+ });
385
+ }
@@ -0,0 +1,75 @@
1
+ /** A base revision checked out on disk, plus the working tree it was taken from. */
2
+ export interface BaseWorktree {
3
+ /** The temporary checkout's root. */
4
+ readonly root: string;
5
+ /** The repository root the ref was resolved against. */
6
+ readonly repoRoot: string;
7
+ }
8
+ /**
9
+ * Run `git` in `cwd` and return its stdout, trimmed.
10
+ *
11
+ * @effects process
12
+ */
13
+ export declare function git(cwd: string, ...args: readonly string[]): Promise<string>;
14
+ /**
15
+ * The same, untrimmed — for `-z` output, where the separator is a NUL and a
16
+ * trailing byte of a path is data rather than whitespace to be tidied away.
17
+ *
18
+ * @effects process
19
+ */
20
+ export declare function gitRaw(cwd: string, ...args: readonly string[]): Promise<string>;
21
+ /**
22
+ * The root of the repository containing `dir`.
23
+ *
24
+ * @effects process
25
+ */
26
+ export declare function repositoryRoot(dir: string): Promise<string>;
27
+ /**
28
+ * Check `ref` out into a temporary `git worktree` and hand the caller its
29
+ * root. The caller must call {@link removeWorktree}, whether or not its own
30
+ * work succeeded (DESIGN.md §6's `diff` entry).
31
+ *
32
+ * The checkout goes under the OS temporary directory, never inside the
33
+ * repository, so a run that is killed outright leaves nothing in the tree
34
+ * being analyzed.
35
+ *
36
+ * `node_modules` is symlinked in from the working tree when the working tree
37
+ * has one. Measured on this repository: without it the base side resolves 19
38
+ * more calls as unresolved and reports an `any-typed` reason the head side
39
+ * does not have, so the two sides would differ by their environment rather
40
+ * than by their contracts. A symlink is enough — nothing writes to it — and
41
+ * `git worktree` never tracks it.
42
+ *
43
+ * @effects process, fs_read, fs_write
44
+ */
45
+ export declare function addWorktree(repoRoot: string, ref: string): Promise<BaseWorktree>;
46
+ /**
47
+ * Remove a worktree {@link addWorktree} created, and the directory under it.
48
+ *
49
+ * Never throws: it runs in a `finally`, where an error would replace the
50
+ * failure the caller is already reporting. A `git worktree remove` that fails
51
+ * still leaves the administrative entry, so `prune` follows unconditionally
52
+ * and the directory is removed directly as the last resort.
53
+ *
54
+ * @effects process, fs_write
55
+ */
56
+ export declare function removeWorktree(worktree: BaseWorktree): Promise<void>;
57
+ /**
58
+ * The files git reports as renamed between `ref` and the working tree, base
59
+ * path to head path, each relative to `subdir` and separated by `"/"`.
60
+ *
61
+ * This is the only identity evidence `ambit diff` uses to carry a symbol
62
+ * across a move (DESIGN.md §6.3). Two properties of `git diff` shape what it
63
+ * can see, and both were measured rather than assumed:
64
+ *
65
+ * - The `-- <subdir>` pathspec restricts renames to the checked directory, and
66
+ * a file renamed *into* it from outside comes out as an addition, not a
67
+ * rename. That is the wanted reading: the checked directory did gain a
68
+ * symbol, whatever the rest of the repository did.
69
+ * - Only tracked paths are compared, so a move whose destination has not been
70
+ * `git add`ed reads as a deletion plus an untracked file — no rename. CI
71
+ * always has a tracked tree; a local run may not.
72
+ *
73
+ * @effects process
74
+ */
75
+ export declare function renamedFiles(repoRoot: string, ref: string, subdir: string): Promise<ReadonlyMap<string, string>>;
@@ -0,0 +1,154 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdtemp, rm, symlink } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * Run `git` in `cwd` and return its stdout, trimmed.
10
+ *
11
+ * @effects process
12
+ */
13
+ export async function git(cwd, ...args) {
14
+ return (await gitRaw(cwd, ...args)).trim();
15
+ }
16
+ /**
17
+ * The same, untrimmed — for `-z` output, where the separator is a NUL and a
18
+ * trailing byte of a path is data rather than whitespace to be tidied away.
19
+ *
20
+ * @effects process
21
+ */
22
+ export async function gitRaw(cwd, ...args) {
23
+ const { stdout } = await execFileAsync("git", [...args], { cwd, maxBuffer: 64 * 1024 * 1024 });
24
+ return stdout;
25
+ }
26
+ /**
27
+ * The root of the repository containing `dir`.
28
+ *
29
+ * @effects process
30
+ */
31
+ export async function repositoryRoot(dir) {
32
+ return await git(dir, "rev-parse", "--show-toplevel");
33
+ }
34
+ /**
35
+ * Check `ref` out into a temporary `git worktree` and hand the caller its
36
+ * root. The caller must call {@link removeWorktree}, whether or not its own
37
+ * work succeeded (DESIGN.md §6's `diff` entry).
38
+ *
39
+ * The checkout goes under the OS temporary directory, never inside the
40
+ * repository, so a run that is killed outright leaves nothing in the tree
41
+ * being analyzed.
42
+ *
43
+ * `node_modules` is symlinked in from the working tree when the working tree
44
+ * has one. Measured on this repository: without it the base side resolves 19
45
+ * more calls as unresolved and reports an `any-typed` reason the head side
46
+ * does not have, so the two sides would differ by their environment rather
47
+ * than by their contracts. A symlink is enough — nothing writes to it — and
48
+ * `git worktree` never tracks it.
49
+ *
50
+ * @effects process, fs_read, fs_write
51
+ */
52
+ export async function addWorktree(repoRoot, ref) {
53
+ const root = await mkdtemp(path.join(os.tmpdir(), "ambit-diff-"));
54
+ try {
55
+ await git(repoRoot, "worktree", "add", "--detach", root, ref);
56
+ }
57
+ catch (error) {
58
+ // The checkout never happened, so there is no worktree to remove — only
59
+ // the empty temporary directory this function made.
60
+ await rm(root, { recursive: true, force: true });
61
+ throw error;
62
+ }
63
+ const installed = path.join(repoRoot, "node_modules");
64
+ if (existsSync(installed)) {
65
+ await symlink(installed, path.join(root, "node_modules"), "dir");
66
+ }
67
+ return { root, repoRoot };
68
+ }
69
+ /**
70
+ * Remove a worktree {@link addWorktree} created, and the directory under it.
71
+ *
72
+ * Never throws: it runs in a `finally`, where an error would replace the
73
+ * failure the caller is already reporting. A `git worktree remove` that fails
74
+ * still leaves the administrative entry, so `prune` follows unconditionally
75
+ * and the directory is removed directly as the last resort.
76
+ *
77
+ * @effects process, fs_write
78
+ */
79
+ export async function removeWorktree(worktree) {
80
+ try {
81
+ await git(worktree.repoRoot, "worktree", "remove", "--force", worktree.root);
82
+ }
83
+ catch {
84
+ // fall through to the direct removal below
85
+ }
86
+ await rm(worktree.root, { recursive: true, force: true });
87
+ try {
88
+ await git(worktree.repoRoot, "worktree", "prune");
89
+ }
90
+ catch {
91
+ // Nothing left to do: the caller's own result is what matters here.
92
+ }
93
+ }
94
+ /**
95
+ * The files git reports as renamed between `ref` and the working tree, base
96
+ * path to head path, each relative to `subdir` and separated by `"/"`.
97
+ *
98
+ * This is the only identity evidence `ambit diff` uses to carry a symbol
99
+ * across a move (DESIGN.md §6.3). Two properties of `git diff` shape what it
100
+ * can see, and both were measured rather than assumed:
101
+ *
102
+ * - The `-- <subdir>` pathspec restricts renames to the checked directory, and
103
+ * a file renamed *into* it from outside comes out as an addition, not a
104
+ * rename. That is the wanted reading: the checked directory did gain a
105
+ * symbol, whatever the rest of the repository did.
106
+ * - Only tracked paths are compared, so a move whose destination has not been
107
+ * `git add`ed reads as a deletion plus an untracked file — no rename. CI
108
+ * always has a tracked tree; a local run may not.
109
+ *
110
+ * @effects process
111
+ */
112
+ export async function renamedFiles(repoRoot, ref, subdir) {
113
+ // `-z` rather than the default: a path is emitted raw between NULs, so a
114
+ // path containing a quote, a tab or a non-ASCII byte survives instead of
115
+ // arriving C-quoted.
116
+ const raw = await gitRaw(repoRoot, "diff", "--find-renames", "--name-status", "-z", ref, "--", subdir === "" ? "." : subdir);
117
+ const fields = raw.split("\0").filter((field) => field.length > 0);
118
+ const renames = new Map();
119
+ for (let i = 0; i < fields.length;) {
120
+ const status = fields[i] ?? "";
121
+ // A rename or copy record spans three fields (`R100`, old, new); every
122
+ // other status spans two.
123
+ if (status.startsWith("R") || status.startsWith("C")) {
124
+ const from = fields[i + 1];
125
+ const to = fields[i + 2];
126
+ if (from !== undefined && to !== undefined) {
127
+ const fromRelative = relativeToSubdir(from, subdir);
128
+ const toRelative = relativeToSubdir(to, subdir);
129
+ // Both ends inside the checked directory, or the pair says nothing
130
+ // about the comparison being made.
131
+ if (fromRelative !== undefined && toRelative !== undefined) {
132
+ renames.set(fromRelative, toRelative);
133
+ }
134
+ }
135
+ i += 3;
136
+ }
137
+ else {
138
+ i += 2;
139
+ }
140
+ }
141
+ return renames;
142
+ }
143
+ /**
144
+ * A repository-relative path re-expressed relative to the checked directory —
145
+ * the form a symbol id carries (DESIGN.md §5.3) — or `undefined` when it is
146
+ * outside that directory.
147
+ */
148
+ function relativeToSubdir(repoPath, subdir) {
149
+ if (subdir === "")
150
+ return repoPath;
151
+ if (!repoPath.startsWith(`${subdir}/`))
152
+ return undefined;
153
+ return repoPath.slice(subdir.length + 1);
154
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `ambit-ts/config` — the entry point a consumer's `ambit.config.ts` imports
3
+ * (DESIGN.md §4.1, "Out-of-code declarations").
4
+ *
5
+ * Deliberately separate from `ambit` (the diagnostic types) and from
6
+ * `ambit-ts/runtime`: a config file is loaded by the CLI at check time, and
7
+ * importing it must not drag the contract model, the runtime, or the checker
8
+ * into the consumer's build.
9
+ */
10
+ export type { BudgetInput, OnExceed } from "./core/budget.ts";
11
+ export type { AmbitConfig, ConfigContract } from "./core/config.ts";
12
+ export { defineConfig } from "./core/config.ts";
package/dist/config.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `ambit-ts/config` — the entry point a consumer's `ambit.config.ts` imports
3
+ * (DESIGN.md §4.1, "Out-of-code declarations").
4
+ *
5
+ * Deliberately separate from `ambit` (the diagnostic types) and from
6
+ * `ambit-ts/runtime`: a config file is loaded by the CLI at check time, and
7
+ * importing it must not drag the contract model, the runtime, or the checker
8
+ * into the consumer's build.
9
+ */
10
+ export { defineConfig } from "./core/config.js";