ambit-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- package/package.json +89 -0
package/dist/cli/main.js
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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";
|