@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.80
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/fleetConfig.ts +73 -0
- package/fleetGuard.test.ts +121 -0
- package/fleetGuard.ts +157 -0
- package/fleetSpoke.test.ts +390 -0
- package/fleetSpoke.ts +719 -0
- 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 +151 -0
- package/slicePlanner.ts +157 -0
- package/ssrScanner.test.ts +301 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @akanjs/devkit
|
|
2
2
|
|
|
3
|
+
## 2.4.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [6d58c7e]
|
|
8
|
+
- Updated dependencies [11aa655]
|
|
9
|
+
- Updated dependencies [25d5b15]
|
|
10
|
+
- Updated dependencies [11aa655]
|
|
11
|
+
- Updated dependencies [11aa655]
|
|
12
|
+
- Updated dependencies [25d5b15]
|
|
13
|
+
- Updated dependencies [42cf7a2]
|
|
14
|
+
- Updated dependencies [25d5b15]
|
|
15
|
+
- Updated dependencies [04cb46d]
|
|
16
|
+
- akanjs@2.4.2
|
|
17
|
+
|
|
3
18
|
## 2.4.1
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/README.ko.md
CHANGED
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small cont
|
|
|
60
60
|
|
|
61
61
|
## Requirements
|
|
62
62
|
|
|
63
|
-
- [Bun](https://bun.sh) `>=1.
|
|
63
|
+
- [Bun](https://bun.sh) `>=1.4.0`
|
|
64
64
|
- TypeScript
|
|
65
65
|
- Optional peers are only needed for the features that use them, such as Capacitor integration.
|
|
66
66
|
|
package/agentsIndex.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
renderRecipeEntries,
|
|
11
11
|
renderScopeAgentBlock,
|
|
12
12
|
renderScopeAgentsMd,
|
|
13
|
+
renderScopeClaudeMd,
|
|
13
14
|
upsertAgentBlock,
|
|
14
15
|
} from "./agentsIndex";
|
|
15
16
|
import type { RecipeInfo } from "./recipeScanner";
|
|
@@ -48,6 +49,15 @@ describe("renderScopeAgentBlock", () => {
|
|
|
48
49
|
});
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
describe("renderScopeClaudeMd", () => {
|
|
53
|
+
test("points at AGENTS.md and restates only the comment rule", () => {
|
|
54
|
+
const claude = renderScopeClaudeMd({ type: "app", name: "minimal" });
|
|
55
|
+
expect(claude).toContain("@AGENTS.md");
|
|
56
|
+
expect(claude).toContain("## Comments — Overrides Your Default");
|
|
57
|
+
expect(claude).not.toContain("## Recipes In Scope");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
51
61
|
describe("upsertAgentBlock / extractAgentBlock", () => {
|
|
52
62
|
test("round-trips: fresh file → replace block → extract equals block", () => {
|
|
53
63
|
const fresh = renderScopeAgentsMd({ type: "app", name: "minimal" }, "OLD");
|
package/agentsIndex.ts
CHANGED
|
@@ -19,6 +19,32 @@ const loadRecipeScanner = () => (recipeScannerLoad ??= import("./recipeScanner")
|
|
|
19
19
|
export const AGENT_BLOCK_START = "<!-- akan:agent:start -->";
|
|
20
20
|
export const AGENT_BLOCK_END = "<!-- akan:agent:end -->";
|
|
21
21
|
|
|
22
|
+
const AGENT_VERSION_PREFIX = "<!-- akan:agent:version ";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The generating release, stamped into the workspace block. Nothing re-runs `akan agent install` on its own, so
|
|
26
|
+
* without a stamp a workspace carrying conventions from four releases ago is indistinguishable from a current one —
|
|
27
|
+
* `akan doctor` compares this against the installed devkit and says so.
|
|
28
|
+
*/
|
|
29
|
+
export const stampBlockVersion = (block: string, version: string): string =>
|
|
30
|
+
`${AGENT_VERSION_PREFIX}${version} -->\n\n${block}`;
|
|
31
|
+
|
|
32
|
+
/** The stamped version of a committed AGENTS.md, or null when it predates stamping or carries no block. */
|
|
33
|
+
export const extractBlockVersion = (content: string): string | null =>
|
|
34
|
+
content.match(/<!-- akan:agent:version ([^\s]+) -->/)?.[1] ?? null;
|
|
35
|
+
|
|
36
|
+
/** The running `@akanjs/devkit` version; null when its package.json is unreadable, which must not fail a doctor run. */
|
|
37
|
+
export const readDevkitVersion = async (): Promise<string | null> => {
|
|
38
|
+
const { readFile } = await import("node:fs/promises");
|
|
39
|
+
const { getDirname } = await import("./getDirname");
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(`${getDirname(import.meta.url)}/package.json`, "utf-8");
|
|
42
|
+
return (JSON.parse(raw) as { version?: string }).version ?? null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
22
48
|
export interface AgentsIndexScope {
|
|
23
49
|
type: "app" | "lib";
|
|
24
50
|
name: string;
|
|
@@ -139,8 +165,28 @@ ${block}
|
|
|
139
165
|
${AGENT_BLOCK_END}
|
|
140
166
|
`;
|
|
141
167
|
|
|
142
|
-
|
|
168
|
+
export const CLAUDE_COMMENT_RULE = `## Comments — Overrides Your Default
|
|
169
|
+
|
|
170
|
+
Write **no comments** unless the comment passes the test below. This is the rule agents break most often here, so it
|
|
171
|
+
is repeated outside the guide: a diff that adds a comment the test rejects is a diff to redo.
|
|
172
|
+
|
|
173
|
+
Before typing \`//\`, \`/*\`, or a doc block, ask — **does this sentence carry a fact that is nowhere in the code?**
|
|
174
|
+
|
|
175
|
+
- Restates the identifier, the signature, or the line under it → delete it.
|
|
176
|
+
- Labels a section (\`// helpers\`, \`// state\`) or narrates a step (\`// fetch the user\`, \`// then save\`) → delete it.
|
|
177
|
+
- JSDoc on an ordinary function, or a why/how preamble on ordinary logic → delete it.
|
|
178
|
+
- Explains the edit you just made, for whoever reads the diff → say it in your reply, not in the file.
|
|
179
|
+
- Names a vendor or protocol quirk, an infrastructure constraint, a library gotcha, security reasoning, a math
|
|
180
|
+
derivation, a domain field's business meaning, a state transition, or why an obvious alternative was rejected →
|
|
181
|
+
keep it, one line.
|
|
182
|
+
|
|
183
|
+
That keep-list is exact — \`Comments\` in the guide is the full version. "It aids readability" and "this logic is
|
|
184
|
+
subtle" are not on it: rename or split the code instead. When you edit an existing file, match its density; if the
|
|
185
|
+
surrounding code carries none, your diff carries none.`;
|
|
186
|
+
|
|
143
187
|
export const renderScopeClaudeMd = (scope: AgentsIndexScope): string => `# ${scope.name} — Claude Code Guide
|
|
144
188
|
|
|
145
189
|
@AGENTS.md
|
|
190
|
+
|
|
191
|
+
${CLAUDE_COMMENT_RULE}
|
|
146
192
|
`;
|
package/aiEditor.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { FileContent } from "./types";
|
|
|
19
19
|
|
|
20
20
|
const MAX_ASK_TRY = 300;
|
|
21
21
|
|
|
22
|
-
const deepSeekLlmModels = ["deepseek-
|
|
22
|
+
const deepSeekLlmModels = ["deepseek-v4-flash", "deepseek-v4-pro"] as const;
|
|
23
23
|
|
|
24
24
|
const openAiLlmModels = ["gpt-5.5"] as const;
|
|
25
25
|
|
|
@@ -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
|
+
}
|