@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10

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 (108) hide show
  1. package/CHANGELOG.md +233 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  44. package/errorCodeReference.test.ts +282 -0
  45. package/errorCodeReference.ts +71 -0
  46. package/errorCodeScan.ts +551 -0
  47. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  48. package/frontendBuild/allRoutesBuilder.ts +24 -6
  49. package/frontendBuild/buildRouteClient.test.ts +43 -0
  50. package/frontendBuild/bundleDefine.ts +2 -0
  51. package/frontendBuild/clientBuildTypes.ts +5 -2
  52. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  53. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  54. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  55. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  56. package/frontendBuild/csrDevBundler.ts +12 -2
  57. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  58. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  59. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  60. package/frontendBuild/csrDevPatcher.ts +47 -7
  61. package/frontendBuild/csrDevTypes.ts +16 -0
  62. package/frontendBuild/cssCompiler.test.ts +63 -1
  63. package/frontendBuild/devBarrelImports.ts +132 -0
  64. package/frontendBuild/devRegistryBundler.ts +19 -4
  65. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  66. package/frontendBuild/nativeCore.test.ts +148 -0
  67. package/frontendBuild/precompressArtifacts.ts +1 -1
  68. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  69. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  70. package/frontendBuild/ssrDevBundler.ts +13 -2
  71. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  72. package/frontendBuild/vendorImportScanner.ts +25 -10
  73. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  74. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  75. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  76. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  77. package/lint/no-import-common-err.grit +31 -0
  78. package/lint/no-init-fetch-in-client.grit +1 -1
  79. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  80. package/maintainabilityBaseline.test.ts +49 -0
  81. package/maintainabilityBaseline.ts +73 -8
  82. package/maintainabilityContract.test.ts +302 -1
  83. package/maintainabilityContract.ts +87 -22
  84. package/maintainabilityExceptionScanner.test.ts +14 -0
  85. package/maintainabilityExceptionScanner.ts +5 -1
  86. package/maintainabilityLimits.ts +12 -0
  87. package/maintainabilityReport.ts +20 -3
  88. package/moduleFieldNames.test.ts +56 -0
  89. package/moduleFieldNames.ts +25 -0
  90. package/package.json +3 -3
  91. package/preview/PreviewView.ts +32 -11
  92. package/preview/previewView.test.ts +76 -9
  93. package/routeJsBudget.test.ts +47 -11
  94. package/routeJsBudget.ts +88 -25
  95. package/transforms/barrelAnalyzer.ts +46 -1
  96. package/transforms/barrelImportsPlugin.ts +61 -45
  97. package/transforms/transforms.test.ts +67 -1
  98. package/typecheck/projectTypecheck.ts +5 -1
  99. package/typecheck/typecheck.proc.ts +3 -2
  100. package/typecheck/typecheckFailure.ts +6 -1
  101. package/workflow/artifacts.ts +25 -7
  102. package/workflow/fieldType.ts +92 -0
  103. package/workflow/index.ts +2 -0
  104. package/workflow/plainName.ts +28 -0
  105. package/workflow/plan.ts +67 -22
  106. package/workflow/source.ts +0 -5
  107. package/workspaceGraph.test.ts +200 -3
  108. package/workspaceGraph.ts +46 -8
