@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.110
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 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +70 -1
- package/artifact/implicitRootLayout.ts +41 -14
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +28 -1
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +135 -7
- package/executors.ts +115 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +63 -10
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.test.ts +75 -2
- package/frontendBuild/fontOptimizer.ts +98 -27
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +124 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +13 -19
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +80 -142
- package/qualityScanner.ts +89 -31
- package/recipeScanner.ts +4 -1
- package/routeSourceValidator.test.ts +63 -0
- package/routeSourceValidator.ts +123 -16
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +105 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/mcpScanner.ts +0 -217
- package/src/capacitorApp.ts +0 -282
- package/storeScanner.ts +0 -173
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/executors.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
|
|
|
23
23
|
import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
|
|
24
24
|
import {
|
|
25
25
|
capitalize,
|
|
26
|
+
getPageSourceFileViolation,
|
|
26
27
|
isRouteSourceFile,
|
|
27
28
|
Logger,
|
|
28
29
|
parseRouteModuleKey,
|
|
@@ -39,6 +40,8 @@ import {
|
|
|
39
40
|
upsertAgentBlock,
|
|
40
41
|
} from "./agentsIndex";
|
|
41
42
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
43
|
+
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
44
|
+
import { CodegenLock } from "./codegenLock";
|
|
42
45
|
import { FileSys } from "./fileSys";
|
|
43
46
|
import { getDirname } from "./getDirname";
|
|
44
47
|
import { Linter } from "./linter";
|
|
@@ -233,7 +236,7 @@ export class Executor {
|
|
|
233
236
|
);
|
|
234
237
|
});
|
|
235
238
|
proc.on("exit", (code, signal) => {
|
|
236
|
-
if (
|
|
239
|
+
if (code || signal)
|
|
237
240
|
reject(
|
|
238
241
|
new CommandExecutionError({
|
|
239
242
|
command,
|
|
@@ -342,7 +345,7 @@ export class Executor {
|
|
|
342
345
|
);
|
|
343
346
|
});
|
|
344
347
|
proc.on("exit", (code, signal) => {
|
|
345
|
-
if (
|
|
348
|
+
if (code || signal)
|
|
346
349
|
reject(
|
|
347
350
|
new CommandExecutionError({
|
|
348
351
|
command: modulePath,
|
|
@@ -430,7 +433,9 @@ export class Executor {
|
|
|
430
433
|
const writePath = this.getPath(filePath);
|
|
431
434
|
const dir = path.dirname(writePath);
|
|
432
435
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
433
|
-
|
|
436
|
+
//? Biome formats every tracked .json and always ends a file with a newline, so a JSON write without one
|
|
437
|
+
//? loses a byte to `akan lint` and takes it back on the next `akan sync` — a permanent one-line git diff.
|
|
438
|
+
let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
|
|
434
439
|
|
|
435
440
|
if (await FileSys.fileExists(writePath)) {
|
|
436
441
|
const currentContent = await FileSys.readText(writePath);
|
|
@@ -686,7 +691,30 @@ export class Executor {
|
|
|
686
691
|
Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
|
|
687
692
|
),
|
|
688
693
|
};
|
|
689
|
-
|
|
694
|
+
const fileContents = await this._applyTemplate({ ...options, dict });
|
|
695
|
+
await this.#formatAppliedTemplate(fileContents);
|
|
696
|
+
return fileContents;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
|
|
701
|
+
* a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
|
|
702
|
+
* scaffold that is not formatted on the way out is red for most model names, whatever the template says.
|
|
703
|
+
*
|
|
704
|
+
* Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
|
|
705
|
+
* and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
|
|
706
|
+
*/
|
|
707
|
+
async #formatAppliedTemplate(fileContents: FileContent[]) {
|
|
708
|
+
const filePaths = fileContents
|
|
709
|
+
.map((fileContent) => fileContent.filePath)
|
|
710
|
+
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
|
711
|
+
if (filePaths.length === 0) return;
|
|
712
|
+
try {
|
|
713
|
+
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
714
|
+
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
|
|
717
|
+
}
|
|
690
718
|
}
|
|
691
719
|
// Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
|
|
692
720
|
// not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
|
|
@@ -909,9 +937,30 @@ export class WorkspaceExecutor extends Executor {
|
|
|
909
937
|
return await getDirs(basePath);
|
|
910
938
|
}
|
|
911
939
|
async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
|
|
912
|
-
if (init) await this.
|
|
913
|
-
if (add) await this.
|
|
914
|
-
|
|
940
|
+
if (init) await this.spawn("git", ["init", "--quiet"]);
|
|
941
|
+
if (add) await this.spawn("git", ["add", "."]);
|
|
942
|
+
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
943
|
+
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
944
|
+
}
|
|
945
|
+
/** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
|
|
946
|
+
async hasChanges() {
|
|
947
|
+
return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
|
|
951
|
+
* committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
|
|
952
|
+
*
|
|
953
|
+
* Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
|
|
954
|
+
* symlinks, env values and the lockfile out of it without any caller restating that list.
|
|
955
|
+
*/
|
|
956
|
+
async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
|
|
957
|
+
if (!paths.length) return [];
|
|
958
|
+
const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
|
|
959
|
+
const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
|
|
960
|
+
return stdout
|
|
961
|
+
.split("\0")
|
|
962
|
+
.filter((file) => !!file)
|
|
963
|
+
.sort();
|
|
915
964
|
}
|
|
916
965
|
async #getDirHasFile(basePath: string, targetFilename: string) {
|
|
917
966
|
const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
|
|
@@ -1006,6 +1055,16 @@ export class SysExecutor extends Executor {
|
|
|
1006
1055
|
const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
|
|
1007
1056
|
return await this.workspace.getDirInModule(path, this.name);
|
|
1008
1057
|
}
|
|
1058
|
+
async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
|
|
1059
|
+
if (!(await this.exists("page"))) return [];
|
|
1060
|
+
const violations: { relativePath: string; reason: string }[] = [];
|
|
1061
|
+
for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
|
|
1062
|
+
const relativePath = entry.split(path.sep).join("/");
|
|
1063
|
+
const reason = getPageSourceFileViolation(relativePath);
|
|
1064
|
+
if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
|
|
1065
|
+
}
|
|
1066
|
+
return violations;
|
|
1067
|
+
}
|
|
1009
1068
|
|
|
1010
1069
|
#scanInfo: AppInfo | LibInfo | null = null;
|
|
1011
1070
|
hasScanInfo() {
|
|
@@ -1078,18 +1137,22 @@ export class SysExecutor extends Executor {
|
|
|
1078
1137
|
: await LibInfo.fromExecutor(this as unknown as LibExecutor, {
|
|
1079
1138
|
refresh,
|
|
1080
1139
|
});
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1140
|
+
//* `writeLib` regenerates every dependency lib's barrels, and each mounting app's `akan start`
|
|
1141
|
+
//* regenerates the same ones — so this region races the other dev servers in the workspace and the
|
|
1142
|
+
//* builders that watch what it writes.
|
|
1143
|
+
if (write)
|
|
1144
|
+
await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
|
|
1145
|
+
await Promise.all(this.#getScanTemplateTasks(scanInfo));
|
|
1146
|
+
await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
|
|
1147
|
+
if (this.type === "lib") this.#updateDependencies(scanInfo);
|
|
1085
1148
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1149
|
+
if (writeLib) {
|
|
1150
|
+
const libInfos = [...scanInfo.getLibInfos().values()];
|
|
1151
|
+
await this.#updateDependencies(scanInfo);
|
|
1152
|
+
await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
|
|
1153
|
+
}
|
|
1154
|
+
await this.syncAgentsIndex(scanInfo);
|
|
1155
|
+
});
|
|
1093
1156
|
this.#scanInfo = scanInfo;
|
|
1094
1157
|
return scanInfo;
|
|
1095
1158
|
}
|
|
@@ -1347,6 +1410,8 @@ export class AppExecutor extends SysExecutor {
|
|
|
1347
1410
|
const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
|
|
1348
1411
|
const routeEnv = {
|
|
1349
1412
|
AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
|
|
1413
|
+
AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
|
|
1414
|
+
AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
|
|
1350
1415
|
AKAN_DATABASE_MODE: databaseMode,
|
|
1351
1416
|
};
|
|
1352
1417
|
Object.assign(process.env, routeEnv);
|
|
@@ -1355,15 +1420,30 @@ export class AppExecutor extends SysExecutor {
|
|
|
1355
1420
|
//* generated against and typechecked. Drop the cache so the build phases re-read it without them.
|
|
1356
1421
|
this.#excludeDevOnlyPages = true;
|
|
1357
1422
|
this.#pageKeys = null;
|
|
1358
|
-
|
|
1423
|
+
await this.dist.removeDir(this.dist.cwdPath);
|
|
1359
1424
|
await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
|
|
1360
1425
|
//* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
|
|
1361
1426
|
//* release tarball root, neither of which follows a link out of itself, so materialize them here.
|
|
1427
|
+
//* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
|
|
1362
1428
|
await Promise.all([
|
|
1363
1429
|
this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
|
|
1364
|
-
this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
|
|
1430
|
+
...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
|
|
1365
1431
|
]);
|
|
1366
|
-
} else
|
|
1432
|
+
} else {
|
|
1433
|
+
await this.removeDir(".akan");
|
|
1434
|
+
//? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
|
|
1435
|
+
//? builder is also the file watcher, so switching it off would take server-code HMR with it.
|
|
1436
|
+
if (!akanConfig.web.ssr || !akanConfig.web.csr)
|
|
1437
|
+
this.logger.verbose(
|
|
1438
|
+
`akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
//? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
|
|
1442
|
+
//? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
|
|
1443
|
+
//? and a dev server that believes it is production serves from the production route cache, whose every
|
|
1444
|
+
//? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
|
|
1445
|
+
//? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
|
|
1446
|
+
if (type === "start") process.env.NODE_ENV = "development";
|
|
1367
1447
|
const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
|
|
1368
1448
|
const env = this.getCommandEnv({
|
|
1369
1449
|
AKAN_COMMAND_TYPE: type,
|
|
@@ -1375,7 +1455,15 @@ export class AppExecutor extends SysExecutor {
|
|
|
1375
1455
|
// which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
|
|
1376
1456
|
// "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
|
|
1377
1457
|
// this does not leak non-public env.
|
|
1378
|
-
|
|
1458
|
+
// The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
|
|
1459
|
+
// artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
|
|
1460
|
+
// is started with and send every SSR self-call to a port nothing bound.
|
|
1461
|
+
if (type === "build") {
|
|
1462
|
+
const buildEnv = { ...env };
|
|
1463
|
+
delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
|
|
1464
|
+
delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
|
|
1465
|
+
Object.assign(process.env, buildEnv);
|
|
1466
|
+
}
|
|
1379
1467
|
return { env };
|
|
1380
1468
|
}
|
|
1381
1469
|
#publicEnv: Record<string, string> | null = null;
|
|
@@ -1452,13 +1540,15 @@ export class AppExecutor extends SysExecutor {
|
|
|
1452
1540
|
);
|
|
1453
1541
|
}
|
|
1454
1542
|
if (!owner) owners.set(routeId, { absPath, fromLib });
|
|
1455
|
-
const
|
|
1543
|
+
const layoutSegments = getRootBoundarySegments(key);
|
|
1544
|
+
const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
|
|
1456
1545
|
const routeSource = await Bun.file(absPath).text();
|
|
1457
1546
|
const validator = await AppExecutor.#getRouteSourceValidator();
|
|
1458
1547
|
if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
|
|
1459
1548
|
else {
|
|
1460
1549
|
const info = validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, {
|
|
1461
1550
|
rootLayout: isRootLayout,
|
|
1551
|
+
pattern: parsed.pattern,
|
|
1462
1552
|
});
|
|
1463
1553
|
if (info.devOnly) {
|
|
1464
1554
|
devOnlyKeys.add(key);
|
|
@@ -1701,7 +1791,7 @@ export class LibExecutor extends SysExecutor {
|
|
|
1701
1791
|
#akanConfig: AkanLibConfig | null = null;
|
|
1702
1792
|
override async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
1703
1793
|
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
1704
|
-
this.#akanConfig = await AkanLibConfig.from(this);
|
|
1794
|
+
this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
|
|
1705
1795
|
return this.#akanConfig;
|
|
1706
1796
|
}
|
|
1707
1797
|
}
|
|
@@ -1807,7 +1897,7 @@ export class PkgExecutor extends Executor {
|
|
|
1807
1897
|
default: "./index.ts",
|
|
1808
1898
|
},
|
|
1809
1899
|
},
|
|
1810
|
-
engines: { bun: ">=1.
|
|
1900
|
+
engines: { bun: ">=1.4.0" },
|
|
1811
1901
|
...dependencyMaps,
|
|
1812
1902
|
};
|
|
1813
1903
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|
package/fileEditor.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
export class FileEditor {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
#filePath: string;
|
|
3
|
+
#content: string;
|
|
4
4
|
|
|
5
5
|
private constructor(filePath: string, content: string) {
|
|
6
|
-
this
|
|
7
|
-
this
|
|
6
|
+
this.#filePath = filePath;
|
|
7
|
+
this.#content = content;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
static async create(filePath: string): Promise<FileEditor> {
|
|
11
11
|
try {
|
|
12
12
|
const content = await Bun.file(filePath).text();
|
|
13
13
|
return new FileEditor(filePath, content);
|
|
14
|
-
} catch (
|
|
14
|
+
} catch (_error) {
|
|
15
15
|
throw new Error(`Failed to read file: ${filePath}`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
find(pattern: string | RegExp): number {
|
|
20
|
-
const lines = this
|
|
20
|
+
const lines = this.#content.split("\n");
|
|
21
21
|
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
22
22
|
|
|
23
23
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -30,7 +30,7 @@ export class FileEditor {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
findAll(pattern: string | RegExp): number[] {
|
|
33
|
-
const lines = this
|
|
33
|
+
const lines = this.#content.split("\n");
|
|
34
34
|
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
35
35
|
const matches: number[] = [];
|
|
36
36
|
|
|
@@ -50,9 +50,9 @@ export class FileEditor {
|
|
|
50
50
|
throw new Error(`Pattern not found: ${pattern}`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const lines = this
|
|
53
|
+
const lines = this.#content.split("\n");
|
|
54
54
|
lines.splice(lineIndex + 1, 0, data);
|
|
55
|
-
this
|
|
55
|
+
this.#content = lines.join("\n");
|
|
56
56
|
|
|
57
57
|
return this;
|
|
58
58
|
}
|
|
@@ -64,43 +64,43 @@ export class FileEditor {
|
|
|
64
64
|
throw new Error(`Pattern not found: ${pattern}`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const lines = this
|
|
67
|
+
const lines = this.#content.split("\n");
|
|
68
68
|
lines.splice(lineIndex, 0, data);
|
|
69
|
-
this
|
|
69
|
+
this.#content = lines.join("\n");
|
|
70
70
|
|
|
71
71
|
return this;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
replace(pattern: string | RegExp, replacement: string): this {
|
|
75
75
|
const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
|
|
76
|
-
this
|
|
76
|
+
this.#content = this.#content.replace(regex, replacement);
|
|
77
77
|
return this;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
append(data: string): this {
|
|
81
|
-
this
|
|
81
|
+
this.#content += `\n${data}`;
|
|
82
82
|
return this;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
prepend(data: string): this {
|
|
86
|
-
this
|
|
86
|
+
this.#content = `${data}\n${this.#content}`;
|
|
87
87
|
return this;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async save(): Promise<void> {
|
|
91
91
|
try {
|
|
92
|
-
await Bun.write(this
|
|
93
|
-
} catch (
|
|
94
|
-
throw new Error(`Failed to save file: ${this
|
|
92
|
+
await Bun.write(this.#filePath, this.#content);
|
|
93
|
+
} catch (_error) {
|
|
94
|
+
throw new Error(`Failed to save file: ${this.#filePath}`);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
getContent(): string {
|
|
99
|
-
return this
|
|
99
|
+
return this.#content;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
setContent(content: string): this {
|
|
103
|
-
this
|
|
103
|
+
this.#content = content;
|
|
104
104
|
return this;
|
|
105
105
|
}
|
|
106
106
|
}
|
package/fileSys.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, stat } from "node:fs/promises";
|
|
1
|
+
import { lstat, rename, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
3
|
|
|
4
4
|
export class FileSys {
|
|
@@ -39,6 +39,24 @@ export class FileSys {
|
|
|
39
39
|
static async writeText(path: string, content: string) {
|
|
40
40
|
return await Bun.file(path).write(content);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Replaces a file in one `rename`, so a concurrent reader sees either the old bytes or the new ones.
|
|
44
|
+
* Generated barrels are written by every dev server watching the same `libs/` tree while those same
|
|
45
|
+
* servers' watchers are reading them, and a half-written barrel reads back as a user edit.
|
|
46
|
+
*
|
|
47
|
+
* The temp file is a sibling because `rename` is only atomic within a filesystem, and it lands in a
|
|
48
|
+
* watched directory — the `.tmp` suffix is what `HmrChangeClassifier` ignores it by.
|
|
49
|
+
*/
|
|
50
|
+
static async writeTextAtomic(filePath: string, content: string) {
|
|
51
|
+
const temp = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
52
|
+
try {
|
|
53
|
+
await Bun.write(temp, content);
|
|
54
|
+
await rename(temp, filePath);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
42
60
|
static async writeJson(path: string, content: object) {
|
|
43
61
|
return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
|
|
44
62
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/tmp/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (content: string) =>
|
|
14
|
+
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
|
+
|
|
16
|
+
describe("FormSetterScanner", () => {
|
|
17
|
+
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
18
|
+
expect(
|
|
19
|
+
scan(`export const General = () => (
|
|
20
|
+
<Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
|
|
21
|
+
);
|
|
22
|
+
`),
|
|
23
|
+
).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
|
|
27
|
+
const warnings = scan(`export const General = () => (
|
|
28
|
+
<>
|
|
29
|
+
<Field.Text onChange={st.do.setTitleOnTask} />
|
|
30
|
+
<Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
|
|
31
|
+
<Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
|
|
32
|
+
<Field.Parent
|
|
33
|
+
onChange={(project) => {
|
|
34
|
+
st.do.setProjectOnTask(project);
|
|
35
|
+
if (project) st.do.addMembersOnTask(project.members ?? []);
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
expect(warnings).toHaveLength(1);
|
|
43
|
+
expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
|
|
44
|
+
expect(warnings[0]?.scope).toBe("agent");
|
|
45
|
+
expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
|
|
46
|
+
expect(warnings[0]?.locations).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a nested path write is unannotatable by design and is not counted", () => {
|
|
50
|
+
expect(
|
|
51
|
+
scan(`export const Rows = ({ idx }: { idx: number }) => (
|
|
52
|
+
<Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
|
|
53
|
+
);
|
|
54
|
+
`),
|
|
55
|
+
).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a zero-parameter handler is a button setting a constant, not a form control", () => {
|
|
59
|
+
expect(
|
|
60
|
+
scan(`export const Actions = () => (
|
|
61
|
+
<Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
|
|
62
|
+
);
|
|
63
|
+
`),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-setter action behind a wrapper is somebody else's rule", () => {
|
|
68
|
+
expect(
|
|
69
|
+
scan(`export const Filters = () => (
|
|
70
|
+
<Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
|
|
71
|
+
);
|
|
72
|
+
`),
|
|
73
|
+
).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only .tsx is scanned", () => {
|
|
77
|
+
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
|
+
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inventory of model fields a screen writes but does not publish.
|
|
6
|
+
*
|
|
7
|
+
* A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
|
|
8
|
+
* `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
|
|
9
|
+
* an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
|
|
10
|
+
* accessibility tree.
|
|
11
|
+
*
|
|
12
|
+
* `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
|
|
13
|
+
* because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
|
|
14
|
+
* per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
|
|
15
|
+
* warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
|
|
16
|
+
*
|
|
17
|
+
* Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
|
|
18
|
+
* ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
|
|
19
|
+
*/
|
|
20
|
+
export class FormSetterScanner {
|
|
21
|
+
static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
|
|
22
|
+
|
|
23
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
24
|
+
return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
28
|
+
const wrapped: { setter: string; line: number }[] = [];
|
|
29
|
+
const visit = (node: ts.Node) => {
|
|
30
|
+
if (ts.isJsxAttribute(node)) {
|
|
31
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
32
|
+
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(sourceFile);
|
|
37
|
+
if (!wrapped.length) return [];
|
|
38
|
+
const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
rule: "akan.agent.unpublished-form-setter",
|
|
42
|
+
scope: "agent",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message:
|
|
45
|
+
setters.length === 1
|
|
46
|
+
? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
|
|
47
|
+
: `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
|
|
48
|
+
file,
|
|
49
|
+
line: wrapped[0]?.line,
|
|
50
|
+
locations: wrapped.map(({ line }) => ({ file, line })),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
56
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
57
|
+
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
58
|
+
const initializer = attribute.initializer;
|
|
59
|
+
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
60
|
+
const handler = initializer.expression;
|
|
61
|
+
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
62
|
+
if (!handler.parameters.length) return null;
|
|
63
|
+
return FormSetterScanner.#setterCallIn(handler.body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #setterCallIn(node: ts.Node): string | null {
|
|
67
|
+
let found: string | null = null;
|
|
68
|
+
const visit = (current: ts.Node) => {
|
|
69
|
+
if (found) return;
|
|
70
|
+
if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
71
|
+
const { expression, name } = current.expression;
|
|
72
|
+
if (
|
|
73
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
74
|
+
ts.isIdentifier(expression.expression) &&
|
|
75
|
+
expression.expression.text === "st" &&
|
|
76
|
+
expression.name.text === "do" &&
|
|
77
|
+
FormSetterScanner.#fieldSetter.test(name.text)
|
|
78
|
+
) {
|
|
79
|
+
found = name.text;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(current, visit);
|
|
84
|
+
};
|
|
85
|
+
visit(node);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
90
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -355,3 +355,61 @@ describe("AutoImportSync.syncForBatch", () => {
|
|
|
355
355
|
expect(await readFile(indexFile, "utf8")).not.toContain("@libs/shared/client");
|
|
356
356
|
});
|
|
357
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("AutoImportSync — scope", () => {
|
|
360
|
+
const writeAt = async (root: string, rel: string, source: string) => {
|
|
361
|
+
const abs = path.join(root, rel);
|
|
362
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
363
|
+
await writeFile(abs, source);
|
|
364
|
+
return abs;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// `lib/__lib/` is generated and gitignored: `akan sync` rewrites it, which is also what makes the
|
|
368
|
+
// watcher hand it to the syncer, so an edit there is churn that the next sync discards.
|
|
369
|
+
test("leaves the generated lib/__lib stubs alone and still syncs lib/__scalar", async () => {
|
|
370
|
+
const root = await makeTempRoot();
|
|
371
|
+
const needsImport = "export class A {\n count = field(Int);\n}\n";
|
|
372
|
+
const generated = await writeAt(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
|
|
373
|
+
const scalar = await writeAt(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
|
|
374
|
+
|
|
375
|
+
const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([generated, scalar]);
|
|
376
|
+
|
|
377
|
+
expect(result.errors).toEqual([]);
|
|
378
|
+
expect(result.changedFiles).toEqual([scalar]);
|
|
379
|
+
expect(await readFile(generated, "utf8")).toBe(needsImport);
|
|
380
|
+
expect(await readFile(scalar, "utf8")).toContain('import { Int } from "akanjs/base";');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// The transform is pure and the write is conditional on a difference, so a second pass over its own
|
|
384
|
+
// output writes nothing — without that the watcher would re-fire on the syncer's own edit.
|
|
385
|
+
test("a second pass over its own output changes nothing", async () => {
|
|
386
|
+
const root = await makeTempRoot();
|
|
387
|
+
const file = await writeAt(
|
|
388
|
+
root,
|
|
389
|
+
"libs/shared/lib/task/task.constant.ts",
|
|
390
|
+
"export class A {\n count = field(Int);\n}\n",
|
|
391
|
+
);
|
|
392
|
+
const sync = new AutoImportSync({ workspaceRoot: root });
|
|
393
|
+
|
|
394
|
+
expect((await sync.syncForBatch([file])).changedFiles).toEqual([file]);
|
|
395
|
+
const afterFirst = await readFile(file, "utf8");
|
|
396
|
+
expect((await sync.syncForBatch([file])).changedFiles).toEqual([]);
|
|
397
|
+
expect(await readFile(file, "utf8")).toBe(afterFirst);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// One try/catch per file: a file that cannot be read is reported and the rest of the batch still runs.
|
|
401
|
+
test("a failing file is reported without abandoning the batch", async () => {
|
|
402
|
+
const root = await makeTempRoot();
|
|
403
|
+
const missing = path.join(root, "libs/shared/lib/task/gone.constant.ts");
|
|
404
|
+
const good = await writeAt(
|
|
405
|
+
root,
|
|
406
|
+
"libs/shared/lib/task/task.constant.ts",
|
|
407
|
+
"export class A {\n count = field(Int);\n}\n",
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([missing, good]);
|
|
411
|
+
|
|
412
|
+
expect(result.changedFiles).toEqual([good]);
|
|
413
|
+
expect(result.errors).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readdir, readFile, stat
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import ts from "typescript";
|
|
4
|
+
import { FileSys } from "../fileSys";
|
|
4
5
|
|
|
5
6
|
//* Auto-import daemon: a source-editing sibling of DevGeneratedIndexSync. When a domain file changes,
|
|
6
7
|
//* framework symbols that are used but not imported (e.g. `Int` in a *.constant.ts, `fetch` in a *.store.ts)
|
|
@@ -250,6 +251,10 @@ export class AutoImportSync {
|
|
|
250
251
|
const parts = rel.split(path.sep).filter(Boolean);
|
|
251
252
|
const [scope, project, facet] = parts;
|
|
252
253
|
if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
|
|
254
|
+
//* `lib/__lib/` holds the generated per-lib re-export stubs — gitignored, and rewritten by every
|
|
255
|
+
//* `akan sync`, which is also what makes the watcher report them. An edit there is churn at best and
|
|
256
|
+
//* silently discarded at worst. `lib/__scalar/` is real source and stays in scope.
|
|
257
|
+
if (facet === "lib" && parts[3] === "__lib") return null;
|
|
253
258
|
const role = roleFor(facet, base);
|
|
254
259
|
if (!role) return null;
|
|
255
260
|
|
|
@@ -263,7 +268,7 @@ export class AutoImportSync {
|
|
|
263
268
|
const resolveExtra = await this.#extraResolverFor(abs, ctx);
|
|
264
269
|
const next = transformSource(source, abs, ctx, resolveExtra);
|
|
265
270
|
if (next === null || next === source) return false;
|
|
266
|
-
await
|
|
271
|
+
await FileSys.writeTextAtomic(abs, next);
|
|
267
272
|
return true;
|
|
268
273
|
}
|
|
269
274
|
|