@akanjs/devkit 3.0.1 → 3.0.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -2
- package/akanConfig/akanConfig.test.ts +3 -0
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/biome.base.json +8 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/csrE2e/csrPageStack.e2e.test.ts +12 -3
- package/executors.test.ts +329 -4
- package/executors.ts +49 -47
- package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
- package/frontendBuild/csrArtifactBuilder.ts +14 -8
- package/frontendBuild/csrDevModuleCompiler.ts +0 -1
- package/frontendBuild/csrDevResolver.ts +0 -2
- package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
- package/frontendBuild/devGeneratedIndexSync.ts +21 -4
- package/frontendBuild/frontendBuild.test.ts +1 -1
- package/jsonFormatter.test.ts +64 -0
- package/jsonFormatter.ts +78 -0
- package/lint/__fixtures__/README.md +12 -3
- package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
- package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
- package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
- package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
- package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
- package/lint/gritRules.test.ts +23 -15
- package/lint/no-double-brace-placeholder.grit +15 -0
- package/lint/no-inline-color.grit +21 -5
- package/lint/no-throw-raw-error.grit +18 -33
- package/lint/no-unguarded-endpoint.grit +73 -0
- package/manifestDependencies.ts +65 -0
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.test.ts +7 -4
- package/mobile/nativeApp.test.ts +57 -8
- package/mobile/nativeApp.ts +27 -9
- package/mobile/nativeConfig.test.ts +26 -4
- package/mobile/nativeConfig.ts +19 -1
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +1 -1
- package/platformTest/PlatformTestTarget.ts +3 -1
- package/platformTest/WindowsSshTarget.ts +1 -1
- package/qualityScanner.test.ts +51 -0
- package/qualityScanner.ts +22 -1
- package/scanInfo.ts +14 -2
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +24 -1
- package/workspaceLayout.ts +19 -2
package/executors.ts
CHANGED
|
@@ -44,13 +44,16 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
|
|
|
44
44
|
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
45
45
|
import { CodegenLock } from "./codegenLock";
|
|
46
46
|
import { FileSys, getDirname } from "./fileSys";
|
|
47
|
+
import { JsonFormatter } from "./jsonFormatter";
|
|
47
48
|
import { Linter } from "./linter";
|
|
49
|
+
import { ManifestDependencies } from "./manifestDependencies";
|
|
48
50
|
import { resolveRepoName } from "./repoIdentity";
|
|
49
|
-
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
51
|
+
import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
50
52
|
import { Spinner } from "./spinner";
|
|
51
53
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
52
54
|
import type { TypeChecker } from "./typeChecker";
|
|
53
55
|
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
56
|
+
import { barrelFacetDirs } from "./workspaceLayout";
|
|
54
57
|
|
|
55
58
|
export interface PageRoot {
|
|
56
59
|
/** App-relative location of the route files, i.e. the symlink for a synced lib. */
|
|
@@ -303,8 +306,7 @@ export class Executor {
|
|
|
303
306
|
const writePath = this.getPath(filePath);
|
|
304
307
|
const dir = path.dirname(writePath);
|
|
305
308
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
306
|
-
|
|
307
|
-
let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
|
|
309
|
+
let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
|
|
308
310
|
|
|
309
311
|
if (await FileSys.fileExists(writePath)) {
|
|
310
312
|
const currentContent = await FileSys.readText(writePath);
|
|
@@ -839,8 +841,6 @@ interface SysExecutorOptions extends NamedExecutorOptions {
|
|
|
839
841
|
type: "app" | "lib";
|
|
840
842
|
}
|
|
841
843
|
|
|
842
|
-
const scanFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
|
|
843
|
-
|
|
844
844
|
export class SysExecutor extends Executor {
|
|
845
845
|
workspace: WorkspaceExecutor;
|
|
846
846
|
override name: string;
|
|
@@ -896,7 +896,7 @@ export class SysExecutor extends Executor {
|
|
|
896
896
|
this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
|
|
897
897
|
this._applyTemplate({ basePath: ".", template: "client.ts", scanInfo }),
|
|
898
898
|
this.type === "lib" ? this._applyTemplate({ basePath: ".", template: "index.ts", scanInfo }) : null,
|
|
899
|
-
...
|
|
899
|
+
...barrelFacetDirs.map((facet) =>
|
|
900
900
|
this._applyTemplate({
|
|
901
901
|
basePath: facet,
|
|
902
902
|
template: "facetIndex/index.ts",
|
|
@@ -936,11 +936,9 @@ export class SysExecutor extends Executor {
|
|
|
936
936
|
await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
|
|
937
937
|
await Promise.all(this.#getScanTemplateTasks(scanInfo));
|
|
938
938
|
await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
|
|
939
|
-
|
|
940
|
-
|
|
939
|
+
await this.#updateDependencies(scanInfo);
|
|
941
940
|
if (writeLib) {
|
|
942
941
|
const libInfos = [...scanInfo.getLibInfos().values()];
|
|
943
|
-
await this.#updateDependencies(scanInfo);
|
|
944
942
|
await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
|
|
945
943
|
}
|
|
946
944
|
await this.syncAgentsIndex(scanInfo);
|
|
@@ -962,39 +960,35 @@ export class SysExecutor extends Executor {
|
|
|
962
960
|
// CLAUDE.md 는 얇은 포인터라 최초 1회만 깔아준다 — 사용자가 지우거나 고친 것을 되살리지 않는다.
|
|
963
961
|
if (!(await this.exists("CLAUDE.md"))) await this.writeFile("CLAUDE.md", renderScopeClaudeMd(scope));
|
|
964
962
|
}
|
|
963
|
+
//* The root is the cached copy the dependency scan read, so a name it declares is judged by the same snapshot.
|
|
965
964
|
async #updateDependencies(scanInfo: AppInfo | LibInfo) {
|
|
966
|
-
const rootPackageJson = await
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
...
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
.map((dep) => [dep, rootPackageJson.devDependencies?.[dep] ?? rootPackageJson.dependencies?.[dep]]),
|
|
994
|
-
) as Record<string, string>),
|
|
995
|
-
},
|
|
996
|
-
};
|
|
997
|
-
await this.setPackageJson(libPkgJsonWithDeps);
|
|
965
|
+
const [rootPackageJson, packageJson] = await Promise.all([
|
|
966
|
+
this.workspace.getPackageJson(),
|
|
967
|
+
this.getPackageJson({ refresh: true }),
|
|
968
|
+
]);
|
|
969
|
+
const { dependencies: runtime, devDependencies: typeOnly, akanConfig } = scanInfo.getScanResult();
|
|
970
|
+
const kept = new Set([
|
|
971
|
+
...ManifestDependencies.keptBy(packageJson),
|
|
972
|
+
...akanConfig.externalLibs,
|
|
973
|
+
...akanConfig.trustedDependencies,
|
|
974
|
+
]);
|
|
975
|
+
//? The scan leaves the framework out of its imports on purpose, so it is never a prune candidate.
|
|
976
|
+
const isKept = (dep: string) => kept.has(dep) || isAkanFrameworkDependency(dep);
|
|
977
|
+
const { dependencies, devDependencies, removed, unverifiable } = new ManifestDependencies(
|
|
978
|
+
rootPackageJson,
|
|
979
|
+
{ runtime, typeOnly },
|
|
980
|
+
isKept,
|
|
981
|
+
).realign(packageJson);
|
|
982
|
+
const manifestPath = `${this.type}s/${this.name}/package.json`;
|
|
983
|
+
if (removed.length)
|
|
984
|
+
this.logger.info(
|
|
985
|
+
`Removed from ${manifestPath}, declared by the root and imported nowhere: ${removed.join(", ")} (list one in akan.keepDependencies to keep it)`,
|
|
986
|
+
);
|
|
987
|
+
if (unverifiable.length)
|
|
988
|
+
this.logger.warn(
|
|
989
|
+
`${manifestPath} lists ${unverifiable.join(", ")}, which the root package.json does not declare, so sync cannot tell whether it is used; kept as is`,
|
|
990
|
+
);
|
|
991
|
+
await this.setPackageJson({ ...packageJson, dependencies, devDependencies });
|
|
998
992
|
}
|
|
999
993
|
override async getLocalFile(targetPath: string) {
|
|
1000
994
|
const filePath = path.isAbsolute(targetPath) ? targetPath : `${this.type}s/${this.name}/${targetPath}`;
|
|
@@ -1119,13 +1113,21 @@ export class AppExecutor extends SysExecutor {
|
|
|
1119
1113
|
static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
|
|
1120
1114
|
return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
|
|
1121
1115
|
}
|
|
1122
|
-
//* Not dev output:
|
|
1123
|
-
static readonly #keptOnStart = [path.join(".akan", "
|
|
1116
|
+
//* Not dev output: the downloaded `bin` sources.
|
|
1117
|
+
static readonly #keptOnStart = [path.join(".akan", "cache", "bin")];
|
|
1118
|
+
//* Native builds moved to dist/native/<app>; the old folder may hold a signed release nobody uploaded yet.
|
|
1119
|
+
static readonly #legacyNative = path.join(".akan", "native");
|
|
1124
1120
|
async #removeDevOutput(dir = ".akan") {
|
|
1125
1121
|
const entries = await readDirEntries(this.getPath(dir)).catch(() => [] as string[]);
|
|
1126
1122
|
await Promise.all(
|
|
1127
1123
|
entries.map(async (name) => {
|
|
1128
1124
|
const entry = path.join(dir, name);
|
|
1125
|
+
if (entry === AppExecutor.#legacyNative) {
|
|
1126
|
+
this.logger.warn(
|
|
1127
|
+
`apps/${this.name}/.akan/native holds native builds from before they moved to dist/native/${this.name}: upload any release waiting in its updates folders, then delete it.`,
|
|
1128
|
+
);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1129
1131
|
if (AppExecutor.#keptOnStart.includes(entry)) return;
|
|
1130
1132
|
if (AppExecutor.#keptOnStart.some((kept) => kept.startsWith(`${entry}${path.sep}`)))
|
|
1131
1133
|
return await this.#removeDevOutput(entry);
|
|
@@ -1209,10 +1211,10 @@ export class AppExecutor extends SysExecutor {
|
|
|
1209
1211
|
// Ports and the operation mode are dropped: `define` would bake them in and outrank the container's own values.
|
|
1210
1212
|
if (type === "build") {
|
|
1211
1213
|
const buildEnv = { ...env };
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1214
|
+
for (const key of ["AKAN_PUBLIC_CLIENT_PORT", "AKAN_PUBLIC_SERVER_PORT", "AKAN_PUBLIC_OPERATION_MODE"]) {
|
|
1215
|
+
delete buildEnv[key];
|
|
1216
|
+
delete process.env[key];
|
|
1217
|
+
}
|
|
1216
1218
|
Object.assign(process.env, buildEnv);
|
|
1217
1219
|
}
|
|
1218
1220
|
return { env };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
|
|
5
|
+
const makeTempRoot = tempDirs("akan-csr-artifact-");
|
|
6
|
+
|
|
7
|
+
describe("CsrArtifactBuilder.bundleOptions", () => {
|
|
8
|
+
//? A child process: Bun reads `.env` only at start, and the bug was that this snapshot outranked `define`.
|
|
9
|
+
test("bakes the env a native build sets on process.env, not the .env value Bun loaded at start", async () => {
|
|
10
|
+
const root = await makeTempRoot();
|
|
11
|
+
await writeText(path.join(root, ".env"), "AKAN_PUBLIC_ENV=local\n");
|
|
12
|
+
await writeText(path.join(root, "entry.ts"), 'console.info("envmark:" + process.env.AKAN_PUBLIC_ENV);\n');
|
|
13
|
+
await writeText(
|
|
14
|
+
path.join(root, "build.ts"),
|
|
15
|
+
`import { CsrArtifactBuilder } from ${JSON.stringify(path.join(import.meta.dir, "csrArtifactBuilder.ts"))};
|
|
16
|
+
process.env.AKAN_PUBLIC_ENV = "debug";
|
|
17
|
+
const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: process.env.AKAN_PUBLIC_ENV }) };
|
|
18
|
+
const result = await Bun.build({ ...CsrArtifactBuilder.bundleOptions(app as never, "build"), entrypoints: ["./entry.ts"] });
|
|
19
|
+
console.info(await result.outputs[0].text());
|
|
20
|
+
`,
|
|
21
|
+
);
|
|
22
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("AKAN_PUBLIC_")));
|
|
23
|
+
const proc = Bun.spawn([process.execPath, "build.ts"], { cwd: root, env, stdout: "pipe", stderr: "pipe" });
|
|
24
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
25
|
+
new Response(proc.stdout).text(),
|
|
26
|
+
new Response(proc.stderr).text(),
|
|
27
|
+
proc.exited,
|
|
28
|
+
]);
|
|
29
|
+
expect(stderr).toBe("");
|
|
30
|
+
expect(exitCode).toBe(0);
|
|
31
|
+
expect(stdout).toContain("envmark:debug");
|
|
32
|
+
expect(stdout).not.toContain("envmark:local");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -54,18 +54,11 @@ export class CsrArtifactBuilder {
|
|
|
54
54
|
);
|
|
55
55
|
|
|
56
56
|
const result = await Bun.build({
|
|
57
|
-
|
|
57
|
+
...CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports),
|
|
58
58
|
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
59
59
|
files: generatedFiles,
|
|
60
60
|
root: this.#generatedDir,
|
|
61
61
|
outdir: this.#outputDir,
|
|
62
|
-
splitting: false,
|
|
63
|
-
minify: true,
|
|
64
|
-
env: "AKAN_PUBLIC_*",
|
|
65
|
-
define: bundleDefine(this.#app, this.#command, "csr"),
|
|
66
|
-
optimizeImports: akanConfig.optimizeImports,
|
|
67
|
-
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
68
|
-
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
69
62
|
});
|
|
70
63
|
|
|
71
64
|
if (!result.success) {
|
|
@@ -78,6 +71,19 @@ export class CsrArtifactBuilder {
|
|
|
78
71
|
return { outputDir: this.#outputDir };
|
|
79
72
|
}
|
|
80
73
|
|
|
74
|
+
//? No `env` option: Bun inlines the `.env` it read at process start over `define`, so a native build's `--env` lost.
|
|
75
|
+
static bundleOptions(app: App, command: "build" | "start", optimizeImports: string[] = []) {
|
|
76
|
+
return {
|
|
77
|
+
target: "browser" as const,
|
|
78
|
+
splitting: false,
|
|
79
|
+
minify: true,
|
|
80
|
+
define: bundleDefine(app, command, "csr"),
|
|
81
|
+
optimizeImports,
|
|
82
|
+
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
83
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
82
88
|
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
83
89
|
return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
|
|
@@ -154,7 +154,6 @@ export class CsrDevModuleCompiler {
|
|
|
154
154
|
naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
|
|
155
155
|
publicPath: this.#routePrefix,
|
|
156
156
|
metafile: true,
|
|
157
|
-
env: "AKAN_PUBLIC_*",
|
|
158
157
|
//? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
|
|
159
158
|
//? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
|
|
160
159
|
define: { ...this.#context.define, "import.meta": "__akanMeta" },
|
|
@@ -88,7 +88,6 @@ export class CsrDevResolver {
|
|
|
88
88
|
target: "browser",
|
|
89
89
|
metafile: true,
|
|
90
90
|
external: [...(this.#context.externals ?? [])],
|
|
91
|
-
env: "AKAN_PUBLIC_*",
|
|
92
91
|
define: this.#context.define,
|
|
93
92
|
optimizeImports: this.#context.optimizeImports,
|
|
94
93
|
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
@@ -112,7 +111,6 @@ export class CsrDevResolver {
|
|
|
112
111
|
metafile: true,
|
|
113
112
|
throw: false,
|
|
114
113
|
external: [...(this.#context.externals ?? [])],
|
|
115
|
-
env: "AKAN_PUBLIC_*",
|
|
116
114
|
define: this.#context.define,
|
|
117
115
|
plugins: [
|
|
118
116
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tempDirs } from "../testHelpers";
|
|
5
5
|
import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
|
|
@@ -40,6 +40,18 @@ describe("DevGeneratedIndexSync facet barrels", () => {
|
|
|
40
40
|
expect(content).toBe(`export * from "./aes";\nexport * from "./cloudflareApi";\nexport * from "./storageApi";\n`);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test("a facet with nothing to export keeps an empty barrel, and a deleted facet gets none", async () => {
|
|
44
|
+
const root = await makeTempRoot();
|
|
45
|
+
const content = await barrelFor(root, "common", { files: ["foo.helper.ts"], dirs: [], trigger: "foo.helper.ts" });
|
|
46
|
+
expect(content).toBe("export {};\n");
|
|
47
|
+
|
|
48
|
+
const dir = path.join(root, "libs", "util", "common");
|
|
49
|
+
await rm(dir, { recursive: true });
|
|
50
|
+
const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
|
|
51
|
+
expect(await sync.syncForBatch([path.join(dir, "foo.helper.ts")])).toEqual({ changedFiles: [], errors: [] });
|
|
52
|
+
expect(await Bun.file(path.join(dir, "index.ts")).exists()).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
43
55
|
test("ui facet exports only clean PascalCase names", async () => {
|
|
44
56
|
const content = await barrelFor(await makeTempRoot(), "ui", {
|
|
45
57
|
files: ["Globe.tsx", "AkanLogo.tsx", "Globe_Dynamic.tsx", "lowerStart.tsx"],
|
|
@@ -114,7 +114,8 @@ export class DevGeneratedIndexSync {
|
|
|
114
114
|
|
|
115
115
|
async #facetContent(dir: string): Promise<string | null> {
|
|
116
116
|
const nameCasePattern = path.basename(dir) === "ui" ? FACET_PASCAL_CASE_RE : FACET_CAMEL_CASE_RE;
|
|
117
|
-
const entries = await readdir(dir, { withFileTypes: true }).catch(() =>
|
|
117
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
|
118
|
+
if (!entries) return null;
|
|
118
119
|
const exportNames = entries
|
|
119
120
|
.flatMap((entry) => {
|
|
120
121
|
const name = entry.name;
|
|
@@ -125,8 +126,8 @@ export class DevGeneratedIndexSync {
|
|
|
125
126
|
const exportName = name.replace(FACET_SOURCE_FILE_RE, "");
|
|
126
127
|
return nameCasePattern.test(exportName) ? [exportName] : [];
|
|
127
128
|
})
|
|
128
|
-
.sort();
|
|
129
|
-
if (exportNames.length === 0) return
|
|
129
|
+
.sort(compareExportNames);
|
|
130
|
+
if (exportNames.length === 0) return "export {};\n";
|
|
130
131
|
return `${exportNames.map((name) => `export * from "./${name}";`).join("\n")}\n`;
|
|
131
132
|
}
|
|
132
133
|
|
|
@@ -149,10 +150,26 @@ export class DevGeneratedIndexSync {
|
|
|
149
150
|
if (await exists(path.join(dir, `${modelName}.${type}.tsx`))) fileTypes.push(type);
|
|
150
151
|
}
|
|
151
152
|
if (fileTypes.length === 0) return null;
|
|
152
|
-
return
|
|
153
|
+
return `${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };\n`;
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
//? Biome's organizeImports order, or `akan lint` reorders the barrel: A < a < B < b, a digit before a letter, and a
|
|
158
|
+
//? run of digits compared by its length first. The facetIndex template sorts the same way.
|
|
159
|
+
const letterRankOf = (char: string) => char.toLowerCase().charCodeAt(0) * 2 + (char === char.toLowerCase() ? 1 : 0);
|
|
160
|
+
const digitRunAt = (name: string, idx: number) => /^\d+/.exec(name.slice(idx))?.[0] ?? "";
|
|
161
|
+
const compareExportNames = (a: string, b: string) => {
|
|
162
|
+
for (let idx = 0; idx < a.length && idx < b.length; ) {
|
|
163
|
+
const [runA, runB] = [digitRunAt(a, idx), digitRunAt(b, idx)];
|
|
164
|
+
if (runA && runB && runA !== runB) return runA.length - runB.length || (runA < runB ? -1 : 1);
|
|
165
|
+
if (runA && runB) idx += runA.length;
|
|
166
|
+
else if (runA || runB) return runA ? -1 : 1;
|
|
167
|
+
else if (a.charAt(idx) !== b.charAt(idx)) return letterRankOf(a.charAt(idx)) - letterRankOf(b.charAt(idx));
|
|
168
|
+
else idx++;
|
|
169
|
+
}
|
|
170
|
+
return a.length - b.length;
|
|
171
|
+
};
|
|
172
|
+
|
|
156
173
|
const exists = async (file: string) =>
|
|
157
174
|
stat(file)
|
|
158
175
|
.then(() => true)
|
|
@@ -354,7 +354,7 @@ describe("DevGeneratedIndexSync", () => {
|
|
|
354
354
|
|
|
355
355
|
expect(removed.errors).toEqual([]);
|
|
356
356
|
expect(removed.changedFiles).toEqual([index]);
|
|
357
|
-
expect(await
|
|
357
|
+
expect(await readFile(index, "utf8")).toBe("export {};\n");
|
|
358
358
|
});
|
|
359
359
|
|
|
360
360
|
test("ignores server/client folders as barrel facets", async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { JsonFormatter } from "./jsonFormatter";
|
|
3
|
+
import { formatWithBiome, hasBiome } from "./testHelpers";
|
|
4
|
+
|
|
5
|
+
const pad = (length: number) => "x".repeat(length);
|
|
6
|
+
const thousands = Array.from({ length: 40 }, (_, idx) => idx * 1000);
|
|
7
|
+
|
|
8
|
+
const fixtures: Record<string, unknown> = {
|
|
9
|
+
collapsed: { scalars: ["accessToken", "accessLog"], empty: [], none: {}, nested: [["a"], [{}]] },
|
|
10
|
+
commaAtTheEdge: { a: [pad(108)], b: [pad(109)], c: [pad(109)] },
|
|
11
|
+
objectsInArray: { routes: [{ path: "/" }, { path: "/a" }] },
|
|
12
|
+
numbers: { sizes: thousands, words: thousands.map(String) },
|
|
13
|
+
wide: { a: ["한".repeat(54)], b: ["한".repeat(55)] },
|
|
14
|
+
rootArray: [pad(60), pad(60)],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("JsonFormatter", () => {
|
|
18
|
+
test("collapses an array that fits the line with its trailing comma, and keeps every object expanded", () => {
|
|
19
|
+
expect(JsonFormatter.stringify(fixtures.collapsed, "akan.lib.json")).toBe(
|
|
20
|
+
'{\n "scalars": ["accessToken", "accessLog"],\n "empty": [],\n "none": {},\n "nested": [["a"], [{}]]\n}\n',
|
|
21
|
+
);
|
|
22
|
+
expect(JsonFormatter.stringify(fixtures.commaAtTheEdge, "akan.lib.json").split("\n")).toEqual([
|
|
23
|
+
"{",
|
|
24
|
+
` "a": ["${pad(108)}"],`,
|
|
25
|
+
' "b": [',
|
|
26
|
+
` "${pad(109)}"`,
|
|
27
|
+
" ],",
|
|
28
|
+
` "c": ["${pad(109)}"]`,
|
|
29
|
+
"}",
|
|
30
|
+
"",
|
|
31
|
+
]);
|
|
32
|
+
expect(JsonFormatter.stringify(fixtures.objectsInArray, "akan.lib.json")).toContain('"routes": [\n {\n');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("fills an array of numbers that overflows, and keeps any other overflowing array one item a line", () => {
|
|
36
|
+
const [, sizesOpen, firstRow, secondRow] = JsonFormatter.stringify(fixtures.numbers, "akan.app.json").split("\n");
|
|
37
|
+
expect(sizesOpen).toBe(' "sizes": [');
|
|
38
|
+
expect(firstRow).toStartWith(" 0, 1000, 2000,");
|
|
39
|
+
expect(firstRow?.length).toBeLessThanOrEqual(JsonFormatter.lineWidth);
|
|
40
|
+
expect(secondRow).toStartWith(" 18000,");
|
|
41
|
+
expect(JsonFormatter.stringify(fixtures.numbers, "akan.app.json")).toContain('"words": [\n "0",\n "1000",');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("measures a wide character as two columns", () => {
|
|
45
|
+
const [, fits, overflows] = JsonFormatter.stringify(fixtures.wide, "akan.lib.json").split("\n");
|
|
46
|
+
expect(fits).toBe(` "a": ["${"한".repeat(54)}"],`);
|
|
47
|
+
expect(overflows).toBe(' "b": [');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("keeps a package.json expanded the way JSON.stringify writes it", () => {
|
|
51
|
+
const manifest = { name: "x", workspaces: ["pkgs/*"], dependencies: {} };
|
|
52
|
+
expect(JsonFormatter.stringify(manifest, "libs/x/package.json")).toBe(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test.skipIf(!hasBiome)("prints what Biome prints", async () => {
|
|
56
|
+
expect(await formatWithBiome('{"a":[1]}', "libs/fixture/akan.lib.json")).toBe('{ "a": [1] }\n');
|
|
57
|
+
for (const [name, fixture] of Object.entries(fixtures)) {
|
|
58
|
+
const printed = JsonFormatter.stringify(fixture, `libs/fixture/${name}.json`);
|
|
59
|
+
expect(await formatWithBiome(printed, `libs/fixture/${name}.json`)).toBe(printed);
|
|
60
|
+
}
|
|
61
|
+
const manifest = JsonFormatter.stringify({ name: "x", workspaces: ["a", "b"] }, "package.json");
|
|
62
|
+
expect(await formatWithBiome(manifest, "libs/fixture/package.json")).toBe(manifest);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/jsonFormatter.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
4
|
+
|
|
5
|
+
interface Placement {
|
|
6
|
+
column: number;
|
|
7
|
+
trailing: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//* Prints what `biome format` would, or `akan lint` and `akan sync` flip-flop a generated .json. Biome keeps a
|
|
11
|
+
//* package.json fully expanded (`expand: always`); elsewhere (`auto`) it keeps an object expanded, collapses an array
|
|
12
|
+
//* that fits the 120-column line (a trailing comma included), fills an array of numbers, and ends with a newline.
|
|
13
|
+
export class JsonFormatter {
|
|
14
|
+
static readonly lineWidth = 120;
|
|
15
|
+
|
|
16
|
+
static stringify(value: unknown, filePath: string) {
|
|
17
|
+
const json = JSON.stringify(value, null, 2);
|
|
18
|
+
if (path.basename(filePath) === "package.json") return `${json}\n`;
|
|
19
|
+
return `${JsonFormatter.#print(JSON.parse(json) as JsonValue, 0, { column: 0, trailing: 0 })}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static #print(value: JsonValue, depth: number, placement: Placement): string {
|
|
23
|
+
if (Array.isArray(value)) return JsonFormatter.#printArray(value, depth, placement);
|
|
24
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
25
|
+
const entries = Object.entries(value);
|
|
26
|
+
if (!entries.length) return "{}";
|
|
27
|
+
const indent = " ".repeat(depth + 1);
|
|
28
|
+
const members = entries.map(([key, member], idx) => {
|
|
29
|
+
const prefix = `${indent}${JSON.stringify(key)}: `;
|
|
30
|
+
const trailing = idx < entries.length - 1 ? 1 : 0;
|
|
31
|
+
return `${prefix}${JsonFormatter.#print(member, depth + 1, { column: Bun.stringWidth(prefix), trailing })}`;
|
|
32
|
+
});
|
|
33
|
+
return `{\n${members.join(",\n")}\n${" ".repeat(depth)}}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static #printArray(items: JsonValue[], depth: number, { column, trailing }: Placement) {
|
|
37
|
+
if (!items.length) return "[]";
|
|
38
|
+
const flat = JsonFormatter.#flat(items);
|
|
39
|
+
if (flat !== null && column + Bun.stringWidth(flat) + trailing <= JsonFormatter.lineWidth) return flat;
|
|
40
|
+
const indent = " ".repeat(depth + 1);
|
|
41
|
+
const isLast = (idx: number) => idx === items.length - 1;
|
|
42
|
+
const body = items.every((item) => typeof item === "number")
|
|
43
|
+
? JsonFormatter.#fill(
|
|
44
|
+
items.map((item, idx) => `${JSON.stringify(item)}${isLast(idx) ? "" : ","}`),
|
|
45
|
+
indent,
|
|
46
|
+
)
|
|
47
|
+
: items
|
|
48
|
+
.map((item, idx) => {
|
|
49
|
+
const placement = { column: indent.length, trailing: isLast(idx) ? 0 : 1 };
|
|
50
|
+
return `${indent}${JsonFormatter.#print(item, depth + 1, placement)}`;
|
|
51
|
+
})
|
|
52
|
+
.join(",\n");
|
|
53
|
+
return `[\n${body}\n${" ".repeat(depth)}]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static #fill(tokens: string[], indent: string) {
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
let line = "";
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
if (line && Bun.stringWidth(line) + 1 + Bun.stringWidth(token) <= JsonFormatter.lineWidth) {
|
|
61
|
+
line = `${line} ${token}`;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (line) lines.push(line);
|
|
65
|
+
line = `${indent}${token}`;
|
|
66
|
+
}
|
|
67
|
+
return [...lines, line].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #flat(value: JsonValue): string | null {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
const items = value.map((item) => JsonFormatter.#flat(item));
|
|
73
|
+
return items.includes(null) ? null : `[${items.join(", ")}]`;
|
|
74
|
+
}
|
|
75
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
76
|
+
return Object.keys(value).length ? null : "{}";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -7,9 +7,12 @@ fixture` fails when a rule is added without one.
|
|
|
7
7
|
|
|
8
8
|
## Contract
|
|
9
9
|
|
|
10
|
-
- One case per line. `// @flag` marks a line the rule must report
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
- One case per line. In `bad`, `// @flag` marks a line the rule must report as an error and `// @warn` one it
|
|
11
|
+
must report as a warning; `// @ok` marks a line in `good`.
|
|
12
|
+
- The `bad` assertion is exact, not containment: every marked line is reported once, at its marker's severity,
|
|
13
|
+
and nothing else is. A marked line nobody reported is a pattern that stopped matching, a reported line nobody
|
|
14
|
+
marked is the rule reaching further than the fixture claims, a line reported twice is one site reported
|
|
15
|
+
twice, and a different severity is a tier that moved.
|
|
13
16
|
- `good` is the half that matters most: an over-matching rule is what makes developers distrust the gate.
|
|
14
17
|
- `fixture.json` is optional:
|
|
15
18
|
- `path` — where the source is written inside the temp workspace, for a rule that reads `$filename`
|
|
@@ -35,6 +38,12 @@ Each of these made a rule silently report nothing, and each was found by adding
|
|
|
35
38
|
that is `no-bang-comment-in-client`'s blind spot, and why its fixture puts the marker between statements.
|
|
36
39
|
- A regex capture (`r"..."($var)`) carries no source range: a diagnostic spanned on one gets no location,
|
|
37
40
|
and `$var` does not interpolate into a `message`.
|
|
41
|
+
- A code snippet also matches JSX text that parses as it: `` `new Error` `` reports `<code>new Error</code>` in a
|
|
42
|
+
docs page. Where a rule meets `.tsx`, match the node kind instead (`JsNewExpression(callee = `Error`)`).
|
|
43
|
+
- A regex is anchored by bare concatenation, so a top-level alternation leaks out of the anchors: `r"fill|stroke"`
|
|
44
|
+
is `^fill|stroke$` and matches `fillOpacity`. Group it: `r"(?:fill|stroke)"`.
|
|
45
|
+
- A `\"` in a GritQL string is unescaped twice and corrupts it: messages quote with `'`, and a regex spells the
|
|
46
|
+
character `\x22`.
|
|
38
47
|
|
|
39
48
|
The five vocabulary fixtures were ported from `frontendBuild/styleGuard.test.ts`, the pre-grit
|
|
40
49
|
implementation of the same rules, when that scanner was retired.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const dictionary = modelDictionary(["en", "ko"])
|
|
2
|
+
.of((t) => t(["Video Board", "비디오 보드"]).desc(["A board of cuts", "컷 보드"]))
|
|
3
|
+
.error({
|
|
4
|
+
videoBlockedByPolicy: [
|
|
5
|
+
"The provider refused this video: {{reason}}", // @flag
|
|
6
|
+
"제공자가 거절했습니다: {{reason}}", // @flag
|
|
7
|
+
],
|
|
8
|
+
invalidValues: [
|
|
9
|
+
"The device rejects these values: {{fields}} ({{command}}).", // @flag
|
|
10
|
+
"기기가 받지 않는 값입니다: {{fields}} ({{command}}).", // @flag
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
.translate({
|
|
14
|
+
greeting: [
|
|
15
|
+
`Hello {{name}}`, // @flag
|
|
16
|
+
`안녕하세요 {{name}}`, // @flag
|
|
17
|
+
],
|
|
18
|
+
turnSteps: [
|
|
19
|
+
`Turn ${"left"} in {{steps}} steps`, // @flag
|
|
20
|
+
"{{steps}}걸음 뒤 회전", // @flag
|
|
21
|
+
],
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "path": "apps/demo/lib/videoBoard/videoBoard.dictionary.ts" }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const dictionary = modelDictionary(["en", "ko"])
|
|
2
|
+
.of((t) => t(["Video Board", "비디오 보드"]).desc(["Cuts name a {{slot}} the user types", "컷에 {{슬롯}}을 적습니다"])) // @ok
|
|
3
|
+
.model((t) => ({
|
|
4
|
+
refAssets: t(["Ref Assets", "참고 자산"]).desc(["Each plays a {{slot}} of the plan", "기획안의 {{슬롯}}을 맡습니다"]), // @ok
|
|
5
|
+
}))
|
|
6
|
+
.endpoint((fn) => ({
|
|
7
|
+
bindSlot: fn(["Bind Slot", "슬롯 연결"]).desc(["Bind an asset to a {{slot}}", "{{슬롯}}에 자산을 연결합니다"]), // @ok
|
|
8
|
+
}))
|
|
9
|
+
.error({
|
|
10
|
+
videoBlockedByPolicy: ["The provider refused this video: {reason}", "제공자가 거절했습니다: {reason}"], // @ok
|
|
11
|
+
})
|
|
12
|
+
.translate({
|
|
13
|
+
greeting: [`Hello {name}`, `안녕하세요 {name}`], // @ok
|
|
14
|
+
bracesExplained: ["Wrap a name in { and }", "이름을 { 와 }로 감쌉니다"], // @ok
|
|
15
|
+
});
|
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
export const HexInStyleObject = () => <div style={{ color: "#fff" }} />; // @flag
|
|
2
2
|
export const RgbInStyleObject = () => <div style={{ background: "rgb(0,0,0)" }} />; // @flag
|
|
3
3
|
export const ColorLiteralInStyleTag = () => <style>{`.x { color: #abcdef; }`}</style>; // @flag
|
|
4
|
+
export const FillInAStyleObject = () => <path style={{ fill: "#2b7fff" }} />; // @flag
|
|
5
|
+
export const FillAttribute = () => <path fill="#2b7fff" />; // @warn
|
|
6
|
+
export const StrokeAttribute = () => <path stroke="#909090" />; // @warn
|
|
7
|
+
export const StopColorAttribute = () => <stop stopColor="rgba(255,72,64,0)" />; // @warn
|
|
8
|
+
export const FloodColorAttribute = () => <feDropShadow floodColor="#000" />; // @warn
|
|
9
|
+
export const LightingColorAttribute = () => <feDiffuseLighting lightingColor="hsl(0 0% 100%)" />; // @warn
|
|
10
|
+
export const ColorAttribute = () => <svg color="#5bbad5" />; // @warn
|
|
11
|
+
export const ComponentFillProp = () => <Icon fill="#ffd700" />; // @warn
|
|
12
|
+
export const ExpressionValue = () => <path fill={isActive ? "#ffffff" : fillColor} />; // @warn
|
|
13
|
+
export const TemplateValue = () => <path stroke={`rgb(${red}, 0, 0)`} />; // @warn
|
|
14
|
+
export const GradientWithFallback = () => <path fill="url(#fade) #2b7fff" />; // @warn
|
|
15
|
+
export const StyleWrite = (path: SVGPathElement) => { path.style.fill = "#2b7fff"; }; // @warn
|
|
16
|
+
export const StyleFilterWrite = (path: SVGPathElement) => { path.style.filter = "drop-shadow(0 0 12px rgba(96, 165, 250, 0.7))"; }; // @warn
|
|
17
|
+
export const NestedStyleWrite = (ref: { current: HTMLElement }) => { ref.current.style.background = "#fff"; }; // @warn
|
|
18
|
+
export const ComputedStyleWrite = (el: HTMLElement) => { el.style["background-color"] = "#fff"; }; // @warn
|
|
19
|
+
export const TemplateStyleWrite = (el: HTMLElement) => { el.style.boxShadow = `0 0 ${blur}px rgba(0, 0, 0, 0.3)`; }; // @warn
|
|
20
|
+
export const SetPropertyWrite = (el: HTMLElement) => { el.style.setProperty("--glow", "#60a5fa"); }; // @warn
|
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
export const VarReferenceInStyleObject = () => <div style={{ color: "var(--primary)" }} />; // @ok
|
|
2
2
|
export const NonColorStyleProperty = () => <div style={{ width: "100%" }} />; // @ok
|
|
3
3
|
export const ComputedNonColorStyle = () => <div style={{ minHeight }} />; // @ok
|
|
4
|
+
export const CurrentColorFill = () => <path fill="currentColor" className="text-primary" />; // @ok
|
|
5
|
+
export const NoneAndTransparent = () => <path fill="none" stroke="transparent" />; // @ok
|
|
6
|
+
export const VarStopColor = () => <stop stopColor="var(--primary)" />; // @ok
|
|
7
|
+
export const RuntimeFill = () => <path fill={fillColor} />; // @ok
|
|
8
|
+
export const GradientReference = () => <path fill="url(#fade)" />; // @ok
|
|
9
|
+
export const QuotedGradientReference = () => <path fill="url('#bead')" />; // @ok
|
|
10
|
+
export const HrefIsNotAColorAttribute = () => <use href="#cafe" />; // @ok
|
|
11
|
+
export const VarStyleWrite = (el: HTMLElement) => { el.style.color = "var(--primary)"; }; // @ok
|
|
12
|
+
export const CurrentColorStyleWrite = (el: HTMLElement) => { el.style.fill = "currentColor"; }; // @ok
|
|
13
|
+
export const NonColorStyleWrite = (el: HTMLElement) => { el.style.display = "none"; }; // @ok
|
|
14
|
+
export const FilterReferenceStyleWrite = (el: HTMLElement) => { el.style.filter = "url(#dead)"; }; // @ok
|
|
15
|
+
// biome-ignore lint/plugin: the Google mark keeps its brand colors
|
|
16
|
+
export const SuppressedBrandMark = () => <path fill="#4285f4" />; // @ok
|
|
@@ -2,3 +2,11 @@ export const throwsNewError = () => { throw new Error("boom"); }; // @flag
|
|
|
2
2
|
export const throwsBareError = () => { throw Error("boom"); }; // @flag
|
|
3
3
|
export const throwsWithCause = () => { throw new Error("boom", { cause: reason }); }; // @flag
|
|
4
4
|
export const throwsWithNoArgument = () => { throw new Error(); }; // @flag
|
|
5
|
+
export const throwsWithoutParens = () => { throw new Error; }; // @flag
|
|
6
|
+
export const rejectsInAPromise = () => new Promise((_, reject) => reject(new Error(`k8s ${path} failed`))); // @warn
|
|
7
|
+
export const rejectsDirectly = () => Promise.reject(new Error("boom")); // @warn
|
|
8
|
+
export const throwsAStoredError = () => { const error = new Error("boom"); throw error; }; // @warn
|
|
9
|
+
export const returnsAnError = () => { return new Error("boom"); }; // @warn
|
|
10
|
+
export const callsWithoutNew = () => Error("boom"); // @warn
|
|
11
|
+
export const causesAnErr = () => { throw new Err("ticket.error.failed", { cause: new Error("boom") }); }; // @warn
|
|
12
|
+
export const throwsAWrappedError = () => { throw wrapError(new Error("boom")); }; // @warn
|