@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.81
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -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 +65 -562
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +412 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +132 -47
- package/akanConfig/types.ts +8 -0
- package/akanContext.ts +70 -14
- package/akanMcpContract.ts +25 -4
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +340 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/commandDecorators/command.ts +30 -6
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +132 -5
- package/executors.ts +89 -14
- package/fileEditor.ts +19 -19
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +4 -0
- 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 +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -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/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
- package/index.ts +0 -5
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +109 -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-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -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 +99 -25
- package/package.json +6 -6
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/repoIdentity.ts +42 -0
- 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 +103 -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/ui/ScrollList.tsx +6 -8
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +49 -4
- 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/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
package/executors.test.ts
CHANGED
|
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
|
|
|
31
31
|
react: "19.0.0",
|
|
32
32
|
"react-dom": "19.0.0",
|
|
33
33
|
"react-server-dom-webpack": "19.0.0",
|
|
34
|
-
sharp: "1.0.0",
|
|
35
34
|
lodash: "4.0.0",
|
|
36
35
|
},
|
|
37
36
|
devDependencies: {
|
|
@@ -128,6 +127,49 @@ describe("Executor filesystem helpers", () => {
|
|
|
128
127
|
expect(await readFile(path.join(root, "local/docker-compose.yaml"), "utf8")).toBe("custom");
|
|
129
128
|
});
|
|
130
129
|
|
|
130
|
+
test("hands every scaffolded TypeScript file to the formatter, and nothing else", async () => {
|
|
131
|
+
// A template emits identifiers it cannot sort — `import { fetch, Task, usePage }` is right for a model
|
|
132
|
+
// named Task and wrong for Zoo — and unsorted imports fail `biome check`. So the scaffold is formatted
|
|
133
|
+
// on the way out, or it is red for most model names whatever the template says.
|
|
134
|
+
const root = await makeTempRoot();
|
|
135
|
+
const exec = new Executor("fixture", root);
|
|
136
|
+
const formatted: string[] = [];
|
|
137
|
+
exec.getLinter = () =>
|
|
138
|
+
({
|
|
139
|
+
fixFiles: async (filePaths: string[]) => {
|
|
140
|
+
formatted.push(...filePaths);
|
|
141
|
+
return { fixed: [] };
|
|
142
|
+
},
|
|
143
|
+
}) as unknown as ReturnType<Executor["getLinter"]>;
|
|
144
|
+
|
|
145
|
+
await exec.applyTemplate({
|
|
146
|
+
basePath: "apps/demo/page/task",
|
|
147
|
+
template: "crudSinglePage",
|
|
148
|
+
dict: { model: "task", appName: "demo" },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a formatter that cannot run does not fail the scaffold", async () => {
|
|
155
|
+
// `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary and often
|
|
156
|
+
// no config above the target. An unformatted file is a lint fix; a failed scaffold is not.
|
|
157
|
+
const root = await makeTempRoot();
|
|
158
|
+
const exec = new Executor("fixture", root);
|
|
159
|
+
exec.getLinter = () => {
|
|
160
|
+
throw new Error("biome.json not found");
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const created = await exec.applyTemplate({
|
|
164
|
+
basePath: "apps/demo/page/task",
|
|
165
|
+
template: "crudSinglePage",
|
|
166
|
+
dict: { model: "task", appName: "demo" },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(created).toHaveLength(1);
|
|
170
|
+
expect(await readFile(path.join(root, "apps/demo/page/task/_index.tsx"), "utf8")).toContain("Task.Zone.Card");
|
|
171
|
+
});
|
|
172
|
+
|
|
131
173
|
test("applies hidden files and directories from CLI templates", async () => {
|
|
132
174
|
const root = await makeTempRoot();
|
|
133
175
|
const exec = new Executor("fixture", root);
|
|
@@ -149,8 +191,10 @@ describe("Executor filesystem helpers", () => {
|
|
|
149
191
|
"AI Development Guide",
|
|
150
192
|
);
|
|
151
193
|
expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
|
|
194
|
+
// Rules and their plugin registrations live in the package's base config, so a framework release reaches an
|
|
195
|
+
// existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
|
|
152
196
|
expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
|
|
153
|
-
"
|
|
197
|
+
'"extends": ["@akanjs/devkit/biome.base.json"]',
|
|
154
198
|
);
|
|
155
199
|
});
|
|
156
200
|
|
|
@@ -202,6 +246,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
202
246
|
serveDomain: "example.com",
|
|
203
247
|
env: "local",
|
|
204
248
|
portOffset: 10,
|
|
249
|
+
workspaceId: undefined,
|
|
205
250
|
});
|
|
206
251
|
|
|
207
252
|
delete process.env.AKAN_PUBLIC_REPO_NAME;
|
|
@@ -232,6 +277,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
232
277
|
serveDomain: "file.example.com",
|
|
233
278
|
env: "develop",
|
|
234
279
|
portOffset: 7,
|
|
280
|
+
workspaceId: undefined,
|
|
235
281
|
});
|
|
236
282
|
});
|
|
237
283
|
|
|
@@ -620,6 +666,64 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
620
666
|
});
|
|
621
667
|
});
|
|
622
668
|
|
|
669
|
+
describe("root layout source validation during page key discovery", () => {
|
|
670
|
+
const makeRouteValidationApp = async (appName: string, config: string, files: Record<string, string>) => {
|
|
671
|
+
const root = await makeTempRoot();
|
|
672
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
673
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
674
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
675
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
676
|
+
await mkdir(path.join(root, "apps", appName), { recursive: true });
|
|
677
|
+
await writeFile(path.join(root, `apps/${appName}/akan.config.ts`), config);
|
|
678
|
+
for (const [file, source] of Object.entries(files)) {
|
|
679
|
+
const filePath = path.join(root, `apps/${appName}/page`, file);
|
|
680
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
681
|
+
await writeFile(filePath, source);
|
|
682
|
+
}
|
|
683
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
684
|
+
return AppExecutor.from(workspace, appName);
|
|
685
|
+
};
|
|
686
|
+
const wsConnectLayout = [
|
|
687
|
+
"export const wsConnect = false;",
|
|
688
|
+
"export default function Layout({ children }) { return children; }",
|
|
689
|
+
"",
|
|
690
|
+
].join("\n");
|
|
691
|
+
|
|
692
|
+
test("accepts wsConnect on a configured base-path root layout", async () => {
|
|
693
|
+
process.env.AKAN_PUBLIC_BASE_PATHS = "web,admin";
|
|
694
|
+
const app = await makeRouteValidationApp(
|
|
695
|
+
"base-path-root-ws",
|
|
696
|
+
[
|
|
697
|
+
"export default {",
|
|
698
|
+
' routes: [{ basePath: "web", domains: {} }, { basePath: "admin", domains: {} }],',
|
|
699
|
+
"};",
|
|
700
|
+
"",
|
|
701
|
+
].join("\n"),
|
|
702
|
+
{ "admin/_layout.tsx": wsConnectLayout },
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./admin/_layout.tsx"]);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("rejects wsConnect on a nested layout", async () => {
|
|
709
|
+
const app = await makeRouteValidationApp(
|
|
710
|
+
"nested-layout-ws",
|
|
711
|
+
'export default { routes: [{ basePath: "admin", domains: {} }] };\n',
|
|
712
|
+
{ "admin/users/_layout.tsx": wsConnectLayout },
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(/unsupported export "wsConnect"/);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("accepts wsConnect on a grouped root layout", async () => {
|
|
719
|
+
const app = await makeRouteValidationApp("grouped-root-ws", "export default {};\n", {
|
|
720
|
+
"(docs)/_layout.tsx": wsConnectLayout,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
await expect(app.getPageKeys({ refresh: true })).resolves.toEqual(["./(docs)/_layout.tsx"]);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
623
727
|
test("accepts metadata route exports during page key discovery", async () => {
|
|
624
728
|
const root = await makeTempRoot();
|
|
625
729
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
@@ -758,6 +862,29 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
758
862
|
expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
|
|
759
863
|
expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
|
|
760
864
|
});
|
|
865
|
+
|
|
866
|
+
test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
|
|
867
|
+
const root = await makeTempRoot();
|
|
868
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
869
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
870
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
871
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
872
|
+
await mkdir(path.join(root, "apps/ambient"), { recursive: true });
|
|
873
|
+
await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
|
|
874
|
+
|
|
875
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
876
|
+
process.env.NODE_ENV = "production";
|
|
877
|
+
try {
|
|
878
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
879
|
+
const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
|
|
880
|
+
expect(started.env.NODE_ENV).toBe("development");
|
|
881
|
+
// The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
|
|
882
|
+
expect(process.env.NODE_ENV).toBe("development");
|
|
883
|
+
} finally {
|
|
884
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
885
|
+
else process.env.NODE_ENV = originalNodeEnv;
|
|
886
|
+
}
|
|
887
|
+
});
|
|
761
888
|
});
|
|
762
889
|
|
|
763
890
|
describe("PkgExecutor package generation", () => {
|
|
@@ -771,7 +898,7 @@ describe("PkgExecutor package generation", () => {
|
|
|
771
898
|
exports: { "./extra": { import: "./extra.ts" } },
|
|
772
899
|
peerDependencies: { react: "19.0.0" },
|
|
773
900
|
peerDependenciesMeta: { react: { optional: true } },
|
|
774
|
-
optionalDependencies: {
|
|
901
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
775
902
|
});
|
|
776
903
|
|
|
777
904
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
@@ -781,12 +908,12 @@ describe("PkgExecutor package generation", () => {
|
|
|
781
908
|
expect(distPackageJson).toMatchObject({
|
|
782
909
|
name: "@sample/tool",
|
|
783
910
|
type: "module",
|
|
784
|
-
engines: { bun: ">=1.
|
|
911
|
+
engines: { bun: ">=1.4.0" },
|
|
785
912
|
dependencies: { lodash: "4.0.0" },
|
|
786
913
|
devDependencies: { typescript: "6.0.0" },
|
|
787
914
|
peerDependencies: { react: "19.0.0" },
|
|
788
915
|
peerDependenciesMeta: { react: { optional: true } },
|
|
789
|
-
optionalDependencies: {
|
|
916
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
790
917
|
});
|
|
791
918
|
expect(distPackageJson.exports?.["."]).toEqual({
|
|
792
919
|
import: "./index.ts",
|
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,9 +40,11 @@ import {
|
|
|
39
40
|
upsertAgentBlock,
|
|
40
41
|
} from "./agentsIndex";
|
|
41
42
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
43
|
+
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
42
44
|
import { FileSys } from "./fileSys";
|
|
43
45
|
import { getDirname } from "./getDirname";
|
|
44
46
|
import { Linter } from "./linter";
|
|
47
|
+
import { resolveRepoName } from "./repoIdentity";
|
|
45
48
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
46
49
|
import { Spinner } from "./spinner";
|
|
47
50
|
// Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
|
|
@@ -232,7 +235,7 @@ export class Executor {
|
|
|
232
235
|
);
|
|
233
236
|
});
|
|
234
237
|
proc.on("exit", (code, signal) => {
|
|
235
|
-
if (
|
|
238
|
+
if (code || signal)
|
|
236
239
|
reject(
|
|
237
240
|
new CommandExecutionError({
|
|
238
241
|
command,
|
|
@@ -341,7 +344,7 @@ export class Executor {
|
|
|
341
344
|
);
|
|
342
345
|
});
|
|
343
346
|
proc.on("exit", (code, signal) => {
|
|
344
|
-
if (
|
|
347
|
+
if (code || signal)
|
|
345
348
|
reject(
|
|
346
349
|
new CommandExecutionError({
|
|
347
350
|
command: modulePath,
|
|
@@ -429,7 +432,9 @@ export class Executor {
|
|
|
429
432
|
const writePath = this.getPath(filePath);
|
|
430
433
|
const dir = path.dirname(writePath);
|
|
431
434
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
432
|
-
|
|
435
|
+
//? Biome formats every tracked .json and always ends a file with a newline, so a JSON write without one
|
|
436
|
+
//? loses a byte to `akan lint` and takes it back on the next `akan sync` — a permanent one-line git diff.
|
|
437
|
+
let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
|
|
433
438
|
|
|
434
439
|
if (await FileSys.fileExists(writePath)) {
|
|
435
440
|
const currentContent = await FileSys.readText(writePath);
|
|
@@ -685,7 +690,30 @@ export class Executor {
|
|
|
685
690
|
Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
|
|
686
691
|
),
|
|
687
692
|
};
|
|
688
|
-
|
|
693
|
+
const fileContents = await this._applyTemplate({ ...options, dict });
|
|
694
|
+
await this.#formatAppliedTemplate(fileContents);
|
|
695
|
+
return fileContents;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
|
|
700
|
+
* a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
|
|
701
|
+
* scaffold that is not formatted on the way out is red for most model names, whatever the template says.
|
|
702
|
+
*
|
|
703
|
+
* Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
|
|
704
|
+
* and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
|
|
705
|
+
*/
|
|
706
|
+
async #formatAppliedTemplate(fileContents: FileContent[]) {
|
|
707
|
+
const filePaths = fileContents
|
|
708
|
+
.map((fileContent) => fileContent.filePath)
|
|
709
|
+
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
|
710
|
+
if (filePaths.length === 0) return;
|
|
711
|
+
try {
|
|
712
|
+
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
713
|
+
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
|
|
716
|
+
}
|
|
689
717
|
}
|
|
690
718
|
// Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
|
|
691
719
|
// not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
|
|
@@ -788,7 +816,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
788
816
|
static #execs = new Map<string, WorkspaceExecutor>();
|
|
789
817
|
static fromRoot({
|
|
790
818
|
workspaceRoot = process.cwd(),
|
|
791
|
-
repoName =
|
|
819
|
+
repoName = resolveRepoName(workspaceRoot),
|
|
792
820
|
}: {
|
|
793
821
|
workspaceRoot?: string;
|
|
794
822
|
repoName?: string;
|
|
@@ -908,9 +936,30 @@ export class WorkspaceExecutor extends Executor {
|
|
|
908
936
|
return await getDirs(basePath);
|
|
909
937
|
}
|
|
910
938
|
async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
|
|
911
|
-
if (init) await this.
|
|
912
|
-
if (add) await this.
|
|
913
|
-
|
|
939
|
+
if (init) await this.spawn("git", ["init", "--quiet"]);
|
|
940
|
+
if (add) await this.spawn("git", ["add", "."]);
|
|
941
|
+
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
942
|
+
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
943
|
+
}
|
|
944
|
+
/** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
|
|
945
|
+
async hasChanges() {
|
|
946
|
+
return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
|
|
950
|
+
* committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
|
|
951
|
+
*
|
|
952
|
+
* Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
|
|
953
|
+
* symlinks, env values and the lockfile out of it without any caller restating that list.
|
|
954
|
+
*/
|
|
955
|
+
async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
|
|
956
|
+
if (!paths.length) return [];
|
|
957
|
+
const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
|
|
958
|
+
const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
|
|
959
|
+
return stdout
|
|
960
|
+
.split("\0")
|
|
961
|
+
.filter((file) => !!file)
|
|
962
|
+
.sort();
|
|
914
963
|
}
|
|
915
964
|
async #getDirHasFile(basePath: string, targetFilename: string) {
|
|
916
965
|
const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
|
|
@@ -1005,6 +1054,16 @@ export class SysExecutor extends Executor {
|
|
|
1005
1054
|
const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
|
|
1006
1055
|
return await this.workspace.getDirInModule(path, this.name);
|
|
1007
1056
|
}
|
|
1057
|
+
async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
|
|
1058
|
+
if (!(await this.exists("page"))) return [];
|
|
1059
|
+
const violations: { relativePath: string; reason: string }[] = [];
|
|
1060
|
+
for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
|
|
1061
|
+
const relativePath = entry.split(path.sep).join("/");
|
|
1062
|
+
const reason = getPageSourceFileViolation(relativePath);
|
|
1063
|
+
if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
|
|
1064
|
+
}
|
|
1065
|
+
return violations;
|
|
1066
|
+
}
|
|
1008
1067
|
|
|
1009
1068
|
#scanInfo: AppInfo | LibInfo | null = null;
|
|
1010
1069
|
hasScanInfo() {
|
|
@@ -1354,15 +1413,30 @@ export class AppExecutor extends SysExecutor {
|
|
|
1354
1413
|
//* generated against and typechecked. Drop the cache so the build phases re-read it without them.
|
|
1355
1414
|
this.#excludeDevOnlyPages = true;
|
|
1356
1415
|
this.#pageKeys = null;
|
|
1357
|
-
|
|
1416
|
+
await this.dist.removeDir(this.dist.cwdPath);
|
|
1358
1417
|
await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
|
|
1359
1418
|
//* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
|
|
1360
1419
|
//* release tarball root, neither of which follows a link out of itself, so materialize them here.
|
|
1420
|
+
//* `public/` has exactly one reader — the web router's catch-all — so an api-only build leaves it out.
|
|
1361
1421
|
await Promise.all([
|
|
1362
1422
|
this.cp("private", `${this.dist.cwdPath}/private`, { dereference: true }),
|
|
1363
|
-
this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
|
|
1423
|
+
...(akanConfig.web.ssr ? [this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true })] : []),
|
|
1364
1424
|
]);
|
|
1365
|
-
} else
|
|
1425
|
+
} else {
|
|
1426
|
+
await this.removeDir(".akan");
|
|
1427
|
+
//? `web` is a build declaration: `akan start` keeps the full dev surface because the incremental
|
|
1428
|
+
//? builder is also the file watcher, so switching it off would take server-code HMR with it.
|
|
1429
|
+
if (!akanConfig.web.ssr || !akanConfig.web.csr)
|
|
1430
|
+
this.logger.verbose(
|
|
1431
|
+
`akan.config.ts disables web.${!akanConfig.web.ssr ? "ssr" : "csr"}; \`akan start\` still serves it, \`akan build\` will not`,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
//? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
|
|
1435
|
+
//? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
|
|
1436
|
+
//? and a dev server that believes it is production serves from the production route cache, whose every
|
|
1437
|
+
//? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
|
|
1438
|
+
//? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
|
|
1439
|
+
if (type === "start") process.env.NODE_ENV = "development";
|
|
1366
1440
|
const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
|
|
1367
1441
|
const env = this.getCommandEnv({
|
|
1368
1442
|
AKAN_COMMAND_TYPE: type,
|
|
@@ -1451,7 +1525,8 @@ export class AppExecutor extends SysExecutor {
|
|
|
1451
1525
|
);
|
|
1452
1526
|
}
|
|
1453
1527
|
if (!owner) owners.set(routeId, { absPath, fromLib });
|
|
1454
|
-
const
|
|
1528
|
+
const layoutSegments = getRootBoundarySegments(key);
|
|
1529
|
+
const isRootLayout = layoutSegments !== null && isRootBoundarySegments(layoutSegments, akanConfig.basePaths);
|
|
1455
1530
|
const routeSource = await Bun.file(absPath).text();
|
|
1456
1531
|
const validator = await AppExecutor.#getRouteSourceValidator();
|
|
1457
1532
|
if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
|
|
@@ -1700,7 +1775,7 @@ export class LibExecutor extends SysExecutor {
|
|
|
1700
1775
|
#akanConfig: AkanLibConfig | null = null;
|
|
1701
1776
|
override async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
1702
1777
|
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
1703
|
-
this.#akanConfig = await AkanLibConfig.from(this);
|
|
1778
|
+
this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
|
|
1704
1779
|
return this.#akanConfig;
|
|
1705
1780
|
}
|
|
1706
1781
|
}
|
|
@@ -1806,7 +1881,7 @@ export class PkgExecutor extends Executor {
|
|
|
1806
1881
|
default: "./index.ts",
|
|
1807
1882
|
},
|
|
1808
1883
|
},
|
|
1809
|
-
engines: { bun: ">=1.
|
|
1884
|
+
engines: { bun: ">=1.4.0" },
|
|
1810
1885
|
...dependencyMaps,
|
|
1811
1886
|
};
|
|
1812
1887
|
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
|
}
|
|
@@ -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
|
+
});
|