@akanjs/devkit 3.1.2-rc.3 → 3.2.0-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 +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface FunctionSize {
|
|
4
|
+
name: string;
|
|
5
|
+
line: number;
|
|
6
|
+
lineCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ClassSize {
|
|
10
|
+
name: string;
|
|
11
|
+
line: number;
|
|
12
|
+
memberCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type FunctionNode =
|
|
16
|
+
| ts.FunctionDeclaration
|
|
17
|
+
| ts.MethodDeclaration
|
|
18
|
+
| ts.ConstructorDeclaration
|
|
19
|
+
| ts.GetAccessorDeclaration
|
|
20
|
+
| ts.SetAccessorDeclaration
|
|
21
|
+
| ts.FunctionExpression
|
|
22
|
+
| ts.ArrowFunction;
|
|
23
|
+
|
|
24
|
+
export class StructureScanner {
|
|
25
|
+
measure(sourceFile: ts.SourceFile): { functions: FunctionSize[]; classes: ClassSize[] } {
|
|
26
|
+
const functions: FunctionSize[] = [];
|
|
27
|
+
const classes: ClassSize[] = [];
|
|
28
|
+
const lineOf = (pos: number) => sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
|
|
29
|
+
const visit = (node: ts.Node, owners: string[]) => {
|
|
30
|
+
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
|
31
|
+
const name = StructureScanner.#classNameOf(node);
|
|
32
|
+
classes.push({
|
|
33
|
+
name: [...owners, name].join("."),
|
|
34
|
+
line: lineOf(node.getStart(sourceFile)),
|
|
35
|
+
memberCount: StructureScanner.#memberCountOf(node),
|
|
36
|
+
});
|
|
37
|
+
ts.forEachChild(node, (child) => visit(child, [...owners, name]));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (StructureScanner.#isFunction(node)) {
|
|
41
|
+
const name = StructureScanner.#functionNameOf(node);
|
|
42
|
+
const line = lineOf(node.getStart(sourceFile));
|
|
43
|
+
functions.push({
|
|
44
|
+
name: [...owners, name].join("."),
|
|
45
|
+
line,
|
|
46
|
+
lineCount: lineOf(node.getEnd()) - line + 1,
|
|
47
|
+
});
|
|
48
|
+
ts.forEachChild(node, (child) => visit(child, [...owners, name]));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
52
|
+
const name = StructureScanner.#boundNameOf(node);
|
|
53
|
+
ts.forEachChild(node, (child) => visit(child, name === "<anonymous>" ? owners : [...owners, name]));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
ts.forEachChild(node, (child) => visit(child, owners));
|
|
57
|
+
};
|
|
58
|
+
ts.forEachChild(sourceFile, (child) => visit(child, []));
|
|
59
|
+
return { functions, classes };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static #memberCountOf(node: ts.ClassDeclaration | ts.ClassExpression) {
|
|
63
|
+
const methods = new Set<string>();
|
|
64
|
+
return node.members.filter((member) => {
|
|
65
|
+
if (
|
|
66
|
+
ts.isSemicolonClassElement(member) ||
|
|
67
|
+
ts.isIndexSignatureDeclaration(member) ||
|
|
68
|
+
ts.isClassStaticBlockDeclaration(member)
|
|
69
|
+
)
|
|
70
|
+
return false;
|
|
71
|
+
if (!ts.isMethodDeclaration(member) && !ts.isConstructorDeclaration(member)) return true;
|
|
72
|
+
const isStatic = ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword);
|
|
73
|
+
const key = `${isStatic ? "static " : ""}${ts.isConstructorDeclaration(member) ? "constructor" : StructureScanner.#textOf(member.name)}`;
|
|
74
|
+
if (methods.has(key)) return false;
|
|
75
|
+
methods.add(key);
|
|
76
|
+
return true;
|
|
77
|
+
}).length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static #isFunction(node: ts.Node): node is FunctionNode {
|
|
81
|
+
return (
|
|
82
|
+
(ts.isFunctionDeclaration(node) ||
|
|
83
|
+
ts.isMethodDeclaration(node) ||
|
|
84
|
+
ts.isConstructorDeclaration(node) ||
|
|
85
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
86
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
87
|
+
ts.isFunctionExpression(node) ||
|
|
88
|
+
ts.isArrowFunction(node)) &&
|
|
89
|
+
node.body !== undefined
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static #functionNameOf(node: FunctionNode) {
|
|
94
|
+
if (ts.isConstructorDeclaration(node)) return "constructor";
|
|
95
|
+
if (ts.isGetAccessorDeclaration(node)) return `get ${StructureScanner.#textOf(node.name)}`;
|
|
96
|
+
if (ts.isSetAccessorDeclaration(node)) return `set ${StructureScanner.#textOf(node.name)}`;
|
|
97
|
+
if (node.name) return StructureScanner.#textOf(node.name);
|
|
98
|
+
return ts.isFunctionDeclaration(node) ? "default" : StructureScanner.#boundNameOf(node);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static #classNameOf(node: ts.ClassDeclaration | ts.ClassExpression) {
|
|
102
|
+
if (node.name) return node.name.text;
|
|
103
|
+
return ts.isClassDeclaration(node) ? "default" : StructureScanner.#boundNameOf(node);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static #boundNameOf(node: ts.Node) {
|
|
107
|
+
let current: ts.Node = StructureScanner.#outerOf(node);
|
|
108
|
+
let call: string | null = null;
|
|
109
|
+
while (ts.isCallExpression(current.parent) || ts.isNewExpression(current.parent)) {
|
|
110
|
+
const callee = current.parent.expression;
|
|
111
|
+
if (current === callee) break;
|
|
112
|
+
call ??= `${ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : "call"}()`;
|
|
113
|
+
current = StructureScanner.#outerOf(current.parent);
|
|
114
|
+
}
|
|
115
|
+
const { parent } = current;
|
|
116
|
+
if (
|
|
117
|
+
(ts.isVariableDeclaration(parent) ||
|
|
118
|
+
ts.isPropertyAssignment(parent) ||
|
|
119
|
+
ts.isPropertyDeclaration(parent) ||
|
|
120
|
+
ts.isParameter(parent) ||
|
|
121
|
+
ts.isBindingElement(parent)) &&
|
|
122
|
+
parent.name &&
|
|
123
|
+
!ts.isObjectBindingPattern(parent.name) &&
|
|
124
|
+
!ts.isArrayBindingPattern(parent.name)
|
|
125
|
+
)
|
|
126
|
+
return StructureScanner.#textOf(parent.name);
|
|
127
|
+
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
128
|
+
return ts.isPropertyAccessExpression(parent.left)
|
|
129
|
+
? parent.left.name.text
|
|
130
|
+
: ts.isIdentifier(parent.left)
|
|
131
|
+
? parent.left.text
|
|
132
|
+
: "assigned";
|
|
133
|
+
if (ts.isExportAssignment(parent)) return "default";
|
|
134
|
+
if (ts.isJsxExpression(parent))
|
|
135
|
+
return ts.isJsxAttribute(parent.parent)
|
|
136
|
+
? ts.isIdentifier(parent.parent.name)
|
|
137
|
+
? parent.parent.name.text
|
|
138
|
+
: parent.parent.name.getText()
|
|
139
|
+
: "children";
|
|
140
|
+
return call ?? "<anonymous>";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static #outerOf(node: ts.Node) {
|
|
144
|
+
let current = node;
|
|
145
|
+
while (
|
|
146
|
+
ts.isParenthesizedExpression(current.parent) ||
|
|
147
|
+
ts.isAsExpression(current.parent) ||
|
|
148
|
+
ts.isSatisfiesExpression(current.parent) ||
|
|
149
|
+
ts.isNonNullExpression(current.parent) ||
|
|
150
|
+
ts.isTypeAssertionExpression(current.parent) ||
|
|
151
|
+
(ts.isPropertyAccessExpression(current.parent) && current.parent.expression === current) ||
|
|
152
|
+
(ts.isCallExpression(current.parent) && current.parent.expression === current)
|
|
153
|
+
)
|
|
154
|
+
current = current.parent;
|
|
155
|
+
return current;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static #textOf(name: ts.PropertyName | ts.BindingName) {
|
|
159
|
+
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
160
|
+
if (ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
|
|
161
|
+
if (ts.isComputedPropertyName(name)) return `[${name.expression.getText()}]`;
|
|
162
|
+
return "<pattern>";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -5,6 +5,8 @@ import { parseNamedList } from "./moduleSyntax";
|
|
|
5
5
|
export interface BarrelExportTarget {
|
|
6
6
|
subpath: string;
|
|
7
7
|
originalName: string;
|
|
8
|
+
/** `export * as name from`: an import of the name binds the whole module at `subpath`. */
|
|
9
|
+
namespace?: boolean;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export type BarrelExportMap = Map<string, BarrelExportTarget>;
|
|
@@ -100,9 +102,14 @@ export class BarrelAnalyzer {
|
|
|
100
102
|
if (!isRelative(spec)) continue;
|
|
101
103
|
|
|
102
104
|
if (star) {
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
105
|
+
if (!nsAs) starSpecs.push(spec);
|
|
106
|
+
else if (authoritative.has(nsAs)) {
|
|
107
|
+
attributed.add(nsAs);
|
|
108
|
+
const targetAbs = await this.#resolveRel(absFile, spec);
|
|
109
|
+
const targetSubpath = targetAbs ? this.#subpathFor(pkg, targetAbs) : null;
|
|
110
|
+
if (targetSubpath && !map.has(nsAs))
|
|
111
|
+
map.set(nsAs, { subpath: targetSubpath, originalName: nsAs, namespace: true });
|
|
112
|
+
}
|
|
106
113
|
continue;
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { BunPlugin } from "bun";
|
|
3
3
|
import ts from "typescript";
|
|
4
|
+
import { StoreBootstrap } from "../artifact/storeBootstrap";
|
|
4
5
|
import type { App } from "../commandDecorators";
|
|
5
6
|
import { BarrelAnalyzer, type BarrelExportMap, type PackageEntry } from "./barrelAnalyzer";
|
|
6
7
|
import { loaderFor, type NamedItem, parseNamedList } from "./moduleSyntax";
|
|
@@ -101,8 +102,9 @@ export const createTsconfigPackageResolver = async (
|
|
|
101
102
|
if (!repl) continue;
|
|
102
103
|
const replPath = repl.endsWith("/*") ? repl.slice(0, -1) : repl;
|
|
103
104
|
const candidate = path.resolve(app.workspace.workspaceRoot, replPath + suffix);
|
|
104
|
-
// A sibling file (`apps/minimal/client.ts`)
|
|
105
|
-
|
|
105
|
+
// A sibling file (`apps/minimal/client.ts`), or the file a specifier names with its extension
|
|
106
|
+
// (`akanjs/ui/System/index.tsx`), is a facet barrel too: parent specifier, as above.
|
|
107
|
+
for (const ext of CANDIDATE_EXTS.includes(path.extname(candidate)) ? ["", ...CANDIDATE_EXTS] : CANDIDATE_EXTS) {
|
|
106
108
|
const file = `${candidate}${ext}`;
|
|
107
109
|
if (await Bun.file(file).exists()) {
|
|
108
110
|
const lastSlash = pkgName.lastIndexOf("/");
|
|
@@ -339,6 +341,7 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
339
341
|
|
|
340
342
|
const remaining: NamedItem[] = [];
|
|
341
343
|
const rewrites = new Map<string, NamedItem[]>();
|
|
344
|
+
const namespaceLines: string[] = [];
|
|
342
345
|
for (const item of clause.named) {
|
|
343
346
|
if (item.isType) {
|
|
344
347
|
remaining.push(item);
|
|
@@ -349,15 +352,19 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
349
352
|
remaining.push(item);
|
|
350
353
|
continue;
|
|
351
354
|
}
|
|
355
|
+
if (target.namespace) {
|
|
356
|
+
namespaceLines.push(`import * as ${item.local} from "${target.subpath}";`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
352
359
|
const list = rewrites.get(target.subpath) ?? [];
|
|
353
360
|
list.push({ imported: target.originalName, local: item.local, isType: false });
|
|
354
361
|
rewrites.set(target.subpath, list);
|
|
355
362
|
}
|
|
356
363
|
|
|
357
|
-
if (rewrites.size === 0) return null;
|
|
364
|
+
if (rewrites.size === 0 && namespaceLines.length === 0) return null;
|
|
358
365
|
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
//? An app or lib client barrel's import-time side effects are its client runtime and store, not its module UI.
|
|
367
|
+
const lines: string[] = [...(StoreBootstrap.ofClientBarrel(stmt.specifier, map) ?? [])];
|
|
361
368
|
|
|
362
369
|
if (clause.defaultImport || remaining.length > 0) {
|
|
363
370
|
const parts: string[] = [];
|
|
@@ -369,12 +376,11 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
|
|
|
369
376
|
for (const [subpath, items] of rewrites) {
|
|
370
377
|
lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from "${subpath}";`);
|
|
371
378
|
}
|
|
379
|
+
lines.push(...namespaceLines);
|
|
372
380
|
|
|
373
381
|
return lines.join("\n");
|
|
374
382
|
};
|
|
375
383
|
|
|
376
|
-
const shouldPreserveBarrelSideEffects = (specifier: string): boolean => /^@(apps|libs)\/[^/]+\/client$/.test(specifier);
|
|
377
|
-
|
|
378
384
|
const serializeNamedItem = (item: NamedItem): string => {
|
|
379
385
|
const prefix = item.isType ? "type " : "";
|
|
380
386
|
if (item.imported === item.local) return `${prefix}${item.imported}`;
|
|
@@ -86,7 +86,7 @@ describe("createServerEnvPlugin", () => {
|
|
|
86
86
|
"env/env.server.develop.ts is not in this build: `akan build` bundles the server env of AKAN_PUBLIC_ENV=main alone",
|
|
87
87
|
);
|
|
88
88
|
expect(proc.stderr.toString()).toContain(
|
|
89
|
-
"To run develop, build for it
|
|
89
|
+
"To run develop, build for it with `--env develop` (`akan build` or a desktop or mobile build), or AKAN_PUBLIC_ENV=develop in the shell or the workspace's .env.",
|
|
90
90
|
);
|
|
91
91
|
});
|
|
92
92
|
|
|
@@ -54,7 +54,7 @@ export const createServerEnvPlugin = ({
|
|
|
54
54
|
build.onLoad({ filter }, async (args) => {
|
|
55
55
|
const name = path.basename(args.path, ".ts").slice("env.server.".length);
|
|
56
56
|
const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
|
|
57
|
-
const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it
|
|
57
|
+
const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it with \`--env ${name}\` (\`akan build\` or a desktop or mobile build), or AKAN_PUBLIC_ENV=${name} in the shell or the workspace's .env.`;
|
|
58
58
|
return {
|
|
59
59
|
loader: "js",
|
|
60
60
|
contents: [
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { ApplicationBuildReporter } from "../applicationBuildReporter";
|
|
4
4
|
import { tempDirs, writeText as write } from "../testHelpers";
|
|
5
5
|
import { BarrelAnalyzer, type BarrelExportTarget } from "./barrelAnalyzer";
|
|
6
|
-
import { rewriteBarrelImports } from "./barrelImportsPlugin";
|
|
6
|
+
import { createTsconfigPackageResolver, rewriteBarrelImports } from "./barrelImportsPlugin";
|
|
7
7
|
import {
|
|
8
8
|
hasUseClientDirective,
|
|
9
9
|
scanUseClientExports,
|
|
@@ -140,7 +140,7 @@ describe('export * in a "use client" module', () => {
|
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
143
|
-
test("analyzes local, named, aliased, and
|
|
143
|
+
test("analyzes local, named, aliased, star and namespace re-exports while skipping type exports", async () => {
|
|
144
144
|
const root = await makeTempRoot();
|
|
145
145
|
const pkgDir = path.join(root, "pkg");
|
|
146
146
|
await write(
|
|
@@ -149,6 +149,7 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
149
149
|
'export { A, B as Bee, type TypeOnly } from "./leaf";',
|
|
150
150
|
'export * from "./star";',
|
|
151
151
|
'export * as ns from "./namespace";',
|
|
152
|
+
'export type * as typeNs from "./namespace";',
|
|
152
153
|
"export const Local = 1;",
|
|
153
154
|
"export type LocalType = string;",
|
|
154
155
|
"",
|
|
@@ -171,7 +172,9 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
171
172
|
expect(map?.get("Star")).toEqual({ subpath: "@scope/pkg/star", originalName: "Star" });
|
|
172
173
|
expect(map?.get("Local")).toEqual({ subpath: "@scope/pkg", originalName: "Local" });
|
|
173
174
|
expect(map?.has("TypeOnly")).toBe(false);
|
|
174
|
-
expect(map?.
|
|
175
|
+
expect(map?.get("ns")).toEqual({ subpath: "@scope/pkg/namespace", originalName: "ns", namespace: true });
|
|
176
|
+
expect(map?.has("typeNs")).toBe(false);
|
|
177
|
+
expect(map?.has("Hidden")).toBe(false);
|
|
175
178
|
});
|
|
176
179
|
|
|
177
180
|
test("a name a module exports itself wins over the same name its star re-exports bring, as ES decides", async () => {
|
|
@@ -284,17 +287,66 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
284
287
|
expect(await rewriteBarrelImports('import { A } from "@other/pkg";', ["@scope/pkg"], analyzer)).toBeNull();
|
|
285
288
|
});
|
|
286
289
|
|
|
287
|
-
test("
|
|
288
|
-
const
|
|
290
|
+
test("keeps a client barrel's runtime and store, never its module UI, when flattening an app or lib import", async () => {
|
|
291
|
+
const root = await makeTempRoot();
|
|
292
|
+
for (const sys of ["apps/demo", "libs/kit"]) {
|
|
293
|
+
await write(
|
|
294
|
+
path.join(root, sys, "client.ts"),
|
|
295
|
+
[
|
|
296
|
+
'export * as cnst from "./lib/cnst";',
|
|
297
|
+
'export { msg, Err, usePage, fetch, sig } from "./lib/useClient";',
|
|
298
|
+
'export { st, RootStore } from "./lib/st";',
|
|
299
|
+
'export * as store from "./lib/st";',
|
|
300
|
+
'export { Memo } from "./lib/memo";',
|
|
301
|
+
"",
|
|
302
|
+
].join("\n"),
|
|
303
|
+
);
|
|
304
|
+
await write(path.join(root, sys, "lib/cnst.ts"), "export const Sample = 1;\n");
|
|
305
|
+
await write(path.join(root, sys, "lib/useClient.ts"), "export const { msg, Err, usePage, fetch, sig } = {};\n");
|
|
306
|
+
await write(path.join(root, sys, "lib/st.ts"), "export class RootStore {}\nexport const st = {};\n");
|
|
307
|
+
await write(path.join(root, sys, "lib/memo/index.ts"), "export const Memo = {};\n");
|
|
308
|
+
}
|
|
309
|
+
await write(path.join(root, "libs/hand/client.ts"), 'export { thing } from "./lib/thing";\n');
|
|
310
|
+
await write(path.join(root, "libs/hand/lib/thing.ts"), "export const thing = 1;\n");
|
|
311
|
+
const analyzer = new BarrelAnalyzer({
|
|
312
|
+
resolvePackage: await createTsconfigPackageResolver({
|
|
313
|
+
workspace: { workspaceRoot: root },
|
|
314
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } } }),
|
|
315
|
+
} as never),
|
|
316
|
+
});
|
|
317
|
+
const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client"];
|
|
318
|
+
const rewrite = async (source: string) => await rewriteBarrelImports(source, barrels, analyzer);
|
|
289
319
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
320
|
+
expect(await rewrite('import { Memo, st } from "@apps/demo/client";\n')).toBe(
|
|
321
|
+
[
|
|
322
|
+
'import "@apps/demo/lib/useClient";',
|
|
323
|
+
'import "@apps/demo/lib/st";',
|
|
324
|
+
'import { Memo } from "@apps/demo/lib/memo";',
|
|
325
|
+
'import { st } from "@apps/demo/lib/st";',
|
|
326
|
+
"",
|
|
327
|
+
].join("\n"),
|
|
294
328
|
);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
329
|
+
expect(await rewrite('import { cnst as c, st, store, type cnst as T } from "@apps/demo/client";\n')).toBe(
|
|
330
|
+
[
|
|
331
|
+
'import "@apps/demo/lib/useClient";',
|
|
332
|
+
'import "@apps/demo/lib/st";',
|
|
333
|
+
'import { type cnst as T } from "@apps/demo/client";',
|
|
334
|
+
'import { st } from "@apps/demo/lib/st";',
|
|
335
|
+
'import * as c from "@apps/demo/lib/cnst";',
|
|
336
|
+
'import * as store from "@apps/demo/lib/st";',
|
|
337
|
+
"",
|
|
338
|
+
].join("\n"),
|
|
339
|
+
);
|
|
340
|
+
expect(await rewrite('import { RootStore as kit } from "@libs/kit/client";\n')).toBe(
|
|
341
|
+
[
|
|
342
|
+
'import "@libs/kit/lib/useClient";',
|
|
343
|
+
'import "@libs/kit/lib/st";',
|
|
344
|
+
'import { RootStore as kit } from "@libs/kit/lib/st";',
|
|
345
|
+
"",
|
|
346
|
+
].join("\n"),
|
|
347
|
+
);
|
|
348
|
+
expect(await rewrite('import { thing } from "@libs/hand/client";\n')).toBe(
|
|
349
|
+
['import "@libs/hand/client";', 'import { thing } from "@libs/hand/lib/thing";', ""].join("\n"),
|
|
298
350
|
);
|
|
299
351
|
});
|
|
300
352
|
|
|
@@ -99,6 +99,18 @@ describe("createTsconfigPackageResolver — wildcard tsconfig mapping", () => {
|
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test("resolves a specifier that names its file with an extension, as the generated root layout imports System", async () => {
|
|
103
|
+
const root = await makeTempRoot();
|
|
104
|
+
const entryFile = await write(root, "pkgs/akanjs/ui/System/index.tsx");
|
|
105
|
+
const resolve = await resolverFor(root, { "akanjs/ui/*": ["./pkgs/akanjs/ui/*"] });
|
|
106
|
+
|
|
107
|
+
expect(await resolve("akanjs/ui/System/index.tsx")).toEqual({
|
|
108
|
+
pkgName: "akanjs/ui/System",
|
|
109
|
+
entryFile,
|
|
110
|
+
pkgDir: path.join(root, "pkgs/akanjs/ui/System"),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
102
114
|
test("resolves a directory barrel and keeps the specifier", async () => {
|
|
103
115
|
const root = await makeTempRoot();
|
|
104
116
|
const entryFile = await write(root, "apps/minimal/index.tsx");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as ts from "typescript";
|
|
4
|
+
import { tempDirs, writeJson, writeText } from "./testHelpers";
|
|
5
|
+
import { TypeChecker } from "./typeChecker";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-devkit-type-checker-");
|
|
8
|
+
|
|
9
|
+
//? `lib: []` adds global diagnostics (TS2318), and `emitDecoratorMetadata` alone an options one (TS5052).
|
|
10
|
+
const project = async (declaration: boolean) => {
|
|
11
|
+
const root = await makeTempRoot();
|
|
12
|
+
const configPath = path.join(root, "tsconfig.json");
|
|
13
|
+
await writeJson(configPath, {
|
|
14
|
+
compilerOptions: {
|
|
15
|
+
strict: true,
|
|
16
|
+
lib: [],
|
|
17
|
+
types: [],
|
|
18
|
+
target: "esnext",
|
|
19
|
+
module: "esnext",
|
|
20
|
+
moduleResolution: "bundler",
|
|
21
|
+
noEmit: true,
|
|
22
|
+
incremental: true,
|
|
23
|
+
tsBuildInfoFile: "./.cache/tsconfig.tsbuildinfo",
|
|
24
|
+
declaration,
|
|
25
|
+
emitDecoratorMetadata: true,
|
|
26
|
+
},
|
|
27
|
+
include: ["src/**/*.ts"],
|
|
28
|
+
});
|
|
29
|
+
await writeText(path.join(root, "src/port.ts"), "export const port: number = 8282;\n");
|
|
30
|
+
await writeText(
|
|
31
|
+
path.join(root, "src/next.ts"),
|
|
32
|
+
'import { port } from "./port";\nexport const next: number = port + 1;\n',
|
|
33
|
+
);
|
|
34
|
+
await writeText(path.join(root, "src/semantic.ts"), 'export const name: number = "akan";\n');
|
|
35
|
+
await writeText(path.join(root, "src/syntax.ts"), "export const broken = (;\n");
|
|
36
|
+
await writeText(path.join(root, "src/declaration.ts"), "export const make = () => class { private secret = 1; };\n");
|
|
37
|
+
return { root, configPath };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const describeAll = (root: string, diagnostics: readonly ts.Diagnostic[]) =>
|
|
41
|
+
diagnostics.map((diagnostic) => ({
|
|
42
|
+
file: diagnostic.file ? path.relative(root, diagnostic.file.fileName) : null,
|
|
43
|
+
start: diagnostic.start,
|
|
44
|
+
length: diagnostic.length,
|
|
45
|
+
code: diagnostic.code,
|
|
46
|
+
category: diagnostic.category,
|
|
47
|
+
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const preEmitDiagnostics = (root: string, configPath: string) => {
|
|
51
|
+
const { fileNames, options } = TypeChecker.parseConfig(configPath);
|
|
52
|
+
return describeAll(root, ts.getPreEmitDiagnostics(ts.createProgram({ rootNames: fileNames, options })));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const nextFileToCheck = (configPath: string) => {
|
|
56
|
+
const { fileNames, options } = TypeChecker.parseConfig(configPath);
|
|
57
|
+
const next = ts
|
|
58
|
+
.createIncrementalProgram({ rootNames: fileNames, options })
|
|
59
|
+
.getSemanticDiagnosticsOfNextAffectedFile();
|
|
60
|
+
return next && ("fileName" in next.affected ? next.affected.fileName : "the whole program");
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
describe("TypeChecker.checkProject", () => {
|
|
64
|
+
test.each([true, false])(
|
|
65
|
+
"reports what getPreEmitDiagnostics does, cold and warm (declaration: %p)",
|
|
66
|
+
async (declaration) => {
|
|
67
|
+
const { root, configPath } = await project(declaration);
|
|
68
|
+
const expected = preEmitDiagnostics(root, configPath);
|
|
69
|
+
expect(new Set(expected.map((diagnostic) => diagnostic.code))).toEqual(
|
|
70
|
+
new Set([5052, 1109, 2318, 2322, ...(declaration ? [4094] : [])]),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(describeAll(root, TypeChecker.checkProject(configPath).diagnostics)).toEqual(expected);
|
|
74
|
+
expect(describeAll(root, TypeChecker.checkProject(configPath).diagnostics)).toEqual(expected);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
test("leaves a cache from which a resumed build has no file left to check", async () => {
|
|
79
|
+
const { configPath } = await project(true);
|
|
80
|
+
TypeChecker.checkProject(configPath);
|
|
81
|
+
expect(nextFileToCheck(configPath)).toBeUndefined();
|
|
82
|
+
TypeChecker.checkProject(configPath);
|
|
83
|
+
expect(nextFileToCheck(configPath)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reports an edit's new errors, and its importer's, and drops a fixed one on a warm run", async () => {
|
|
87
|
+
const { root, configPath } = await project(true);
|
|
88
|
+
TypeChecker.checkProject(configPath);
|
|
89
|
+
await writeText(path.join(root, "src/port.ts"), "export const port: string = 8282;\n");
|
|
90
|
+
await writeText(path.join(root, "src/semantic.ts"), 'export const name: string = "akan";\n');
|
|
91
|
+
|
|
92
|
+
const warm = describeAll(root, TypeChecker.checkProject(configPath).diagnostics);
|
|
93
|
+
expect(warm).toEqual(preEmitDiagnostics(root, configPath));
|
|
94
|
+
expect(warm.filter((diagnostic) => diagnostic.code === 2322).map((diagnostic) => diagnostic.file)).toEqual([
|
|
95
|
+
path.join("src", "next.ts"),
|
|
96
|
+
path.join("src", "port.ts"),
|
|
97
|
+
]);
|
|
98
|
+
expect(nextFileToCheck(configPath)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
package/typeChecker.ts
CHANGED
|
@@ -177,8 +177,7 @@ export class TypeChecker {
|
|
|
177
177
|
configFileParsingDiagnostics: parsedConfig.errors,
|
|
178
178
|
host,
|
|
179
179
|
});
|
|
180
|
-
const
|
|
181
|
-
const diagnostics = [...ts.getPreEmitDiagnostics(program), ...builderProgram.emit().diagnostics];
|
|
180
|
+
const diagnostics = [...TypeChecker.#preEmitDiagnostics(builderProgram), ...builderProgram.emit().diagnostics];
|
|
182
181
|
const errors = diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
|
183
182
|
const warnings = diagnostics.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Warning);
|
|
184
183
|
return {
|
|
@@ -190,6 +189,19 @@ export class TypeChecker {
|
|
|
190
189
|
};
|
|
191
190
|
}
|
|
192
191
|
|
|
192
|
+
//* getPreEmitDiagnostics on the Program re-checks every file; the builder answers unchanged ones from .tsbuildinfo.
|
|
193
|
+
static #preEmitDiagnostics(builderProgram: ts.BuilderProgram) {
|
|
194
|
+
const { declaration, composite } = builderProgram.getCompilerOptions();
|
|
195
|
+
return ts.sortAndDeduplicateDiagnostics([
|
|
196
|
+
...builderProgram.getConfigFileParsingDiagnostics(),
|
|
197
|
+
...builderProgram.getOptionsDiagnostics(),
|
|
198
|
+
...builderProgram.getSyntacticDiagnostics(),
|
|
199
|
+
...builderProgram.getGlobalDiagnostics(),
|
|
200
|
+
...builderProgram.getSemanticDiagnostics(),
|
|
201
|
+
...(declaration || composite ? builderProgram.getDeclarationDiagnostics() : []),
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
static parseConfig(configPath: string): ts.ParsedCommandLine {
|
|
194
206
|
const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
|
|
195
207
|
const configDiagnostics = configFile.error ? [configFile.error] : [];
|
package/workflow/plan.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { capitalize } from "akanjs/common";
|
|
2
|
-
import type { Workspace } from "../commandDecorators";
|
|
2
|
+
import type { Sys, Workspace } from "../commandDecorators";
|
|
3
3
|
import { workflowPlanApproval } from "./artifacts";
|
|
4
4
|
import { resolveWorkflowSys } from "./executor";
|
|
5
5
|
import { InteractionScaffold } from "./interactionScaffold";
|
|
6
6
|
import { KeyRename } from "./keyRename";
|
|
7
7
|
import { MutationScaffold } from "./mutationScaffold";
|
|
8
|
-
import { coerceFieldDefault, moduleSourcePaths } from "./source";
|
|
8
|
+
import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
|
|
9
9
|
import type {
|
|
10
10
|
WorkflowDiagnostic,
|
|
11
11
|
WorkflowInputSpec,
|
|
@@ -479,25 +479,87 @@ const inspectRenameKeyPlan = async (workspace: Workspace, plan: WorkflowPlan): P
|
|
|
479
479
|
};
|
|
480
480
|
};
|
|
481
481
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
const syssHolding = async (workspace: Workspace, module: string, except: Sys) => {
|
|
483
|
+
const [apps, libs] = await workspace.getSyss();
|
|
484
|
+
const others = await Promise.all(
|
|
485
|
+
[...apps, ...libs]
|
|
486
|
+
.filter((name) => name !== except.name)
|
|
487
|
+
.map(async (name) => await resolveWorkflowSys(workspace, name)),
|
|
488
|
+
);
|
|
489
|
+
const holding = await Promise.all(
|
|
490
|
+
others.map(async (other) => (other && (await other.readdir("lib")).includes(module) ? other : null)),
|
|
491
|
+
);
|
|
492
|
+
return holding.filter((other): other is Sys => other !== null);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
|
|
496
|
+
if ((await sys.readdir("lib")).includes(module)) return null;
|
|
497
|
+
const root = getSysRoot(sys);
|
|
498
|
+
const modules = [...(await sys.getDatabaseModules()), ...(await sys.getServiceModules())];
|
|
499
|
+
const sameName = modules.find((name) => name.replace(/^_/, "").toLowerCase() === module.toLowerCase());
|
|
500
|
+
const isScalar = (await sys.getScalarModules()).includes(module);
|
|
501
|
+
const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
|
|
502
|
+
return {
|
|
503
|
+
severity: "error",
|
|
504
|
+
code: "workflow-module-missing",
|
|
505
|
+
input: "module",
|
|
506
|
+
message: [
|
|
507
|
+
`No module named ${module} in ${root}: ${root}/lib/${module} does not exist.`,
|
|
508
|
+
modules.length ? `Its modules are ${modules.join(", ")}.` : `${root} has no modules yet.`,
|
|
509
|
+
sameName
|
|
510
|
+
? `Did you mean ${sameName}?`
|
|
511
|
+
: isScalar
|
|
512
|
+
? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
|
|
513
|
+
: holder
|
|
514
|
+
? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
|
|
515
|
+
: `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
|
|
516
|
+
].join(" "),
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
export const workflowTargetDiagnostics = async (
|
|
521
|
+
workspace: Workspace,
|
|
522
|
+
spec: WorkflowSpec,
|
|
523
|
+
inputs: Record<string, WorkflowInputValue>,
|
|
524
|
+
): Promise<WorkflowDiagnostic[]> => {
|
|
525
|
+
const app = planString(inputs, "app");
|
|
526
|
+
if (!app) return [];
|
|
487
527
|
const sys = await resolveWorkflowSys(workspace, app).catch(() => null);
|
|
488
|
-
if (!sys)
|
|
528
|
+
if (!sys) {
|
|
529
|
+
const [apps, libs] = await workspace.getSyss();
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
severity: "error",
|
|
533
|
+
code: "workflow-target-missing",
|
|
534
|
+
input: "app",
|
|
535
|
+
message: `No app or library named ${app}. Apps: ${apps.join(", ") || "none"}. Libraries: ${libs.join(", ") || "none"}.`,
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
const module = planString(inputs, "module");
|
|
540
|
+
const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
|
|
541
|
+
if (!module || !moduleSpec?.mustExist) return [];
|
|
542
|
+
const missing = await moduleMissing(workspace, sys, module);
|
|
543
|
+
return missing ? [missing] : [];
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export const inspectWorkflowPlan = async (
|
|
547
|
+
workspace: Workspace,
|
|
548
|
+
spec: WorkflowSpec,
|
|
549
|
+
plan: WorkflowPlan,
|
|
550
|
+
): Promise<WorkflowPlan> => {
|
|
551
|
+
const targetDiagnostics = await workflowTargetDiagnostics(workspace, spec, plan.inputs);
|
|
552
|
+
if (targetDiagnostics.length)
|
|
489
553
|
return {
|
|
490
554
|
...plan,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
severity: "error",
|
|
495
|
-
code: "workflow-target-missing",
|
|
496
|
-
input: "app",
|
|
497
|
-
message: `No app or library named ${app}.`,
|
|
498
|
-
},
|
|
499
|
-
],
|
|
555
|
+
predictedChanges: [],
|
|
556
|
+
diagnostics: [...plan.diagnostics, ...targetDiagnostics],
|
|
557
|
+
recommendations: [],
|
|
500
558
|
};
|
|
559
|
+
if (plan.workflow === "rename-key") return await inspectRenameKeyPlan(workspace, plan);
|
|
560
|
+
if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
|
|
561
|
+
const sys = await resolveWorkflowSys(workspace, planString(plan.inputs, "app")).catch(() => null);
|
|
562
|
+
if (!sys) return plan;
|
|
501
563
|
const inspected =
|
|
502
564
|
plan.workflow === "add-mutation"
|
|
503
565
|
? await MutationScaffold.build(
|