@akanjs/devkit 3.2.0-rc.0 → 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.
- package/CHANGELOG.md +238 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/createTunnel.test.ts +12 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +48 -9
- package/preview/previewView.test.ts +154 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ApiDeclarationPrinter } from "./apiDeclarationPrinter";
|
|
4
|
+
import { type ApiTarget, ApiTargets } from "./apiTargets";
|
|
5
|
+
|
|
6
|
+
export interface ApiEntry {
|
|
7
|
+
subpath: string;
|
|
8
|
+
specifier: string;
|
|
9
|
+
file: string;
|
|
10
|
+
reportName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiEntryPoints {
|
|
14
|
+
packageName: string;
|
|
15
|
+
entries: ApiEntry[];
|
|
16
|
+
wildcards: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ApiBlock {
|
|
20
|
+
key: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type PackageExports = Record<string, string | Record<string, unknown>>;
|
|
25
|
+
|
|
26
|
+
export class ApiReport {
|
|
27
|
+
static readonly extension = ".api.md";
|
|
28
|
+
static readonly referencedHeading = "## Referenced, not exported";
|
|
29
|
+
readonly #packageDir: string;
|
|
30
|
+
readonly #program: ts.Program;
|
|
31
|
+
readonly #targets: ApiTargets;
|
|
32
|
+
#exported: Set<string> | null = null;
|
|
33
|
+
|
|
34
|
+
static compare(a: string, b: string) {
|
|
35
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static entriesOf(packageJson: { name: string; exports?: PackageExports }, packageDir: string): ApiEntryPoints {
|
|
39
|
+
const entries: ApiEntry[] = [];
|
|
40
|
+
const wildcards: string[] = [];
|
|
41
|
+
for (const [subpath, value] of Object.entries(packageJson.exports ?? {})) {
|
|
42
|
+
const target = typeof value === "string" ? value : (value.types ?? value.import ?? value.default);
|
|
43
|
+
if (typeof target !== "string") continue;
|
|
44
|
+
if (subpath.includes("*")) {
|
|
45
|
+
wildcards.push(subpath);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!/\.tsx?$/.test(target)) continue;
|
|
49
|
+
const specifier = subpath === "." ? packageJson.name : `${packageJson.name}/${subpath.slice(2)}`;
|
|
50
|
+
entries.push({
|
|
51
|
+
subpath,
|
|
52
|
+
specifier,
|
|
53
|
+
file: path.resolve(packageDir, target),
|
|
54
|
+
reportName: `${specifier.replace(/^@/, "").replaceAll("/", ".")}${ApiReport.extension}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { packageName: packageJson.name, entries, wildcards: wildcards.sort(ApiReport.compare) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static async generate(packageDir: string, tsconfigPath = path.join(packageDir, "tsconfig.json")) {
|
|
61
|
+
const packageJson = (await Bun.file(path.join(packageDir, "package.json")).json()) as {
|
|
62
|
+
name: string;
|
|
63
|
+
exports?: PackageExports;
|
|
64
|
+
};
|
|
65
|
+
const { packageName, entries, wildcards } = ApiReport.entriesOf(packageJson, packageDir);
|
|
66
|
+
const report = new ApiReport(packageDir, entries, tsconfigPath);
|
|
67
|
+
const files = new Map(entries.map((entry) => [entry.reportName, report.render(entry, entries)]));
|
|
68
|
+
if (wildcards.length) files.set(`${packageName}.wildcards${ApiReport.extension}`, ApiReport.#wildcards(wildcards));
|
|
69
|
+
return new Map([...files].sort(([a], [b]) => ApiReport.compare(a, b)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
constructor(packageDir: string, entries: ApiEntry[], tsconfigPath: string) {
|
|
73
|
+
this.#packageDir = packageDir;
|
|
74
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
75
|
+
if (configFile.error) throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
|
76
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(tsconfigPath), {
|
|
77
|
+
declaration: true,
|
|
78
|
+
declarationMap: false,
|
|
79
|
+
emitDeclarationOnly: true,
|
|
80
|
+
noEmit: false,
|
|
81
|
+
noEmitOnError: false,
|
|
82
|
+
sourceMap: false,
|
|
83
|
+
incremental: false,
|
|
84
|
+
composite: false,
|
|
85
|
+
});
|
|
86
|
+
const rootNames = entries.map((entry) => entry.file);
|
|
87
|
+
const reached = ts.createProgram({ rootNames, options: parsed.options });
|
|
88
|
+
//? A workspace package the build embeds is reached through a node_modules link, so tsc treats it as external and
|
|
89
|
+
//? emits nothing for it until it is named as a root.
|
|
90
|
+
const embedded = reached
|
|
91
|
+
.getSourceFiles()
|
|
92
|
+
.filter((sourceFile) => !sourceFile.isDeclarationFile && reached.isSourceFileFromExternalLibrary(sourceFile))
|
|
93
|
+
.map((sourceFile) => sourceFile.fileName);
|
|
94
|
+
this.#program = embedded.length
|
|
95
|
+
? ts.createProgram({ rootNames: [...rootNames, ...embedded], options: parsed.options, oldProgram: reached })
|
|
96
|
+
: reached;
|
|
97
|
+
const emitted = new Map<string, string>();
|
|
98
|
+
this.#program.emit(
|
|
99
|
+
undefined,
|
|
100
|
+
(_fileName, text, _bom, _onError, sourceFiles) => {
|
|
101
|
+
const source = sourceFiles?.[0];
|
|
102
|
+
if (source) emitted.set(source.fileName, text);
|
|
103
|
+
},
|
|
104
|
+
undefined,
|
|
105
|
+
true,
|
|
106
|
+
);
|
|
107
|
+
this.#targets = new ApiTargets(this.#program, emitted);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
render(entry: ApiEntry, entries: ApiEntry[] = [entry]) {
|
|
111
|
+
const exported = this.#exportedBy(entries);
|
|
112
|
+
const symbols = this.#exportsOfEntry(entry);
|
|
113
|
+
const blocks = symbols
|
|
114
|
+
.map((symbol) => ({ key: symbol.getName(), text: this.#exportBlockOf(symbol) }))
|
|
115
|
+
.sort((a, b) => ApiReport.compare(a.key, b.key));
|
|
116
|
+
const referenced = this.#referencedBlocks(
|
|
117
|
+
this.#targets.expand(symbols.flatMap((symbol) => this.#targets.ofSymbol(symbol))),
|
|
118
|
+
exported,
|
|
119
|
+
);
|
|
120
|
+
const text = [
|
|
121
|
+
`# \`${entry.specifier}\``,
|
|
122
|
+
"",
|
|
123
|
+
"Generated from the declaration emit by `bun run api:report`. Do not edit it by hand.",
|
|
124
|
+
"",
|
|
125
|
+
...ApiReport.#fenced(blocks),
|
|
126
|
+
...(referenced.length
|
|
127
|
+
? [
|
|
128
|
+
ApiReport.referencedHeading,
|
|
129
|
+
"",
|
|
130
|
+
"The declarations above name these, and no entry point exports them.",
|
|
131
|
+
"",
|
|
132
|
+
...ApiReport.#fenced(referenced),
|
|
133
|
+
]
|
|
134
|
+
: []),
|
|
135
|
+
].join("\n");
|
|
136
|
+
if (text.includes('import("/'))
|
|
137
|
+
throw new Error(`The API report of ${entry.specifier} names a module by an absolute path.`);
|
|
138
|
+
return text;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#exportsOfEntry(entry: ApiEntry) {
|
|
142
|
+
const sourceFile = this.#program.getSourceFile(entry.file);
|
|
143
|
+
if (!sourceFile) throw new Error(`${entry.file} is not in the program built for the API report.`);
|
|
144
|
+
return this.#targets.exportsOf(sourceFile);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#exportedBy(entries: ApiEntry[]) {
|
|
148
|
+
if (this.#exported) return this.#exported;
|
|
149
|
+
const targets = entries.flatMap((entry) =>
|
|
150
|
+
this.#exportsOfEntry(entry).flatMap((symbol) => this.#targets.ofSymbol(symbol)),
|
|
151
|
+
);
|
|
152
|
+
this.#exported = new Set([...targets, ...this.#targets.expand(targets)].map((target) => target.id));
|
|
153
|
+
return this.#exported;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#exportBlockOf(symbol: ts.Symbol): string {
|
|
157
|
+
const name = symbol.getName();
|
|
158
|
+
const texts = [...new Set(this.#targets.ofSymbol(symbol).flatMap((target) => this.#exportTextsOf(target, name)))];
|
|
159
|
+
return texts.length ? texts.join("\n") : `export declare const ${name}: ${this.#targets.typeTextOf(symbol)};`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#exportTextsOf(target: ApiTarget, name: string): string[] {
|
|
163
|
+
if (target.thirdParty) {
|
|
164
|
+
if (target.members) return [`export * as ${name} from "${target.thirdParty}";`];
|
|
165
|
+
const binding = target.name === name ? name : `${target.name} as ${name}`;
|
|
166
|
+
return [`export { ${binding} } from "${target.thirdParty}";`];
|
|
167
|
+
}
|
|
168
|
+
if (target.members) {
|
|
169
|
+
const members = target.members
|
|
170
|
+
.map((member) => ({ key: member.getName(), text: this.#exportBlockOf(member) }))
|
|
171
|
+
.sort((a, b) => ApiReport.compare(a.key, b.key))
|
|
172
|
+
.map(({ text }) => text.replace(/^export (?:declare )?(?![{*])/gm, "").replace(/^/gm, " "));
|
|
173
|
+
return [`export declare namespace ${name} {\n${members.join("\n")}\n}`];
|
|
174
|
+
}
|
|
175
|
+
const file = target.file;
|
|
176
|
+
if (!file) return [];
|
|
177
|
+
return target.statements.map((statement) =>
|
|
178
|
+
ApiDeclarationPrinter.print(statement, file, { localName: target.name, name, mode: "export" }),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#referencedBlocks(roots: ApiTarget[], exported: ReadonlySet<string>): ApiBlock[] {
|
|
183
|
+
const found = new Map<string, ApiTarget>();
|
|
184
|
+
const queue = [...roots];
|
|
185
|
+
for (let target = queue.pop(); target; target = queue.pop()) {
|
|
186
|
+
for (const reference of this.#targets.referencesOf(target)) {
|
|
187
|
+
if (reference.thirdParty || exported.has(reference.id) || found.has(reference.id)) continue;
|
|
188
|
+
found.set(reference.id, reference);
|
|
189
|
+
queue.push(reference);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const rendered = [...found.values()].flatMap((target) => {
|
|
193
|
+
const file = target.file;
|
|
194
|
+
if (!file || !target.statements.length) return [];
|
|
195
|
+
const text = target.statements
|
|
196
|
+
.map((statement) =>
|
|
197
|
+
ApiDeclarationPrinter.print(statement, file, { localName: target.name, name: target.name, mode: "declare" }),
|
|
198
|
+
)
|
|
199
|
+
.join("\n");
|
|
200
|
+
return [{ name: target.name, module: this.#modulePathOf(target.origin), text }];
|
|
201
|
+
});
|
|
202
|
+
const counts = new Map<string, number>();
|
|
203
|
+
for (const { name } of rendered) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
204
|
+
return rendered
|
|
205
|
+
.map(({ name, module, text }) =>
|
|
206
|
+
(counts.get(name) ?? 0) > 1 ? { key: `${name} ${module}`, text: `// ${module}\n${text}` } : { key: name, text },
|
|
207
|
+
)
|
|
208
|
+
.sort((a, b) => ApiReport.compare(a.key, b.key));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#modulePathOf(fileName: string) {
|
|
212
|
+
return path
|
|
213
|
+
.relative(this.#packageDir, fileName)
|
|
214
|
+
.replaceAll("\\", "/")
|
|
215
|
+
.replace(/(?:\.d)?\.tsx?$/, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
static #fenced(blocks: ApiBlock[]) {
|
|
219
|
+
return ["```ts", blocks.map((block) => block.text).join("\n\n"), "```", ""];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static #wildcards(wildcards: string[]) {
|
|
223
|
+
return [
|
|
224
|
+
"# Wildcard subpaths",
|
|
225
|
+
"",
|
|
226
|
+
"Each of these makes every file under it importable. Their files are not reported one by one.",
|
|
227
|
+
"",
|
|
228
|
+
...wildcards.map((wildcard) => `- \`${wildcard}\``),
|
|
229
|
+
"",
|
|
230
|
+
].join("\n");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ApiReport } from "./apiReport";
|
|
2
|
+
|
|
3
|
+
export interface ApiBreak {
|
|
4
|
+
report: string;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: "removed" | "changed";
|
|
7
|
+
before?: string;
|
|
8
|
+
after?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ApiDrift {
|
|
12
|
+
changed: string[];
|
|
13
|
+
added: string[];
|
|
14
|
+
removed: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ApiReportFiles = ReadonlyMap<string, string>;
|
|
18
|
+
|
|
19
|
+
export class ApiReportDiff {
|
|
20
|
+
static readonly #declarationPattern =
|
|
21
|
+
/^(?:export )?(?:declare )?(?:abstract |const )?(?:class|function|interface|type|enum|namespace|module|const|let|var) ([^\s<(:=;{]+)/;
|
|
22
|
+
static readonly #reexportPattern = /^export \{ (\S+)(?: as (\S+))? \} from /;
|
|
23
|
+
static readonly #namespaceReexportPattern = /^export \* as (\S+) from /;
|
|
24
|
+
static readonly #tagLine = /^\/\*\* (?:@\w+ ?)+\*\/$/;
|
|
25
|
+
static readonly #subpathPattern = /^- `(\S+)`$/gm;
|
|
26
|
+
static readonly #referencedSuffix = " (referenced)";
|
|
27
|
+
static readonly #referencedMark = " (referenced";
|
|
28
|
+
|
|
29
|
+
static blocksOf(text: string) {
|
|
30
|
+
const [exported = "", referenced = ""] = text.split(`\n${ApiReport.referencedHeading}\n`);
|
|
31
|
+
const blocks = new Map<string, string>();
|
|
32
|
+
for (const [name, block] of ApiReportDiff.#namedBlocksOf(exported)) blocks.set(name, block);
|
|
33
|
+
for (const [block, subpath = ""] of ApiReportDiff.#subpathsOf(exported)) blocks.set(subpath, block);
|
|
34
|
+
for (const [name, block, module] of ApiReportDiff.#namedBlocksOf(referenced))
|
|
35
|
+
blocks.set(module ? `${name} (referenced, ${module})` : `${name}${ApiReportDiff.#referencedSuffix}`, block);
|
|
36
|
+
return blocks;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static drift(committed: ApiReportFiles, generated: ApiReportFiles): ApiDrift {
|
|
40
|
+
return {
|
|
41
|
+
changed: [...generated]
|
|
42
|
+
.filter(([file, text]) => committed.has(file) && committed.get(file) !== text)
|
|
43
|
+
.map(([file]) => file),
|
|
44
|
+
added: [...generated.keys()].filter((file) => !committed.has(file)),
|
|
45
|
+
removed: [...committed.keys()].filter((file) => !generated.has(file)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static hasDrift({ changed, added, removed }: ApiDrift) {
|
|
50
|
+
return changed.length + added.length + removed.length > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static breaks(before: ApiReportFiles, after: ApiReportFiles): ApiBreak[] {
|
|
54
|
+
const nextReports = new Map([...after].map(([report, text]) => [report, ApiReportDiff.blocksOf(text)]));
|
|
55
|
+
return [...before].flatMap(([report, text]) => {
|
|
56
|
+
const next = nextReports.get(report) ?? new Map<string, string>();
|
|
57
|
+
return [...ApiReportDiff.blocksOf(text)].flatMap(([name, block]): ApiBreak[] => {
|
|
58
|
+
const current = next.get(name);
|
|
59
|
+
if (current !== undefined)
|
|
60
|
+
return ApiReportDiff.#sameDeclaration(current, block)
|
|
61
|
+
? []
|
|
62
|
+
: [{ report, name, kind: "changed", before: block, after: current }];
|
|
63
|
+
const referenced = ApiReportDiff.#referencedNameOf(name);
|
|
64
|
+
if (referenced === null) return [{ report, name, kind: "removed", before: block }];
|
|
65
|
+
const successors = ApiReportDiff.#successorsOf(referenced, report, nextReports);
|
|
66
|
+
//? Found nowhere, it is named by nothing now: whatever named it changed, and that change is the break.
|
|
67
|
+
if (!successors.length || successors.some((successor) => ApiReportDiff.#sameDeclaration(successor, block)))
|
|
68
|
+
return [];
|
|
69
|
+
return [{ report, name, kind: "changed", before: block, after: successors.join("\n\n") }];
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static #successorsOf(name: string, report: string, nextReports: ReadonlyMap<string, ReadonlyMap<string, string>>) {
|
|
75
|
+
const others = [...nextReports].filter(([other]) => other !== report).map(([, blocks]) => blocks);
|
|
76
|
+
const found = [nextReports.get(report) ?? new Map<string, string>(), ...others].flatMap((blocks) =>
|
|
77
|
+
[...blocks]
|
|
78
|
+
.filter(([key]) => key === name || ApiReportDiff.#referencedNameOf(key) === name)
|
|
79
|
+
.map(([, block]) => block),
|
|
80
|
+
);
|
|
81
|
+
return found.filter(
|
|
82
|
+
(block, idx) => found.findIndex((other) => ApiReportDiff.#sameDeclaration(other, block)) === idx,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static #namedBlocksOf(section: string): [string, string, string | null][] {
|
|
87
|
+
const body = /```ts\n([\s\S]*?)\n?```/.exec(section)?.[1] ?? "";
|
|
88
|
+
return body.split("\n\n").flatMap((block): [string, string, string | null][] => {
|
|
89
|
+
const lines = block.split("\n");
|
|
90
|
+
const module = lines[0]?.startsWith("// ") ? lines[0].slice(3) : null;
|
|
91
|
+
const head = lines.find((line) => !line.startsWith("/**") && !line.startsWith("// ")) ?? "";
|
|
92
|
+
const name = ApiReportDiff.#nameOf(head);
|
|
93
|
+
return name === null ? [] : [[name, block, module]];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static #subpathsOf(section: string) {
|
|
98
|
+
return section.replace(/```ts\n[\s\S]*?```/g, "").matchAll(ApiReportDiff.#subpathPattern);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static #nameOf(head: string) {
|
|
102
|
+
if (head.startsWith("export default ")) return "default";
|
|
103
|
+
const reexport = ApiReportDiff.#reexportPattern.exec(head);
|
|
104
|
+
if (reexport) return reexport[2] ?? reexport[1] ?? null;
|
|
105
|
+
const namespaceReexport = ApiReportDiff.#namespaceReexportPattern.exec(head);
|
|
106
|
+
if (namespaceReexport) return namespaceReexport[1] ?? null;
|
|
107
|
+
return ApiReportDiff.#declarationPattern.exec(head)?.[1] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static #referencedNameOf(key: string) {
|
|
111
|
+
const index = key.indexOf(ApiReportDiff.#referencedMark);
|
|
112
|
+
return index < 0 ? null : key.slice(0, index);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static #sameDeclaration(a: string, b: string) {
|
|
116
|
+
return ApiReportDiff.#declarationOf(a) === ApiReportDiff.#declarationOf(b);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static #declarationOf(block: string) {
|
|
120
|
+
return block
|
|
121
|
+
.split("\n")
|
|
122
|
+
.filter((line, idx) => !ApiReportDiff.#tagLine.test(line) && !(idx === 0 && line.startsWith("// ")))
|
|
123
|
+
.map((line) => (line.startsWith("export ") ? line.slice("export ".length) : line))
|
|
124
|
+
.join("\n");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ApiReport } from "./apiReport";
|
|
4
|
+
import { type ApiBreak, ApiReportDiff } from "./apiReportDiff";
|
|
5
|
+
import { ChangesetBump } from "./changesetBump";
|
|
6
|
+
|
|
7
|
+
export interface ApiPolicyVerdict {
|
|
8
|
+
breaks: ApiBreak[];
|
|
9
|
+
bumped: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ApiReportPolicy {
|
|
13
|
+
readonly #root: string;
|
|
14
|
+
readonly #reportDir: string;
|
|
15
|
+
readonly #packageName: string;
|
|
16
|
+
|
|
17
|
+
constructor(root: string, reportDir: string, packageName: string) {
|
|
18
|
+
this.#root = root;
|
|
19
|
+
this.#reportDir = reportDir;
|
|
20
|
+
this.#packageName = packageName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get reportDir() {
|
|
24
|
+
return this.#reportDir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async readReports() {
|
|
28
|
+
const dir = path.join(this.#root, this.#reportDir);
|
|
29
|
+
const files = (await readdir(dir).catch(() => [] as string[])).filter((file) => file.endsWith(ApiReport.extension));
|
|
30
|
+
const reports = new Map<string, string>();
|
|
31
|
+
for (const file of files.sort(ApiReport.compare)) reports.set(file, await Bun.file(path.join(dir, file)).text());
|
|
32
|
+
return reports;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async check(base: string): Promise<ApiPolicyVerdict | null> {
|
|
36
|
+
if ((await this.#git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`])) === null) return null;
|
|
37
|
+
const mergeBase = (await this.#git(["merge-base", base, "HEAD"]))?.trim();
|
|
38
|
+
if (!mergeBase) return null;
|
|
39
|
+
const before = new Map<string, string>();
|
|
40
|
+
const listed = (await this.#git(["ls-tree", "--name-only", `${mergeBase}:${this.#reportDir}`])) ?? "";
|
|
41
|
+
for (const file of listed.split("\n").filter((name) => name.endsWith(ApiReport.extension)))
|
|
42
|
+
before.set(file, (await this.#git(["show", `${mergeBase}:${this.#reportDir}/${file}`])) ?? "");
|
|
43
|
+
const breaks = ApiReportDiff.breaks(before, await this.readReports());
|
|
44
|
+
if (!breaks.length) return { breaks, bumped: false };
|
|
45
|
+
const added = [
|
|
46
|
+
...(
|
|
47
|
+
(await this.#git(["diff", "--name-only", "--no-renames", "--diff-filter=A", mergeBase, "--", ".changeset"])) ??
|
|
48
|
+
""
|
|
49
|
+
).split("\n"),
|
|
50
|
+
...((await this.#git(["ls-files", "--others", "--exclude-standard", "--", ".changeset"])) ?? "").split("\n"),
|
|
51
|
+
].filter((file) => file.endsWith(".md") && !file.endsWith("README.md"));
|
|
52
|
+
const bumps = await Promise.all(
|
|
53
|
+
added.map(async (file) =>
|
|
54
|
+
ChangesetBump.of(await Bun.file(path.join(this.#root, file)).text(), this.#packageName),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
return { breaks, bumped: bumps.some((bump) => ChangesetBump.atLeast(bump, "minor")) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #git(args: string[]) {
|
|
61
|
+
const proc = Bun.spawn(["git", ...args], { cwd: this.#root, stdout: "pipe", stderr: "ignore" });
|
|
62
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
63
|
+
return exitCode === 0 ? stdout : null;
|
|
64
|
+
}
|
|
65
|
+
}
|