@akanjs/devkit 3.1.1-rc.9 → 3.1.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 +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
package/envSeed.test.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { EnvSeed } from "./envSeed";
|
|
5
|
+
import { tempDirs, writeText } from "./testHelpers";
|
|
6
|
+
|
|
7
|
+
const makeEnvDir = tempDirs("akan-env-seed-");
|
|
8
|
+
const templates = path.resolve(import.meta.dir, "../cli/templates");
|
|
9
|
+
const template = async (file: string) => await readFile(path.join(templates, file), "utf8");
|
|
10
|
+
|
|
11
|
+
const selector = (side: "server" | "client", names: string[]) =>
|
|
12
|
+
`${names.map((name) => `import { env as ${name} } from "./env.${side}.${name}";`).join("\n")}\n\nexport const env = { ${names.join(", ")} };\n`;
|
|
13
|
+
|
|
14
|
+
const seededIn = async (envDir: string) =>
|
|
15
|
+
Object.fromEntries((await new EnvSeed(envDir).missing()).map(({ fileName, content }) => [fileName, content]));
|
|
16
|
+
|
|
17
|
+
describe("EnvSeed", () => {
|
|
18
|
+
test("names each environment file the selectors import and the testing one, of those missing", async () => {
|
|
19
|
+
const envDir = await makeEnvDir();
|
|
20
|
+
await writeText(path.join(envDir, "env.server.ts"), selector("server", ["debug", "local"]));
|
|
21
|
+
await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local", "staging"]));
|
|
22
|
+
await writeText(path.join(envDir, "env.server.debug.ts"), "export const env = { kept: true };\n");
|
|
23
|
+
|
|
24
|
+
expect(Object.keys(await seededIn(envDir))).toEqual([
|
|
25
|
+
"env.client.local.ts",
|
|
26
|
+
"env.client.staging.ts",
|
|
27
|
+
"env.server.local.ts",
|
|
28
|
+
"env.server.testing.ts",
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("passes over the generated type, the tracked example and a build profile", async () => {
|
|
33
|
+
const envDir = await makeEnvDir();
|
|
34
|
+
await writeText(
|
|
35
|
+
path.join(envDir, "env.server.ts"),
|
|
36
|
+
[
|
|
37
|
+
'import { libEnv } from "./env.server.type";',
|
|
38
|
+
'import { env as example } from "./env.server.example";',
|
|
39
|
+
'import { env as desktop } from "./env.server.debug.desktop";',
|
|
40
|
+
"",
|
|
41
|
+
"export const env = { ...libEnv, ...example, ...desktop };",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n"),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(await seededIn(envDir))).toEqual(["env.server.testing.ts"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("writes what the app scaffold writes when the folder has no example", async () => {
|
|
50
|
+
const envDir = await makeEnvDir();
|
|
51
|
+
await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local", "testing"]));
|
|
52
|
+
await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
|
|
53
|
+
await writeText(path.join(envDir, "env.client.type.ts"), "export type AppClientEnv = Record<never, never>;\n");
|
|
54
|
+
|
|
55
|
+
expect(await seededIn(envDir)).toEqual({
|
|
56
|
+
"env.client.local.ts": await template("app/env/env.client.local.ts.template"),
|
|
57
|
+
"env.server.local.ts": await template("app/env/env.server.local.ts.template"),
|
|
58
|
+
"env.server.testing.ts": await template("app/env/env.server.testing.ts.template"),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("copies the side's tracked example into each of its files", async () => {
|
|
63
|
+
const envDir = await makeEnvDir();
|
|
64
|
+
const example =
|
|
65
|
+
'import type { ModulesOptions } from "../lib/option";\n\nexport const env: ModulesOptions = { port: 1 };\n';
|
|
66
|
+
await writeText(path.join(envDir, "env.server.ts"), selector("server", ["local"]));
|
|
67
|
+
await writeText(path.join(envDir, "env.server.example.ts"), example);
|
|
68
|
+
|
|
69
|
+
expect(await seededIn(envDir)).toEqual({ "env.server.local.ts": example, "env.server.testing.ts": example });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("leaves a client env untyped when the folder has no env.client.type.ts to type it", async () => {
|
|
73
|
+
const envDir = await makeEnvDir();
|
|
74
|
+
await writeText(path.join(envDir, "env.client.ts"), selector("client", ["local"]));
|
|
75
|
+
|
|
76
|
+
expect((await seededIn(envDir))["env.client.local.ts"]).toBe("export const env = {};\n");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("gives a lib its testing env from the example the lib scaffold tracks", async () => {
|
|
80
|
+
const envDir = await makeEnvDir();
|
|
81
|
+
await writeText(
|
|
82
|
+
path.join(envDir, "env.server.example.ts"),
|
|
83
|
+
await template("libRoot/env/env.server.example.ts.template"),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(await seededIn(envDir)).toEqual({
|
|
87
|
+
"env.server.testing.ts": await template("libRoot/env/env.server.testing.ts.template"),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
package/envSeed.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FileSys } from "./fileSys";
|
|
3
|
+
|
|
4
|
+
export interface EnvSeedFile {
|
|
5
|
+
fileName: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//? These files are gitignored for the secrets they hold, so a fresh clone has none of them.
|
|
10
|
+
export class EnvSeed {
|
|
11
|
+
static readonly #server = `import type { ModulesOptions } from "../lib/option";
|
|
12
|
+
|
|
13
|
+
export const env: ModulesOptions = {};
|
|
14
|
+
`;
|
|
15
|
+
static readonly #testingServer = `import type { ModulesOptions } from "../lib/option";
|
|
16
|
+
import { libEnv } from "./env.server.type";
|
|
17
|
+
|
|
18
|
+
export const env: ModulesOptions = {
|
|
19
|
+
...libEnv,
|
|
20
|
+
};
|
|
21
|
+
`;
|
|
22
|
+
static readonly #client = `import type { AppClientEnv } from "./env.client.type";
|
|
23
|
+
|
|
24
|
+
export const env: AppClientEnv = {};
|
|
25
|
+
`;
|
|
26
|
+
static readonly #untypedClient = "export const env = {};\n";
|
|
27
|
+
static readonly #selectors = ["env.server.ts", "env.client.ts"];
|
|
28
|
+
//? `type` is generated and `example` tracked; a dotted name (`debug.desktop`) is a build profile no selector imports.
|
|
29
|
+
static readonly #selectedPattern = /^\.\/(env\.(?:server|client)\.(?!(?:type|example)$)[\w-]+)$/;
|
|
30
|
+
//? A lib's server.ts and the signal test runtime import it by name, whatever the selector imports.
|
|
31
|
+
static readonly #testing = "env.server.testing.ts";
|
|
32
|
+
|
|
33
|
+
readonly #envDir: string;
|
|
34
|
+
constructor(envDir: string) {
|
|
35
|
+
this.#envDir = envDir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async missing(): Promise<EnvSeedFile[]> {
|
|
39
|
+
const wanted = [...new Set([EnvSeed.#testing, ...(await this.#selected())])].sort();
|
|
40
|
+
const exists = await Promise.all(wanted.map((fileName) => FileSys.fileExists(path.join(this.#envDir, fileName))));
|
|
41
|
+
const missing = wanted.filter((_, idx) => !exists[idx]);
|
|
42
|
+
return await Promise.all(missing.map(async (fileName) => ({ fileName, content: await this.#contentOf(fileName) })));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async #selected() {
|
|
46
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
47
|
+
const selected = await Promise.all(
|
|
48
|
+
EnvSeed.#selectors.map(async (selector) => {
|
|
49
|
+
const file = path.join(this.#envDir, selector);
|
|
50
|
+
if (!(await FileSys.fileExists(file))) return [];
|
|
51
|
+
return transpiler
|
|
52
|
+
.scan(await FileSys.readText(file))
|
|
53
|
+
.imports.flatMap(({ path: specifier }) => specifier.match(EnvSeed.#selectedPattern)?.[1] ?? [])
|
|
54
|
+
.map((name) => `${name}.ts`);
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return selected.flat();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #contentOf(fileName: string) {
|
|
61
|
+
const side = fileName.startsWith("env.client.") ? "client" : "server";
|
|
62
|
+
const example = path.join(this.#envDir, `env.${side}.example.ts`);
|
|
63
|
+
if (await FileSys.fileExists(example)) return await FileSys.readText(example);
|
|
64
|
+
if (side === "server") return fileName === EnvSeed.#testing ? EnvSeed.#testingServer : EnvSeed.#server;
|
|
65
|
+
const typed = await FileSys.fileExists(path.join(this.#envDir, "env.client.type.ts"));
|
|
66
|
+
return typed ? EnvSeed.#client : EnvSeed.#untypedClient;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/executors.test.ts
CHANGED
|
@@ -275,10 +275,6 @@ describe("Executor filesystem helpers", () => {
|
|
|
275
275
|
"Akan workspace agent guide",
|
|
276
276
|
);
|
|
277
277
|
expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
|
|
278
|
-
expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
|
|
279
|
-
"AI Development Guide",
|
|
280
|
-
);
|
|
281
|
-
expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
|
|
282
278
|
// Rules live in the package's base config, so a framework release reaches an existing workspace on `bun update`.
|
|
283
279
|
expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
|
|
284
280
|
'"extends": ["@akanjs/devkit/biome.base.json"]',
|
|
@@ -380,6 +376,23 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
380
376
|
});
|
|
381
377
|
});
|
|
382
378
|
|
|
379
|
+
test("a workspace with no .env, as a fresh clone has, runs local unless the shell names an environment", async () => {
|
|
380
|
+
const root = await makeTempRoot();
|
|
381
|
+
delete process.env.AKAN_PUBLIC_ENV;
|
|
382
|
+
|
|
383
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
384
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("local");
|
|
385
|
+
|
|
386
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
387
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
388
|
+
expect(process.env.AKAN_PUBLIC_ENV).toBe("main");
|
|
389
|
+
|
|
390
|
+
delete process.env.AKAN_PUBLIC_ENV;
|
|
391
|
+
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
392
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
393
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("debug");
|
|
394
|
+
});
|
|
395
|
+
|
|
383
396
|
test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
|
|
384
397
|
const baseDevEnv = {
|
|
385
398
|
repoName: "fixture-repo",
|
|
@@ -1309,7 +1322,8 @@ const createScannableLib = async (
|
|
|
1309
1322
|
await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
|
|
1310
1323
|
await writeText(path.join(libDir, "akan.config.ts"), config);
|
|
1311
1324
|
await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
|
|
1312
|
-
|
|
1325
|
+
const libFiles = { "env/env.server.testing.ts": "export const env = {};\n", ...files };
|
|
1326
|
+
for (const [file, content] of Object.entries(libFiles)) await writeText(path.join(libDir, file), content);
|
|
1313
1327
|
return { ...temp, libDir };
|
|
1314
1328
|
};
|
|
1315
1329
|
|
package/executors.ts
CHANGED
|
@@ -44,11 +44,13 @@ import {
|
|
|
44
44
|
import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
45
45
|
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
46
46
|
import { CodegenLock } from "./codegenLock";
|
|
47
|
+
import { EnvSeed } from "./envSeed";
|
|
47
48
|
import { FileSys, getDirname } from "./fileSys";
|
|
48
49
|
import { JsonFormatter } from "./jsonFormatter";
|
|
49
50
|
import { Linter } from "./linter";
|
|
50
51
|
import { ManifestDependencies } from "./manifestDependencies";
|
|
51
52
|
import { resolveRepoName } from "./repoIdentity";
|
|
53
|
+
import type { RouteSourceInfo } from "./routeSourceValidator";
|
|
52
54
|
import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
53
55
|
import { Spinner } from "./spinner";
|
|
54
56
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
@@ -66,6 +68,12 @@ export interface PageRoot {
|
|
|
66
68
|
keyPrefix: string;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
export interface AppRouteSource {
|
|
72
|
+
key: string;
|
|
73
|
+
absPath: string;
|
|
74
|
+
info: RouteSourceInfo;
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
export interface WrittenFileContent extends FileContent {
|
|
70
78
|
written: boolean;
|
|
71
79
|
}
|
|
@@ -422,11 +430,13 @@ export class Executor {
|
|
|
422
430
|
targetPath,
|
|
423
431
|
scanInfo,
|
|
424
432
|
overwrite = true,
|
|
433
|
+
dryRun = false,
|
|
425
434
|
}: {
|
|
426
435
|
templatePath: string;
|
|
427
436
|
targetPath: string;
|
|
428
437
|
scanInfo?: AppInfo | LibInfo | null;
|
|
429
438
|
overwrite?: boolean;
|
|
439
|
+
dryRun?: boolean;
|
|
430
440
|
},
|
|
431
441
|
dict: { [key: string]: string } = {},
|
|
432
442
|
options: { [key: string]: unknown } = {},
|
|
@@ -449,6 +459,7 @@ export class Executor {
|
|
|
449
459
|
const filename = typeof result === "object" ? result.filename : path.basename(targetPath).replace(".js", ".ts");
|
|
450
460
|
const content = typeof result === "object" ? result.content : result;
|
|
451
461
|
const convertedTargetPath = convertPath(`${path.dirname(targetPath)}/${filename}`);
|
|
462
|
+
if (dryRun) return { filePath: this.getPath(convertedTargetPath), content, written: false };
|
|
452
463
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
453
464
|
return this.writeFile(convertedTargetPath, content, { overwrite });
|
|
454
465
|
} else if (targetPath.endsWith(".template")) {
|
|
@@ -458,11 +469,13 @@ export class Executor {
|
|
|
458
469
|
(data, [key, value]) => data.replace(new RegExp(`<%= ${key} %>`, "g"), value),
|
|
459
470
|
content,
|
|
460
471
|
);
|
|
472
|
+
if (dryRun) return { filePath: this.getPath(convertedTargetPath), content: convertedContent, written: false };
|
|
461
473
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
462
474
|
return this.writeFile(convertedTargetPath, convertedContent, { overwrite });
|
|
463
475
|
} else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
|
|
464
476
|
const convertedTargetPath = convertPath(targetPath);
|
|
465
477
|
const writePath = this.getPath(convertedTargetPath);
|
|
478
|
+
if (dryRun) return { filePath: writePath, content: "", written: false };
|
|
466
479
|
if (!overwrite && (await FileSys.fileExists(writePath)))
|
|
467
480
|
return { filePath: writePath, content: "", written: false };
|
|
468
481
|
const dirname = path.dirname(writePath);
|
|
@@ -479,6 +492,7 @@ export class Executor {
|
|
|
479
492
|
dict = {},
|
|
480
493
|
options = {},
|
|
481
494
|
overwrite = true,
|
|
495
|
+
dryRun = false,
|
|
482
496
|
}: {
|
|
483
497
|
basePath: string;
|
|
484
498
|
template: string;
|
|
@@ -486,6 +500,7 @@ export class Executor {
|
|
|
486
500
|
dict?: { [key: string]: string };
|
|
487
501
|
options?: { [key: string]: unknown };
|
|
488
502
|
overwrite?: boolean;
|
|
503
|
+
dryRun?: boolean;
|
|
489
504
|
}): Promise<WrittenFileContent[]> {
|
|
490
505
|
const templateRoot = await this.#resolveTemplateRoot();
|
|
491
506
|
const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
|
|
@@ -496,6 +511,7 @@ export class Executor {
|
|
|
496
511
|
targetPath: path.join(basePath, path.basename(templatePath)),
|
|
497
512
|
scanInfo,
|
|
498
513
|
overwrite,
|
|
514
|
+
dryRun,
|
|
499
515
|
},
|
|
500
516
|
dict,
|
|
501
517
|
options,
|
|
@@ -514,6 +530,7 @@ export class Executor {
|
|
|
514
530
|
targetPath: path.join(basePath, subdir),
|
|
515
531
|
scanInfo,
|
|
516
532
|
overwrite,
|
|
533
|
+
dryRun,
|
|
517
534
|
},
|
|
518
535
|
dict,
|
|
519
536
|
options,
|
|
@@ -526,6 +543,7 @@ export class Executor {
|
|
|
526
543
|
scanInfo,
|
|
527
544
|
dict,
|
|
528
545
|
overwrite,
|
|
546
|
+
dryRun,
|
|
529
547
|
options,
|
|
530
548
|
});
|
|
531
549
|
}),
|
|
@@ -551,14 +569,15 @@ export class Executor {
|
|
|
551
569
|
dict?: { [key: string]: string };
|
|
552
570
|
options?: { [key: string]: unknown };
|
|
553
571
|
overwrite?: boolean;
|
|
572
|
+
dryRun?: boolean;
|
|
554
573
|
}): Promise<FileContent[]> {
|
|
555
574
|
const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
|
|
556
575
|
await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
|
|
557
576
|
return fileContents;
|
|
558
577
|
}
|
|
559
578
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
579
|
+
// Best-effort: `create-akan-workspace` scaffolds before `bun install`, with no local Biome binary yet, so a
|
|
580
|
+
// template emits Biome's form itself (`templates/biomeForm.ts`).
|
|
562
581
|
async #formatAppliedTemplate(fileContents: FileContent[]) {
|
|
563
582
|
const filePaths = fileContents
|
|
564
583
|
.map((fileContent) => fileContent.filePath)
|
|
@@ -705,6 +724,10 @@ export class WorkspaceExecutor extends Executor {
|
|
|
705
724
|
defaulted,
|
|
706
725
|
};
|
|
707
726
|
}
|
|
727
|
+
//? A clone has no `.env` (gitignored for its secrets): it runs local, as a new workspace and env.server.ts do, not debug.
|
|
728
|
+
static defaultCloneToLocalEnv(workspaceRoot: string) {
|
|
729
|
+
if (!existsSync(path.join(workspaceRoot, ".env"))) process.env.AKAN_PUBLIC_ENV ??= "local";
|
|
730
|
+
}
|
|
708
731
|
//? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
|
|
709
732
|
static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
|
|
710
733
|
const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
|
|
@@ -797,11 +820,19 @@ export class WorkspaceExecutor extends Executor {
|
|
|
797
820
|
};
|
|
798
821
|
return await getDirs(basePath);
|
|
799
822
|
}
|
|
800
|
-
async commit(
|
|
823
|
+
async commit(
|
|
824
|
+
message: string,
|
|
825
|
+
{
|
|
826
|
+
init = false,
|
|
827
|
+
add = true,
|
|
828
|
+
identity,
|
|
829
|
+
}: { init?: boolean; add?: boolean; identity?: { name: string; email: string } } = {},
|
|
830
|
+
) {
|
|
801
831
|
if (init) await this.spawn("git", ["init", "--quiet"]);
|
|
802
832
|
if (add) await this.spawn("git", ["add", "."]);
|
|
833
|
+
const author = identity ? ["-c", `user.name=${identity.name}`, "-c", `user.email=${identity.email}`] : [];
|
|
803
834
|
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
804
|
-
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
835
|
+
await this.spawn("git", [...author, "commit", "--quiet", "-m", message]);
|
|
805
836
|
}
|
|
806
837
|
/** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
|
|
807
838
|
async hasChanges() {
|
|
@@ -916,8 +947,23 @@ export class SysExecutor extends Executor {
|
|
|
916
947
|
if (!this.hasScanInfo() && !allowEmpty) throw new Error("Scan info is not available");
|
|
917
948
|
return this.#scanInfo as AllowEmpty extends true ? AppInfo | LibInfo | null : AppInfo | LibInfo;
|
|
918
949
|
}
|
|
950
|
+
async #seedEnvFiles(): Promise<FileContent[]> {
|
|
951
|
+
const envDir = path.join(this.cwdPath, "env");
|
|
952
|
+
const missing = await new EnvSeed(envDir).missing();
|
|
953
|
+
if (!missing.length) return [];
|
|
954
|
+
const written = await Promise.all(
|
|
955
|
+
missing.map(({ fileName, content }) =>
|
|
956
|
+
this.writeFile(path.join(envDir, fileName), content, { overwrite: false, silent: true }),
|
|
957
|
+
),
|
|
958
|
+
);
|
|
959
|
+
this.logger.info(
|
|
960
|
+
`${missing.map(({ fileName }) => `env/${fileName}`).join(", ")} were missing (gitignored, so a clone has none) and now hold local defaults: put this machine's values in them, or fetch the team's with \`akan download-env\`.`,
|
|
961
|
+
);
|
|
962
|
+
return written;
|
|
963
|
+
}
|
|
919
964
|
#getScanTemplateTasks(scanInfo: AppInfo | LibInfo): (Promise<FileContent[]> | null)[] {
|
|
920
965
|
return [
|
|
966
|
+
this.#seedEnvFiles(),
|
|
921
967
|
this._applyTemplate({ basePath: "env", template: "env", scanInfo }),
|
|
922
968
|
this._applyTemplate({ basePath: "lib", template: "lib", scanInfo }),
|
|
923
969
|
this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
|
|
@@ -1278,6 +1324,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1278
1324
|
}
|
|
1279
1325
|
|
|
1280
1326
|
#pageKeys: string[] | null = null;
|
|
1327
|
+
#routeSources: AppRouteSource[] = [];
|
|
1281
1328
|
#excludeDevOnlyPages = false;
|
|
1282
1329
|
async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
1283
1330
|
if (this.#pageKeys && !refresh) return this.#pageKeys;
|
|
@@ -1288,6 +1335,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1288
1335
|
const devOnlyKeys = new Set<string>();
|
|
1289
1336
|
const devOnlyDirs: string[] = [];
|
|
1290
1337
|
const pageRoots = await this.getPageRoots();
|
|
1338
|
+
const routeSources: AppRouteSource[] = [];
|
|
1291
1339
|
const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
|
|
1292
1340
|
for (const root of pageRoots) {
|
|
1293
1341
|
if (!(await FileSys.dirExists(root.dir))) continue;
|
|
@@ -1333,6 +1381,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1333
1381
|
rootLayout: isRootLayout,
|
|
1334
1382
|
pattern: parsed.pattern,
|
|
1335
1383
|
});
|
|
1384
|
+
routeSources.push({ key, absPath, info });
|
|
1336
1385
|
if (info.devOnly) {
|
|
1337
1386
|
devOnlyKeys.add(key);
|
|
1338
1387
|
//* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
|
|
@@ -1343,9 +1392,15 @@ export class AppExecutor extends SysExecutor {
|
|
|
1343
1392
|
}
|
|
1344
1393
|
}
|
|
1345
1394
|
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1395
|
+
this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1346
1396
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1347
1397
|
return this.#pageKeys;
|
|
1348
1398
|
}
|
|
1399
|
+
/** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
|
|
1400
|
+
async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
|
|
1401
|
+
await this.getPageKeys({ refresh });
|
|
1402
|
+
return this.#routeSources;
|
|
1403
|
+
}
|
|
1349
1404
|
#dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
|
|
1350
1405
|
if (!devOnlyKeys.size) return pageKeys;
|
|
1351
1406
|
const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
|
package/fileSys.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { getDirname } from "./fileSys";
|
|
6
|
+
import { FileSys, getDirname } from "./fileSys";
|
|
5
7
|
|
|
6
8
|
describe("getDirname", () => {
|
|
7
9
|
test("converts file URLs to filesystem paths", () => {
|
|
@@ -19,3 +21,29 @@ describe("getDirname", () => {
|
|
|
19
21
|
expect(dirname).toBe("C:\\Users\\ken78\\.bun\\install\\global\\node_modules\\@akanjs\\devkit");
|
|
20
22
|
});
|
|
21
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("FileSys.ensurePrivateDir", () => {
|
|
26
|
+
//? macOS refuses even the owner a chmod of a folder flagged user-immutable; Linux needs root for the same.
|
|
27
|
+
test.skipIf(process.platform !== "darwin")(
|
|
28
|
+
"warns instead of throwing when the folder's mode cannot be changed",
|
|
29
|
+
() => {
|
|
30
|
+
const root = mkdtempSync(path.join(tmpdir(), "akan-private-dir-"));
|
|
31
|
+
const dir = path.join(root, "locked");
|
|
32
|
+
mkdirSync(dir);
|
|
33
|
+
chmodSync(dir, 0o755);
|
|
34
|
+
Bun.spawnSync(["chflags", "uchg", dir]);
|
|
35
|
+
const warn = spyOn(FileSys.logger, "warn").mockImplementation(() => undefined);
|
|
36
|
+
try {
|
|
37
|
+
expect(FileSys.ensurePrivateDir(dir)).toBe(dir);
|
|
38
|
+
expect(statSync(dir).mode & 0o777).toBe(0o755);
|
|
39
|
+
expect(warn.mock.calls.map(([line]) => String(line))).toEqual([
|
|
40
|
+
expect.stringContaining(`${dir} stays at mode 755`),
|
|
41
|
+
]);
|
|
42
|
+
} finally {
|
|
43
|
+
warn.mockRestore();
|
|
44
|
+
Bun.spawnSync(["chflags", "nouchg", dir]);
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
});
|
package/fileSys.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, statSync } from "node:fs";
|
|
1
2
|
import { lstat, rename, rm, stat } from "node:fs/promises";
|
|
2
3
|
import nodePath from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -70,6 +71,19 @@ export class FileSys {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
//? `mkdir`'s mode only applies to a directory it creates, so one an older build made is tightened here.
|
|
75
|
+
static ensurePrivateDir(dir: string) {
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
77
|
+
if (process.platform === "win32") return dir;
|
|
78
|
+
const { mode, uid } = statSync(dir);
|
|
79
|
+
if ((mode & 0o077) === 0 || uid !== process.getuid?.()) return dir;
|
|
80
|
+
try {
|
|
81
|
+
chmodSync(dir, 0o700);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
FileSys.logger.warn(`${dir} stays at mode ${(mode & 0o777).toString(8)}: ${(error as Error).message}`);
|
|
84
|
+
}
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
73
87
|
static async writeJson(path: string, content: object) {
|
|
74
88
|
return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
|
|
75
89
|
}
|
|
@@ -10,8 +10,26 @@ const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
|
10
10
|
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
const taskConstant = fileOf(
|
|
14
|
+
"apps/demo/lib/task/task.constant.ts",
|
|
15
|
+
`export class TaskInput extends via((field) => ({
|
|
16
|
+
title: field(String),
|
|
17
|
+
type: field(String),
|
|
18
|
+
phone: field(String),
|
|
19
|
+
project: field(Project).optional(),
|
|
20
|
+
members: field([User]),
|
|
21
|
+
})) {}
|
|
22
|
+
|
|
23
|
+
export class TaskObject extends via(TaskInput, (field) => {
|
|
24
|
+
return { status: field(String) };
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
27
|
+
export class TaskInsight extends via(Task, (field) => ({ onSale: field(Int) })) {}
|
|
28
|
+
`,
|
|
29
|
+
);
|
|
30
|
+
|
|
13
31
|
const scan = (content: string) =>
|
|
14
|
-
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
32
|
+
new FormSetterScanner().scan([taskConstant, fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
33
|
|
|
16
34
|
describe("FormSetterScanner", () => {
|
|
17
35
|
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
@@ -73,6 +91,31 @@ describe("FormSetterScanner", () => {
|
|
|
73
91
|
).toEqual([]);
|
|
74
92
|
});
|
|
75
93
|
|
|
94
|
+
test("a slice setter is not a field setter, though its name reads set…On…", () => {
|
|
95
|
+
expect(
|
|
96
|
+
scan(`export const Filters = () => (
|
|
97
|
+
<Select onChange={(page) => st.do.setPageOfTaskOnSale(page)} />
|
|
98
|
+
);
|
|
99
|
+
`),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a field is known from its model's constant, so another model's setter is not counted", () => {
|
|
104
|
+
expect(
|
|
105
|
+
scan(`export const General = () => (
|
|
106
|
+
<>
|
|
107
|
+
<Field.Text onChange={(title) => st.do.setTitleOnProject(title)} />
|
|
108
|
+
<Field.Text onChange={(onSale) => st.do.setOnSaleOnTask(onSale)} />
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
`),
|
|
112
|
+
).toEqual([]);
|
|
113
|
+
expect(
|
|
114
|
+
scan(`export const General = () => <Field.Text onChange={(status) => st.do.setStatusOnTask(status)} />;\n`)[0]
|
|
115
|
+
?.message,
|
|
116
|
+
).toContain("setStatusOnTask");
|
|
117
|
+
});
|
|
118
|
+
|
|
76
119
|
test("only .tsx is scanned", () => {
|
|
77
120
|
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
121
|
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
package/formSetterScanner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { capitalize, formSetterNames } from "akanjs/common";
|
|
1
2
|
import ts from "typescript";
|
|
2
3
|
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
4
|
|
|
@@ -5,17 +6,61 @@ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
|
5
6
|
// forwarding wrappers; this counts every field left unpublished whatever the reason. A zero-parameter handler is a
|
|
6
7
|
// button setting a constant, not a form control, so it is not counted.
|
|
7
8
|
export class FormSetterScanner {
|
|
8
|
-
static #
|
|
9
|
+
static #constantPath = /(?:^|\/)lib\/([^_/][^/]*)\/\1\.constant\.ts$/;
|
|
10
|
+
static #baseFields = ["id", "createdAt", "updatedAt", "removedAt"];
|
|
9
11
|
|
|
10
12
|
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
11
|
-
|
|
13
|
+
const fieldSetters = FormSetterScanner.#fieldSettersOf(sourceFiles);
|
|
14
|
+
return sourceFiles
|
|
15
|
+
.filter((sourceFile) => sourceFile.file.endsWith(".tsx"))
|
|
16
|
+
.flatMap((f) => this.#scanFile(f, fieldSetters));
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
// Built forward from the constants: matching `set…On…` also caught slice setters such as `setPageOfProductOnSale`.
|
|
20
|
+
static #fieldSettersOf(sourceFiles: SourceFileInfo[]) {
|
|
21
|
+
const setters = new Set<string>();
|
|
22
|
+
for (const { file, sourceFile } of sourceFiles) {
|
|
23
|
+
const refName = FormSetterScanner.#constantPath.exec(file)?.[1];
|
|
24
|
+
if (!refName) continue;
|
|
25
|
+
const className = capitalize(refName);
|
|
26
|
+
const fullModelClasses = new Set([`${className}Input`, `${className}Object`, `Light${className}`, className]);
|
|
27
|
+
const keys = new Set(FormSetterScanner.#baseFields);
|
|
28
|
+
for (const statement of sourceFile.statements)
|
|
29
|
+
if (ts.isClassDeclaration(statement) && statement.name && fullModelClasses.has(statement.name.text))
|
|
30
|
+
for (const key of FormSetterScanner.#viaFieldKeysOf(statement)) keys.add(key);
|
|
31
|
+
for (const key of keys) setters.add(formSetterNames(className, key).setFieldOnModel);
|
|
32
|
+
}
|
|
33
|
+
return setters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static #viaFieldKeysOf(declaration: ts.ClassDeclaration): string[] {
|
|
37
|
+
const via = declaration.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
|
|
38
|
+
?.expression;
|
|
39
|
+
if (!via || !ts.isCallExpression(via)) return [];
|
|
40
|
+
return via.arguments.flatMap((argument) => {
|
|
41
|
+
if (!ts.isArrowFunction(argument) && !ts.isFunctionExpression(argument)) return [];
|
|
42
|
+
const fields = FormSetterScanner.#returnedObjectOf(argument.body);
|
|
43
|
+
return (fields?.properties ?? []).flatMap((property) =>
|
|
44
|
+
property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
|
|
45
|
+
? [property.name.text]
|
|
46
|
+
: [],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static #returnedObjectOf(body: ts.ConciseBody): ts.ObjectLiteralExpression | null {
|
|
52
|
+
let expression: ts.Node | undefined = body;
|
|
53
|
+
if (ts.isBlock(body)) expression = body.statements.find(ts.isReturnStatement)?.expression;
|
|
54
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)))
|
|
55
|
+
expression = expression.expression;
|
|
56
|
+
return expression && ts.isObjectLiteralExpression(expression) ? expression : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#scanFile({ file, sourceFile }: SourceFileInfo, fieldSetters: Set<string>): QualityWarning[] {
|
|
15
60
|
const wrapped: { setter: string; line: number }[] = [];
|
|
16
61
|
const visit = (node: ts.Node) => {
|
|
17
62
|
if (ts.isJsxAttribute(node)) {
|
|
18
|
-
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
63
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node, fieldSetters);
|
|
19
64
|
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
20
65
|
}
|
|
21
66
|
ts.forEachChild(node, visit);
|
|
@@ -40,17 +85,17 @@ export class FormSetterScanner {
|
|
|
40
85
|
}
|
|
41
86
|
|
|
42
87
|
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
43
|
-
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
88
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute, fieldSetters: Set<string>): string | null {
|
|
44
89
|
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
45
90
|
const initializer = attribute.initializer;
|
|
46
91
|
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
47
92
|
const handler = initializer.expression;
|
|
48
93
|
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
49
94
|
if (!handler.parameters.length) return null;
|
|
50
|
-
return FormSetterScanner.#setterCallIn(handler.body);
|
|
95
|
+
return FormSetterScanner.#setterCallIn(handler.body, fieldSetters);
|
|
51
96
|
}
|
|
52
97
|
|
|
53
|
-
static #setterCallIn(node: ts.Node): string | null {
|
|
98
|
+
static #setterCallIn(node: ts.Node, fieldSetters: Set<string>): string | null {
|
|
54
99
|
let found: string | null = null;
|
|
55
100
|
const visit = (current: ts.Node) => {
|
|
56
101
|
if (found) return;
|
|
@@ -61,7 +106,7 @@ export class FormSetterScanner {
|
|
|
61
106
|
ts.isIdentifier(expression.expression) &&
|
|
62
107
|
expression.expression.text === "st" &&
|
|
63
108
|
expression.name.text === "do" &&
|
|
64
|
-
|
|
109
|
+
fieldSetters.has(name.text)
|
|
65
110
|
) {
|
|
66
111
|
found = name.text;
|
|
67
112
|
return;
|