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,84 @@
1
+ import type { AmbitConfig, ConfigContract, KnownEffect, SymbolId } from "../core/index.ts";
2
+ /** Thrown for every config problem. `main` turns it into exit 2 (§3.4). */
3
+ export declare class ConfigError extends Error {
4
+ }
5
+ export interface LoadedConfig {
6
+ /** Absolute path to the config file. Keys are resolved relative to its directory. */
7
+ readonly configPath: string;
8
+ readonly config: AmbitConfig;
9
+ /** The file's own text, so a diagnostic about a key can point at its line. */
10
+ readonly sourceText: string;
11
+ }
12
+ /**
13
+ * Walk up from `startDir` looking for a config file, stopping after the first
14
+ * directory that holds a `package.json` or `.git` (§4.1 (c)) — a config
15
+ * outside the project must never be picked up silently.
16
+ */
17
+ export declare function findConfigFile(startDir: string): string | undefined;
18
+ /**
19
+ * Load the config that governs `startDir`, or `undefined` when there is none.
20
+ *
21
+ * The file is imported, not parsed: it is TypeScript or JavaScript, and Node
22
+ * strips types for any file outside `node_modules` — which a consumer's
23
+ * config always is, including when the CLI itself is running from `dist/`.
24
+ * A file that throws on import (a syntax error, a bad import) becomes a
25
+ * {@link ConfigError}: a config that could not be read must stop the run, not
26
+ * be treated as "no config" (§3.4).
27
+ */
28
+ export declare function loadConfig(startDir: string): Promise<LoadedConfig | undefined>;
29
+ /**
30
+ * Check the imported value against {@link AmbitConfig} by hand.
31
+ *
32
+ * An unknown key is rejected rather than ignored, for the reason a misspelled
33
+ * effect name is (AMB-E002): a `contarcts:` block that is silently dropped
34
+ * reads as a set of declarations and is not one.
35
+ */
36
+ export declare function validateConfig(value: unknown, where: string): AmbitConfig;
37
+ /**
38
+ * A config resolved against one analysis root: contract lookup by symbol id,
39
+ * per-directory `strict`, and the user-defined effect table.
40
+ */
41
+ export interface ResolvedConfig {
42
+ readonly configPath: string;
43
+ /**
44
+ * The config file as diagnostics and fixes should name it: relative to the
45
+ * analysis root when it sits inside it, absolute when it does not.
46
+ *
47
+ * Every other `location.file` and `edits[].file` in the output is
48
+ * root-relative, and a consumer that resolves them against the checked
49
+ * directory (`test/e2e.realistic.test.ts` does exactly that) would break on
50
+ * one absolute path among them.
51
+ */
52
+ readonly displayPath: string;
53
+ readonly sourceText: string;
54
+ /** User-defined effect names → the standard effects they stand for (§4.1 (d)). */
55
+ readonly effectAliases: ReadonlyMap<string, readonly KnownEffect[]>;
56
+ /** The contract declared for `id`, or `undefined`. Records the match for {@link unmatchedExactKeys}. */
57
+ contractFor(id: SymbolId): ConfigContract | undefined;
58
+ /** Whether `relativeFile`'s diagnostics get `--strict`'s promotion (§4.3). */
59
+ isStrictFile(relativeFile: string): boolean;
60
+ /**
61
+ * Exact keys that named no extracted symbol, after every lookup has run.
62
+ * Glob keys are excluded on purpose: a glob matching nothing under the
63
+ * directory being checked is normal, an exact key naming nothing is a typo.
64
+ */
65
+ unmatchedExactKeys(): readonly string[];
66
+ }
67
+ /**
68
+ * Bind a loaded config to the directory being analyzed.
69
+ *
70
+ * Keys are written relative to the config file (§4.1 (c)) and symbol ids are
71
+ * relative to the analysis root, so every key is rebased once, here, and
72
+ * nothing downstream has to remember which base it is holding.
73
+ */
74
+ export declare function resolveConfig(loaded: LoadedConfig, rootDir: string): ResolvedConfig;
75
+ /**
76
+ * §4.1 (b)'s glob: `*` matches within one path segment, `**` crosses
77
+ * directories. Hand-rolled rather than delegated to `path.matchesGlob`, whose
78
+ * semantics are the shell's and are not the two lines the spec commits to.
79
+ *
80
+ * A pattern that escapes the analysis root (`../…` after rebasing) still
81
+ * compiles; it simply matches no root-relative file, which is the honest
82
+ * answer for a key naming something outside the run.
83
+ */
84
+ export declare function globToRegExp(pattern: string): RegExp;
@@ -0,0 +1,391 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * Finding, loading, validating and resolving `ambit.config.ts`
11
+ * (DESIGN.md §4.1, "Out-of-code declarations").
12
+ *
13
+ * Imports no compiler. A config file is plain data about symbols the backend
14
+ * already produced ids for, so nothing here needs to know what a
15
+ * `ts.Node` is (§3.4).
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { isKnownEffect, isOnExceed, KNOWN_EFFECTS, parseCapability } from "../core/index.js";
21
+ /**
22
+ * The file names looked for, in order. §4.1 (c): the first one found in a
23
+ * directory is the one used — a second file in the same directory is never
24
+ * read, so a stale `ambit.config.js` beside a `.ts` cannot silently win.
25
+ */
26
+ const CONFIG_FILENAMES = [
27
+ "ambit.config.ts",
28
+ "ambit.config.mts",
29
+ "ambit.config.js",
30
+ "ambit.config.mjs",
31
+ ];
32
+ /** Thrown for every config problem. `main` turns it into exit 2 (§3.4). */
33
+ export class ConfigError extends Error {
34
+ }
35
+ /**
36
+ * Walk up from `startDir` looking for a config file, stopping after the first
37
+ * directory that holds a `package.json` or `.git` (§4.1 (c)) — a config
38
+ * outside the project must never be picked up silently.
39
+ */
40
+ export function findConfigFile(startDir) {
41
+ let dir = path.resolve(startDir);
42
+ for (;;) {
43
+ for (const name of CONFIG_FILENAMES) {
44
+ const candidate = path.join(dir, name);
45
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
46
+ return candidate;
47
+ }
48
+ if (isProjectBoundary(dir))
49
+ return undefined;
50
+ const parent = path.dirname(dir);
51
+ if (parent === dir)
52
+ return undefined;
53
+ dir = parent;
54
+ }
55
+ }
56
+ function isProjectBoundary(dir) {
57
+ return fs.existsSync(path.join(dir, "package.json")) || fs.existsSync(path.join(dir, ".git"));
58
+ }
59
+ /**
60
+ * Load the config that governs `startDir`, or `undefined` when there is none.
61
+ *
62
+ * The file is imported, not parsed: it is TypeScript or JavaScript, and Node
63
+ * strips types for any file outside `node_modules` — which a consumer's
64
+ * config always is, including when the CLI itself is running from `dist/`.
65
+ * A file that throws on import (a syntax error, a bad import) becomes a
66
+ * {@link ConfigError}: a config that could not be read must stop the run, not
67
+ * be treated as "no config" (§3.4).
68
+ */
69
+ export async function loadConfig(startDir) {
70
+ const configPath = findConfigFile(startDir);
71
+ if (configPath === undefined)
72
+ return undefined;
73
+ let sourceText;
74
+ try {
75
+ sourceText = fs.readFileSync(configPath, "utf8");
76
+ }
77
+ catch (error) {
78
+ throw new ConfigError(`cannot read ${configPath}: ${messageOf(error)}`);
79
+ }
80
+ let module;
81
+ try {
82
+ module = (await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href)));
83
+ }
84
+ catch (error) {
85
+ throw new ConfigError(`cannot load ${configPath}: ${messageOf(error)}`);
86
+ }
87
+ if (module.default === undefined) {
88
+ throw new ConfigError(`${configPath} has no default export`);
89
+ }
90
+ return { configPath, config: validateConfig(module.default, configPath), sourceText };
91
+ }
92
+ const TOP_LEVEL_KEYS = ["effects", "contracts", "strict"];
93
+ const CONTRACT_KEYS = ["effects", "capabilities", "budget", "entrypoint", "boundary"];
94
+ const BUDGET_KEYS = ["timeMs", "costUsd", "llmCalls", "onExceed"];
95
+ /**
96
+ * Check the imported value against {@link AmbitConfig} by hand.
97
+ *
98
+ * An unknown key is rejected rather than ignored, for the reason a misspelled
99
+ * effect name is (AMB-E002): a `contarcts:` block that is silently dropped
100
+ * reads as a set of declarations and is not one.
101
+ */
102
+ export function validateConfig(value, where) {
103
+ const root = asObject(value, where, "default export");
104
+ rejectUnknownKeys(root, TOP_LEVEL_KEYS, where, "");
105
+ const effects = root.effects === undefined ? undefined : validateEffectAliases(root.effects, where);
106
+ const strict = root.strict === undefined ? undefined : validateStringArray(root.strict, where, "strict");
107
+ const contracts = root.contracts === undefined
108
+ ? undefined
109
+ : validateContracts(root.contracts, where, new Set(Object.keys(effects ?? {})));
110
+ return {
111
+ ...(effects ? { effects } : {}),
112
+ ...(contracts ? { contracts } : {}),
113
+ ...(strict ? { strict } : {}),
114
+ };
115
+ }
116
+ function validateEffectAliases(value, where) {
117
+ const raw = asObject(value, where, "effects");
118
+ const out = {};
119
+ for (const [name, members] of Object.entries(raw)) {
120
+ if (name.trim().length === 0)
121
+ throw new ConfigError(`${where}: effects has an empty name`);
122
+ // A definition that shadows a standard effect would make `@effects env`
123
+ // mean something different in two files (§4.1 (d)).
124
+ if (isKnownEffect(name)) {
125
+ throw new ConfigError(`${where}: effects.${name} redefines the standard effect "${name}"; user-defined names must not collide with ${KNOWN_EFFECTS.join(", ")}`);
126
+ }
127
+ const list = validateStringArray(members, where, `effects.${name}`);
128
+ for (const member of list) {
129
+ // §4.1 (d): definitions do not expand into other definitions. Allowing
130
+ // it would need a cycle check and would buy nothing a flat list cannot
131
+ // express.
132
+ if (!isKnownEffect(member)) {
133
+ throw new ConfigError(`${where}: effects.${name} contains "${member}", which is not a standard effect (${KNOWN_EFFECTS.join(", ")})`);
134
+ }
135
+ }
136
+ if (list.length === 0)
137
+ throw new ConfigError(`${where}: effects.${name} is empty`);
138
+ out[name] = list;
139
+ }
140
+ return out;
141
+ }
142
+ function validateContracts(value, where, aliasNames) {
143
+ const raw = asObject(value, where, "contracts");
144
+ const out = {};
145
+ for (const [key, contract] of Object.entries(raw)) {
146
+ if (!key.includes("#")) {
147
+ throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} is not "<file>#<symbol>"`);
148
+ }
149
+ const [file = "", symbol = ""] = splitKey(key);
150
+ if (file.length === 0 || symbol.length === 0) {
151
+ throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} is not "<file>#<symbol>"`);
152
+ }
153
+ if (symbol.includes("*")) {
154
+ // §4.1 (b): the symbol half does not glob. A `#*` that quietly matched
155
+ // everything in a file would be a contract nobody wrote.
156
+ throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} globs the symbol half; only the file half may use * or **`);
157
+ }
158
+ out[key] = validateContract(contract, where, key, aliasNames);
159
+ }
160
+ return out;
161
+ }
162
+ function validateContract(value, where, key, aliasNames) {
163
+ const raw = asObject(value, where, `contracts[${JSON.stringify(key)}]`);
164
+ rejectUnknownKeys(raw, CONTRACT_KEYS, where, `contracts[${JSON.stringify(key)}].`);
165
+ const at = `contracts[${JSON.stringify(key)}]`;
166
+ const effects = raw.effects === undefined
167
+ ? undefined
168
+ : validateStringArray(raw.effects, where, `${at}.effects`);
169
+ const capabilities = raw.capabilities === undefined
170
+ ? undefined
171
+ : validateStringArray(raw.capabilities, where, `${at}.capabilities`);
172
+ if (raw.entrypoint !== undefined && typeof raw.entrypoint !== "boolean") {
173
+ throw new ConfigError(`${where}: ${at}.entrypoint must be a boolean`);
174
+ }
175
+ if (raw.boundary !== undefined && typeof raw.boundary !== "string") {
176
+ throw new ConfigError(`${where}: ${at}.boundary must be a string (the reason §4.6 requires)`);
177
+ }
178
+ if (raw.boundary !== undefined && raw.boundary.trim().length === 0) {
179
+ throw new ConfigError(`${where}: ${at}.boundary must give a non-empty reason (§4.6)`);
180
+ }
181
+ const budget = raw.budget === undefined ? undefined : validateBudget(raw.budget, where, at);
182
+ // A misspelled effect or a malformed capability is rejected here rather
183
+ // than turned into an "invalid" contract downstream: a JSDoc typo has a tag
184
+ // location a diagnostic can point at, a config typo has a file the run has
185
+ // already decided to trust, and §3.4 says a declaration that does not mean
186
+ // what it says must stop the run rather than narrow silently.
187
+ for (const effect of effects ?? []) {
188
+ if (!isKnownEffect(effect) && !aliasNames.has(effect)) {
189
+ throw new ConfigError(`${where}: ${at}.effects contains "${effect}", which is neither a standard effect nor defined under effects`);
190
+ }
191
+ }
192
+ for (const capability of capabilities ?? []) {
193
+ if (parseCapability(capability) === undefined) {
194
+ throw new ConfigError(`${where}: ${at}.capabilities contains "${capability}", which is not <resource>:<action>:<target>`);
195
+ }
196
+ }
197
+ if (effects === undefined &&
198
+ capabilities === undefined &&
199
+ budget === undefined &&
200
+ raw.entrypoint === undefined &&
201
+ raw.boundary === undefined) {
202
+ throw new ConfigError(`${where}: ${at} declares nothing`);
203
+ }
204
+ return {
205
+ ...(effects ? { effects } : {}),
206
+ ...(capabilities ? { capabilities } : {}),
207
+ ...(budget ? { budget } : {}),
208
+ ...(raw.entrypoint === undefined ? {} : { entrypoint: raw.entrypoint }),
209
+ ...(raw.boundary === undefined ? {} : { boundary: raw.boundary.trim() }),
210
+ };
211
+ }
212
+ function validateBudget(value, where, at) {
213
+ const raw = asObject(value, where, `${at}.budget`);
214
+ rejectUnknownKeys(raw, BUDGET_KEYS, where, `${at}.budget.`);
215
+ const out = {};
216
+ for (const key of ["timeMs", "costUsd", "llmCalls"]) {
217
+ const limit = raw[key];
218
+ if (limit === undefined)
219
+ continue;
220
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 0) {
221
+ throw new ConfigError(`${where}: ${at}.budget.${key} must be a non-negative number`);
222
+ }
223
+ if (key === "llmCalls" && !Number.isInteger(limit)) {
224
+ throw new ConfigError(`${where}: ${at}.budget.llmCalls must be an integer`);
225
+ }
226
+ out[key] = limit;
227
+ }
228
+ if (raw.onExceed !== undefined) {
229
+ if (typeof raw.onExceed !== "string" || !isOnExceed(raw.onExceed)) {
230
+ throw new ConfigError(`${where}: ${at}.budget.onExceed must be throw, warn or abort`);
231
+ }
232
+ out.onExceed = raw.onExceed;
233
+ }
234
+ // Same rule as `parseBudgetTag`: a policy with nothing to exceed is not a
235
+ // budget.
236
+ if (out.timeMs === undefined && out.costUsd === undefined && out.llmCalls === undefined) {
237
+ throw new ConfigError(`${where}: ${at}.budget declares no limit`);
238
+ }
239
+ return out;
240
+ }
241
+ function asObject(value, where, at) {
242
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
243
+ throw new ConfigError(`${where}: ${at} must be an object`);
244
+ }
245
+ return value;
246
+ }
247
+ function rejectUnknownKeys(raw, known, where, prefix) {
248
+ for (const key of Object.keys(raw)) {
249
+ if (!known.includes(key)) {
250
+ throw new ConfigError(`${where}: unknown key ${prefix}${key} (known keys: ${known.join(", ")})`);
251
+ }
252
+ }
253
+ }
254
+ function validateStringArray(value, where, at) {
255
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
256
+ throw new ConfigError(`${where}: ${at} must be an array of strings`);
257
+ }
258
+ return value;
259
+ }
260
+ function messageOf(error) {
261
+ return error instanceof Error ? error.message : String(error);
262
+ }
263
+ /** `"a/b.ts#Foo.bar"` → `["a/b.ts", "Foo.bar"]`, splitting on the first `#` only. */
264
+ function splitKey(key) {
265
+ const hash = key.indexOf("#");
266
+ return [key.slice(0, hash), key.slice(hash + 1)];
267
+ }
268
+ /**
269
+ * Bind a loaded config to the directory being analyzed.
270
+ *
271
+ * Keys are written relative to the config file (§4.1 (c)) and symbol ids are
272
+ * relative to the analysis root, so every key is rebased once, here, and
273
+ * nothing downstream has to remember which base it is holding.
274
+ */
275
+ export function resolveConfig(loaded, rootDir) {
276
+ const configDir = path.dirname(loaded.configPath);
277
+ const absoluteRoot = path.resolve(rootDir);
278
+ const entries = [];
279
+ for (const [key, contract] of Object.entries(loaded.config.contracts ?? {})) {
280
+ const hash = key.indexOf("#");
281
+ const filePattern = key.slice(0, hash);
282
+ const symbol = key.slice(hash + 1);
283
+ const rebased = rebase(filePattern, configDir, absoluteRoot);
284
+ entries.push({
285
+ key,
286
+ symbol,
287
+ matcher: globToRegExp(rebased),
288
+ exact: !filePattern.includes("*"),
289
+ file: rebased,
290
+ contract,
291
+ });
292
+ }
293
+ const strictMatchers = (loaded.config.strict ?? []).map((pattern) => globToRegExp(rebase(pattern, configDir, absoluteRoot)));
294
+ const effectAliases = new Map();
295
+ for (const [name, members] of Object.entries(loaded.config.effects ?? {})) {
296
+ effectAliases.set(name, members.filter(isKnownEffect));
297
+ }
298
+ const matchedKeys = new Set();
299
+ const relative = path.relative(absoluteRoot, loaded.configPath);
300
+ const displayPath = relative.startsWith("..") || path.isAbsolute(relative)
301
+ ? loaded.configPath
302
+ : relative.split(path.sep).join("/");
303
+ return {
304
+ configPath: loaded.configPath,
305
+ displayPath,
306
+ sourceText: loaded.sourceText,
307
+ effectAliases,
308
+ contractFor(id) {
309
+ const hash = id.indexOf("#");
310
+ if (hash < 0)
311
+ return undefined;
312
+ const file = id.slice(0, hash);
313
+ const symbol = id.slice(hash + 1);
314
+ const candidates = entries.filter((entry) => entry.symbol === symbol && entry.matcher.test(file));
315
+ if (candidates.length === 0)
316
+ return undefined;
317
+ const exact = candidates.filter((entry) => entry.exact);
318
+ // §4.1 (b): an exact key always wins, and two globs on one symbol is an
319
+ // ambiguity the config author has to resolve — never a silent pick.
320
+ if (exact.length > 0) {
321
+ for (const entry of exact)
322
+ matchedKeys.add(entry.key);
323
+ if (exact.length > 1) {
324
+ throw new ConfigError(`${loaded.configPath}: ${exact.map((e) => JSON.stringify(e.key)).join(" and ")} both name ${id}`);
325
+ }
326
+ return exact[0]?.contract;
327
+ }
328
+ if (candidates.length > 1) {
329
+ throw new ConfigError(`${loaded.configPath}: ${candidates
330
+ .map((e) => JSON.stringify(e.key))
331
+ .join(" and ")} both match ${id}; an exact key is needed to say which contract applies`);
332
+ }
333
+ for (const entry of candidates)
334
+ matchedKeys.add(entry.key);
335
+ return candidates[0]?.contract;
336
+ },
337
+ isStrictFile(relativeFile) {
338
+ return strictMatchers.some((matcher) => matcher.test(relativeFile));
339
+ },
340
+ unmatchedExactKeys() {
341
+ return entries
342
+ .filter((entry) => entry.exact && !matchedKeys.has(entry.key))
343
+ .map((entry) => entry.key);
344
+ },
345
+ };
346
+ }
347
+ /**
348
+ * A config-relative pattern rewritten relative to the analysis root, using
349
+ * `/` throughout (a {@link SymbolId}'s file half always does, on every
350
+ * platform).
351
+ */
352
+ function rebase(pattern, configDir, absoluteRoot) {
353
+ const absolute = path.resolve(configDir, pattern);
354
+ return path.relative(absoluteRoot, absolute).split(path.sep).join("/");
355
+ }
356
+ /**
357
+ * §4.1 (b)'s glob: `*` matches within one path segment, `**` crosses
358
+ * directories. Hand-rolled rather than delegated to `path.matchesGlob`, whose
359
+ * semantics are the shell's and are not the two lines the spec commits to.
360
+ *
361
+ * A pattern that escapes the analysis root (`../…` after rebasing) still
362
+ * compiles; it simply matches no root-relative file, which is the honest
363
+ * answer for a key naming something outside the run.
364
+ */
365
+ export function globToRegExp(pattern) {
366
+ let out = "";
367
+ for (let i = 0; i < pattern.length; i++) {
368
+ const char = pattern[i];
369
+ if (char !== "*") {
370
+ out += escapeRegExp(char ?? "");
371
+ continue;
372
+ }
373
+ if (pattern[i + 1] === "*") {
374
+ // `**/` spans zero or more directories, so `src/**/a.ts` matches
375
+ // `src/a.ts` as well as `src/x/y/a.ts`.
376
+ if (pattern[i + 2] === "/") {
377
+ out += "(?:[^/]*(?:/|$))*";
378
+ i += 2;
379
+ continue;
380
+ }
381
+ out += "[\\s\\S]*";
382
+ i += 1;
383
+ continue;
384
+ }
385
+ out += "[^/]*";
386
+ }
387
+ return new RegExp(`^${out}$`);
388
+ }
389
+ function escapeRegExp(text) {
390
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
391
+ }
@@ -0,0 +1,78 @@
1
+ import type { FunctionSummary, SkippedFunctionKind, SymbolId, UnresolvedReason } from "../core/index.ts";
2
+ import type { PropagatedFunction } from "./propagate.ts";
3
+ export interface CoverageInput {
4
+ readonly filesAnalyzed: number;
5
+ readonly skippedFunctions: ReadonlyMap<SkippedFunctionKind, number>;
6
+ readonly summaries: readonly FunctionSummary[];
7
+ readonly state: ReadonlyMap<SymbolId, PropagatedFunction>;
8
+ }
9
+ export interface CoverageReport {
10
+ readonly filesAnalyzed: number;
11
+ readonly functionsExtracted: number;
12
+ readonly functionsDeclared: number;
13
+ /**
14
+ * How {@link functionsDeclared} splits by where the declaration was written
15
+ * (DESIGN.md §4.1, "Out-of-code declarations"). Reported apart because the
16
+ * two are not
17
+ * interchangeable evidence: a JSDoc contract travels with the code and
18
+ * survives the package being removed (P5), while a config contract is a
19
+ * statement *about* code that was not touched — often third-party or
20
+ * generated. A coverage figure that merged them would hide how much of the
21
+ * declared surface lives outside the source it describes.
22
+ */
23
+ readonly functionsDeclaredByJsDoc: number;
24
+ readonly functionsDeclaredByConfig: number;
25
+ /**
26
+ * Functions whose body is excluded from static analysis by `@boundary`
27
+ * (DESIGN.md §4.6). Counted apart from everything else because §4.3
28
+ * requires it: "Moving something to a boundary is tallied separately from
29
+ * succeeding at analysis". A boundary is a
30
+ * declared hole, and a coverage figure that folded it into the resolved
31
+ * count would report the hole as progress.
32
+ */
33
+ readonly functionsBoundary: number;
34
+ /** Functions marked `@entrypoint`, and how many of those declare no `@capabilities`. */
35
+ readonly functionsEntrypoint: number;
36
+ readonly entrypointsWithoutCapabilities: number;
37
+ readonly functionsSkipped: number;
38
+ readonly skippedByKind: ReadonlyMap<SkippedFunctionKind, number>;
39
+ /**
40
+ * The primary KPI (DESIGN.md §4.3: "`ambit check --coverage` outputs the
41
+ * proportion of the codebase that depends on `unknown` and where it occurs.
42
+ * It is treated as a primary KPI"): the fraction of *all* extracted functions
43
+ * — declared or not — whose propagated effect set carries `unknown`. This
44
+ * is what a user actually cares about ("can Ambit say anything definite
45
+ * about this function?"), not the raw call-site resolution rate below,
46
+ * which is an internal diagnostic, not the target itself.
47
+ */
48
+ readonly functionUnknownRate: number;
49
+ /**
50
+ * Boundary functions as a fraction of extracted functions, reported next to
51
+ * {@link functionUnknownRate} because tagging a function `@boundary` moves
52
+ * it out of the unknown numerator while leaving it in the denominator.
53
+ * Without this figure beside it, declaring boundaries would read as an
54
+ * improving KPI (DESIGN.md §4.3: "Moving something to a boundary is tallied
55
+ * separately from succeeding at analysis").
56
+ * The two rates together are the fraction of functions whose contract is
57
+ * not backed by a verified body.
58
+ */
59
+ readonly functionBoundaryRate: number;
60
+ readonly callSitesTotal: number;
61
+ readonly callSitesResolved: number;
62
+ readonly callSitesStub: number;
63
+ readonly callSitesPure: number;
64
+ /**
65
+ * In-place mutation sites (DESIGN.md §4.2, "Local mutation and `pure`").
66
+ * Counted apart from `callSitesPure` and `callSitesStub`: a local mutation
67
+ * carries no effect but is not the same evidence as a call proven pure, and
68
+ * an escaping one is a `state_write` that no stub table produced.
69
+ */
70
+ readonly callSitesMutation: number;
71
+ readonly callSitesUnresolved: number;
72
+ readonly unresolvedByReason: ReadonlyMap<UnresolvedReason, number>;
73
+ readonly topUnresolvedNames: readonly {
74
+ readonly name: string;
75
+ readonly count: number;
76
+ }[];
77
+ }
78
+ export declare function computeCoverage(input: CoverageInput): CoverageReport;
@@ -0,0 +1,84 @@
1
+ /** How many top unresolved-call names to surface — the "what to stub next" signal. */
2
+ const TOP_UNRESOLVED_NAMES = 10;
3
+ export function computeCoverage(input) {
4
+ const { filesAnalyzed, skippedFunctions, summaries, state } = input;
5
+ const functionsExtracted = summaries.length;
6
+ const declaredSummaries = summaries.filter((s) => s.declared.kind === "declared");
7
+ const functionsDeclared = declaredSummaries.length;
8
+ const functionsDeclaredByConfig = declaredSummaries.filter((s) => s.declaredBy?.effects === "config").length;
9
+ const functionsDeclaredByJsDoc = functionsDeclared - functionsDeclaredByConfig;
10
+ const functionsBoundary = summaries.filter((s) => s.boundary.kind === "declared").length;
11
+ const entrypoints = summaries.filter((s) => s.entrypoint);
12
+ const entrypointsWithoutCapabilities = entrypoints.filter((s) => s.capabilities.kind !== "declared").length;
13
+ let unknownCount = 0;
14
+ for (const summary of summaries) {
15
+ if (state.get(summary.id)?.observed.unknown)
16
+ unknownCount++;
17
+ }
18
+ const functionUnknownRate = functionsExtracted === 0 ? 0 : unknownCount / functionsExtracted;
19
+ const functionBoundaryRate = functionsExtracted === 0 ? 0 : functionsBoundary / functionsExtracted;
20
+ let callSitesResolved = 0;
21
+ let callSitesStub = 0;
22
+ let callSitesPure = 0;
23
+ let callSitesMutation = 0;
24
+ let callSitesUnresolved = 0;
25
+ const unresolvedByReason = new Map();
26
+ const nameFrequency = new Map();
27
+ for (const summary of summaries) {
28
+ // A boundary's body is excluded from propagation, so its call sites say
29
+ // nothing about how well the analysis resolves things. Counting them
30
+ // would make a declared hole look like unresolved analysis (and pad
31
+ // `topUnresolvedNames`, the "what to stub next" signal, with names no
32
+ // stub would help).
33
+ if (summary.boundary.kind === "declared")
34
+ continue;
35
+ for (const call of summary.calls) {
36
+ if (call.kind === "resolved") {
37
+ callSitesResolved++;
38
+ }
39
+ else if (call.kind === "stub") {
40
+ callSitesStub++;
41
+ }
42
+ else if (call.kind === "known-pure") {
43
+ callSitesPure++;
44
+ }
45
+ else if (call.kind === "mutation") {
46
+ callSitesMutation++;
47
+ }
48
+ else {
49
+ callSitesUnresolved++;
50
+ unresolvedByReason.set(call.reason, (unresolvedByReason.get(call.reason) ?? 0) + 1);
51
+ if (call.qualifiedName) {
52
+ nameFrequency.set(call.qualifiedName, (nameFrequency.get(call.qualifiedName) ?? 0) + 1);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ const topUnresolvedNames = [...nameFrequency.entries()]
58
+ .sort(([nameA, countA], [nameB, countB]) => countB - countA || nameA.localeCompare(nameB))
59
+ .slice(0, TOP_UNRESOLVED_NAMES)
60
+ .map(([name, count]) => ({ name, count }));
61
+ const functionsSkipped = [...skippedFunctions.values()].reduce((total, n) => total + n, 0);
62
+ return {
63
+ filesAnalyzed,
64
+ functionsExtracted,
65
+ functionsDeclared,
66
+ functionsDeclaredByJsDoc,
67
+ functionsDeclaredByConfig,
68
+ functionsBoundary,
69
+ functionsEntrypoint: entrypoints.length,
70
+ entrypointsWithoutCapabilities,
71
+ functionsSkipped,
72
+ skippedByKind: skippedFunctions,
73
+ functionUnknownRate,
74
+ functionBoundaryRate,
75
+ callSitesTotal: callSitesResolved + callSitesStub + callSitesPure + callSitesMutation + callSitesUnresolved,
76
+ callSitesResolved,
77
+ callSitesStub,
78
+ callSitesPure,
79
+ callSitesMutation,
80
+ callSitesUnresolved,
81
+ unresolvedByReason,
82
+ topUnresolvedNames,
83
+ };
84
+ }