@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.
- package/CHANGELOG.md +233 -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/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/PreviewView.ts +32 -11
- package/preview/previewView.test.ts +76 -9
- 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
package/routeJsBudget.test.ts
CHANGED
|
@@ -96,30 +96,50 @@ describe("RouteJsBudget", () => {
|
|
|
96
96
|
const setup = async (text?: string) => {
|
|
97
97
|
const budgetPath = path.join(await makeTempRoot(), "akan.budget.json");
|
|
98
98
|
if (text !== undefined) await write(budgetPath, text);
|
|
99
|
-
return { budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
|
|
99
|
+
return { app: "shop", budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
test("enforce fails a route over its limit
|
|
102
|
+
test("enforce fails a route over its limit, a route with no entry, a file it cannot read, and an app with no file", async () => {
|
|
103
103
|
const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
|
|
104
104
|
await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
|
|
105
105
|
"1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
|
|
106
106
|
);
|
|
107
|
+
await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
|
|
108
|
+
"in apps/shop/akan.budget.json by hand.\n1 route with no entry in apps/shop/akan.budget.json",
|
|
109
|
+
);
|
|
110
|
+
const missing = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
|
|
111
|
+
await expect(RouteJsBudget.check(sizes, { ...missing, mode: "enforce" })).rejects.toThrow(
|
|
112
|
+
[
|
|
113
|
+
"1 route with no entry in apps/shop/akan.budget.json, which --check-budget checks every route against:",
|
|
114
|
+
" /:lang/new",
|
|
115
|
+
"Record it in the same change: `akan build shop --update-budget` adds each new route at its measured size.",
|
|
116
|
+
].join("\n"),
|
|
117
|
+
);
|
|
107
118
|
const broken = await setup("{");
|
|
108
119
|
await expect(RouteJsBudget.check(sizes, { ...broken, mode: "enforce" })).rejects.toThrow("is not valid JSON");
|
|
120
|
+
await expect(RouteJsBudget.check(sizes, { ...(await setup()), mode: "enforce" })).rejects.toThrow(
|
|
121
|
+
"--check-budget found no apps/shop/akan.budget.json: record one with `akan build shop --update-budget`.",
|
|
122
|
+
);
|
|
109
123
|
});
|
|
110
124
|
|
|
111
|
-
test("report warns of an overage and a file it cannot read, and fails
|
|
125
|
+
test("report warns of an overage, a route with no entry and a file it cannot read, and fails none", async () => {
|
|
126
|
+
const unbudgetedWarning = {
|
|
127
|
+
file: "apps/shop/akan.budget.json",
|
|
128
|
+
message: "/:lang/new has no entry; akan build --check-budget fails on it",
|
|
129
|
+
fix: "record it with akan build shop --update-budget",
|
|
130
|
+
};
|
|
112
131
|
const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
|
|
113
132
|
expect(await RouteJsBudget.check(sizes, { ...over, mode: "report" })).toEqual({
|
|
114
133
|
summary:
|
|
115
|
-
"1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo",
|
|
134
|
+
"1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo; 1 route(s) without an entry",
|
|
116
135
|
warnings: [
|
|
117
136
|
{
|
|
118
137
|
file: "apps/shop/akan.budget.json",
|
|
119
138
|
message:
|
|
120
|
-
"/:lang/memo loads 133,120 bytes brotli, over its limit of 126,976 (recorded for main, built for local); akan build fails on it",
|
|
121
|
-
fix: "shrink what the route loads, or raise its entry
|
|
139
|
+
"/:lang/memo loads 133,120 bytes brotli, over its limit of 126,976 (recorded for main, built for local); akan build --check-budget fails on it",
|
|
140
|
+
fix: "shrink what the route loads, or raise its entry with akan build shop --raise-budget",
|
|
122
141
|
},
|
|
142
|
+
unbudgetedWarning,
|
|
123
143
|
],
|
|
124
144
|
});
|
|
125
145
|
const broken = await setup("{");
|
|
@@ -128,8 +148,8 @@ describe("RouteJsBudget", () => {
|
|
|
128
148
|
warnings: [
|
|
129
149
|
{
|
|
130
150
|
file: "apps/shop/akan.budget.json",
|
|
131
|
-
message: "apps/shop/akan.budget.json is not valid JSON; akan build fails on it",
|
|
132
|
-
fix: "correct it, or rewrite it with akan build
|
|
151
|
+
message: "apps/shop/akan.budget.json is not valid JSON; akan build --check-budget fails on it",
|
|
152
|
+
fix: "correct it, or rewrite it with akan build shop --update-budget",
|
|
133
153
|
},
|
|
134
154
|
],
|
|
135
155
|
});
|
|
@@ -138,7 +158,8 @@ describe("RouteJsBudget", () => {
|
|
|
138
158
|
});
|
|
139
159
|
const within = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
|
|
140
160
|
expect(await RouteJsBudget.check(sizes, { ...within, mode: "report" })).toEqual({
|
|
141
|
-
summary: "within apps/shop/akan.budget.json
|
|
161
|
+
summary: "within apps/shop/akan.budget.json; 1 route(s) without an entry",
|
|
162
|
+
warnings: [unbudgetedWarning],
|
|
142
163
|
});
|
|
143
164
|
});
|
|
144
165
|
|
|
@@ -157,20 +178,35 @@ describe("RouteJsBudget", () => {
|
|
|
157
178
|
await expect(RouteJsBudget.check(sizes, { ...over, mode: "update" })).rejects.toThrow("/:lang/memo");
|
|
158
179
|
expect(RouteJsBudget.parse(await Bun.file(over.budgetPath).text()).kbByRoute.get("/:lang/memo")).toBe(120);
|
|
159
180
|
});
|
|
181
|
+
|
|
182
|
+
test("raise re-records every route at its measured size and names the entries it raised", async () => {
|
|
183
|
+
const over = await setup(
|
|
184
|
+
JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120, "/:lang/new": 90 } }),
|
|
185
|
+
);
|
|
186
|
+
expect(await RouteJsBudget.check(sizes, { ...over, mode: "raise" })).toEqual({
|
|
187
|
+
summary: "apps/shop/akan.budget.json re-recorded at each route's measured size, raised: /:lang/memo",
|
|
188
|
+
});
|
|
189
|
+
const written = RouteJsBudget.parse(await Bun.file(over.budgetPath).text());
|
|
190
|
+
expect(written.env).toBe("local");
|
|
191
|
+
expect(Object.fromEntries(written.kbByRoute)).toEqual({ "/:lang/memo": 130, "/:lang/new": 70 });
|
|
192
|
+
expect(await RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).toEqual({
|
|
193
|
+
summary: "within apps/shop/akan.budget.json",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
160
196
|
});
|
|
161
197
|
|
|
162
198
|
test("names each route over budget, its limit, and how to resolve it", () => {
|
|
163
199
|
expect(
|
|
164
200
|
RouteJsBudget.formatOverages(
|
|
165
201
|
[{ routeId: "/:lang/memo", brotliBytes: 128_000, budgetKB: 120, limitBytes: 126_976 }],
|
|
166
|
-
"apps/shop/akan.budget.json",
|
|
202
|
+
{ app: "shop", source: "apps/shop/akan.budget.json" },
|
|
167
203
|
" (recorded for main, built for local)",
|
|
168
204
|
),
|
|
169
205
|
).toBe(
|
|
170
206
|
[
|
|
171
207
|
"1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
|
|
172
208
|
" /:lang/memo: 128,000 bytes brotli, over its limit of 126,976 (120KB + the larger of 1% and 4KB)",
|
|
173
|
-
|
|
209
|
+
'Shrink what the route loads. If the growth is intended, record it in the same change: `akan build shop --raise-budget` re-records every route at its measured size, or raise the route\'s entry under "routeJsBrotliKB" in apps/shop/akan.budget.json by hand.',
|
|
174
210
|
].join("\n"),
|
|
175
211
|
);
|
|
176
212
|
});
|
package/routeJsBudget.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface RouteJsOverage {
|
|
|
17
17
|
limitBytes: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export type RouteJsBudgetMode = "enforce" | "update" | "report" | "off";
|
|
20
|
+
export type RouteJsBudgetMode = "enforce" | "update" | "raise" | "report" | "off";
|
|
21
21
|
|
|
22
22
|
export interface RouteJsBudgetWarning {
|
|
23
23
|
file: string;
|
|
@@ -30,8 +30,20 @@ export interface RouteJsBudgetStatus {
|
|
|
30
30
|
warnings?: RouteJsBudgetWarning[];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface RouteJsBudgetFlags {
|
|
34
|
+
checkBudget?: boolean;
|
|
35
|
+
updateBudget?: boolean;
|
|
36
|
+
raiseBudget?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BudgetTarget {
|
|
40
|
+
app: string;
|
|
41
|
+
source: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
export interface RouteJsBudgetCheck {
|
|
34
45
|
mode: Exclude<RouteJsBudgetMode, "off">;
|
|
46
|
+
app: string;
|
|
35
47
|
budgetPath: string;
|
|
36
48
|
source: string;
|
|
37
49
|
environment: string;
|
|
@@ -65,47 +77,85 @@ export class RouteJsBudget {
|
|
|
65
77
|
.sort((a, b) => b.brotliBytes - a.brotliBytes || RouteJsBudget.#byCodeUnit(a.routeId, b.routeId));
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
static modeOf({ checkBudget = false, updateBudget = false, raiseBudget = false }: RouteJsBudgetFlags) {
|
|
81
|
+
if ([checkBudget, updateBudget, raiseBudget].filter(Boolean).length > 1)
|
|
82
|
+
throw new Error("Pass at most one of --check-budget, --update-budget and --raise-budget.");
|
|
83
|
+
return checkBudget ? "enforce" : updateBudget ? "update" : raiseBudget ? "raise" : "report";
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
static async load(filePath: string, source = filePath): Promise<RouteJsBudget | null> {
|
|
69
87
|
const file = Bun.file(filePath);
|
|
70
88
|
if (!(await file.exists())) return null;
|
|
71
89
|
return RouteJsBudget.parse(await file.text(), source);
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
//?
|
|
75
|
-
static async check(
|
|
76
|
-
|
|
77
|
-
{ mode, budgetPath, source, environment }: RouteJsBudgetCheck,
|
|
78
|
-
): Promise<RouteJsBudgetStatus> {
|
|
92
|
+
//? A deploy build and `akan verify`'s edit loop only report: `enforce` (`--check-budget`, run by CI) is the gate.
|
|
93
|
+
static async check(sizes: RouteJsSize[], check: RouteJsBudgetCheck): Promise<RouteJsBudgetStatus> {
|
|
94
|
+
const { mode, app, budgetPath, source, environment } = check;
|
|
79
95
|
const { committed, unreadable } = await RouteJsBudget.#loadFor(mode, budgetPath, source);
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
if (mode === "enforce" && !committed)
|
|
97
|
+
throw new Error(`--check-budget found no ${source}: record one with \`akan build ${app} --update-budget\`.`);
|
|
98
|
+
const budget =
|
|
99
|
+
mode === "raise"
|
|
100
|
+
? new RouteJsBudget().tightenedBy(sizes, environment)
|
|
101
|
+
: mode === "update"
|
|
102
|
+
? (committed ?? new RouteJsBudget()).tightenedBy(sizes, environment)
|
|
103
|
+
: committed;
|
|
104
|
+
if ((mode === "update" || mode === "raise") && budget) await Bun.write(budgetPath, budget.stringify());
|
|
82
105
|
const overages = budget?.overages(sizes) ?? [];
|
|
106
|
+
const unbudgeted = budget?.unbudgeted(sizes) ?? [];
|
|
83
107
|
const envNote = committed?.envNote(environment) ?? "";
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
108
|
+
const target = { app, source };
|
|
109
|
+
const failures = [
|
|
110
|
+
...(overages.length && mode !== "report" ? [RouteJsBudget.formatOverages(overages, target, envNote)] : []),
|
|
111
|
+
...(unbudgeted.length && mode === "enforce" ? [RouteJsBudget.formatUnbudgeted(unbudgeted, target)] : []),
|
|
112
|
+
];
|
|
113
|
+
if (failures.length) throw new Error(failures.join("\n"));
|
|
114
|
+
if (mode === "update" || mode === "raise")
|
|
115
|
+
return { summary: RouteJsBudget.#writtenSummary(check, unreadable, committed, budget) };
|
|
87
116
|
if (unreadable)
|
|
88
117
|
return {
|
|
89
118
|
summary: `${unreadable}, not checked`,
|
|
90
119
|
warnings: [
|
|
91
120
|
{
|
|
92
121
|
file: source,
|
|
93
|
-
message: `${unreadable}; akan build fails on it`,
|
|
94
|
-
fix:
|
|
122
|
+
message: `${unreadable}; akan build --check-budget fails on it`,
|
|
123
|
+
fix: `correct it, or rewrite it with akan build ${app} --update-budget`,
|
|
95
124
|
},
|
|
96
125
|
],
|
|
97
126
|
};
|
|
98
127
|
if (!budget) return { summary: `no ${RouteJsBudget.fileName}` };
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
const warnings = [
|
|
129
|
+
...overages.map(({ routeId, brotliBytes, limitBytes }) => ({
|
|
130
|
+
file: source,
|
|
131
|
+
message: `${routeId} loads ${RouteJsBudget.#bytes(brotliBytes)} bytes brotli, over its limit of ${RouteJsBudget.#bytes(limitBytes)}${envNote}; akan build --check-budget fails on it`,
|
|
132
|
+
fix: `shrink what the route loads, or raise its entry with akan build ${app} --raise-budget`,
|
|
133
|
+
})),
|
|
134
|
+
...unbudgeted.map((routeId) => ({
|
|
135
|
+
file: source,
|
|
136
|
+
message: `${routeId} has no entry; akan build --check-budget fails on it`,
|
|
137
|
+
fix: `record it with akan build ${app} --update-budget`,
|
|
138
|
+
})),
|
|
139
|
+
];
|
|
140
|
+
const verdict = overages.length
|
|
141
|
+
? `${overages.length} route(s) over ${source}${envNote}, reported only: ${overages.map(({ routeId }) => routeId).join(", ")}`
|
|
142
|
+
: `within ${source}${envNote}`;
|
|
143
|
+
const unbudgetedNote = unbudgeted.length ? `; ${unbudgeted.length} route(s) without an entry` : "";
|
|
144
|
+
return { summary: `${verdict}${unbudgetedNote}`, ...(warnings.length ? { warnings } : {}) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static #writtenSummary(
|
|
148
|
+
{ mode, source }: RouteJsBudgetCheck,
|
|
149
|
+
unreadable: string | null,
|
|
150
|
+
committed: RouteJsBudget | null,
|
|
151
|
+
written: RouteJsBudget | null,
|
|
152
|
+
) {
|
|
153
|
+
if (unreadable) return `${source} rewritten (${unreadable})`;
|
|
154
|
+
if (mode === "update") return `${source} updated`;
|
|
155
|
+
const raised = [...(written?.kbByRoute ?? [])].filter(
|
|
156
|
+
([routeId, kb]) => kb > (committed?.kbByRoute.get(routeId) ?? kb),
|
|
157
|
+
);
|
|
158
|
+
return `${source} re-recorded at each route's measured size${raised.length ? `, raised: ${raised.map(([routeId]) => routeId).join(", ")}` : ""}`;
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
static async #loadFor(mode: RouteJsBudgetCheck["mode"], budgetPath: string, source: string) {
|
|
@@ -158,18 +208,31 @@ export class RouteJsBudget {
|
|
|
158
208
|
];
|
|
159
209
|
}
|
|
160
210
|
|
|
161
|
-
static formatOverages(overages: RouteJsOverage[],
|
|
211
|
+
static formatOverages(overages: RouteJsOverage[], target: BudgetTarget, envNote = ""): string {
|
|
162
212
|
const bytes = RouteJsBudget.#bytes;
|
|
213
|
+
const { source } = target;
|
|
163
214
|
return [
|
|
164
215
|
`${overages.length} route${overages.length === 1 ? "" : "s"} over the JS budget in ${source}${envNote}:`,
|
|
165
216
|
...overages.map(
|
|
166
217
|
({ routeId, brotliBytes, budgetKB, limitBytes }) =>
|
|
167
218
|
` ${routeId}: ${bytes(brotliBytes)} bytes brotli, over its limit of ${bytes(limitBytes)} (${budgetKB}KB + the larger of 1% and 4KB)`,
|
|
168
219
|
),
|
|
169
|
-
|
|
220
|
+
RouteJsBudget.#overageFix(target),
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
static formatUnbudgeted(routeIds: string[], { app, source }: BudgetTarget): string {
|
|
225
|
+
return [
|
|
226
|
+
`${routeIds.length} route${routeIds.length === 1 ? "" : "s"} with no entry in ${source}, which --check-budget checks every route against:`,
|
|
227
|
+
...routeIds.map((routeId) => ` ${routeId}`),
|
|
228
|
+
`Record ${routeIds.length === 1 ? "it" : "them"} in the same change: \`akan build ${app} --update-budget\` adds each new route at its measured size.`,
|
|
170
229
|
].join("\n");
|
|
171
230
|
}
|
|
172
231
|
|
|
232
|
+
static #overageFix({ app, source }: BudgetTarget) {
|
|
233
|
+
return `Shrink what the route loads. If the growth is intended, record it in the same change: \`akan build ${app} --raise-budget\` re-records every route at its measured size, or raise the route's entry under "${RouteJsBudget.section}" in ${source} by hand.`;
|
|
234
|
+
}
|
|
235
|
+
|
|
173
236
|
static #bytes(value: number) {
|
|
174
237
|
return value.toLocaleString("en-US");
|
|
175
238
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { Logger } from "akanjs/common";
|
|
3
4
|
import { parseNamedList } from "./moduleSyntax";
|
|
@@ -41,6 +42,8 @@ export class BarrelAnalyzer {
|
|
|
41
42
|
readonly #logger = new Logger("BarrelAnalyzer");
|
|
42
43
|
readonly #opts: BarrelAnalyzerOptions;
|
|
43
44
|
readonly #cache = new Map<string, Promise<BarrelExportMap | null>>();
|
|
45
|
+
readonly #read = new Map<string, Set<string>>();
|
|
46
|
+
readonly #realDirs = new Map<string, string>();
|
|
44
47
|
readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
|
|
45
48
|
readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
46
49
|
|
|
@@ -56,12 +59,44 @@ export class BarrelAnalyzer {
|
|
|
56
59
|
return promise;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
async filesOf(pkgName: string): Promise<string[]> {
|
|
63
|
+
await this.analyze(pkgName);
|
|
64
|
+
return [...(this.#read.get(pkgName) ?? [])];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//? A file beside one the analysis read counts too: created, it can take over a star export's specifier (`Foo.tsx`
|
|
68
|
+
//? beside `Foo/index.tsx`) with no read file changed. Both sides by realpath: a watcher names a symlinked lib's
|
|
69
|
+
//? files by their real path, where the walk went through the link.
|
|
70
|
+
forget(files: string[]): Map<string, Promise<BarrelExportMap | null>> {
|
|
71
|
+
const dirs = new Set(files.map((file) => realDirOf(file)));
|
|
72
|
+
const forgotten = new Map<string, Promise<BarrelExportMap | null>>();
|
|
73
|
+
for (const [pkgName, read] of this.#read) {
|
|
74
|
+
const analysis = this.#cache.get(pkgName);
|
|
75
|
+
if (!analysis || ![...read].some((file) => dirs.has(this.#realDirOf(file)))) continue;
|
|
76
|
+
forgotten.set(pkgName, analysis);
|
|
77
|
+
this.#cache.delete(pkgName);
|
|
78
|
+
this.#read.delete(pkgName);
|
|
79
|
+
}
|
|
80
|
+
return forgotten;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#realDirOf(file: string): string {
|
|
84
|
+
let dir = this.#realDirs.get(file);
|
|
85
|
+
if (dir === undefined) {
|
|
86
|
+
dir = realDirOf(file);
|
|
87
|
+
this.#realDirs.set(file, dir);
|
|
88
|
+
}
|
|
89
|
+
return dir;
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
async #analyzeSafe(pkgName: string): Promise<BarrelExportMap | null> {
|
|
60
93
|
try {
|
|
61
94
|
const pkg = await this.#opts.resolvePackage(pkgName);
|
|
62
95
|
if (!pkg) return null;
|
|
63
96
|
const map: BarrelExportMap = new Map();
|
|
64
|
-
|
|
97
|
+
const read = new Set<string>();
|
|
98
|
+
this.#read.set(pkgName, read);
|
|
99
|
+
await this.#walk(pkg.entryFile, pkg, map, read);
|
|
65
100
|
return map;
|
|
66
101
|
} catch (err) {
|
|
67
102
|
this.#logger.error(`analyze failed for ${pkgName}: ${(err as Error).message}`);
|
|
@@ -170,6 +205,16 @@ export class BarrelAnalyzer {
|
|
|
170
205
|
}
|
|
171
206
|
}
|
|
172
207
|
|
|
208
|
+
const realDirOf = (file: string): string => {
|
|
209
|
+
const dir = path.dirname(path.resolve(file));
|
|
210
|
+
try {
|
|
211
|
+
return fs.realpathSync.native(dir);
|
|
212
|
+
} catch {
|
|
213
|
+
// A directory removed with the file keeps its own path.
|
|
214
|
+
return dir;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
173
218
|
const isRelative = (spec: string): boolean => {
|
|
174
219
|
return spec.startsWith("./") || spec.startsWith("../") || spec === "." || spec === "..";
|
|
175
220
|
};
|
|
@@ -4,7 +4,7 @@ import ts from "typescript";
|
|
|
4
4
|
import { StoreBootstrap } from "../artifact/storeBootstrap";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { BarrelAnalyzer, type BarrelExportMap, type PackageEntry } from "./barrelAnalyzer";
|
|
7
|
-
import { loaderFor, type NamedItem
|
|
7
|
+
import { loaderFor, type NamedItem } from "./moduleSyntax";
|
|
8
8
|
|
|
9
9
|
export interface BarrelImportsPluginOptions {
|
|
10
10
|
/** Absolute paths whose content is returned unchanged (e.g. node_modules). */
|
|
@@ -234,10 +234,22 @@ const resolveFileCandidate = async (candidate: string): Promise<string | null> =
|
|
|
234
234
|
|
|
235
235
|
const MACRO_ATTR_RE = /with\s*\{\s*type\s*:\s*["']macro["']\s*\}/;
|
|
236
236
|
|
|
237
|
+
export interface RewriteBarrelImportsOptions {
|
|
238
|
+
/** Each replacement takes one line plus the newlines the statement spanned, so later lines keep their numbers. */
|
|
239
|
+
keepLines?: boolean;
|
|
240
|
+
specifierOf?: (subpath: string) => string;
|
|
241
|
+
/** Called for each import of a listed barrel with the value names whose rewrite reads its export map. */
|
|
242
|
+
onImport?: (barrel: string, names: string[]) => void;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const workspaceBarrels = (barrels: readonly string[]): string[] =>
|
|
246
|
+
barrels.filter((barrel) => /^@(?:apps|libs)\//.test(barrel));
|
|
247
|
+
|
|
237
248
|
export const rewriteBarrelImports = async (
|
|
238
249
|
source: string,
|
|
239
250
|
barrels: string[],
|
|
240
251
|
analyzer: BarrelAnalyzer,
|
|
252
|
+
{ keepLines = false, specifierOf = (subpath: string) => subpath, onImport }: RewriteBarrelImportsOptions = {},
|
|
241
253
|
): Promise<string | null> => {
|
|
242
254
|
// Sound pre-filter before the costly parse: a static import cannot name a specifier absent from the text.
|
|
243
255
|
if (!barrels.some((barrel) => source.includes(barrel))) return null;
|
|
@@ -251,20 +263,32 @@ export const rewriteBarrelImports = async (
|
|
|
251
263
|
const stmt = statements[i];
|
|
252
264
|
if (!stmt) continue;
|
|
253
265
|
if (!barrels.includes(stmt.specifier)) continue;
|
|
266
|
+
const names = valueNamesOf(stmt.clause);
|
|
267
|
+
if (names.length > 0) onImport?.(stmt.specifier, names);
|
|
254
268
|
const map = await analyzer.analyze(stmt.specifier);
|
|
255
269
|
if (!map || map.size === 0) continue;
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
270
|
+
const lines = rewriteSingleStatement(stmt, map, specifierOf);
|
|
271
|
+
if (lines === null) continue;
|
|
272
|
+
const replacement = keepLines
|
|
273
|
+
? `${lines.join(" ")}${"\n".repeat(source.slice(stmt.start, stmt.end).split("\n").length - 1)}`
|
|
274
|
+
: lines.join("\n");
|
|
258
275
|
out = out.slice(0, stmt.start) + replacement + out.slice(stmt.end);
|
|
259
276
|
changed = true;
|
|
260
277
|
}
|
|
261
278
|
return changed ? out : null;
|
|
262
279
|
};
|
|
263
280
|
|
|
281
|
+
interface ParsedClause {
|
|
282
|
+
defaultImport?: string;
|
|
283
|
+
namespaceImport?: string;
|
|
284
|
+
named?: NamedItem[];
|
|
285
|
+
typeOnly: boolean;
|
|
286
|
+
}
|
|
287
|
+
|
|
264
288
|
interface ImportStatement {
|
|
265
289
|
start: number;
|
|
266
290
|
end: number;
|
|
267
|
-
clause:
|
|
291
|
+
clause: ParsedClause | null;
|
|
268
292
|
specifier: string;
|
|
269
293
|
}
|
|
270
294
|
|
|
@@ -286,52 +310,42 @@ const findImportStatements = (source: string): ImportStatement[] => {
|
|
|
286
310
|
statements.push({
|
|
287
311
|
start: statement.getStart(sourceFile),
|
|
288
312
|
end: statement.end,
|
|
289
|
-
clause:
|
|
313
|
+
clause: clauseOf(importClause),
|
|
290
314
|
specifier: statement.moduleSpecifier.text,
|
|
291
315
|
});
|
|
292
316
|
}
|
|
293
317
|
return statements;
|
|
294
318
|
};
|
|
295
319
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const parsed: ParsedClause = { typeOnly };
|
|
311
|
-
const commaMatch = /^(\w+)\s*,\s*(.+)$/.exec(rest);
|
|
312
|
-
if (commaMatch) {
|
|
313
|
-
parsed.defaultImport = commaMatch[1];
|
|
314
|
-
rest = commaMatch[2] ?? "";
|
|
315
|
-
} else if (/^\w+$/.test(rest)) {
|
|
316
|
-
parsed.defaultImport = rest;
|
|
317
|
-
return parsed;
|
|
318
|
-
}
|
|
319
|
-
if (rest.startsWith("*")) {
|
|
320
|
-
const ns = /^\*\s+as\s+(\w+)$/.exec(rest);
|
|
321
|
-
if (!ns) return null;
|
|
322
|
-
parsed.namespaceImport = ns[1];
|
|
323
|
-
return parsed;
|
|
324
|
-
}
|
|
325
|
-
if (rest.startsWith("{")) {
|
|
326
|
-
const close = rest.indexOf("}");
|
|
327
|
-
if (close === -1) return null;
|
|
328
|
-
parsed.named = parseNamedList(rest.slice(1, close));
|
|
320
|
+
const clauseOf = (importClause: ts.ImportClause): ParsedClause | null => {
|
|
321
|
+
const parsed: ParsedClause = { typeOnly: importClause.phaseModifier === ts.SyntaxKind.TypeKeyword };
|
|
322
|
+
if (importClause.name) parsed.defaultImport = importClause.name.text;
|
|
323
|
+
const bindings = importClause.namedBindings;
|
|
324
|
+
if (!bindings) return parsed;
|
|
325
|
+
if (ts.isNamespaceImport(bindings)) return { ...parsed, namespaceImport: bindings.name.text };
|
|
326
|
+
const named: NamedItem[] = [];
|
|
327
|
+
for (const element of bindings.elements) {
|
|
328
|
+
if (element.propertyName && !ts.isIdentifier(element.propertyName)) return null;
|
|
329
|
+
named.push({
|
|
330
|
+
imported: (element.propertyName ?? element.name).text,
|
|
331
|
+
local: element.name.text,
|
|
332
|
+
isType: element.isTypeOnly,
|
|
333
|
+
});
|
|
329
334
|
}
|
|
330
|
-
return parsed;
|
|
335
|
+
return { ...parsed, named };
|
|
331
336
|
};
|
|
332
337
|
|
|
333
|
-
const
|
|
334
|
-
|
|
338
|
+
const valueNamesOf = (clause: ParsedClause | null): string[] => {
|
|
339
|
+
if (!clause || clause.namespaceImport || clause.typeOnly) return [];
|
|
340
|
+
return (clause.named ?? []).filter((item) => !item.isType).map((item) => item.imported);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const rewriteSingleStatement = (
|
|
344
|
+
stmt: ImportStatement,
|
|
345
|
+
map: BarrelExportMap,
|
|
346
|
+
specifierOf: (subpath: string) => string,
|
|
347
|
+
): string[] | null => {
|
|
348
|
+
const { clause } = stmt;
|
|
335
349
|
if (!clause) return null;
|
|
336
350
|
// Namespace imports need the whole barrel — cannot rewrite safely.
|
|
337
351
|
if (clause.namespaceImport) return null;
|
|
@@ -353,7 +367,7 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
353
367
|
continue;
|
|
354
368
|
}
|
|
355
369
|
if (target.namespace) {
|
|
356
|
-
namespaceLines.push(`import * as ${item.local} from
|
|
370
|
+
namespaceLines.push(`import * as ${item.local} from ${JSON.stringify(specifierOf(target.subpath))};`);
|
|
357
371
|
continue;
|
|
358
372
|
}
|
|
359
373
|
const list = rewrites.get(target.subpath) ?? [];
|
|
@@ -364,7 +378,9 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
364
378
|
if (rewrites.size === 0 && namespaceLines.length === 0) return null;
|
|
365
379
|
|
|
366
380
|
//? An app or lib client barrel's import-time side effects are its client runtime and store, not its module UI.
|
|
367
|
-
const lines
|
|
381
|
+
const lines = (StoreBootstrap.ofClientBarrel(stmt.specifier, map) ?? []).map(
|
|
382
|
+
(specifier) => `import ${JSON.stringify(specifierOf(specifier))};`,
|
|
383
|
+
);
|
|
368
384
|
|
|
369
385
|
if (clause.defaultImport || remaining.length > 0) {
|
|
370
386
|
const parts: string[] = [];
|
|
@@ -374,11 +390,11 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
374
390
|
}
|
|
375
391
|
|
|
376
392
|
for (const [subpath, items] of rewrites) {
|
|
377
|
-
lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from
|
|
393
|
+
lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from ${JSON.stringify(specifierOf(subpath))};`);
|
|
378
394
|
}
|
|
379
395
|
lines.push(...namespaceLines);
|
|
380
396
|
|
|
381
|
-
return lines
|
|
397
|
+
return lines;
|
|
382
398
|
};
|
|
383
399
|
|
|
384
400
|
const serializeNamedItem = (item: NamedItem): string => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { ApplicationBuildReporter } from "../applicationBuildReporter";
|
|
4
5
|
import { tempDirs, writeText as write } from "../testHelpers";
|
|
@@ -262,6 +263,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
262
263
|
expect(await rewriteBarrelImports(source, ["@libs/util/common"], analyzer)).toBeNull();
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
test("forgets an analysis when a file beside one it read changes, under the link or the real path", async () => {
|
|
267
|
+
const root = await makeTempRoot();
|
|
268
|
+
await write(path.join(root, "real/ui/index.ts"), 'export * from "./Foo";\n');
|
|
269
|
+
await write(path.join(root, "real/ui/Foo.ts"), "export const Foo = 1;\n");
|
|
270
|
+
await write(path.join(root, "real/other/Bar.ts"), "export const Bar = 1;\n");
|
|
271
|
+
fs.symlinkSync(path.join(root, "real"), path.join(root, "linked"));
|
|
272
|
+
const pkgDir = path.join(root, "linked/ui");
|
|
273
|
+
const analyzer = new BarrelAnalyzer({
|
|
274
|
+
resolvePackage: async () => ({ pkgName: "@scope/ui", entryFile: path.join(pkgDir, "index.ts"), pkgDir }),
|
|
275
|
+
});
|
|
276
|
+
const analyzed = async () => {
|
|
277
|
+
await analyzer.analyze("@scope/ui");
|
|
278
|
+
return analyzer;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
expect([...(await analyzed()).forget([path.join(root, "real/other/Bar.ts")]).keys()]).toEqual([]);
|
|
282
|
+
expect([...(await analyzed()).forget([path.join(root, "real/ui/Foo.ts")]).keys()]).toEqual(["@scope/ui"]);
|
|
283
|
+
expect([...(await analyzed()).forget([path.join(pkgDir, "Created.ts")]).keys()]).toEqual(["@scope/ui"]);
|
|
284
|
+
expect([...analyzer.forget([path.join(pkgDir, "Foo.ts")]).keys()]).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
265
287
|
test("rewrites flattenable named imports and preserves default, type, and unknown imports", async () => {
|
|
266
288
|
const analyzer = fakeAnalyzer([
|
|
267
289
|
["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
|
|
@@ -287,6 +309,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
287
309
|
expect(await rewriteBarrelImports('import { A } from "@other/pkg";', ["@scope/pkg"], analyzer)).toBeNull();
|
|
288
310
|
});
|
|
289
311
|
|
|
312
|
+
test("reads every name of an import whose braces hold comments, aliases and type-only names", async () => {
|
|
313
|
+
const analyzer = fakeAnalyzer([
|
|
314
|
+
["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
|
|
315
|
+
["st", { subpath: "@scope/pkg/store", originalName: "st" }],
|
|
316
|
+
]);
|
|
317
|
+
const rewrite = async (source: string) => await rewriteBarrelImports(source, ["@scope/pkg"], analyzer);
|
|
318
|
+
const narrowed = 'import { A } from "@scope/pkg/leaf";\nimport { st } from "@scope/pkg/store";\n';
|
|
319
|
+
|
|
320
|
+
expect(await rewrite('import {\n A, // the leaf\n st,\n} from "@scope/pkg";\n')).toBe(narrowed);
|
|
321
|
+
expect(await rewrite('import { A, /* the store */ st } from "@scope/pkg";\n')).toBe(narrowed);
|
|
322
|
+
expect(await rewrite('import { type Shape, A as Leaf, st as store } from "@scope/pkg";\n')).toBe(
|
|
323
|
+
[
|
|
324
|
+
'import { type Shape } from "@scope/pkg";',
|
|
325
|
+
'import { A as Leaf } from "@scope/pkg/leaf";',
|
|
326
|
+
'import { st as store } from "@scope/pkg/store";',
|
|
327
|
+
"",
|
|
328
|
+
].join("\n"),
|
|
329
|
+
);
|
|
330
|
+
expect(await rewrite('import { "A" as quoted } from "@scope/pkg";\n')).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
290
333
|
test("keeps a client barrel's runtime and store, never its module UI, when flattening an app or lib import", async () => {
|
|
291
334
|
const root = await makeTempRoot();
|
|
292
335
|
for (const sys of ["apps/demo", "libs/kit"]) {
|
|
@@ -308,13 +351,15 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
308
351
|
}
|
|
309
352
|
await write(path.join(root, "libs/hand/client.ts"), 'export { thing } from "./lib/thing";\n');
|
|
310
353
|
await write(path.join(root, "libs/hand/lib/thing.ts"), "export const thing = 1;\n");
|
|
354
|
+
await write(path.join(root, "libs/half/client.ts"), 'export { st } from "./lib/st";\n');
|
|
355
|
+
await write(path.join(root, "libs/half/lib/st.ts"), "export const st = {};\n");
|
|
311
356
|
const analyzer = new BarrelAnalyzer({
|
|
312
357
|
resolvePackage: await createTsconfigPackageResolver({
|
|
313
358
|
workspace: { workspaceRoot: root },
|
|
314
359
|
getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } } }),
|
|
315
360
|
} as never),
|
|
316
361
|
});
|
|
317
|
-
const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client"];
|
|
362
|
+
const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client", "@libs/half/client"];
|
|
318
363
|
const rewrite = async (source: string) => await rewriteBarrelImports(source, barrels, analyzer);
|
|
319
364
|
|
|
320
365
|
expect(await rewrite('import { Memo, st } from "@apps/demo/client";\n')).toBe(
|
|
@@ -348,6 +393,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
348
393
|
expect(await rewrite('import { thing } from "@libs/hand/client";\n')).toBe(
|
|
349
394
|
['import "@libs/hand/client";', 'import { thing } from "@libs/hand/lib/thing";', ""].join("\n"),
|
|
350
395
|
);
|
|
396
|
+
expect(await rewrite('import { st } from "@libs/half/client";\n')).toBe(
|
|
397
|
+
['import "@libs/half/client";', 'import { st } from "@libs/half/lib/st";', ""].join("\n"),
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("keeps each statement's line count and writes the specifier specifierOf gives a target", async () => {
|
|
402
|
+
const analyzer = fakeAnalyzer([
|
|
403
|
+
["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
|
|
404
|
+
["ns", { subpath: "@scope/pkg/namespace", originalName: "ns", namespace: true }],
|
|
405
|
+
]);
|
|
406
|
+
const source = 'import {\n A,\n ns,\n} from "@scope/pkg";\nconsole.log(A, ns);\n';
|
|
407
|
+
|
|
408
|
+
const rewritten = await rewriteBarrelImports(source, ["@scope/pkg"], analyzer, {
|
|
409
|
+
keepLines: true,
|
|
410
|
+
specifierOf: (subpath) => `./${subpath.slice("@scope/pkg/".length)}.ts`,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(rewritten).toBe(
|
|
414
|
+
'import { A } from "./leaf.ts"; import * as ns from "./namespace.ts";\n\n\n\nconsole.log(A, ns);\n',
|
|
415
|
+
);
|
|
416
|
+
expect(rewritten?.split("\n")).toHaveLength(source.split("\n").length);
|
|
351
417
|
});
|
|
352
418
|
|
|
353
419
|
test("does not rewrite import-looking code inside template literals", async () => {
|