@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
3
|
+
import type { WritingActionIndex } from "./writingActionIndex";
|
|
4
|
+
|
|
5
|
+
export interface StoreAccessCount {
|
|
6
|
+
component: string;
|
|
7
|
+
line: number;
|
|
8
|
+
accessCount: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//? A generated form setter only binds a field to its control: a long form is a size breach, not a busy component.
|
|
12
|
+
export class StoreAccessScanner {
|
|
13
|
+
static readonly #formSetter = /^(?:(?:set|add|sub|addOrSub|upload)[A-Z]\w*On|writeOn)[A-Z]\w*$/;
|
|
14
|
+
readonly #index: WritingActionIndex;
|
|
15
|
+
|
|
16
|
+
constructor(index: WritingActionIndex) {
|
|
17
|
+
this.#index = index;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static stNamesOf(sourceFile: ts.SourceFile) {
|
|
21
|
+
return StoreAccessScanner.localNamesOf(sourceFile, "st");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static localNamesOf(sourceFile: ts.SourceFile, imported: string) {
|
|
25
|
+
const names = new Set<string>();
|
|
26
|
+
for (const statement of sourceFile.statements) {
|
|
27
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
28
|
+
const bindings = statement.importClause?.namedBindings;
|
|
29
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
30
|
+
for (const element of bindings.elements)
|
|
31
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === imported)
|
|
32
|
+
names.add(element.name.text);
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
measure(sourceFile: ts.SourceFile): StoreAccessCount[] {
|
|
38
|
+
const stNames = StoreAccessScanner.stNamesOf(sourceFile);
|
|
39
|
+
if (!stNames.size) return [];
|
|
40
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).map(({ name, node }) => {
|
|
41
|
+
let accessCount = 0;
|
|
42
|
+
const visit = (child: ts.Node) => {
|
|
43
|
+
if (
|
|
44
|
+
ts.isPropertyAccessExpression(child) &&
|
|
45
|
+
ts.isIdentifier(child.expression) &&
|
|
46
|
+
stNames.has(child.expression.text) &&
|
|
47
|
+
!this.#isFormSetter(child)
|
|
48
|
+
)
|
|
49
|
+
accessCount += 1;
|
|
50
|
+
ts.forEachChild(child, visit);
|
|
51
|
+
};
|
|
52
|
+
visit(node);
|
|
53
|
+
return {
|
|
54
|
+
component: name,
|
|
55
|
+
line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
56
|
+
accessCount,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#isFormSetter(access: ts.PropertyAccessExpression) {
|
|
62
|
+
const action = access.parent;
|
|
63
|
+
return (
|
|
64
|
+
access.name.text === "do" &&
|
|
65
|
+
ts.isPropertyAccessExpression(action) &&
|
|
66
|
+
action.expression === access &&
|
|
67
|
+
StoreAccessScanner.#formSetter.test(action.name.text) &&
|
|
68
|
+
!this.#index.isWritingAction(action.name.text)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/storeSource.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { modelNames } from "akanjs/common";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
|
|
5
|
+
export interface StoreClass {
|
|
6
|
+
name: string;
|
|
7
|
+
refName: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StoreMethod {
|
|
11
|
+
store: string;
|
|
12
|
+
name: string;
|
|
13
|
+
isStatic: boolean;
|
|
14
|
+
body: ts.Block;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type StoreCallTarget =
|
|
18
|
+
| { kind: "fetch"; name: string }
|
|
19
|
+
| { kind: "action"; name: string; isOwn: boolean }
|
|
20
|
+
| { kind: "static"; store: string; name: string };
|
|
21
|
+
|
|
22
|
+
export class StoreSource {
|
|
23
|
+
readonly file: string;
|
|
24
|
+
readonly stores: StoreClass[] = [];
|
|
25
|
+
readonly methods: StoreMethod[] = [];
|
|
26
|
+
readonly #fetchNames: Set<string>;
|
|
27
|
+
readonly #ownActionsByStore = new Map<string, Set<string>>();
|
|
28
|
+
readonly #rootAliasesByMethod = new Map<StoreMethod, Set<string>>();
|
|
29
|
+
|
|
30
|
+
//? The actions `store()` generates that send a mutation; `upload<Field>On<Model>` only stages a file the form saves.
|
|
31
|
+
static generatedWritesOf(refName: string) {
|
|
32
|
+
const names = modelNames(refName);
|
|
33
|
+
return [
|
|
34
|
+
names.createModel,
|
|
35
|
+
names.updateModel,
|
|
36
|
+
names.removeModel,
|
|
37
|
+
names.mergeModel,
|
|
38
|
+
names.createModelInForm,
|
|
39
|
+
names.updateModelInForm,
|
|
40
|
+
names.submitModel,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
constructor({ file, sourceFile }: Pick<SourceFileInfo, "file" | "sourceFile">) {
|
|
45
|
+
this.file = file;
|
|
46
|
+
this.#fetchNames = StoreSource.#fetchBindingsOf(sourceFile);
|
|
47
|
+
for (const statement of sourceFile.statements) {
|
|
48
|
+
if (!ts.isClassDeclaration(statement) || !statement.name) continue;
|
|
49
|
+
const refName = StoreSource.#refNameOf(statement);
|
|
50
|
+
if (refName === undefined) continue;
|
|
51
|
+
const store = statement.name.text;
|
|
52
|
+
this.stores.push({ name: store, refName });
|
|
53
|
+
const ownActions = new Set(refName ? StoreSource.generatedWritesOf(refName) : []);
|
|
54
|
+
for (const member of statement.members) {
|
|
55
|
+
if (!ts.isMethodDeclaration(member) || !member.body) continue;
|
|
56
|
+
const isStatic = !!ts.getModifiers(member)?.some(({ kind }) => kind === ts.SyntaxKind.StaticKeyword);
|
|
57
|
+
const name = member.name.getText(sourceFile);
|
|
58
|
+
if (!isStatic) ownActions.add(name);
|
|
59
|
+
const method = { store, name, isStatic, body: member.body };
|
|
60
|
+
this.methods.push(method);
|
|
61
|
+
this.#rootAliasesByMethod.set(method, StoreSource.#rootAliasesIn(member));
|
|
62
|
+
}
|
|
63
|
+
this.#ownActionsByStore.set(store, ownActions);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
targetsIn(method: StoreMethod) {
|
|
68
|
+
const targets: StoreCallTarget[] = [];
|
|
69
|
+
const visit = (node: ts.Node) => {
|
|
70
|
+
const target = ts.isCallExpression(node) ? this.targetOf(method, node) : null;
|
|
71
|
+
if (target) targets.push(target);
|
|
72
|
+
ts.forEachChild(node, visit);
|
|
73
|
+
};
|
|
74
|
+
visit(method.body);
|
|
75
|
+
return targets;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
targetOf(method: StoreMethod, call: ts.CallExpression): StoreCallTarget | null {
|
|
79
|
+
const callee = call.expression;
|
|
80
|
+
if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return null;
|
|
81
|
+
const name = ts.isPropertyAccessExpression(callee)
|
|
82
|
+
? callee.name.text
|
|
83
|
+
: ts.isStringLiteralLike(callee.argumentExpression)
|
|
84
|
+
? callee.argumentExpression.text
|
|
85
|
+
: null;
|
|
86
|
+
if (name === null) return null;
|
|
87
|
+
const receiver = StoreSource.#unwrap(callee.expression);
|
|
88
|
+
const isThis = receiver.kind === ts.SyntaxKind.ThisKeyword;
|
|
89
|
+
if (isThis && method.isStatic) return { kind: "static", store: method.store, name };
|
|
90
|
+
if (!isThis && !ts.isIdentifier(receiver)) return null;
|
|
91
|
+
if (ts.isIdentifier(receiver) && this.#fetchNames.has(receiver.text)) return { kind: "fetch", name };
|
|
92
|
+
if (isThis || (ts.isIdentifier(receiver) && this.#rootAliasesByMethod.get(method)?.has(receiver.text)))
|
|
93
|
+
return { kind: "action", name, isOwn: !!this.#ownActionsByStore.get(method.store)?.has(name) };
|
|
94
|
+
return ts.isIdentifier(receiver) ? { kind: "static", store: receiver.text, name } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//? `this`, cast to `RootStore` or not, an alias of it, or a `RootStore` parameter reaches every store's action.
|
|
98
|
+
static #rootAliasesIn(method: ts.MethodDeclaration) {
|
|
99
|
+
const aliases = new Set<string>();
|
|
100
|
+
const visit = (node: ts.Node) => {
|
|
101
|
+
if (ts.isParameter(node) && ts.isIdentifier(node.name) && StoreSource.#isRootStoreType(node.type))
|
|
102
|
+
aliases.add(node.name.text);
|
|
103
|
+
if (
|
|
104
|
+
ts.isVariableDeclaration(node) &&
|
|
105
|
+
ts.isIdentifier(node.name) &&
|
|
106
|
+
node.initializer &&
|
|
107
|
+
StoreSource.#unwrap(node.initializer).kind === ts.SyntaxKind.ThisKeyword
|
|
108
|
+
)
|
|
109
|
+
aliases.add(node.name.text);
|
|
110
|
+
ts.forEachChild(node, visit);
|
|
111
|
+
};
|
|
112
|
+
visit(method);
|
|
113
|
+
return aliases;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static #isRootStoreType(type: ts.TypeNode | undefined) {
|
|
117
|
+
return (
|
|
118
|
+
!!type && ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === "RootStore"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static #unwrap(expression: ts.Expression) {
|
|
123
|
+
let current = expression;
|
|
124
|
+
while (
|
|
125
|
+
ts.isParenthesizedExpression(current) ||
|
|
126
|
+
ts.isAsExpression(current) ||
|
|
127
|
+
ts.isTypeAssertionExpression(current) ||
|
|
128
|
+
ts.isSatisfiesExpression(current) ||
|
|
129
|
+
ts.isNonNullExpression(current)
|
|
130
|
+
)
|
|
131
|
+
current = current.expression;
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static #refNameOf(statement: ts.ClassDeclaration) {
|
|
136
|
+
for (const clause of statement.heritageClauses ?? []) {
|
|
137
|
+
if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
138
|
+
for (const { expression } of clause.types) {
|
|
139
|
+
if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) continue;
|
|
140
|
+
if (expression.expression.text !== "store") continue;
|
|
141
|
+
const sig = expression.arguments[0];
|
|
142
|
+
return sig && ts.isPropertyAccessExpression(sig) ? sig.name.text : null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
static #fetchBindingsOf(sourceFile: ts.SourceFile) {
|
|
149
|
+
const names = new Set<string>();
|
|
150
|
+
for (const statement of sourceFile.statements) {
|
|
151
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
152
|
+
const bindings = statement.importClause?.namedBindings;
|
|
153
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
154
|
+
for (const element of bindings.elements)
|
|
155
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === "fetch")
|
|
156
|
+
names.add(element.name.text);
|
|
157
|
+
}
|
|
158
|
+
return names;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
5
|
+
import { StoreWriteScanner } from "./storeWriteScanner";
|
|
6
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
7
|
+
|
|
8
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
9
|
+
file,
|
|
10
|
+
absolutePath: `/repo/${file}`,
|
|
11
|
+
content,
|
|
12
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const signal = fileOf(
|
|
16
|
+
"apps/demo/lib/court/court.signal.ts",
|
|
17
|
+
`
|
|
18
|
+
export class CourtSlice extends slice(srv.court, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
|
|
19
|
+
export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
|
|
20
|
+
assignCourtPc: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(async function (courtId) {
|
|
21
|
+
return await this.courtService.assignPc(courtId);
|
|
22
|
+
}),
|
|
23
|
+
pushPcCommand: mutation(Boolean, { guards: [User] }).param("courtId", ID).exec(async function (courtId) {
|
|
24
|
+
return await this.courtService.pushPcCommand(courtId);
|
|
25
|
+
}),
|
|
26
|
+
courtCount: query(Int, { guards: [User] }).exec(async function () {
|
|
27
|
+
return 0;
|
|
28
|
+
}),
|
|
29
|
+
})) {}
|
|
30
|
+
`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const storeOf = (body: string) =>
|
|
34
|
+
fileOf(
|
|
35
|
+
"apps/demo/lib/court/court.store.ts",
|
|
36
|
+
`import { store } from "akanjs/store";\nimport * as cnst from "../cnst";\nimport { fetch, sig } from "../useClient";\n\nexport class CourtStore extends store(sig.court, () => ({ count: 0 })) {\n${body}\n}\n`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const scan = (store: SourceFileInfo, otherStores: SourceFileInfo[] = []) =>
|
|
40
|
+
new StoreWriteScanner(new WritingActionIndex(new SignalMutationIndex([signal]), [store, ...otherStores])).scan(store);
|
|
41
|
+
|
|
42
|
+
const pushStore = fileOf(
|
|
43
|
+
"apps/demo/lib/push/push.store.ts",
|
|
44
|
+
`import { fetch, sig } from "../useClient";
|
|
45
|
+
export class PushStore extends store(sig.push, () => ({})) {
|
|
46
|
+
async pushCommandOnPc(courtId: string) {
|
|
47
|
+
await fetch.pushPcCommand(courtId);
|
|
48
|
+
}
|
|
49
|
+
async countPushes() {
|
|
50
|
+
return await fetch.courtCount();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
describe("StoreWriteScanner", () => {
|
|
57
|
+
test("reports an action sending two mutations at the second one", () => {
|
|
58
|
+
const writes = scan(
|
|
59
|
+
storeOf(` async assignCourtPcOnCourt(courtId: string) {
|
|
60
|
+
const court = await fetch.assignCourtPc(courtId);
|
|
61
|
+
await fetch.pushPcCommand(court.id);
|
|
62
|
+
this.setCourt(court);
|
|
63
|
+
}`),
|
|
64
|
+
);
|
|
65
|
+
expect(writes).toEqual([
|
|
66
|
+
{
|
|
67
|
+
action: "assignCourtPcOnCourt",
|
|
68
|
+
line: 8,
|
|
69
|
+
writes: ["fetch.assignCourtPc", "fetch.pushPcCommand"],
|
|
70
|
+
writeCount: 2,
|
|
71
|
+
isRootChain: false,
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reads a mutation and a query, or a model read, as one write", () => {
|
|
77
|
+
const writes = scan(
|
|
78
|
+
storeOf(` async refreshCourt(courtId: string) {
|
|
79
|
+
const court = await fetch.assignCourtPc(courtId);
|
|
80
|
+
this.set({ count: await fetch.courtCount() });
|
|
81
|
+
this.setCourt(await fetch.court(court.id));
|
|
82
|
+
}`),
|
|
83
|
+
);
|
|
84
|
+
expect(writes).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reads writes on exclusive branches as one", () => {
|
|
88
|
+
const writes = scan(
|
|
89
|
+
storeOf(` async pushOnCourt(courtId: string, many: boolean) {
|
|
90
|
+
if (many) await fetch.assignCourtPc(courtId);
|
|
91
|
+
else await fetch.pushPcCommand(courtId);
|
|
92
|
+
}`),
|
|
93
|
+
);
|
|
94
|
+
expect(writes).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("counts the generated CRUD and a write inside a nested callback", () => {
|
|
98
|
+
const writes = scan(
|
|
99
|
+
storeOf(` async mergeCourts(courtIds: string[]) {
|
|
100
|
+
await Promise.all(courtIds.map(async (courtId) => fetch.mergeCourt(courtId, {})));
|
|
101
|
+
setTimeout(() => void fetch["pushPcCommand"](courtIds[0]), 0);
|
|
102
|
+
}`),
|
|
103
|
+
);
|
|
104
|
+
expect(writes.map(({ action, writes }) => ({ action, writes }))).toEqual([
|
|
105
|
+
{ action: "mergeCourts", writes: ["fetch.mergeCourt (per item)", 'fetch["pushPcCommand"]'] },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("reads one write per item as two, in a loop or a per-element callback, but not the list it loops over", () => {
|
|
110
|
+
const writes = scan(
|
|
111
|
+
storeOf(` async removeCourts(courtIds: string[]) {
|
|
112
|
+
for (const courtId of courtIds) await fetch.mergeCourt(courtId, {});
|
|
113
|
+
}
|
|
114
|
+
async pushToEach(courtIds: string[]) {
|
|
115
|
+
await Promise.all(courtIds.map((courtId) => fetch.pushPcCommand(courtId)));
|
|
116
|
+
}
|
|
117
|
+
async assignOnce(courtId: string) {
|
|
118
|
+
for (const pc of [await fetch.assignCourtPc(courtId)]) this.setCourt(pc);
|
|
119
|
+
}`),
|
|
120
|
+
);
|
|
121
|
+
expect(writes.map(({ action, writes, writeCount }) => ({ action, writes, writeCount }))).toEqual([
|
|
122
|
+
{ action: "removeCourts", writes: ["fetch.mergeCourt (per item)"], writeCount: 2 },
|
|
123
|
+
{ action: "pushToEach", writes: ["fetch.pushPcCommand (per item)"], writeCount: 2 },
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("counts a call to another writing action of the same store", () => {
|
|
128
|
+
const writes = scan(
|
|
129
|
+
storeOf(` async pushCommandOnCourt(courtId: string) {
|
|
130
|
+
await fetch.pushPcCommand(courtId);
|
|
131
|
+
}
|
|
132
|
+
async readCourt(courtId: string) {
|
|
133
|
+
return fetch.court(courtId);
|
|
134
|
+
}
|
|
135
|
+
async assignCourtPcOnCourt(courtId: string) {
|
|
136
|
+
await this.readCourt(courtId);
|
|
137
|
+
await fetch.assignCourtPc(courtId);
|
|
138
|
+
await this.pushCommandOnCourt(courtId);
|
|
139
|
+
}`),
|
|
140
|
+
);
|
|
141
|
+
expect(writes).toEqual([
|
|
142
|
+
{
|
|
143
|
+
action: "assignCourtPcOnCourt",
|
|
144
|
+
line: 15,
|
|
145
|
+
writes: ["fetch.assignCourtPc", "this.pushCommandOnCourt"],
|
|
146
|
+
writeCount: 2,
|
|
147
|
+
isRootChain: false,
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("counts a generated CRUD action of the store's own model as its own write", () => {
|
|
153
|
+
const writes = scan(
|
|
154
|
+
storeOf(` async createAndPushCourt(courtId: string) {
|
|
155
|
+
await this.createCourt({ name: courtId });
|
|
156
|
+
await fetch.pushPcCommand(courtId);
|
|
157
|
+
}`),
|
|
158
|
+
);
|
|
159
|
+
expect(writes).toMatchObject([
|
|
160
|
+
{ action: "createAndPushCourt", writes: ["this.createCourt", "fetch.pushPcCommand"] },
|
|
161
|
+
]);
|
|
162
|
+
expect(writes[0]?.isRootChain).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("reads another store's writing action through the RootStore cast, an alias of it or a RootStore parameter", () => {
|
|
166
|
+
const writes = scan(
|
|
167
|
+
storeOf(` async assignAndPush(courtId: string) {
|
|
168
|
+
await fetch.assignCourtPc(courtId);
|
|
169
|
+
await (this as unknown as RootStore).pushCommandOnPc(courtId);
|
|
170
|
+
}
|
|
171
|
+
async assignThroughAlias(courtId: string) {
|
|
172
|
+
const root = this as unknown as RootStore;
|
|
173
|
+
await root.countPushes();
|
|
174
|
+
await fetch.assignCourtPc(courtId);
|
|
175
|
+
await root.pushCommandOnPc(courtId);
|
|
176
|
+
}
|
|
177
|
+
static async assignFrom(root: RootStore, courtId: string) {
|
|
178
|
+
await root.assignCourtPcOnCourt(courtId);
|
|
179
|
+
await root.pushCommandOnPc(courtId);
|
|
180
|
+
}
|
|
181
|
+
async assignCourtPcOnCourt(courtId: string) {
|
|
182
|
+
await fetch.assignCourtPc(courtId);
|
|
183
|
+
}`),
|
|
184
|
+
[pushStore],
|
|
185
|
+
);
|
|
186
|
+
expect(writes.map(({ action, writes, isRootChain }) => ({ action, writes, isRootChain }))).toEqual([
|
|
187
|
+
{
|
|
188
|
+
action: "assignAndPush",
|
|
189
|
+
writes: ["fetch.assignCourtPc", "(this as unknown as RootStore).pushCommandOnPc"],
|
|
190
|
+
isRootChain: true,
|
|
191
|
+
},
|
|
192
|
+
{ action: "assignThroughAlias", writes: ["fetch.assignCourtPc", "root.pushCommandOnPc"], isRootChain: true },
|
|
193
|
+
{ action: "assignFrom", writes: ["root.assignCourtPcOnCourt", "root.pushCommandOnPc"], isRootChain: true },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("counts a call to a writing static helper, and the writes a callback hands it", () => {
|
|
198
|
+
const writes = scan(
|
|
199
|
+
storeOf(` static async guarded(root: RootStore, write: () => Promise<void>) {
|
|
200
|
+
await write();
|
|
201
|
+
void root.countPushes();
|
|
202
|
+
}
|
|
203
|
+
static async assignOnce(root: RootStore, courtId: string) {
|
|
204
|
+
await fetch.assignCourtPc(courtId);
|
|
205
|
+
}
|
|
206
|
+
async assignAndPush(courtId: string) {
|
|
207
|
+
const root = this as unknown as RootStore;
|
|
208
|
+
await CourtStore.assignOnce(root, courtId);
|
|
209
|
+
await CourtStore.guarded(root, () => root.pushCommandOnPc(courtId));
|
|
210
|
+
}`),
|
|
211
|
+
[pushStore],
|
|
212
|
+
);
|
|
213
|
+
expect(writes).toMatchObject([
|
|
214
|
+
{
|
|
215
|
+
action: "assignAndPush",
|
|
216
|
+
writes: ["CourtStore.assignOnce", "root.pushCommandOnPc"],
|
|
217
|
+
isRootChain: true,
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("reads the store's own writing static as its own write, not a chain through another store", () => {
|
|
223
|
+
const writes = scan(
|
|
224
|
+
storeOf(` static async assignOnce(courtId: string) {
|
|
225
|
+
await fetch.assignCourtPc(courtId);
|
|
226
|
+
}
|
|
227
|
+
async assignAndPush(courtId: string) {
|
|
228
|
+
await CourtStore.assignOnce(courtId);
|
|
229
|
+
await fetch.pushPcCommand(courtId);
|
|
230
|
+
}`),
|
|
231
|
+
);
|
|
232
|
+
expect(writes).toMatchObject([
|
|
233
|
+
{ action: "assignAndPush", writes: ["CourtStore.assignOnce", "fetch.pushPcCommand"], isRootChain: false },
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("reads a recursive call as no write, and a static helper's own writes as a chain", () => {
|
|
238
|
+
const writes = scan(
|
|
239
|
+
storeOf(` async undoOnCourt(courtId: string) {
|
|
240
|
+
await fetch.assignCourtPc(courtId);
|
|
241
|
+
await this.undoOnCourt(courtId);
|
|
242
|
+
}
|
|
243
|
+
static async sync(courtId: string) {
|
|
244
|
+
await fetch.assignCourtPc(courtId);
|
|
245
|
+
await fetch.pushPcCommand(courtId);
|
|
246
|
+
}`),
|
|
247
|
+
);
|
|
248
|
+
expect(writes.map(({ action, isRootChain }) => ({ action, isRootChain }))).toEqual([
|
|
249
|
+
{ action: "sync", isRootChain: false },
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("ignores getters, class-property arrows and classes that are not stores", () => {
|
|
254
|
+
const writes = scan(
|
|
255
|
+
fileOf(
|
|
256
|
+
"apps/demo/lib/court/court.store.ts",
|
|
257
|
+
`import { fetch, sig } from "../useClient";
|
|
258
|
+
export class CourtStore extends store(sig.court, () => ({})) {
|
|
259
|
+
get both() {
|
|
260
|
+
void fetch.assignCourtPc("a");
|
|
261
|
+
void fetch.pushPcCommand("a");
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
assignBoth = async (courtId: string) => {
|
|
265
|
+
await fetch.assignCourtPc(courtId);
|
|
266
|
+
await fetch.pushPcCommand(courtId);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export class Helper extends Base {
|
|
270
|
+
async run(courtId: string) {
|
|
271
|
+
await fetch.assignCourtPc(courtId);
|
|
272
|
+
await fetch.pushPcCommand(courtId);
|
|
273
|
+
}
|
|
274
|
+
}`,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
expect(writes).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("reads nothing from a store that does not import fetch", () => {
|
|
281
|
+
const store = fileOf(
|
|
282
|
+
"apps/demo/lib/court/court.store.ts",
|
|
283
|
+
`export class CourtStore extends store(sig.court, () => ({})) {
|
|
284
|
+
async run(courtId: string) {
|
|
285
|
+
await fetch.assignCourtPc(courtId);
|
|
286
|
+
await fetch.pushPcCommand(courtId);
|
|
287
|
+
}
|
|
288
|
+
}`,
|
|
289
|
+
);
|
|
290
|
+
expect(scan(store)).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
import { type StoreCallTarget, StoreSource } from "./storeSource";
|
|
4
|
+
import { WritePath } from "./writePath";
|
|
5
|
+
import type { WritingActionIndex } from "./writingActionIndex";
|
|
6
|
+
|
|
7
|
+
export interface StoreMultiWrite {
|
|
8
|
+
action: string;
|
|
9
|
+
line: number;
|
|
10
|
+
writes: string[];
|
|
11
|
+
writeCount: number;
|
|
12
|
+
isRootChain: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class StoreWriteScanner {
|
|
16
|
+
readonly #index: WritingActionIndex;
|
|
17
|
+
|
|
18
|
+
constructor(index: WritingActionIndex) {
|
|
19
|
+
this.#index = index;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
scan({ file, sourceFile }: Pick<SourceFileInfo, "file" | "sourceFile">): StoreMultiWrite[] {
|
|
23
|
+
const source = new StoreSource({ file, sourceFile });
|
|
24
|
+
return source.methods.flatMap((method) => {
|
|
25
|
+
const targets = new Map<ts.CallExpression, StoreCallTarget>();
|
|
26
|
+
const path = new WritePath((call) => {
|
|
27
|
+
const target = this.#index.writeOf(source, method, call);
|
|
28
|
+
if (target) targets.set(call, target);
|
|
29
|
+
return !!target;
|
|
30
|
+
});
|
|
31
|
+
const writes = path.along(method.body);
|
|
32
|
+
const writeCount = path.countOf(writes);
|
|
33
|
+
const second = writes[1] ?? writes[0];
|
|
34
|
+
if (writeCount < 2 || !second) return [];
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
action: method.name,
|
|
38
|
+
line: sourceFile.getLineAndCharacterOfPosition(second.getStart(sourceFile)).line + 1,
|
|
39
|
+
writes: writes.map((call) => path.labelOf(call, sourceFile)),
|
|
40
|
+
writeCount,
|
|
41
|
+
isRootChain: writes.some((call) => StoreWriteScanner.#crossesStores(targets.get(call), method.store)),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static #crossesStores(target: StoreCallTarget | undefined, ownStore: string) {
|
|
48
|
+
return (target?.kind === "static" && target.store !== ownStore) || (target?.kind === "action" && !target.isOwn);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/subspace.test.ts
CHANGED
|
@@ -513,6 +513,16 @@ describe("SubspaceConfig", () => {
|
|
|
513
513
|
).toThrow(/duplicate subspace/);
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
test("refuses a subspace name that is not a plain folder name", () => {
|
|
517
|
+
for (const name of ["..", "../outside", "a/b", ".hidden", "-x", "a b"])
|
|
518
|
+
expect(() => new SubspaceConfig({ subspaces: [{ name, repo: "a.git", apps: ["one"] }] })).toThrow(
|
|
519
|
+
/subspace name .* takes letters, digits/,
|
|
520
|
+
);
|
|
521
|
+
expect(
|
|
522
|
+
new SubspaceConfig({ subspaces: [{ name: "acme.v2_beta-1", repo: "a.git", apps: ["one"] }] }).subspaces,
|
|
523
|
+
).toHaveLength(1);
|
|
524
|
+
});
|
|
525
|
+
|
|
516
526
|
test("refuses two subspaces on one cloud workspace, and takes a declaration with none", () => {
|
|
517
527
|
expect(
|
|
518
528
|
() =>
|
package/subspaceClone.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Executor, WorkspaceExecutor } from "./executors";
|
|
|
5
5
|
import { FileSys } from "./fileSys";
|
|
6
6
|
import { LibSource } from "./libSource";
|
|
7
7
|
import { type SlicePlan, SlicePlanner } from "./slicePlanner";
|
|
8
|
-
import type
|
|
8
|
+
import { SubspaceConfig, type SubspaceDeclaration } from "./subspaceConfig";
|
|
9
9
|
import type { PackageJson } from "./types";
|
|
10
10
|
|
|
11
11
|
export class SubspaceClone {
|
|
@@ -43,6 +43,8 @@ export class SubspaceClone {
|
|
|
43
43
|
constructor(workspace: WorkspaceExecutor, declaration: SubspaceDeclaration) {
|
|
44
44
|
this.#workspace = workspace;
|
|
45
45
|
this.#declaration = declaration;
|
|
46
|
+
if (!SubspaceConfig.namePattern.test(declaration.name))
|
|
47
|
+
throw new Error(`Subspace name "${declaration.name}" cannot name a folder under .akan/subspace.`);
|
|
46
48
|
this.#clonePath = path.join(workspace.workspaceRoot, ".akan/subspace", declaration.name);
|
|
47
49
|
this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
|
|
48
50
|
}
|
package/subspaceConfig.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type { SubspaceConfigInput, SubspaceDeclaration };
|
|
|
7
7
|
export class SubspaceConfig {
|
|
8
8
|
static readonly source = "subspace in the workspace root akan.config.ts";
|
|
9
9
|
static readonly defaultPushableBranches = ["main", "develop", "debug"];
|
|
10
|
+
static readonly namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
11
|
|
|
11
12
|
static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
|
|
12
13
|
const { subspace } = await workspace.getConfig();
|
|
@@ -31,6 +32,11 @@ export class SubspaceConfig {
|
|
|
31
32
|
for (const subspace of this.subspaces) {
|
|
32
33
|
if (!subspace.name || !subspace.repo)
|
|
33
34
|
throw new Error(`${SubspaceConfig.source}: every subspace needs a name and a repo`);
|
|
35
|
+
//? The name is the clone folder under .akan/subspace, which a fresh clone empties first.
|
|
36
|
+
if (!SubspaceConfig.namePattern.test(subspace.name))
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${SubspaceConfig.source}: subspace name "${subspace.name}" takes letters, digits, ".", "_" and "-", starting with a letter or digit`,
|
|
39
|
+
);
|
|
34
40
|
if (seenNames.has(subspace.name))
|
|
35
41
|
throw new Error(`${SubspaceConfig.source}: duplicate subspace "${subspace.name}"`);
|
|
36
42
|
seenNames.add(subspace.name);
|