@@ -0,0 +1,497 @@
1
+ import { describe, expect, setDefaultTimeout, test } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText } from "../testHelpers";
5
+ import { ApiReport } from "./apiReport";
6
+ import { ApiReportDiff } from "./apiReportDiff";
7
+ import { ApiReportPolicy } from "./apiReportPolicy";
8
+ import { ChangesetBump } from "./changesetBump";
9
+
10
+ const makeTempRoot = tempDirs("akan-api-report-");
11
+
12
+ const fixture: Record<string, string> = {
13
+ "package.json": JSON.stringify({
14
+ name: "demo",
15
+ exports: {
16
+ ".": { types: "./index.ts", import: "./index.ts" },
17
+ "./package.json": "./package.json",
18
+ "./sub": "./sub/index.ts",
19
+ "./styles.css": "./styles.css",
20
+ "./parts/*": "./parts/*",
21
+ },
22
+ }),
23
+ "tsconfig.json": JSON.stringify({
24
+ compilerOptions: { target: "ESNext", module: "esnext", moduleResolution: "bundler", strict: true, noEmit: true },
25
+ }),
26
+ "node_modules/thirdparty/package.json": JSON.stringify({ name: "thirdparty", types: "./index.d.ts" }),
27
+ "node_modules/thirdparty/index.d.ts": "export declare class Clock { now(): number; }\n",
28
+ "index.ts": `import { makeHidden } from "./hidden";
29
+ import { endpoint } from "./mixin";
30
+ export { Clock as Timer } from "thirdparty";
31
+ export * from "./store";
32
+ export * as tools from "./tools";
33
+ const local = 1;
34
+ export { local as renamed };
35
+ /** @deprecated use \`next\` */
36
+ export const legacy = (value: string) => value.length;
37
+ export function pick(value: string): string;
38
+ export function pick(value: number): number;
39
+ export function pick(value: string | number) {
40
+ return value;
41
+ }
42
+ export type Mode = "b" | "a" | "c";
43
+ export interface Shape {
44
+ mode: Mode;
45
+ }
46
+ export const made = makeHidden();
47
+ export class Endpoint extends endpoint({ ping: (id: string) => id.length }) {}
48
+ `,
49
+ "store.ts": `import type { Options } from "./options";
50
+ export class Store {
51
+ #secret = 1;
52
+ private hidden = 2;
53
+ protected guarded = 3;
54
+ private constructor(readonly name: string) {}
55
+ static make(name: string) {
56
+ return new Store(name);
57
+ }
58
+ configure(options: Options) {
59
+ return options;
60
+ }
61
+ private helper() {
62
+ return this.#secret + this.hidden;
63
+ }
64
+ }
65
+ `,
66
+ "options.ts": "export interface Options {\n retries: number;\n}\n",
67
+ "hidden.ts": `interface Options {
68
+ verbose: boolean;
69
+ }
70
+ interface Inner {
71
+ depth: number;
72
+ options: Options;
73
+ }
74
+ export interface Hidden {
75
+ label: string;
76
+ inner: Inner;
77
+ }
78
+ export const makeHidden = (): Hidden => ({ label: "", inner: { depth: 0, options: { verbose: false } } });
79
+ `,
80
+ "mixin.ts": `export const endpoint = <Api extends Record<string, (...args: never[]) => unknown>>(api: Api) =>
81
+ class {
82
+ api = api;
83
+ };
84
+ `,
85
+ "tools.ts": `import type { Options } from "./options";
86
+ export const version = "1";
87
+ export interface Tool {
88
+ options: Options;
89
+ }
90
+ `,
91
+ "sub/index.ts": "export const answer = 42 as const;\n",
92
+ };
93
+
94
+ const writeFixture = async (overrides: Record<string, string> = {}) => {
95
+ const root = await makeTempRoot();
96
+ for (const [file, content] of Object.entries({ ...fixture, ...overrides }))
97
+ await writeText(path.join(root, file), content);
98
+ return root;
99
+ };
100
+
101
+ //? Each generate runs the TypeScript declaration emit, which took up to 5.5 s on a CI runner under coverage.
102
+ setDefaultTimeout(30_000);
103
+
104
+ const generate = async (overrides: Record<string, string> = {}) =>
105
+ await ApiReport.generate(await writeFixture(overrides));
106
+
107
+ describe("ApiReport", () => {
108
+ test("reads named TS entry points and lists wildcards apart", () => {
109
+ const { entries, wildcards } = ApiReport.entriesOf(JSON.parse(fixture["package.json"] ?? "{}"), "/pkg");
110
+ expect(entries.map(({ specifier, reportName, file }) => ({ specifier, reportName, file }))).toEqual([
111
+ { specifier: "demo", reportName: "demo.api.md", file: "/pkg/index.ts" },
112
+ { specifier: "demo/sub", reportName: "demo.sub.api.md", file: "/pkg/sub/index.ts" },
113
+ ]);
114
+ expect(wildcards).toEqual(["./parts/*"]);
115
+ });
116
+
117
+ test("renders each export, then what the exports name that no entry exports", async () => {
118
+ const files = await generate();
119
+ expect([...files.keys()]).toEqual(["demo.api.md", "demo.sub.api.md", "demo.wildcards.api.md"]);
120
+ expect(files.get("demo.api.md")).toBe(`# \`demo\`
121
+
122
+ Generated from the declaration emit by \`bun run api:report\`. Do not edit it by hand.
123
+
124
+ \`\`\`ts
125
+ export declare class Endpoint extends Endpoint_base {
126
+ }
127
+
128
+ export type Mode = "a" | "b" | "c";
129
+
130
+ export interface Shape {
131
+ mode: Mode;
132
+ }
133
+
134
+ export declare class Store {
135
+ readonly name: string;
136
+ protected guarded: number;
137
+ private constructor();
138
+ static make(name: string): Store;
139
+ configure(options: Options): Options;
140
+ }
141
+
142
+ export { Clock as Timer } from "thirdparty";
143
+
144
+ /** @deprecated */
145
+ export declare const legacy: (value: string) => number;
146
+
147
+ export declare const made: import("./hidden").Hidden;
148
+
149
+ export declare function pick(value: string): string;
150
+ export declare function pick(value: number): number;
151
+
152
+ export declare const renamed = 1;
153
+
154
+ export declare namespace tools {
155
+ interface Tool {
156
+ options: Options;
157
+ }
158
+ const version = "1";
159
+ }
160
+ \`\`\`
161
+
162
+ ${ApiReport.referencedHeading}
163
+
164
+ The declarations above name these, and no entry point exports them.
165
+
166
+ \`\`\`ts
167
+ declare const Endpoint_base: {
168
+ new (): {
169
+ api: {
170
+ ping: (id: string) => number;
171
+ };
172
+ };
173
+ };
174
+
175
+ interface Hidden {
176
+ label: string;
177
+ inner: Inner;
178
+ }
179
+
180
+ interface Inner {
181
+ depth: number;
182
+ options: Options;
183
+ }
184
+
185
+ // hidden
186
+ interface Options {
187
+ verbose: boolean;
188
+ }
189
+
190
+ // options
191
+ interface Options {
192
+ retries: number;
193
+ }
194
+ \`\`\`
195
+ `);
196
+ expect(files.get("demo.sub.api.md")).toContain("export declare const answer: 42;");
197
+ expect(files.get("demo.wildcards.api.md")).toContain("- `./parts/*`");
198
+ });
199
+
200
+ test("shows a change to a mixin's fields as a break of its emitted base", async () => {
201
+ const before = await generate();
202
+ const after = await generate({
203
+ "index.ts": (fixture["index.ts"] ?? "").replace("ping: (id: string)", "pong: (id: string)"),
204
+ });
205
+ expect(ApiReportDiff.breaks(before, after).map(({ name, kind }) => `${kind} ${name}`)).toEqual([
206
+ "changed Endpoint_base (referenced)",
207
+ ]);
208
+ });
209
+
210
+ test("shows a change to a type only an export's type names", async () => {
211
+ const before = await generate();
212
+ const after = await generate({
213
+ "hidden.ts": (fixture["hidden.ts"] ?? "").replace("depth: number", "depth: string"),
214
+ });
215
+ expect(ApiReportDiff.breaks(before, after).map(({ name, kind }) => `${kind} ${name}`)).toEqual([
216
+ "changed Inner (referenced)",
217
+ ]);
218
+ });
219
+
220
+ test("compares a referenced type the same change exports with its old block", async () => {
221
+ const hidden = fixture["hidden.ts"] ?? "";
222
+ const exportingInner = (from: "index.ts" | "sub/index.ts", source: string) => ({
223
+ [from]: `export type { Inner } from "${from === "index.ts" ? "." : ".."}/hidden";\n${fixture[from] ?? ""}`,
224
+ "hidden.ts": source.replace("interface Inner", "export interface Inner"),
225
+ });
226
+ const before = await generate();
227
+ expect(ApiReportDiff.breaks(before, await generate(exportingInner("index.ts", hidden)))).toEqual([]);
228
+ expect(
229
+ ApiReportDiff.breaks(
230
+ before,
231
+ await generate(exportingInner("index.ts", hidden.replace("depth: number", "depth: string"))),
232
+ ),
233
+ ).toEqual([
234
+ {
235
+ report: "demo.api.md",
236
+ name: "Inner (referenced)",
237
+ kind: "changed",
238
+ before: "interface Inner {\n depth: number;\n options: Options;\n}",
239
+ after: "export interface Inner {\n depth: string;\n options: Options;\n}",
240
+ },
241
+ ]);
242
+ expect(ApiReportDiff.breaks(before, await generate(exportingInner("sub/index.ts", hidden)))).toEqual([]);
243
+ expect(
244
+ ApiReportDiff.breaks(
245
+ before,
246
+ await generate(exportingInner("sub/index.ts", hidden.replace("depth: number", "depth: string"))),
247
+ ).map(({ report, name, kind }) => `${kind} ${name} (${report})`),
248
+ ).toEqual(["changed Inner (referenced) (demo.api.md)"]);
249
+ });
250
+
251
+ test("compares a referenced type that a second one of its name moves to a module key", async () => {
252
+ const hidden = fixture["hidden.ts"] ?? "";
253
+ const withOtherInner = (source: string) => ({
254
+ "index.ts": `import { makeExtra } from "./extra";\n${fixture["index.ts"] ?? ""}export const extra = makeExtra();\n`,
255
+ "extra.ts": `interface Inner {
256
+ size: number;
257
+ }
258
+ export interface Extra {
259
+ inner: Inner;
260
+ }
261
+ export const makeExtra = (): Extra => ({ inner: { size: 0 } });
262
+ `,
263
+ "hidden.ts": source,
264
+ });
265
+ const before = await generate();
266
+ expect(ApiReportDiff.breaks(before, await generate(withOtherInner(hidden)))).toEqual([]);
267
+ expect(
268
+ ApiReportDiff.breaks(
269
+ before,
270
+ await generate(withOtherInner(hidden.replace("depth: number", "depth: string"))),
271
+ ).map(({ name, kind }) => `${kind} ${name}`),
272
+ ).toEqual(["changed Inner (referenced)"]);
273
+ });
274
+
275
+ test("shows a removed wildcard subpath as a break, and not an added one", async () => {
276
+ const before = await generate();
277
+ const after = await generate({
278
+ "package.json": (fixture["package.json"] ?? "").replace('"./parts/*":"./parts/*"', '"./more/*":"./more/*"'),
279
+ });
280
+ expect(after.get("demo.wildcards.api.md")).toContain("- `./more/*`");
281
+ expect(ApiReportDiff.breaks(before, after)).toEqual([
282
+ { report: "demo.wildcards.api.md", name: "./parts/*", kind: "removed", before: "- `./parts/*`" },
283
+ ]);
284
+ });
285
+
286
+ test("gives the same text on a second run", async () => {
287
+ expect(await generate()).toEqual(await generate());
288
+ });
289
+ });
290
+
291
+ describe("ApiReportDiff", () => {
292
+ const reportOf = (blocks: string[], referenced: string[] = []) =>
293
+ `# \`demo\`\n\n\`\`\`ts\n${blocks.join("\n\n")}\n\`\`\`\n${
294
+ referenced.length ? `\n${ApiReport.referencedHeading}\n\n\`\`\`ts\n${referenced.join("\n\n")}\n\`\`\`\n` : ""
295
+ }`;
296
+
297
+ test("keys each block by its export name, and a referenced one apart", () => {
298
+ const blocks = ApiReportDiff.blocksOf(
299
+ reportOf(
300
+ [
301
+ "/** @deprecated */\nexport declare const legacy: number;",
302
+ 'export { Clock as Timer } from "thirdparty";',
303
+ 'export * as tools from "thirdparty";',
304
+ "export declare function pick(value: string): string;\nexport declare function pick(value: number): number;",
305
+ "export declare abstract class Base {\n}",
306
+ "export default function main(): void;",
307
+ ],
308
+ ["declare const Base_base: {\n};", "// a\ninterface Options {\n}", "// b\ninterface Options {\n}"],
309
+ ),
310
+ );
311
+ expect([...blocks.keys()]).toEqual([
312
+ "legacy",
313
+ "Timer",
314
+ "tools",
315
+ "pick",
316
+ "Base",
317
+ "default",
318
+ "Base_base (referenced)",
319
+ "Options (referenced, a)",
320
+ "Options (referenced, b)",
321
+ ]);
322
+ });
323
+
324
+ test("names the files that drifted", () => {
325
+ const committed = new Map([
326
+ ["a.api.md", "1"],
327
+ ["b.api.md", "2"],
328
+ ["gone.api.md", "3"],
329
+ ]);
330
+ const generated = new Map([
331
+ ["a.api.md", "1"],
332
+ ["b.api.md", "changed"],
333
+ ["new.api.md", "4"],
334
+ ]);
335
+ expect(ApiReportDiff.drift(committed, generated)).toEqual({
336
+ changed: ["b.api.md"],
337
+ added: ["new.api.md"],
338
+ removed: ["gone.api.md"],
339
+ });
340
+ expect(ApiReportDiff.hasDrift(ApiReportDiff.drift(committed, committed))).toBe(false);
341
+ });
342
+
343
+ test("reports a removed or changed export with both texts, not an added one or a new tag", () => {
344
+ const before = new Map([
345
+ [
346
+ "demo.api.md",
347
+ reportOf(
348
+ ["export declare const a: number;", "export declare const b: number;", "export declare const d: number;"],
349
+ ["declare const Gone_base: {\n};", "interface Kept {\n x: string;\n}"],
350
+ ),
351
+ ],
352
+ ["demo.old.api.md", reportOf(["export type Old = string;"])],
353
+ ]);
354
+ const after = new Map([
355
+ [
356
+ "demo.api.md",
357
+ reportOf(
358
+ [
359
+ "export declare const a: string;",
360
+ "export declare const c: number;",
361
+ "/** @deprecated */\nexport declare const d: number;",
362
+ ],
363
+ ["interface Kept {\n x: number;\n}"],
364
+ ),
365
+ ],
366
+ ]);
367
+ expect(ApiReportDiff.breaks(before, after)).toEqual([
368
+ {
369
+ report: "demo.api.md",
370
+ name: "a",
371
+ kind: "changed",
372
+ before: "export declare const a: number;",
373
+ after: "export declare const a: string;",
374
+ },
375
+ { report: "demo.api.md", name: "b", kind: "removed", before: "export declare const b: number;" },
376
+ {
377
+ report: "demo.api.md",
378
+ name: "Kept (referenced)",
379
+ kind: "changed",
380
+ before: "interface Kept {\n x: string;\n}",
381
+ after: "interface Kept {\n x: number;\n}",
382
+ },
383
+ { report: "demo.old.api.md", name: "Old", kind: "removed", before: "export type Old = string;" },
384
+ ]);
385
+ });
386
+ });
387
+
388
+ describe("ChangesetBump", () => {
389
+ test("reads one package's bump from the frontmatter, quoted or not", () => {
390
+ const changeset = `---\n"@akanjs/cli": patch\n'akanjs': minor\n@akanjs/devkit: major\n---\n\nUpgrade note.\n`;
391
+ expect(ChangesetBump.of(changeset, "akanjs")).toBe("minor");
392
+ expect(ChangesetBump.of(changeset, "@akanjs/cli")).toBe("patch");
393
+ expect(ChangesetBump.of(changeset, "@akanjs/devkit")).toBe("major");
394
+ expect(ChangesetBump.of(changeset, "@akanjs/language")).toBeNull();
395
+ expect(ChangesetBump.of("no frontmatter", "akanjs")).toBeNull();
396
+ });
397
+
398
+ test("reads past a byte-order mark and a trailing comment", () => {
399
+ expect(ChangesetBump.of(`---\nakanjs: minor\n---\n`, "akanjs")).toBe("minor");
400
+ expect(ChangesetBump.of(`---\n"akanjs": minor # drops Err.legacy\n---\n`, "akanjs")).toBe("minor");
401
+ expect(ChangesetBump.of(`---\r\n"akanjs": major\r\n---\r\n`, "akanjs")).toBe("major");
402
+ });
403
+
404
+ test("ranks a bump against a floor", () => {
405
+ expect(ChangesetBump.atLeast("major", "minor")).toBe(true);
406
+ expect(ChangesetBump.atLeast("minor", "minor")).toBe(true);
407
+ expect(ChangesetBump.atLeast("patch", "minor")).toBe(false);
408
+ expect(ChangesetBump.atLeast(null, "minor")).toBe(false);
409
+ });
410
+ });
411
+
412
+ describe("ApiReportPolicy", () => {
413
+ const reportOf = (...blocks: string[]) => `# \`demo\`\n\n\`\`\`ts\n${blocks.join("\n\n")}\n\`\`\`\n`;
414
+ const git = (root: string, ...args: string[]) => {
415
+ const result = Bun.spawnSync(
416
+ [
417
+ "git",
418
+ "-c",
419
+ "user.name=Akan Test",
420
+ "-c",
421
+ "user.email=test@akanjs.com",
422
+ "-c",
423
+ "commit.gpgsign=false",
424
+ "-c",
425
+ "core.hooksPath=/dev/null",
426
+ ...args,
427
+ ],
428
+ { cwd: root, stdout: "pipe", stderr: "pipe" },
429
+ );
430
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
431
+ };
432
+ const changeset = (bump: string) => `---\n"akanjs": ${bump}\n---\n\nNote.\n`;
433
+ const makeRepo = async (baseReports: Record<string, string>, baseFiles: Record<string, string> = {}) => {
434
+ const root = await makeTempRoot();
435
+ git(root, "init", "-q", "-b", "main");
436
+ for (const [file, content] of Object.entries({ ".changeset/README.md": "changesets\n", ...baseFiles }))
437
+ await writeText(path.join(root, file), content);
438
+ for (const [file, content] of Object.entries(baseReports))
439
+ await writeText(path.join(root, "api-report/demo", file), content);
440
+ git(root, "add", "-A");
441
+ git(root, "commit", "-q", "-m", "base");
442
+ git(root, "checkout", "-q", "-b", "feature");
443
+ return { root, policy: new ApiReportPolicy(root, path.join("api-report", "demo"), "akanjs") };
444
+ };
445
+
446
+ test("finds no break when the base had no reports", async () => {
447
+ const { root, policy } = await makeRepo({});
448
+ await writeText(path.join(root, "api-report/demo/demo.api.md"), reportOf("export declare const a: number;"));
449
+ expect(await policy.check("main")).toEqual({ breaks: [], bumped: false });
450
+ });
451
+
452
+ test("compares with the merge base, not the base branch's tip", async () => {
453
+ const { root, policy } = await makeRepo({ "demo.api.md": reportOf("export declare const a: number;") });
454
+ git(root, "checkout", "-q", "main");
455
+ await writeText(path.join(root, "api-report/demo/demo.api.md"), reportOf("export declare const z: number;"));
456
+ git(root, "commit", "-q", "-am", "main moves on");
457
+ git(root, "checkout", "-q", "feature");
458
+ expect(await policy.check("main")).toEqual({ breaks: [], bumped: false });
459
+ });
460
+
461
+ test("asks for a minor changeset added on the branch, committed or not", async () => {
462
+ const { root, policy } = await makeRepo(
463
+ { "demo.api.md": reportOf("export declare const a: number;", "export declare const b: number;") },
464
+ { ".changeset/older.md": changeset("major") },
465
+ );
466
+ await writeText(path.join(root, "api-report/demo/demo.api.md"), reportOf("export declare const a: number;"));
467
+ expect(await policy.check("main")).toMatchObject({ breaks: [{ name: "b", kind: "removed" }], bumped: false });
468
+ await writeText(path.join(root, ".changeset/patch.md"), changeset("patch"));
469
+ expect((await policy.check("main"))?.bumped).toBe(false);
470
+ await writeText(path.join(root, ".changeset/untracked.md"), changeset("minor"));
471
+ expect((await policy.check("main"))?.bumped).toBe(true);
472
+ git(root, "add", "-A");
473
+ git(root, "commit", "-q", "-m", "drop b");
474
+ expect((await policy.check("main"))?.bumped).toBe(true);
475
+ await rm(path.join(root, ".changeset/untracked.md"));
476
+ expect((await policy.check("main"))?.bumped).toBe(false);
477
+ });
478
+
479
+ test("counts a changeset added in place of a similar one from the base", async () => {
480
+ const note = "Every app's client declaration names the Err type instead of spelling out its class.\n".repeat(4);
481
+ const { root, policy } = await makeRepo(
482
+ { "demo.api.md": reportOf("export declare const a: number;", "export declare const b: number;") },
483
+ { ".changeset/older.md": `---\n"akanjs": patch\n---\n\n${note}` },
484
+ );
485
+ await writeText(path.join(root, "api-report/demo/demo.api.md"), reportOf("export declare const a: number;"));
486
+ await rm(path.join(root, ".changeset/older.md"));
487
+ await writeText(path.join(root, ".changeset/newer.md"), `---\n"akanjs": minor\n---\n\n${note}`);
488
+ git(root, "add", "-A");
489
+ git(root, "commit", "-q", "-m", "drop b");
490
+ expect((await policy.check("main"))?.bumped).toBe(true);
491
+ });
492
+
493
+ test("answers null for a ref that names no commit", async () => {
494
+ const { policy } = await makeRepo({});
495
+ expect(await policy.check("origin/missing")).toBeNull();
496
+ });
497
+ });