@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +70 -1
- package/artifact/implicitRootLayout.ts +41 -14
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +28 -1
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +135 -7
- package/executors.ts +115 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +63 -10
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.test.ts +75 -2
- package/frontendBuild/fontOptimizer.ts +98 -27
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +124 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +13 -19
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +80 -142
- package/qualityScanner.ts +89 -31
- package/recipeScanner.ts +4 -1
- package/routeSourceValidator.test.ts +63 -0
- package/routeSourceValidator.ts +123 -16
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +105 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/mcpScanner.ts +0 -217
- package/src/capacitorApp.ts +0 -282
- package/storeScanner.ts +0 -173
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/routeSourceValidator.ts
CHANGED
|
@@ -3,10 +3,27 @@ import ts from "typescript";
|
|
|
3
3
|
|
|
4
4
|
/** What the build needs out of a route module without evaluating it. */
|
|
5
5
|
export interface RouteSourceInfo {
|
|
6
|
-
/** `pageConfig.devOnly === true`, read straight off the AST. */
|
|
6
|
+
/** `pageConfig.devOnly === true`, or `.config({ devOnly: true })` on a chain, read straight off the AST. */
|
|
7
7
|
devOnly: boolean;
|
|
8
|
+
/** Present when the default export is a `page()` / `layout()` / `rootLayout()` chain. */
|
|
9
|
+
chain?: RouteSourceChain;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
export interface RouteSourceChain {
|
|
13
|
+
kind: "page" | "layout" | "rootLayout";
|
|
14
|
+
/** The `.param()` names, in chain order. */
|
|
15
|
+
params: string[];
|
|
16
|
+
/** The `.prompt()` name, when the page publishes itself as an MCP prompt. */
|
|
17
|
+
prompt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ChainStage {
|
|
21
|
+
name: string;
|
|
22
|
+
args: ts.NodeArray<ts.Expression>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const chainRoots = new Set(["page", "layout", "rootLayout"]);
|
|
26
|
+
|
|
10
27
|
/**
|
|
11
28
|
* Static enforcement of the `page/` route conventions, split out of `executors.ts` so that importing
|
|
12
29
|
* an executor does not pull `typescript` (+65MB resident) into the module graph. Both validators need
|
|
@@ -21,7 +38,7 @@ export class RouteSourceValidator {
|
|
|
21
38
|
source: string,
|
|
22
39
|
filePath: string,
|
|
23
40
|
kind: "page" | "layout",
|
|
24
|
-
options: { rootLayout?: boolean } = {},
|
|
41
|
+
options: { rootLayout?: boolean; pattern?: string } = {},
|
|
25
42
|
): RouteSourceInfo {
|
|
26
43
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
27
44
|
const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
|
|
@@ -81,7 +98,91 @@ export class RouteSourceValidator {
|
|
|
81
98
|
if (exported.has("metadata") && exported.has("generateMetadata")) {
|
|
82
99
|
throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
|
|
83
100
|
}
|
|
84
|
-
|
|
101
|
+
const chain = RouteSourceValidator.#readChain(sourceFile, filePath);
|
|
102
|
+
if (!chain) return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
|
|
103
|
+
const named = [...exported].filter((name) => name !== "default");
|
|
104
|
+
if (named.length)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[route-convention] ${filePath} exports ${named.join(", ")} beside its ${chain.kind}() chain — every route setting is a stage of the chain`,
|
|
107
|
+
);
|
|
108
|
+
if (kind === "page" && chain.kind !== "page")
|
|
109
|
+
throw new Error(`[route-convention] ${filePath} is a page file but exports ${chain.kind}()`);
|
|
110
|
+
if (kind === "layout" && chain.kind === "page")
|
|
111
|
+
throw new Error(`[route-convention] ${filePath} is a layout file but exports page()`);
|
|
112
|
+
if (options.pattern) RouteSourceValidator.#assertChainParams(chain, options.pattern, filePath);
|
|
113
|
+
return {
|
|
114
|
+
devOnly: chain.devOnly,
|
|
115
|
+
chain: { kind: chain.kind, params: chain.params, ...(chain.prompt ? { prompt: chain.prompt } : {}) },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The default export as a `page()` / `layout()` / `rootLayout()` chain, or null when the module is the legacy
|
|
121
|
+
* shape. Read off the source for the same reason `devOnly` is: the build enumerates routes without importing
|
|
122
|
+
* them, and `akan sync` should name a `[projectId]` folder whose page declares no `.param("projectId")` before
|
|
123
|
+
* the first request does.
|
|
124
|
+
*/
|
|
125
|
+
static #readChain(sourceFile: ts.SourceFile, filePath: string) {
|
|
126
|
+
for (const statement of sourceFile.statements) {
|
|
127
|
+
if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
|
|
128
|
+
let node = RouteSourceValidator.#unwrapExpression(statement.expression);
|
|
129
|
+
const stages: ChainStage[] = [];
|
|
130
|
+
while (node && ts.isCallExpression(node)) {
|
|
131
|
+
const callee = node.expression;
|
|
132
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
133
|
+
stages.unshift({ name: callee.name.text, args: node.arguments });
|
|
134
|
+
node = callee.expression;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (ts.isIdentifier(callee) && chainRoots.has(callee.text))
|
|
138
|
+
return RouteSourceValidator.#chainOf(callee.text as RouteSourceChain["kind"], stages, filePath);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
|
|
147
|
+
const params: string[] = [];
|
|
148
|
+
let prompt: string | undefined;
|
|
149
|
+
let devOnly = false;
|
|
150
|
+
const literal = (stage: ChainStage) => {
|
|
151
|
+
const first = stage.args[0];
|
|
152
|
+
if (!first || !ts.isStringLiteralLike(first))
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[route-convention] .${stage.name}() takes a string literal first in ${filePath} — the build reads it without evaluating the module`,
|
|
155
|
+
);
|
|
156
|
+
return first.text;
|
|
157
|
+
};
|
|
158
|
+
for (const stage of stages) {
|
|
159
|
+
if (stage.name === "param") params.push(literal(stage));
|
|
160
|
+
else if (stage.name === "prompt") prompt = literal(stage);
|
|
161
|
+
else if (stage.name === "config")
|
|
162
|
+
devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
|
|
163
|
+
}
|
|
164
|
+
return { kind, params, prompt, devOnly };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
|
|
168
|
+
static #assertChainParams(chain: { kind: string; params: string[] }, pattern: string, filePath: string) {
|
|
169
|
+
const inPath = pattern
|
|
170
|
+
.split("/")
|
|
171
|
+
.filter((part) => part.startsWith(":"))
|
|
172
|
+
.map((part) => part.slice(1))
|
|
173
|
+
.filter((name) => name !== "lang");
|
|
174
|
+
if (chain.params.includes("lang"))
|
|
175
|
+
throw new Error(`[route-convention] ${filePath} declares .param("lang"), which every route receives undeclared`);
|
|
176
|
+
const unknown = chain.params.find((name) => !inPath.includes(name));
|
|
177
|
+
if (unknown)
|
|
178
|
+
throw new Error(
|
|
179
|
+
`[route-convention] ${filePath} declares .param("${unknown}") but no [${unknown}] segment is in its path`,
|
|
180
|
+
);
|
|
181
|
+
const undeclared = inPath.find((name) => !chain.params.includes(name));
|
|
182
|
+
if (chain.kind === "page" && undeclared)
|
|
183
|
+
throw new Error(
|
|
184
|
+
`[route-convention] ${filePath} sits under [${undeclared}] but declares no .param("${undeclared}") — a page reads only what it declares`,
|
|
185
|
+
);
|
|
85
186
|
}
|
|
86
187
|
|
|
87
188
|
/**
|
|
@@ -98,24 +199,30 @@ export class RouteSourceValidator {
|
|
|
98
199
|
for (const declaration of statement.declarationList.declarations) {
|
|
99
200
|
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "pageConfig") continue;
|
|
100
201
|
const initializer = RouteSourceValidator.#unwrapExpression(declaration.initializer);
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
if (!ts.isPropertyAssignment(property)) continue;
|
|
104
|
-
const name = property.name;
|
|
105
|
-
const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null;
|
|
106
|
-
if (key !== "devOnly") continue;
|
|
107
|
-
const value = RouteSourceValidator.#unwrapExpression(property.initializer);
|
|
108
|
-
if (value?.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
109
|
-
if (value?.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
110
|
-
throw new Error(
|
|
111
|
-
`[route-convention] pageConfig.devOnly must be a literal true or false in ${filePath} — the build reads it without evaluating the module`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
202
|
+
if (initializer && ts.isObjectLiteralExpression(initializer))
|
|
203
|
+
return RouteSourceValidator.#devOnlyOf(initializer, filePath);
|
|
114
204
|
}
|
|
115
205
|
}
|
|
116
206
|
return false;
|
|
117
207
|
}
|
|
118
208
|
|
|
209
|
+
static #devOnlyOf(config: ts.Expression | undefined, filePath: string): boolean {
|
|
210
|
+
if (!config || !ts.isObjectLiteralExpression(config)) return false;
|
|
211
|
+
for (const property of config.properties) {
|
|
212
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
213
|
+
const name = property.name;
|
|
214
|
+
const key = ts.isIdentifier(name) ? name.text : ts.isStringLiteral(name) ? name.text : null;
|
|
215
|
+
if (key !== "devOnly") continue;
|
|
216
|
+
const value = RouteSourceValidator.#unwrapExpression(property.initializer);
|
|
217
|
+
if (value?.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
218
|
+
if (value?.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[route-convention] devOnly must be a literal true or false in ${filePath} — the build reads it without evaluating the module`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
119
226
|
static #unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
|
|
120
227
|
let current = expression;
|
|
121
228
|
while (
|
package/scanInfo.ts
CHANGED
|
@@ -9,9 +9,9 @@ import type {
|
|
|
9
9
|
PkgScanResult,
|
|
10
10
|
ScanResult,
|
|
11
11
|
} from "./akanConfig";
|
|
12
|
-
|
|
12
|
+
import { AkanAppConfig } from "./akanConfig";
|
|
13
13
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
14
|
-
import {
|
|
14
|
+
import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
15
15
|
|
|
16
16
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
17
17
|
type ScalarFileType = (typeof scalarFileTypes)[number];
|
|
@@ -57,7 +57,6 @@ const moduleUiFileTypes = {
|
|
|
57
57
|
scalar: new Set(["Template", "Unit"]),
|
|
58
58
|
} satisfies Record<ModuleKind, Set<string>>;
|
|
59
59
|
const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
|
|
60
|
-
const rootSignalTestFilePattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
|
|
61
60
|
|
|
62
61
|
// The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
|
|
63
62
|
// load it on demand rather than through the module graph of every process that imports scan info.
|
|
@@ -65,8 +64,6 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
|
|
|
65
64
|
(await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
|
|
66
65
|
|
|
67
66
|
const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
|
|
68
|
-
const isAllowedLibRootFile = (filename: string) =>
|
|
69
|
-
libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
70
67
|
const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
|
|
71
68
|
path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
|
|
72
69
|
async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
|
|
@@ -85,24 +82,29 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
85
82
|
violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
|
|
86
83
|
};
|
|
87
84
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
});
|
|
100
|
-
|
|
85
|
+
const allowedRootFiles: ReadonlySet<string> = rootAllowedFiles[exec.type];
|
|
86
|
+
const allowedRootDirs: ReadonlySet<string> = rootAllowedDirs[exec.type];
|
|
87
|
+
const { files, dirs } = await exec.getFilesAndDirs(".");
|
|
88
|
+
files
|
|
89
|
+
.filter((filename) => !allowedRootFiles.has(filename))
|
|
90
|
+
.forEach((filename) => {
|
|
91
|
+
addViolation(filename, `unsupported ${exec.type} root file`);
|
|
92
|
+
});
|
|
93
|
+
dirs
|
|
94
|
+
.filter((dirname) => !allowedRootDirs.has(dirname))
|
|
95
|
+
.forEach((dirname) => {
|
|
96
|
+
addViolation(dirname, `unsupported ${exec.type} root folder`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
//* An app validates its page tree in `getPageKeys`, which a lib has no equivalent of — so a lib's own
|
|
100
|
+
//* routes went unchecked until whichever app opted into them with `syncPageLibs` blew up on the sync.
|
|
101
|
+
if (exec.type === "lib")
|
|
102
|
+
for (const { relativePath, reason } of await exec.getPageConventionViolations()) addViolation(relativePath, reason);
|
|
101
103
|
|
|
102
104
|
libRoot.files
|
|
103
|
-
.filter((filename) => !
|
|
105
|
+
.filter((filename) => !isAllowedLibFacetRootFile(filename))
|
|
104
106
|
.forEach((filename) => {
|
|
105
|
-
addViolation(path.join("lib", filename), "unsupported lib root file");
|
|
107
|
+
addViolation(path.join("lib", filename), "unsupported lib facet root file");
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
libRoot.dirs
|
|
@@ -284,12 +286,11 @@ class ScanInfo {
|
|
|
284
286
|
}),
|
|
285
287
|
]);
|
|
286
288
|
const routes = exec.type === "lib" ? [] : await (exec as AppExecutor).getPageKeys();
|
|
287
|
-
const
|
|
289
|
+
const common = {
|
|
288
290
|
name: exec.name,
|
|
289
291
|
type: exec.type,
|
|
290
292
|
repoName: exec.workspace.repoName,
|
|
291
293
|
serveDomain: WorkspaceExecutor.getBaseDevEnv(path.join(exec.workspace.workspaceRoot, ".env")).serveDomain,
|
|
292
|
-
akanConfig,
|
|
293
294
|
files,
|
|
294
295
|
libDeps,
|
|
295
296
|
pkgDeps,
|
|
@@ -297,6 +298,11 @@ class ScanInfo {
|
|
|
297
298
|
devDependencies: npmDevDeps.filter((dep) => !isAkanFrameworkDependency(dep)),
|
|
298
299
|
routes,
|
|
299
300
|
};
|
|
301
|
+
//? `exec.type` and the resolved config class are correlated, which the union alone cannot express.
|
|
302
|
+
const scanResult: AppScanResult | LibScanResult =
|
|
303
|
+
akanConfig instanceof AkanAppConfig
|
|
304
|
+
? ({ ...common, akanConfig } satisfies AppScanResult)
|
|
305
|
+
: ({ ...common, akanConfig } satisfies LibScanResult);
|
|
300
306
|
return scanResult;
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -461,7 +467,7 @@ export class LibInfo extends ScanInfo {
|
|
|
461
467
|
LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
|
|
462
468
|
}),
|
|
463
469
|
);
|
|
464
|
-
const libInfo = new LibInfo(exec, scanResult);
|
|
470
|
+
const libInfo = new LibInfo(exec, scanResult as LibScanResult);
|
|
465
471
|
LibInfo.libInfos.set(exec.name, libInfo);
|
|
466
472
|
LibInfo.#sortedLibIndices = null;
|
|
467
473
|
return libInfo;
|
|
@@ -512,7 +518,7 @@ export class LibInfo extends ScanInfo {
|
|
|
512
518
|
export class PkgInfo {
|
|
513
519
|
readonly exec: PkgExecutor;
|
|
514
520
|
readonly name: string;
|
|
515
|
-
|
|
521
|
+
#scanResult: PkgScanResult;
|
|
516
522
|
|
|
517
523
|
static async scanExecutor(exec: PkgExecutor) {
|
|
518
524
|
const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
|
|
@@ -554,10 +560,10 @@ export class PkgInfo {
|
|
|
554
560
|
constructor(exec: PkgExecutor, scanResult: PkgScanResult) {
|
|
555
561
|
this.exec = exec;
|
|
556
562
|
this.name = exec.name;
|
|
557
|
-
this
|
|
563
|
+
this.#scanResult = scanResult;
|
|
558
564
|
}
|
|
559
565
|
getScanResult() {
|
|
560
|
-
return this
|
|
566
|
+
return this.#scanResult;
|
|
561
567
|
}
|
|
562
568
|
}
|
|
563
569
|
|
package/semver.test.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compareSemver } from "./semver";
|
|
3
|
+
|
|
4
|
+
describe("compareSemver", () => {
|
|
5
|
+
test("orders plain versions", () => {
|
|
6
|
+
expect(compareSemver("1.4.0", "1.3.13")).toBe(1);
|
|
7
|
+
expect(compareSemver("1.3.13", "1.4.0")).toBe(-1);
|
|
8
|
+
expect(compareSemver("19.2.7", "19.2.7")).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("compares the version behind a range operator, not the range", () => {
|
|
12
|
+
expect(compareSemver("^3.1049.0", "^3.721.0")).toBe(1);
|
|
13
|
+
expect(compareSemver("~1.2.0", "^1.10.0")).toBe(-1);
|
|
14
|
+
expect(compareSemver(">=1.3.13", "1.4.0")).toBe(-1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("sorts a prerelease below its release", () => {
|
|
18
|
+
expect(compareSemver("1.0.0-alpha", "1.0.0")).toBe(-1);
|
|
19
|
+
expect(compareSemver("0.0.0-experimental-603e6108-20241029", "0.0.0")).toBe(-1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("falls back to numeric comparison for specs that are not versions", () => {
|
|
23
|
+
expect(compareSemver("workspace:*", "0.0.0")).toBe(0);
|
|
24
|
+
expect(compareSemver("workspace:*", "1.0.0")).toBe(-1);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/semver.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `Bun.semver.order` compares range strings loosely — `^3.1049.0` vs `^3.721.0` answers 0 — so the
|
|
2
|
+
// operator prefix has to come off before it sees the version.
|
|
3
|
+
const stripRangeOperator = (version: string) => version.replace(/^[\s^~>=<v]+/, "").trim();
|
|
4
|
+
|
|
5
|
+
const parseVersion = (version: string): number[] => {
|
|
6
|
+
return version
|
|
7
|
+
.replace(/^[^\d]*/, "")
|
|
8
|
+
.split(/[.-]/)
|
|
9
|
+
.map((part) => Number.parseInt(part, 10))
|
|
10
|
+
.map((part) => (Number.isFinite(part) ? part : 0));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const compareNumeric = (a: string, b: string): number => {
|
|
14
|
+
const left = parseVersion(a);
|
|
15
|
+
const right = parseVersion(b);
|
|
16
|
+
const length = Math.max(left.length, right.length);
|
|
17
|
+
for (let i = 0; i < length; i++) {
|
|
18
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
19
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const compareSemver = (a: string, b: string): number => {
|
|
25
|
+
try {
|
|
26
|
+
return Bun.semver.order(stripRangeOperator(a), stripRangeOperator(b));
|
|
27
|
+
} catch {
|
|
28
|
+
// Not every dependency spec is a version — `workspace:*`, a git URL, a tarball path.
|
|
29
|
+
return compareNumeric(a, b);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
|
|
6
|
+
import { formatSlicePlan, SlicePlanner } from "./slicePlanner";
|
|
7
|
+
import type { PackageJson } from "./types";
|
|
8
|
+
|
|
9
|
+
const tempRoots: string[] = [];
|
|
10
|
+
const originalEnv = { ...process.env };
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
process.env = { ...originalEnv };
|
|
14
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
|
|
15
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
16
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
process.env = { ...originalEnv };
|
|
21
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const write = async (filePath: string, content: string) => {
|
|
25
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
26
|
+
await writeFile(filePath, content);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const gitignore = [
|
|
30
|
+
"node_modules",
|
|
31
|
+
"apps/*/lib/cnst.ts",
|
|
32
|
+
"libs/*/common/index.ts",
|
|
33
|
+
"**/env.server.local.ts",
|
|
34
|
+
"**/akan.app.json",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
// `AppExecutor.from` and `AppInfo.fromExecutor` both memoise by name, so each fixture needs a fresh one.
|
|
38
|
+
const makeWorkspace = async (appName: string, libName: string) => {
|
|
39
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-slice-"));
|
|
40
|
+
tempRoots.push(root);
|
|
41
|
+
const rootPackageJson: PackageJson = {
|
|
42
|
+
name: "workspace",
|
|
43
|
+
version: "0.0.1",
|
|
44
|
+
description: "workspace",
|
|
45
|
+
workspaces: ["pkgs/*"],
|
|
46
|
+
dependencies: { lodash: "4.0.0", "unused-dep": "1.0.0", ioredis: "5.0.0" },
|
|
47
|
+
devDependencies: { typescript: "6.0.0", "@types/lodash": "4.1.0", "@types/qrcode": "1.0.0" },
|
|
48
|
+
patchedDependencies: {
|
|
49
|
+
"lodash@4.0.0": "patches/lodash@4.0.0.patch",
|
|
50
|
+
"unused-dep@1.0.0": "patches/unused-dep@1.0.0.patch",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
await write(path.join(root, "package.json"), `${JSON.stringify(rootPackageJson, null, 2)}\n`);
|
|
54
|
+
await write(path.join(root, ".gitignore"), `${gitignore}\n`);
|
|
55
|
+
await write(path.join(root, "biome.json"), "{}\n");
|
|
56
|
+
//? The framework's peers are declared by akanjs, not by any import, so the planner reads them here.
|
|
57
|
+
await write(
|
|
58
|
+
path.join(root, "node_modules/akanjs/package.json"),
|
|
59
|
+
'{ "name": "akanjs", "version": "3.0.0", "peerDependencies": { "react": "19.0.0", "ioredis": "5.0.0" } }\n',
|
|
60
|
+
);
|
|
61
|
+
await write(path.join(root, "patches/lodash@4.0.0.patch"), "");
|
|
62
|
+
await write(path.join(root, "patches/unused-dep@1.0.0.patch"), "");
|
|
63
|
+
|
|
64
|
+
await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
|
|
65
|
+
await write(path.join(root, `apps/${appName}/tsconfig.json`), "{}\n");
|
|
66
|
+
await write(
|
|
67
|
+
path.join(root, `apps/${appName}/lib/task/task.constant.ts`),
|
|
68
|
+
[
|
|
69
|
+
`import { helper } from "@libs/${libName}/common";`,
|
|
70
|
+
'import lodash from "lodash";',
|
|
71
|
+
"",
|
|
72
|
+
"export { helper, lodash };",
|
|
73
|
+
"",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
);
|
|
76
|
+
await write(path.join(root, `apps/${appName}/lib/cnst.ts`), "export {};\n");
|
|
77
|
+
await write(path.join(root, `apps/${appName}/env/env.server.local.ts`), "export const env = {};\n");
|
|
78
|
+
|
|
79
|
+
await write(path.join(root, `libs/${libName}/akan.config.ts`), "export default {};\n");
|
|
80
|
+
await write(path.join(root, `libs/${libName}/tsconfig.json`), "{}\n");
|
|
81
|
+
await mkdir(path.join(root, `libs/${libName}/lib`), { recursive: true });
|
|
82
|
+
await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
|
|
83
|
+
await write(path.join(root, `libs/${libName}/common/index.ts`), 'export * from "./helper";\n');
|
|
84
|
+
|
|
85
|
+
await write(path.join(root, "pkgs/in-tree/package.json"), '{ "name": "in-tree" }\n');
|
|
86
|
+
|
|
87
|
+
const git = new Executor("fixture", root);
|
|
88
|
+
await git.spawn("git", ["init", "--quiet"]);
|
|
89
|
+
await git.spawn("git", ["add", "-A"]);
|
|
90
|
+
await git.spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "fixture"]);
|
|
91
|
+
|
|
92
|
+
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${appName}` });
|
|
93
|
+
return { root, plan: async () => await new SlicePlanner(AppExecutor.from(workspace, appName)).plan() };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("SlicePlanner", () => {
|
|
97
|
+
test("resolves the lib closure and lists only git-tracked slice files", async () => {
|
|
98
|
+
const { plan } = await makeWorkspace("slice-app", "slice-lib");
|
|
99
|
+
const result = await plan();
|
|
100
|
+
|
|
101
|
+
expect(result.app).toBe("slice-app");
|
|
102
|
+
expect(result.libs).toEqual(["slice-lib"]);
|
|
103
|
+
expect(result.appFiles).toContain("apps/slice-app/lib/task/task.constant.ts");
|
|
104
|
+
expect(result.libFiles["slice-lib"]).toContain("libs/slice-lib/common/helper.ts");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("leaves out generated barrels, env values and every other workspace member", async () => {
|
|
108
|
+
const { plan } = await makeWorkspace("ignore-app", "ignore-lib");
|
|
109
|
+
const result = await plan();
|
|
110
|
+
|
|
111
|
+
expect(result.appFiles).not.toContain("apps/ignore-app/lib/cnst.ts");
|
|
112
|
+
expect(result.appFiles).not.toContain("apps/ignore-app/env/env.server.local.ts");
|
|
113
|
+
expect(result.libFiles["ignore-lib"]).not.toContain("libs/ignore-lib/common/index.ts");
|
|
114
|
+
expect(result.rootFiles).toEqual(expect.arrayContaining(["package.json", ".gitignore", "biome.json"]));
|
|
115
|
+
expect(result.rootFiles.filter((file) => file.startsWith("pkgs/"))).toEqual([]);
|
|
116
|
+
expect(result.rootFiles.filter((file) => /^(apps|libs)\//.test(file))).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("drops `workspaces` from the slice manifest and reports it", async () => {
|
|
120
|
+
const { plan } = await makeWorkspace("manifest-app", "manifest-lib");
|
|
121
|
+
const result = await plan();
|
|
122
|
+
|
|
123
|
+
expect(result.packageJson.workspaces).toBeUndefined();
|
|
124
|
+
expect(result.warnings.join("\n")).toContain("workspaces");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("prunes what the slice does not import, keeping the toolchain and the type packages", async () => {
|
|
128
|
+
const { plan } = await makeWorkspace("deps-app", "deps-lib");
|
|
129
|
+
const result = await plan();
|
|
130
|
+
|
|
131
|
+
expect(result.requiredDependencies).toContain("lodash");
|
|
132
|
+
//? `ioredis` is an akanjs peer: nothing imports it, and pruning it would break the server at runtime.
|
|
133
|
+
expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", ioredis: "5.0.0" });
|
|
134
|
+
//? Nothing imports `@types/lodash` by name, so only its companion keeps it.
|
|
135
|
+
expect(result.packageJson.devDependencies).toEqual({ typescript: "6.0.0", "@types/lodash": "4.1.0" });
|
|
136
|
+
expect(result.packageJson.patchedDependencies).toEqual({ "lodash@4.0.0": "patches/lodash@4.0.0.patch" });
|
|
137
|
+
expect(result.unusedDependencies).toEqual(["@types/qrcode", "unused-dep"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("carries the dependencies whole when there is no akanjs manifest to read the peers from", async () => {
|
|
141
|
+
const { root, plan } = await makeWorkspace("nopeer-app", "nopeer-lib");
|
|
142
|
+
await rm(path.join(root, "node_modules"), { recursive: true, force: true });
|
|
143
|
+
const result = await plan();
|
|
144
|
+
|
|
145
|
+
expect(result.unusedDependencies).toEqual([]);
|
|
146
|
+
expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", "unused-dep": "1.0.0", ioredis: "5.0.0" });
|
|
147
|
+
expect(result.warnings.join("\n")).toContain("carried whole");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("names a type package the way DefinitelyTyped does", () => {
|
|
151
|
+
expect(SlicePlanner.typesPackageOf("lodash")).toBe("@types/lodash");
|
|
152
|
+
expect(SlicePlanner.typesPackageOf("@capacitor/core")).toBe("@types/capacitor__core");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("warns about untracked files under the slice paths", async () => {
|
|
156
|
+
const { root, plan } = await makeWorkspace("untracked-app", "untracked-lib");
|
|
157
|
+
await write(path.join(root, "apps/untracked-app/lib/task/task.service.ts"), "export {};\n");
|
|
158
|
+
const result = await plan();
|
|
159
|
+
|
|
160
|
+
expect(result.warnings.join("\n")).toContain("apps/untracked-app/lib/task/task.service.ts");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("formatSlicePlan", () => {
|
|
165
|
+
test("collapses the workspace shell to its top-level entries", () => {
|
|
166
|
+
const text = formatSlicePlan({
|
|
167
|
+
app: "demo",
|
|
168
|
+
libs: ["util"],
|
|
169
|
+
appFiles: ["apps/demo/main.ts"],
|
|
170
|
+
libFiles: { util: ["libs/util/index.ts"] },
|
|
171
|
+
rootFiles: ["package.json", "infra/app/values/main.yaml", "infra/app/templates/app.yaml"],
|
|
172
|
+
packageJson: { name: "demo", version: "0.0.1", description: "demo" },
|
|
173
|
+
requiredDependencies: [],
|
|
174
|
+
unusedDependencies: [],
|
|
175
|
+
warnings: [],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(text).toContain("app: demo (apps/demo: 1 files)");
|
|
179
|
+
expect(text).toContain("3 files across infra, package.json");
|
|
180
|
+
expect(text).toContain("(none)");
|
|
181
|
+
});
|
|
182
|
+
});
|