@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.16
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/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +2 -1
- package/biome.base.json +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/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/index.ts +1 -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/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 +142 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/workflow/artifacts.ts +6 -1
- 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 +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +15 -1
- package/workflow/source.test.ts +130 -0
- package/workflow/source.ts +122 -14
- package/workflow/types.ts +17 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { TestOnlyExportScanner } from "./testOnlyExportScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(
|
|
11
|
+
file,
|
|
12
|
+
content,
|
|
13
|
+
ts.ScriptTarget.Latest,
|
|
14
|
+
true,
|
|
15
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const scan = (files: Record<string, string>) =>
|
|
20
|
+
new TestOnlyExportScanner().scan(Object.entries(files).map(([file, content]) => fileOf(file, content)));
|
|
21
|
+
|
|
22
|
+
const layout = [
|
|
23
|
+
"export const groupLabelHeight = 30;",
|
|
24
|
+
"export const computeGroupBoxes = () => [groupLabelHeight];",
|
|
25
|
+
"export const groupEdgeStrips = (pad: number): number[] => (pad > 0 ? groupEdgeStrips(pad - 1) : []);",
|
|
26
|
+
"export const layoutBoard = () => 1;",
|
|
27
|
+
"",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
const layoutTest = [
|
|
31
|
+
'import { computeGroupBoxes, groupEdgeStrips, groupLabelHeight, layoutBoard } from "./boardLayout";',
|
|
32
|
+
"test(() => [computeGroupBoxes(), groupEdgeStrips(1), groupLabelHeight, layoutBoard()]);",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
|
|
36
|
+
describe("TestOnlyExportScanner", () => {
|
|
37
|
+
test("flags an export only tests reference, even when it calls itself", () => {
|
|
38
|
+
const warnings = scan({
|
|
39
|
+
"apps/demo/webkit/boardLayout.ts": layout,
|
|
40
|
+
"apps/demo/webkit/boardLayout.test.ts": layoutTest,
|
|
41
|
+
"apps/demo/ui/Board.tsx":
|
|
42
|
+
'import { layoutBoard } from "@apps/demo/webkit";\nexport const Board = () => layoutBoard();\n',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(warnings.map((warning) => warning.message)).toEqual([
|
|
46
|
+
'Exported "computeGroupBoxes" is referenced only by tests: apps/demo/webkit/boardLayout.test.ts.',
|
|
47
|
+
'Exported "groupEdgeStrips" is referenced only by tests: apps/demo/webkit/boardLayout.test.ts.',
|
|
48
|
+
]);
|
|
49
|
+
expect(warnings[0]).toMatchObject({
|
|
50
|
+
rule: "akan.global.test-only-export",
|
|
51
|
+
scope: "global",
|
|
52
|
+
file: "apps/demo/webkit/boardLayout.ts",
|
|
53
|
+
line: 2,
|
|
54
|
+
locations: [{ file: "apps/demo/webkit/boardLayout.test.ts", line: 1 }],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("passes an export another source file uses, or that a function in its own file uses", () => {
|
|
59
|
+
const warnings = scan({
|
|
60
|
+
"apps/demo/webkit/boardLayout.ts": layout,
|
|
61
|
+
"apps/demo/webkit/boardLayout.test.ts": layoutTest,
|
|
62
|
+
"apps/demo/ui/Board.tsx": [
|
|
63
|
+
'import * as webkit from "@apps/demo/webkit";',
|
|
64
|
+
"export const Board = () => [webkit.computeGroupBoxes(), webkit.groupEdgeStrips(2), webkit.layoutBoard()];",
|
|
65
|
+
"",
|
|
66
|
+
].join("\n"),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(warnings).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("passes an export no test references, and leaves lib facets and lib modules alone", () => {
|
|
73
|
+
expect(
|
|
74
|
+
scan({
|
|
75
|
+
"apps/demo/common/unused.ts": "export const unused = 1;\n",
|
|
76
|
+
"libs/demo/webkit/signedIn.ts": "export const signedIn = () => true;\n",
|
|
77
|
+
"libs/demo/webkit/signedIn.test.ts": 'import { signedIn } from "./signedIn";\ntest(() => signedIn());\n',
|
|
78
|
+
"apps/demo/lib/post/post.constant.ts": "export class PostInput {}\n",
|
|
79
|
+
"apps/demo/lib/post/post.signal.test.ts": "const input = new PostInput();\n",
|
|
80
|
+
}),
|
|
81
|
+
).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
interface ExportedDeclaration {
|
|
5
|
+
name: string;
|
|
6
|
+
line: number;
|
|
7
|
+
statement: ts.Statement;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TestReference {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TestOnlyExportScanner {
|
|
16
|
+
static readonly rule = "akan.global.test-only-export";
|
|
17
|
+
static #facets = new Set(["common", "webkit", "srvkit", "ui"]);
|
|
18
|
+
|
|
19
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
20
|
+
const liveFileNumByName = new Map<string, number>();
|
|
21
|
+
const testRefsByName = new Map<string, TestReference[]>();
|
|
22
|
+
for (const { file, sourceFile } of sourceFiles) {
|
|
23
|
+
const isTest = TestOnlyExportScanner.#isTest(file);
|
|
24
|
+
for (const [name, line] of TestOnlyExportScanner.#identifierLinesOf(sourceFile)) {
|
|
25
|
+
if (!isTest) liveFileNumByName.set(name, (liveFileNumByName.get(name) ?? 0) + 1);
|
|
26
|
+
else if (testRefsByName.has(name)) testRefsByName.get(name)?.push({ file, line });
|
|
27
|
+
else testRefsByName.set(name, [{ file, line }]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return sourceFiles
|
|
31
|
+
.filter(({ file }) => TestOnlyExportScanner.#isFacetSource(file))
|
|
32
|
+
.flatMap(({ file, sourceFile }) => {
|
|
33
|
+
const declarations = TestOnlyExportScanner.#exportedDeclarationsOf(sourceFile);
|
|
34
|
+
return declarations
|
|
35
|
+
.filter(({ name }) => (liveFileNumByName.get(name) ?? 0) <= 1)
|
|
36
|
+
.filter(({ name }) => !TestOnlyExportScanner.#isUsedInFile(sourceFile, name, declarations))
|
|
37
|
+
.flatMap(({ name, line }) => {
|
|
38
|
+
const testRefs = testRefsByName.get(name);
|
|
39
|
+
if (!testRefs?.length) return [];
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
rule: TestOnlyExportScanner.rule,
|
|
43
|
+
scope: "global" as const,
|
|
44
|
+
severity: "warning" as const,
|
|
45
|
+
file,
|
|
46
|
+
line,
|
|
47
|
+
message: `Exported "${name}" is referenced only by tests: ${testRefs.map((ref) => ref.file).join(", ")}.`,
|
|
48
|
+
locations: testRefs,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static #isTest(file: string) {
|
|
56
|
+
return /\.(test|spec)\.tsx?$/.test(file);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//? A lib's facets are public API for every workspace that installs it, so an export no app here calls is not dead.
|
|
60
|
+
static #isFacetSource(file: string) {
|
|
61
|
+
const segments = file.split("/");
|
|
62
|
+
if (segments[0] !== "apps") return false;
|
|
63
|
+
return TestOnlyExportScanner.#facets.has(segments[2] ?? "") && !TestOnlyExportScanner.#isTest(file);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #identifierLinesOf(sourceFile: ts.SourceFile) {
|
|
67
|
+
const lines = new Map<string, number>();
|
|
68
|
+
const visit = (node: ts.Node) => {
|
|
69
|
+
if (ts.isIdentifier(node) && !lines.has(node.text))
|
|
70
|
+
lines.set(node.text, sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
|
|
71
|
+
ts.forEachChild(node, visit);
|
|
72
|
+
};
|
|
73
|
+
visit(sourceFile);
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static #exportedDeclarationsOf(sourceFile: ts.SourceFile): ExportedDeclaration[] {
|
|
78
|
+
return sourceFile.statements.flatMap((statement) => {
|
|
79
|
+
if (!TestOnlyExportScanner.#isNamedExport(statement)) return [];
|
|
80
|
+
return TestOnlyExportScanner.#declaredNamesOf(statement).map((identifier) => ({
|
|
81
|
+
name: identifier.text,
|
|
82
|
+
line: sourceFile.getLineAndCharacterOfPosition(identifier.getStart(sourceFile)).line + 1,
|
|
83
|
+
statement,
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static #isNamedExport(statement: ts.Statement) {
|
|
89
|
+
if (!ts.canHaveModifiers(statement)) return false;
|
|
90
|
+
const modifiers = ts.getModifiers(statement) ?? [];
|
|
91
|
+
return (
|
|
92
|
+
modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) &&
|
|
93
|
+
!modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static #declaredNamesOf(statement: ts.Statement): ts.Identifier[] {
|
|
98
|
+
if (ts.isVariableStatement(statement))
|
|
99
|
+
return statement.declarationList.declarations
|
|
100
|
+
.map((declaration) => declaration.name)
|
|
101
|
+
.filter((name): name is ts.Identifier => ts.isIdentifier(name));
|
|
102
|
+
if (
|
|
103
|
+
ts.isFunctionDeclaration(statement) ||
|
|
104
|
+
ts.isClassDeclaration(statement) ||
|
|
105
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
106
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
107
|
+
ts.isEnumDeclaration(statement)
|
|
108
|
+
)
|
|
109
|
+
return statement.name ? [statement.name] : [];
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static #isUsedInFile(sourceFile: ts.SourceFile, name: string, declarations: ExportedDeclaration[]) {
|
|
114
|
+
const ownStatements = new Set(declarations.filter((entry) => entry.name === name).map((entry) => entry.statement));
|
|
115
|
+
let used = false;
|
|
116
|
+
const visit = (node: ts.Node) => {
|
|
117
|
+
if (used || ownStatements.has(node as ts.Statement)) return;
|
|
118
|
+
if (ts.isIdentifier(node) && node.text === name) used = true;
|
|
119
|
+
else ts.forEachChild(node, visit);
|
|
120
|
+
};
|
|
121
|
+
visit(sourceFile);
|
|
122
|
+
return used;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/workflow/artifacts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { capitalize } from "akanjs/common";
|
|
2
2
|
import type { Workspace } from "../commandDecorators";
|
|
3
|
+
import { InteractionScaffold } from "./interactionScaffold";
|
|
3
4
|
import type {
|
|
4
5
|
GeneratedSyncState,
|
|
5
6
|
PrimitiveChangedFile,
|
|
@@ -156,11 +157,15 @@ export const workflowPathsForPlan = (plan: WorkflowPlan) => {
|
|
|
156
157
|
const app = planInputString(plan, "app");
|
|
157
158
|
const module = planInputString(plan, "module");
|
|
158
159
|
const moduleClass = module ? capitalize(module) : "<Module>";
|
|
160
|
+
const rawInteraction = planInputString(plan, "interaction");
|
|
161
|
+
const interaction = rawInteraction ? (InteractionScaffold.nameOf(rawInteraction) ?? rawInteraction) : null;
|
|
159
162
|
return plan.predictedChanges.map((change) =>
|
|
160
163
|
change.target
|
|
161
164
|
.replace(/^\*\//, app ? `apps/${app}/` : "")
|
|
162
165
|
.replaceAll("<module>", module || "<module>")
|
|
163
|
-
.replaceAll("<Module>", moduleClass)
|
|
166
|
+
.replaceAll("<Module>", moduleClass)
|
|
167
|
+
.replaceAll("<interaction>", interaction || "<interaction>")
|
|
168
|
+
.replaceAll("<Interaction>", interaction ? capitalize(interaction) : "<Interaction>"),
|
|
164
169
|
);
|
|
165
170
|
};
|
|
166
171
|
|
package/workflow/executor.ts
CHANGED
|
@@ -312,7 +312,7 @@ const checkAddFieldStructure = async (workspace: Workspace, plan: WorkflowPlan):
|
|
|
312
312
|
return { diagnostics, postApplyChecks };
|
|
313
313
|
};
|
|
314
314
|
|
|
315
|
-
const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
|
|
315
|
+
export const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
|
|
316
316
|
if (!target) return null;
|
|
317
317
|
const [apps, libs] = await workspace.getSyss();
|
|
318
318
|
if (apps.includes(target)) return AppExecutor.from(workspace, target);
|
|
@@ -375,10 +375,10 @@ const addMutationActionSurfaceInspection = (plan: WorkflowPlan): WorkflowStepRes
|
|
|
375
375
|
const moduleClassName = moduleComponentName(module);
|
|
376
376
|
return surfaceReview(plan, {
|
|
377
377
|
code: "add-mutation-action-surface-review",
|
|
378
|
-
target: `*/${moduleSourcePaths(module).
|
|
379
|
-
action: `
|
|
380
|
-
message: `Workflow apply does not write
|
|
381
|
-
reason: "Review action surface guidance before manually editing
|
|
378
|
+
target: `*/${moduleSourcePaths(module).util}`,
|
|
379
|
+
action: `Fill the ${moduleClassName} chain method (validate, mutate, return this) and replace its transition comment. After sync, call st.do.${mutation}(id) from the control in ${moduleClassName}.Util.tsx; a second write that must land with it is another with/after entry, never a second fetch in the store action.`,
|
|
380
|
+
message: `Workflow apply does not write UI code for ${module}.${mutation}; review which screen calls it.`,
|
|
381
|
+
reason: "Review action surface guidance before manually editing UI files.",
|
|
382
382
|
});
|
|
383
383
|
};
|
|
384
384
|
|
|
@@ -404,6 +404,7 @@ export const createWorkflowStepRegistry = ({
|
|
|
404
404
|
addEnumField,
|
|
405
405
|
addMutation,
|
|
406
406
|
addSlice,
|
|
407
|
+
createInteraction,
|
|
407
408
|
}: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
|
|
408
409
|
const inspect = async () => undefined;
|
|
409
410
|
const commandOnly = async () => undefined;
|
|
@@ -484,17 +485,31 @@ export const createWorkflowStepRegistry = ({
|
|
|
484
485
|
),
|
|
485
486
|
[workflowStepKey("add-enum-field", "update-dictionary")]: inspect,
|
|
486
487
|
[workflowStepKey("add-enum-field", "update-option")]: inspect,
|
|
487
|
-
[workflowStepKey("add-mutation", "update-
|
|
488
|
+
[workflowStepKey("add-mutation", "update-document")]: async (_step, plan) =>
|
|
488
489
|
primitiveReportToWorkflowStepResult(
|
|
489
490
|
await addMutation({
|
|
490
491
|
app: workflowStringInput(plan.inputs.app),
|
|
491
492
|
module: workflowStringInput(plan.inputs.module),
|
|
492
493
|
mutation: workflowStringInput(plan.inputs.mutation),
|
|
494
|
+
guards: workflowStringArrayInput(plan.inputs.guard),
|
|
495
|
+
withCalls: workflowStringArrayInput(plan.inputs.with),
|
|
496
|
+
afterCalls: workflowStringArrayInput(plan.inputs.after),
|
|
493
497
|
}),
|
|
494
498
|
),
|
|
499
|
+
[workflowStepKey("add-mutation", "update-service")]: inspect,
|
|
495
500
|
[workflowStepKey("add-mutation", "update-signal")]: inspect,
|
|
496
|
-
[workflowStepKey("add-mutation", "update-
|
|
501
|
+
[workflowStepKey("add-mutation", "update-store")]: inspect,
|
|
502
|
+
[workflowStepKey("add-mutation", "update-dictionary")]: inspect,
|
|
503
|
+
[workflowStepKey("add-mutation", "add-signal-test")]: inspect,
|
|
504
|
+
[workflowStepKey("add-mutation", "connect-action-ui")]: async (_step, plan) =>
|
|
497
505
|
addMutationActionSurfaceInspection(plan),
|
|
506
|
+
[workflowStepKey("create-interaction", "create-interaction")]: async (_step, plan) =>
|
|
507
|
+
primitiveReportToWorkflowStepResult(
|
|
508
|
+
await createInteraction({
|
|
509
|
+
app: workflowStringInput(plan.inputs.app),
|
|
510
|
+
interaction: workflowStringInput(plan.inputs.interaction),
|
|
511
|
+
}),
|
|
512
|
+
),
|
|
498
513
|
[workflowStepKey("add-slice", "update-service-query")]: async (_step, plan) =>
|
|
499
514
|
primitiveReportToWorkflowStepResult(
|
|
500
515
|
await addSlice({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createTempApp, tempRoots, writeJson, writeText } from "../testHelpers";
|
|
4
|
+
import { GuardCatalog } from "./guardCatalog";
|
|
5
|
+
|
|
6
|
+
const track = tempRoots();
|
|
7
|
+
|
|
8
|
+
const guardFile = (names: string[]) =>
|
|
9
|
+
names.map((name) => `export class ${name} implements Guard {\n canPass() {\n return true;\n }\n}\n`).join("\n");
|
|
10
|
+
|
|
11
|
+
const createAppWithLibs = async () => {
|
|
12
|
+
const temp = track(await createTempApp("demo"));
|
|
13
|
+
const lib = (name: string, files: Record<string, string>) =>
|
|
14
|
+
Promise.all([
|
|
15
|
+
writeText(path.join(temp.root, "libs", name, "akan.config.ts"), "export default {};\n"),
|
|
16
|
+
...Object.entries(files).map(([file, content]) => writeText(path.join(temp.root, "libs", name, file), content)),
|
|
17
|
+
]);
|
|
18
|
+
await lib("shared", {
|
|
19
|
+
"srvkit/guards.ts": guardFile(["Admin", "User"]),
|
|
20
|
+
"srvkit/internalArgs.ts": "export class Self implements InternalArg {}\n",
|
|
21
|
+
"srvkit/guards.signal.test.ts": guardFile(["TestOnly"]),
|
|
22
|
+
"akan.lib.json": JSON.stringify({ libDeps: ["util"] }),
|
|
23
|
+
});
|
|
24
|
+
await lib("util", { "srvkit/guards.ts": guardFile(["Machine"]) });
|
|
25
|
+
await lib("other", { "srvkit/guards.ts": guardFile(["Elsewhere"]) });
|
|
26
|
+
await writeText(path.join(temp.root, "apps/demo/srvkit/guards.ts"), guardFile(["LocalOnly"]));
|
|
27
|
+
return temp;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("GuardCatalog", () => {
|
|
31
|
+
test("reads the target's srvkit, the libs it reaches, and the framework's Public", async () => {
|
|
32
|
+
const { root, app } = await createAppWithLibs();
|
|
33
|
+
await writeJson(path.join(root, "apps/demo/akan.app.json"), { libDeps: ["shared"] });
|
|
34
|
+
|
|
35
|
+
const catalog = await GuardCatalog.load(app);
|
|
36
|
+
|
|
37
|
+
expect(catalog.reachedLibs).toEqual(["shared", "util"]);
|
|
38
|
+
expect(catalog.names.sort()).toEqual(["Admin", "LocalOnly", "Machine", "Public", "User"]);
|
|
39
|
+
expect(catalog.resolve("LocalOnly").specifier).toBe("@apps/demo/srvkit");
|
|
40
|
+
expect(catalog.resolve("Admin").specifier).toBe("@libs/shared/srvkit");
|
|
41
|
+
expect(catalog.resolve("Public").specifier).toBe("akanjs/signal");
|
|
42
|
+
expect(catalog.resolve("Elsewhere").specifier).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("falls back to every workspace lib before a sync has recorded the target's deps", async () => {
|
|
46
|
+
const { app } = await createAppWithLibs();
|
|
47
|
+
|
|
48
|
+
expect((await GuardCatalog.load(app)).resolve("Elsewhere").specifier).toBe("@libs/other/srvkit");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("names the acting user only when the guard guarantees one and its internal arg exists", async () => {
|
|
52
|
+
const { app } = await createAppWithLibs();
|
|
53
|
+
const catalog = await GuardCatalog.load(app);
|
|
54
|
+
|
|
55
|
+
expect(catalog.actingUserOf(["User"])).toMatchObject({ argName: "Self", specifier: "@libs/shared/srvkit" });
|
|
56
|
+
expect(catalog.actingUserOf(["Admin"])).toBeNull();
|
|
57
|
+
expect(catalog.actingUserOf(["SignedIn"])).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Sys } from "../commandDecorators";
|
|
2
|
+
|
|
3
|
+
interface CatalogEntry {
|
|
4
|
+
name: string;
|
|
5
|
+
specifier: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ActingUser {
|
|
9
|
+
argName: string;
|
|
10
|
+
specifier: string;
|
|
11
|
+
param: string;
|
|
12
|
+
value: string;
|
|
13
|
+
serviceParam: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class GuardCatalog {
|
|
17
|
+
static readonly frameworkGuards = ["Public"] as const;
|
|
18
|
+
static readonly #actingArgOf = new Map<string, Omit<ActingUser, "specifier">>([
|
|
19
|
+
["User", { argName: "Self", param: "self", value: "self.id", serviceParam: "selfId" }],
|
|
20
|
+
[
|
|
21
|
+
"SignedIn",
|
|
22
|
+
{ argName: "CurrentUserId", param: "currentUserId", value: "currentUserId", serviceParam: "currentUserId" },
|
|
23
|
+
],
|
|
24
|
+
]);
|
|
25
|
+
readonly #guards: CatalogEntry[];
|
|
26
|
+
readonly #internalArgs: CatalogEntry[];
|
|
27
|
+
readonly reachedLibs: string[];
|
|
28
|
+
|
|
29
|
+
constructor(guards: CatalogEntry[], internalArgs: CatalogEntry[], reachedLibs: string[]) {
|
|
30
|
+
this.#guards = guards;
|
|
31
|
+
this.#internalArgs = internalArgs;
|
|
32
|
+
this.reachedLibs = reachedLibs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static async load(sys: Sys) {
|
|
36
|
+
const reachedLibs = await GuardCatalog.#reachedLibs(sys);
|
|
37
|
+
const scopes = [{ type: sys.type, name: sys.name }, ...reachedLibs.map((name) => ({ type: "lib" as const, name }))];
|
|
38
|
+
const exports = (await Promise.all(scopes.map((scope) => GuardCatalog.#srvkitExports(sys, scope)))).flat();
|
|
39
|
+
const frameworkGuards = GuardCatalog.frameworkGuards.map((name) => ({ name, specifier: "akanjs/signal" }));
|
|
40
|
+
return new GuardCatalog(
|
|
41
|
+
[...exports.filter((entry) => entry.kind === "Guard"), ...frameworkGuards],
|
|
42
|
+
exports.filter((entry) => entry.kind === "InternalArg"),
|
|
43
|
+
reachedLibs,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get names() {
|
|
48
|
+
return [...new Set(this.#guards.map((guard) => guard.name))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
resolve(name: string) {
|
|
52
|
+
const matches = this.#guards.filter((guard) => guard.name === name);
|
|
53
|
+
const specifiers = [...new Set(matches.map((guard) => guard.specifier))];
|
|
54
|
+
return { specifier: specifiers[0] ?? null, ambiguous: specifiers.length > 1 ? specifiers : null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
actingUserOf(guards: readonly string[]): ActingUser | null {
|
|
58
|
+
for (const guard of guards) {
|
|
59
|
+
const acting = GuardCatalog.#actingArgOf.get(guard);
|
|
60
|
+
const specifier = acting
|
|
61
|
+
? this.#internalArgs.find((entry) => entry.name === acting.argName)?.specifier
|
|
62
|
+
: undefined;
|
|
63
|
+
if (acting && specifier) return { ...acting, specifier };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async #reachedLibs(sys: Sys) {
|
|
69
|
+
const workspaceLibs = await sys.workspace.getLibs();
|
|
70
|
+
const depsOf = async (type: "app" | "lib", name: string) => {
|
|
71
|
+
const jsonPath = `${type}s/${name}/akan.${type}.json`;
|
|
72
|
+
if (!(await sys.workspace.exists(jsonPath))) return null;
|
|
73
|
+
const { libDeps } = (await sys.workspace.readJson(jsonPath)) as { libDeps?: unknown };
|
|
74
|
+
return Array.isArray(libDeps) ? libDeps.filter((lib): lib is string => workspaceLibs.includes(lib)) : [];
|
|
75
|
+
};
|
|
76
|
+
const direct = await depsOf(sys.type, sys.name);
|
|
77
|
+
if (!direct) return workspaceLibs.filter((lib) => sys.type !== "lib" || lib !== sys.name);
|
|
78
|
+
const reached = new Set<string>();
|
|
79
|
+
const queue = [...direct];
|
|
80
|
+
while (queue.length) {
|
|
81
|
+
const lib = queue.shift();
|
|
82
|
+
if (!lib || reached.has(lib) || (sys.type === "lib" && lib === sys.name)) continue;
|
|
83
|
+
reached.add(lib);
|
|
84
|
+
queue.push(...((await depsOf("lib", lib)) ?? []));
|
|
85
|
+
}
|
|
86
|
+
return [...reached];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static async #srvkitExports(sys: Sys, scope: { type: "app" | "lib"; name: string }) {
|
|
90
|
+
const dir = `${scope.type}s/${scope.name}/srvkit`;
|
|
91
|
+
const files = (await sys.workspace.readdir(dir)).filter(
|
|
92
|
+
(file) => /\.tsx?$/.test(file) && !/\.(test|spec)\.tsx?$/.test(file) && file !== "index.ts",
|
|
93
|
+
);
|
|
94
|
+
const specifier = `@${scope.type}s/${scope.name}/srvkit`;
|
|
95
|
+
const contents = await Promise.all(files.map((file) => sys.workspace.readFile(`${dir}/${file}`)));
|
|
96
|
+
return contents.flatMap((content) =>
|
|
97
|
+
[...content.matchAll(/export class ([A-Z][A-Za-z0-9_]*)\s+implements\s+([^{]+)\{/g)].flatMap((match) => {
|
|
98
|
+
const implemented = (match[2] ?? "").split(",").map((name) => name.replace(/<.*$/s, "").trim());
|
|
99
|
+
const kind = implemented.includes("Guard")
|
|
100
|
+
? "Guard"
|
|
101
|
+
: implemented.includes("InternalArg")
|
|
102
|
+
? "InternalArg"
|
|
103
|
+
: null;
|
|
104
|
+
return kind && match[1] ? [{ kind, name: match[1], specifier }] : [];
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/workflow/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export * from "./artifacts";
|
|
2
2
|
export * from "./executor";
|
|
3
|
+
export * from "./guardCatalog";
|
|
4
|
+
export * from "./interactionScaffold";
|
|
3
5
|
export * from "./moduleIndex";
|
|
6
|
+
export * from "./mutationScaffold";
|
|
7
|
+
export * from "./mutationSource";
|
|
4
8
|
export * from "./plan";
|
|
5
9
|
export * from "./render";
|
|
6
10
|
export * from "./rolloutGate";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { capitalize, lowerlize } from "akanjs/common";
|
|
2
|
+
import type { Sys } from "../commandDecorators";
|
|
3
|
+
import type { ScaffoldFile, WorkflowDiagnostic } from "./types";
|
|
4
|
+
|
|
5
|
+
export class InteractionScaffold {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly className: string;
|
|
8
|
+
readonly hookName: string;
|
|
9
|
+
readonly #commonSpecifier: string;
|
|
10
|
+
|
|
11
|
+
constructor(sys: Sys, name: string) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.className = capitalize(name);
|
|
14
|
+
this.hookName = `use${this.className}`;
|
|
15
|
+
this.#commonSpecifier = `@${sys.type}s/${sys.name}/common`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static nameOf(raw: string) {
|
|
19
|
+
const name = lowerlize(raw.replace(/^use(?=[A-Z])/, ""));
|
|
20
|
+
return /^[a-z][A-Za-z0-9]*$/.test(name) ? name : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static async build(sys: Sys, raw: string | null) {
|
|
24
|
+
const diagnostics: WorkflowDiagnostic[] = [];
|
|
25
|
+
const name = raw ? InteractionScaffold.nameOf(raw) : null;
|
|
26
|
+
if (!name) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
severity: "error",
|
|
29
|
+
code: "create-interaction-name-invalid",
|
|
30
|
+
input: "interaction",
|
|
31
|
+
message: `Interaction name "${raw ?? ""}" must be camelCase (boardKeys), optionally prefixed with use.`,
|
|
32
|
+
});
|
|
33
|
+
return { diagnostics, files: [] as ScaffoldFile[] };
|
|
34
|
+
}
|
|
35
|
+
const scaffold = new InteractionScaffold(sys, name);
|
|
36
|
+
const files = scaffold.files;
|
|
37
|
+
const taken = (
|
|
38
|
+
await Promise.all(
|
|
39
|
+
scaffold.claimedPaths.map(async (path) =>
|
|
40
|
+
(await sys.exists(path)) ? `${sys.type}s/${sys.name}/${path}` : null,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
).filter((path): path is string => !!path);
|
|
44
|
+
if (taken.length)
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
severity: "error",
|
|
47
|
+
code: "create-interaction-exists",
|
|
48
|
+
input: "interaction",
|
|
49
|
+
message: `${taken.join(", ")} already exists; pick another interaction name or extend the existing files.`,
|
|
50
|
+
});
|
|
51
|
+
return { diagnostics, files: taken.length ? [] : files };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get claimedPaths() {
|
|
55
|
+
const { name, hookName } = this;
|
|
56
|
+
return [
|
|
57
|
+
`common/${name}.ts`,
|
|
58
|
+
`common/${name}.tsx`,
|
|
59
|
+
`common/${name}.test.ts`,
|
|
60
|
+
`webkit/${hookName}.tsx`,
|
|
61
|
+
`webkit/${hookName}.ts`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get files(): ScaffoldFile[] {
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
path: `common/${this.name}.ts`,
|
|
69
|
+
content: this.logicSource,
|
|
70
|
+
action: "create",
|
|
71
|
+
reason: "Pure interaction logic.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: `common/${this.name}.test.ts`,
|
|
75
|
+
content: this.testSource,
|
|
76
|
+
action: "create",
|
|
77
|
+
reason: "Test of the pure interaction logic.",
|
|
78
|
+
},
|
|
79
|
+
{ path: `webkit/${this.hookName}.tsx`, content: this.hookSource, action: "create", reason: "Client hook." },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get logicSource() {
|
|
84
|
+
return [
|
|
85
|
+
`export class ${this.className} {`,
|
|
86
|
+
" static next<State extends object>(state: State, input: Partial<State>) {",
|
|
87
|
+
" return { ...state, ...input };",
|
|
88
|
+
" }",
|
|
89
|
+
"}",
|
|
90
|
+
"",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get testSource() {
|
|
95
|
+
return [
|
|
96
|
+
'import { describe, expect, it } from "bun:test";',
|
|
97
|
+
`import { ${this.className} } from "./${this.name}";`,
|
|
98
|
+
"",
|
|
99
|
+
`describe(${JSON.stringify(this.className)}, () => {`,
|
|
100
|
+
' it("applies an input over the state it is handed", () => {',
|
|
101
|
+
` expect(${this.className}.next({ idx: 0, isOpen: false }, { idx: 1 })).toEqual({ idx: 1, isOpen: false });`,
|
|
102
|
+
" });",
|
|
103
|
+
"});",
|
|
104
|
+
"",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get hookSource() {
|
|
109
|
+
return [
|
|
110
|
+
'"use client";',
|
|
111
|
+
`import { ${this.className} } from "${this.#commonSpecifier}";`,
|
|
112
|
+
"",
|
|
113
|
+
`export const ${this.hookName} = <State extends object>(state: State, commit: (next: State) => void) => {`,
|
|
114
|
+
" const change = async (input: Partial<State>) => {",
|
|
115
|
+
` commit(${this.className}.next(state, input));`,
|
|
116
|
+
" };",
|
|
117
|
+
" return { change };",
|
|
118
|
+
"};",
|
|
119
|
+
"",
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
}
|