@akanjs/devkit 3.0.0-alpha.70 → 3.0.0-alpha.72

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.
Files changed (121) hide show
  1. package/akanApp/BackendImportGraph.test.ts +120 -0
  2. package/akanApp/BackendImportGraph.ts +167 -0
  3. package/akanApp/akanApp.host.test.ts +16 -678
  4. package/akanApp/akanApp.host.ts +65 -562
  5. package/akanApp/devHostPolicy.test.ts +542 -0
  6. package/akanApp/devHostPolicy.ts +412 -0
  7. package/akanApp/index.ts +2 -0
  8. package/akanConfig/akanConfig.test.ts +1 -0
  9. package/akanConfig/akanConfig.ts +4 -0
  10. package/akanContext.ts +17 -3
  11. package/akanMcpContract.ts +25 -4
  12. package/artifact/routeSeedIndex.test.ts +1 -0
  13. package/biome.base.json +4 -0
  14. package/capacitorApp.ts +10 -0
  15. package/cloud/globalConfig.ts +9 -2
  16. package/commandDecorators/command.ts +30 -6
  17. package/commandDecorators/commandBuilder.ts +20 -5
  18. package/commandDecorators/commandDecorators.test.ts +115 -3
  19. package/dependencyScanner.test.ts +99 -0
  20. package/dependencyScanner.ts +27 -24
  21. package/devkitUtils.test.ts +0 -104
  22. package/executors.test.ts +45 -0
  23. package/executors.ts +29 -5
  24. package/fileEditor.ts +19 -19
  25. package/frontendBuild/autoImportSync.test.ts +58 -0
  26. package/frontendBuild/autoImportSync.ts +4 -0
  27. package/frontendBuild/buildRouteClient.test.ts +19 -22
  28. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  29. package/frontendBuild/fontOptimizer.ts +37 -18
  30. package/frontendBuild/index.ts +0 -1
  31. package/frontendBuild/styleContract.ts +14 -20
  32. package/frontendBuild/themeValidator.ts +22 -17
  33. package/incrementalBuilder/devWatchBatch.ts +6 -2
  34. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
  35. package/index.ts +0 -5
  36. package/lint/__fixtures__/README.md +40 -0
  37. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  38. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  39. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  40. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  41. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  42. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  43. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  44. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  45. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  46. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  47. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  48. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  49. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  50. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  51. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  52. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  53. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  54. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  55. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  56. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  57. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  58. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  59. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  60. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  61. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  62. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  63. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  64. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  65. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  66. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  67. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  68. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  69. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  70. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  71. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  72. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  73. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  74. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  75. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  76. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  77. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  78. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  79. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  80. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  81. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  83. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  84. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  85. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  86. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  87. package/lint/gritRules.test.ts +178 -0
  88. package/lint/no-arbitrary-color.grit +1 -1
  89. package/lint/no-bang-comment-in-client.grit +23 -10
  90. package/lint/no-daisyui-legacy-class.grit +2 -2
  91. package/lint/no-deprecated-log-level.grit +17 -0
  92. package/lint/no-inline-color.grit +10 -8
  93. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  94. package/lint/no-raw-palette-class.grit +3 -3
  95. package/lint/non-scalar-props-restricted.grit +16 -7
  96. package/linter.test.ts +80 -0
  97. package/linter.ts +82 -13
  98. package/package.json +3 -3
  99. package/prompter.ts +9 -4
  100. package/qualityScanner.test.ts +64 -0
  101. package/qualityScanner.ts +43 -3
  102. package/recipeScanner.ts +4 -1
  103. package/scanInfo.ts +3 -3
  104. package/ssrScanner.test.ts +301 -0
  105. package/transforms/barrelImportsPlugin.ts +1 -1
  106. package/transforms/transforms.test.ts +5 -5
  107. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  108. package/tsconfig.json +5 -1
  109. package/typeChecker.ts +1 -1
  110. package/types.ts +1 -0
  111. package/ui/ScrollList.tsx +6 -8
  112. package/uploadRelease.ts +2 -2
  113. package/workflow/executor.test.ts +146 -0
  114. package/builder.ts +0 -164
  115. package/extractDeps.ts +0 -86
  116. package/frontendBuild/styleGuard.test.ts +0 -180
  117. package/frontendBuild/styleGuard.ts +0 -339
  118. package/getCredentials.ts +0 -17
  119. package/getModelFileData.ts +0 -62
  120. package/src/capacitorApp.ts +0 -282
  121. package/streamAi.ts +0 -45
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Logger } from "akanjs/common";
6
+ import type { App } from "../commandDecorators";
7
+ import { BackendImportGraph } from "./BackendImportGraph";
8
+ import { filesChangedSince } from "./devHostPolicy";
9
+
10
+ describe("BackendImportGraph", () => {
11
+ const tempRoots: string[] = [];
12
+
13
+ const makeGraph = async (files: Record<string, string>) => {
14
+ // Realpath, not the mkdtemp path: `Bun.resolveSync` returns real paths, and on macOS `/var/folders`
15
+ // is a symlink, so an unresolved root makes every resolved import look like it escapes the workspace.
16
+ const workspaceRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "akan-devkit-graph-")));
17
+ tempRoots.push(workspaceRoot);
18
+ const cwdPath = path.join(workspaceRoot, "apps/demo");
19
+ for (const [rel, source] of Object.entries(files)) {
20
+ const filePath = path.join(cwdPath, rel);
21
+ await mkdir(path.dirname(filePath), { recursive: true });
22
+ await writeFile(filePath, source);
23
+ }
24
+ const app = { cwdPath, workspace: { workspaceRoot } } as unknown as App;
25
+ return { graph: new BackendImportGraph(app, new Logger("test")), cwdPath };
26
+ };
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
30
+ });
31
+
32
+ test("walks the backend entrypoints' import graph", async () => {
33
+ const { graph, cwdPath } = await makeGraph({
34
+ "main.ts": 'import "./server";\n',
35
+ "server.ts": 'import { handler } from "./lib/handler";\nexport default handler;\n',
36
+ "lib/handler.ts": "export const handler = () => null;\n",
37
+ "lib/unreachable.ts": "export const nope = 1;\n",
38
+ });
39
+
40
+ expect(await graph.refresh()).toBe(true);
41
+ expect(graph.has(path.join(cwdPath, "lib/handler.ts"))).toBe(true);
42
+ expect(graph.has(path.join(cwdPath, "lib/unreachable.ts"))).toBe(false);
43
+ });
44
+
45
+ test("picks up an import added to an already-scanned file", async () => {
46
+ const { graph, cwdPath } = await makeGraph({
47
+ "main.ts": 'import "./server";\n',
48
+ "server.ts": "export default 1;\n",
49
+ "lib/added.ts": "export const added = 1;\n",
50
+ });
51
+ await graph.refresh();
52
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(false);
53
+
54
+ // The scan cache is keyed on (mtimeMs, size), so the rewrite must invalidate it.
55
+ await writeFile(path.join(cwdPath, "server.ts"), 'import "./lib/added";\nexport default 1;\n');
56
+
57
+ await graph.refresh();
58
+ expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(true);
59
+ });
60
+
61
+ test("drops a file that left the graph", async () => {
62
+ const { graph, cwdPath } = await makeGraph({
63
+ "main.ts": 'import "./server";\n',
64
+ "server.ts": 'import "./lib/leaving";\nexport default 1;\n',
65
+ "lib/leaving.ts": "export const leaving = 1;\n",
66
+ });
67
+ await graph.refresh();
68
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(true);
69
+
70
+ await writeFile(path.join(cwdPath, "server.ts"), "export default 1;\n");
71
+ await graph.refresh();
72
+ expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(false);
73
+ });
74
+
75
+ test("reports which backend files moved while nobody was watching", async () => {
76
+ const { graph, cwdPath } = await makeGraph({
77
+ "main.ts": 'import "./server";\n',
78
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
79
+ "lib/handler.ts": "export const handler = () => null;\n",
80
+ });
81
+ await graph.refresh();
82
+ const before = await graph.fingerprint();
83
+
84
+ // The builder is gone here, so no watcher event exists for this save — which is the whole reason
85
+ // the stamps are taken. `mtimeMs` has a coarse clock on Linux, so the size has to move too.
86
+ await writeFile(path.join(cwdPath, "lib/handler.ts"), "export const handler = () => 'changed';\n");
87
+
88
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
89
+ });
90
+
91
+ test("says nothing when the tree is untouched, and names a deleted file", async () => {
92
+ const { graph, cwdPath } = await makeGraph({
93
+ "main.ts": 'import "./server";\n',
94
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
95
+ "lib/handler.ts": "export const handler = () => null;\n",
96
+ });
97
+ await graph.refresh();
98
+ const before = await graph.fingerprint();
99
+ // A recycle with no edit in it is the common case, and it must not cost a backend restart.
100
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([]);
101
+
102
+ await rm(path.join(cwdPath, "lib/handler.ts"));
103
+ // Deleted counts as changed: the backend is still running what used to be there.
104
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
105
+ });
106
+
107
+ test("keeps the previous graph when a refresh finds no entrypoints", async () => {
108
+ const { graph, cwdPath } = await makeGraph({
109
+ "main.ts": 'import "./lib/kept";\n',
110
+ "lib/kept.ts": "export const kept = 1;\n",
111
+ });
112
+ await graph.refresh();
113
+ expect(graph.ready).toBe(true);
114
+
115
+ await rm(path.join(cwdPath, "main.ts"));
116
+ await graph.refresh();
117
+ // An empty scan is not a failure, so the graph legitimately empties out.
118
+ expect(graph.has(path.join(cwdPath, "lib/kept.ts"))).toBe(false);
119
+ });
120
+ });
@@ -0,0 +1,167 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Logger } from "akanjs/common";
4
+ import type { App } from "../commandDecorators";
5
+ import { SOURCE_EXTS, type SourceFingerprints } from "./devHostPolicy";
6
+
7
+ const NON_SOURCE_EXT_RE =
8
+ /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
9
+
10
+ const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
11
+ "import-statement",
12
+ "require-call",
13
+ "require-resolve",
14
+ "dynamic-import",
15
+ ]);
16
+
17
+ export class BackendImportGraph {
18
+ readonly #app: App;
19
+ readonly #logger: Logger;
20
+ readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
21
+ readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
22
+ readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
23
+ readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
24
+ #files = new Set<string>();
25
+ /**
26
+ * `refresh()` runs on every server-side save and on every dev-host recycle, and re-reading plus
27
+ * re-parsing files that did not change is the whole cost of it. Keyed on (mtimeMs, size).
28
+ *
29
+ * Specifiers are cached, not resolved paths: creating a file can change what an *unchanged* importer's
30
+ * specifier resolves to, and `Bun.resolveSync` is cheap next to a read plus a transpiler scan. Only
31
+ * the scan result is retained — never the source text.
32
+ */
33
+ #scanCache = new Map<string, { mtimeMs: number; size: number; specifiers: Bun.Import[] }>();
34
+ #ready = false;
35
+ #lastRefreshSucceeded = false;
36
+
37
+ constructor(app: App, logger: Logger) {
38
+ this.#app = app;
39
+ this.#logger = logger;
40
+ }
41
+
42
+ get ready() {
43
+ return this.#ready;
44
+ }
45
+
46
+ get lastRefreshSucceeded() {
47
+ return this.#lastRefreshSucceeded;
48
+ }
49
+
50
+ has(file: string) {
51
+ return this.#files.has(path.resolve(file));
52
+ }
53
+
54
+ /**
55
+ * Stamp every file the backend runs, so a caller can ask later what moved.
56
+ *
57
+ * Taken when the builder goes away and compared when its replacement is up, because nothing watches
58
+ * the tree in between: the departing builder's watcher left with it, and the replacement's index
59
+ * primes from the disk it finds, so an edit that lands in the gap is *baseline* to it and is never
60
+ * reported at all. The client half of such an edit is rescued by the replacement's boot build; the
61
+ * backend half is a server left running code that no longer exists, with nothing on screen to say so.
62
+ *
63
+ * One `stat` per graph file, against a gap that costs a whole boot build anyway.
64
+ */
65
+ async fingerprint(): Promise<SourceFingerprints> {
66
+ const stamps = await Promise.all(
67
+ [...this.#files].map(async (file) => {
68
+ const stats = await stat(file).catch(() => null);
69
+ return [file, stats ? `${Math.round(stats.mtimeMs)}:${stats.size}` : "(gone)"] as const;
70
+ }),
71
+ );
72
+ return new Map(stamps);
73
+ }
74
+
75
+ async refresh(): Promise<boolean> {
76
+ try {
77
+ const files = await this.#build();
78
+ this.#files = files;
79
+ this.#ready = true;
80
+ this.#lastRefreshSucceeded = true;
81
+ this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
82
+ return true;
83
+ } catch (err) {
84
+ this.#ready = this.#files.size > 0;
85
+ this.#lastRefreshSucceeded = false;
86
+ this.#logger.warn(
87
+ `[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
88
+ );
89
+ return this.#ready;
90
+ }
91
+ }
92
+
93
+ async #build(): Promise<Set<string>> {
94
+ const roots = await this.#entrypoints();
95
+ const files = new Set<string>();
96
+ const queue = [...roots];
97
+ const workspaceRoot = path.resolve(this.#app.workspace.workspaceRoot);
98
+
99
+ while (queue.length > 0) {
100
+ const current = path.resolve(queue.pop() as string);
101
+ if (files.has(current)) continue;
102
+ if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
103
+ const imports = await this.#importsOf(current);
104
+ if (!imports) continue;
105
+
106
+ files.add(current);
107
+ const importerDir = path.dirname(current);
108
+ for (const imp of imports) {
109
+ if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
110
+ const resolved = this.#resolve(imp.path, importerDir);
111
+ if (!resolved || files.has(resolved)) continue;
112
+ queue.push(resolved);
113
+ }
114
+ }
115
+ // Files that dropped out of the graph would otherwise be cached for the life of the dev session.
116
+ for (const cached of this.#scanCache.keys()) if (!files.has(cached)) this.#scanCache.delete(cached);
117
+ return files;
118
+ }
119
+
120
+ /** Null when the file is gone, which is the existence check the walk used to make separately. */
121
+ async #importsOf(file: string): Promise<Bun.Import[] | null> {
122
+ const stats = await stat(file).catch(() => null);
123
+ if (!stats?.isFile()) return null;
124
+ const mtimeMs = Math.round(stats.mtimeMs);
125
+ const cached = this.#scanCache.get(file);
126
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === stats.size) return cached.specifiers;
127
+ const specifiers = this.#scanImports(file, await Bun.file(file).text());
128
+ this.#scanCache.set(file, { mtimeMs, size: stats.size, specifiers });
129
+ return specifiers;
130
+ }
131
+
132
+ async #entrypoints(): Promise<string[]> {
133
+ const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
134
+ const existing: string[] = [];
135
+ for (const root of roots) {
136
+ const abs = path.resolve(root);
137
+ if (await Bun.file(abs).exists()) existing.push(abs);
138
+ }
139
+ return existing;
140
+ }
141
+
142
+ #resolve(specifier: string, importerDir: string): string | null {
143
+ try {
144
+ const resolved = Bun.resolveSync(specifier, importerDir);
145
+ if (!path.isAbsolute(resolved)) return null;
146
+ if (!SOURCE_EXTS.has(path.extname(resolved).toLowerCase())) return null;
147
+ return path.resolve(resolved);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ #isWorkspaceSource(file: string, workspaceRoot: string): boolean {
154
+ const rel = path.relative(workspaceRoot, file);
155
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
156
+ if (rel.includes(`${path.sep}node_modules${path.sep}`) || rel.includes(`${path.sep}.akan${path.sep}`)) return false;
157
+ return SOURCE_EXTS.has(path.extname(file).toLowerCase());
158
+ }
159
+
160
+ #scanImports(file: string, source: string): Bun.Import[] {
161
+ const ext = path.extname(file).toLowerCase();
162
+ if (ext === ".tsx") return this.#tsxTranspiler.scanImports(source);
163
+ if (ext === ".jsx") return this.#jsxTranspiler.scanImports(source);
164
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return this.#jsTranspiler.scanImports(source);
165
+ return this.#tsTranspiler.scanImports(source);
166
+ }
167
+ }