@akanjs/devkit 3.1.0 → 3.1.1-rc.1
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/akanConfig/akanConfig.ts +1 -1
- package/artifact/implicitRootLayout.ts +1 -1
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +14 -10
- package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
- package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
- package/frontendBuild/precompressArtifacts.test.ts +42 -0
- package/frontendBuild/precompressArtifacts.ts +21 -10
- package/frontendBuild/routeClientBuilder.ts +1 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/index.ts +0 -1
- package/mobile/desktopServerStage.ts +1 -1
- package/mobile/nativeApi.ts +1 -1
- package/mobile/nativeApp.ts +1 -1
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updatePackFolder.ts +1 -1
- package/mobile/updateUploader.ts +1 -1
- package/package.json +2 -2
- package/renderMutationScanner.test.ts +223 -0
- package/renderMutationScanner.ts +209 -0
- package/signalMutationIndex.test.ts +120 -0
- package/signalMutationIndex.ts +127 -0
- package/ssrScanner.test.ts +31 -0
- package/ssrScanner.ts +31 -12
- package/transforms/barrelImportsPlugin.ts +4 -2
- package/uploadRelease.ts +0 -80
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { RenderMutationScanner } from "./renderMutationScanner";
|
|
5
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
6
|
+
|
|
7
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
8
|
+
file,
|
|
9
|
+
absolutePath: `/repo/${file}`,
|
|
10
|
+
content,
|
|
11
|
+
sourceFile: ts.createSourceFile(
|
|
12
|
+
file,
|
|
13
|
+
content,
|
|
14
|
+
ts.ScriptTarget.Latest,
|
|
15
|
+
true,
|
|
16
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const signal = fileOf(
|
|
21
|
+
"apps/demo/lib/item/item.signal.ts",
|
|
22
|
+
`
|
|
23
|
+
export class ItemSlice extends slice(srv.item, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
|
|
24
|
+
export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
25
|
+
approveItem: mutation(cnst.Item, { guards: [User] }).param("itemId", ID).exec(async function (itemId) {
|
|
26
|
+
return await this.itemService.approve(itemId);
|
|
27
|
+
}),
|
|
28
|
+
itemCount: query(Int, { guards: [User] }).exec(async function () {
|
|
29
|
+
return 0;
|
|
30
|
+
}),
|
|
31
|
+
})) {}
|
|
32
|
+
`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const PAGE = "apps/demo/page/item/[itemId].tsx";
|
|
36
|
+
const pageOf = (chain: string, file = PAGE) =>
|
|
37
|
+
fileOf(
|
|
38
|
+
file,
|
|
39
|
+
`import { fetch, usePage } from "@apps/demo/client";\nimport { ID } from "akanjs/base";\nimport { page } from "akanjs/client";\n\nexport default ${chain};\n`,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const scan = (...files: SourceFileInfo[]) =>
|
|
43
|
+
new RenderMutationScanner(new SignalMutationIndex([signal, ...files])).scan(files);
|
|
44
|
+
|
|
45
|
+
describe("akan.ssr.render-mutation", () => {
|
|
46
|
+
test("flags one diagnostic per mutation a render sends, awaited or not", () => {
|
|
47
|
+
const warnings = scan(
|
|
48
|
+
pageOf(`page()
|
|
49
|
+
.param("itemId", ID)
|
|
50
|
+
.render(async ({ itemId }) => {
|
|
51
|
+
const [item] = await Promise.all([fetch.approveItem(itemId), fetch.itemCount()]);
|
|
52
|
+
void fetch.updateItem(itemId, {});
|
|
53
|
+
return <p>{item.id}</p>;
|
|
54
|
+
})`),
|
|
55
|
+
);
|
|
56
|
+
expect(warnings.map(({ rule, line }) => ({ rule, line }))).toEqual([
|
|
57
|
+
{ rule: "akan.ssr.render-mutation", line: 8 },
|
|
58
|
+
{ rule: "akan.ssr.render-mutation", line: 9 },
|
|
59
|
+
]);
|
|
60
|
+
expect(warnings[0]?.message).toContain("fetch.approveItem()");
|
|
61
|
+
expect(warnings[0]?.fix).toContain("{ duringRender: true }");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a query, a store action and a client loader are not findings", () => {
|
|
65
|
+
const warnings = scan(
|
|
66
|
+
pageOf(`page()
|
|
67
|
+
.param("itemId", ID)
|
|
68
|
+
.render(({ itemId }) => {
|
|
69
|
+
const { itemInitInOrg } = fetch.initItemInOrg(itemId);
|
|
70
|
+
return <Item.Zone.Card init={itemInitInOrg} count={fetch.itemCount()} />;
|
|
71
|
+
})`),
|
|
72
|
+
);
|
|
73
|
+
expect(warnings).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a JSX event handler runs on a user action, while a mapped call still runs in the render", () => {
|
|
77
|
+
const warnings = scan(
|
|
78
|
+
pageOf(`page()
|
|
79
|
+
.param("itemId", ID)
|
|
80
|
+
.render(({ itemId }) => {
|
|
81
|
+
return (
|
|
82
|
+
<form onSubmit={async () => { await fetch.approveItem(itemId); }}>
|
|
83
|
+
{[itemId].map((id) => <p key={id}>{String(fetch.approveItem(id))}</p>)}
|
|
84
|
+
</form>
|
|
85
|
+
);
|
|
86
|
+
})`),
|
|
87
|
+
);
|
|
88
|
+
expect(warnings.map(({ line }) => line)).toEqual([10]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a call that opts in with { duringRender: true } is not a finding", () => {
|
|
92
|
+
expect(
|
|
93
|
+
scan(
|
|
94
|
+
pageOf(
|
|
95
|
+
`page().param("itemId", ID).render(async ({ itemId }) => { await fetch.approveItem(itemId, { duringRender: true }); return null; })`,
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
).toEqual([]);
|
|
99
|
+
expect(
|
|
100
|
+
scan(
|
|
101
|
+
pageOf(`page().render(async () => { await fetch.approveItem("x", { duringRender: false }); return null; })`),
|
|
102
|
+
),
|
|
103
|
+
).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("reads the opt-in only from the slot the runtime reads, right after the declared arguments", () => {
|
|
107
|
+
const renderOf = (call: string) =>
|
|
108
|
+
scan(pageOf(`page().param("itemId", ID).render(async ({ itemId }) => { await ${call}; return null; })`)).length;
|
|
109
|
+
expect(renderOf(`fetch.updateItem(itemId, { duringRender: true })`)).toBe(1);
|
|
110
|
+
expect(renderOf(`fetch.updateItem(itemId, { title: "x", duringRender: true })`)).toBe(1);
|
|
111
|
+
expect(renderOf(`fetch.updateItem(itemId, { title: "x" }, { duringRender: true })`)).toBe(0);
|
|
112
|
+
expect(renderOf(`fetch.mergeItem(itemId, {}, { duringRender: true } as const)`)).toBe(0);
|
|
113
|
+
expect(renderOf(`fetch.addItemFiles([], { duringRender: true })`)).toBe(1);
|
|
114
|
+
expect(renderOf(`fetch.addItemFiles([], undefined, { duringRender: true })`)).toBe(0);
|
|
115
|
+
expect(renderOf(`fetch.createItem({ duringRender: true })`)).toBe(1);
|
|
116
|
+
expect(renderOf(`fetch.approveItem(itemId, { "duringRender": true })`)).toBe(0);
|
|
117
|
+
expect(renderOf(`fetch.approveItem(itemId, { duringRender: undefined })`)).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("takes a policy it cannot evaluate as an opt-in, and a value that cannot be one as none", () => {
|
|
121
|
+
const renderOf = (call: string) =>
|
|
122
|
+
scan(
|
|
123
|
+
pageOf(
|
|
124
|
+
`page().param("itemId", ID).render(async ({ itemId }) => { const warm = { duringRender: true }; await ${call}; return null; })`,
|
|
125
|
+
),
|
|
126
|
+
).length;
|
|
127
|
+
for (const call of [
|
|
128
|
+
"fetch.approveItem(itemId, warm)",
|
|
129
|
+
"fetch.approveItem(itemId, { ...warm })",
|
|
130
|
+
"fetch.approveItem(itemId, policyOf(itemId))",
|
|
131
|
+
"fetch.approveItem(...[itemId, warm])",
|
|
132
|
+
])
|
|
133
|
+
expect(renderOf(call)).toBe(0);
|
|
134
|
+
for (const call of [
|
|
135
|
+
"fetch.approveItem(itemId, undefined)",
|
|
136
|
+
"fetch.approveItem(itemId, null)",
|
|
137
|
+
'fetch.approveItem(itemId, "warm")',
|
|
138
|
+
"fetch.updateItem(itemId, warm)",
|
|
139
|
+
])
|
|
140
|
+
expect(renderOf(call)).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("follows a same-file function the stage calls or renders, one level deep", () => {
|
|
144
|
+
const file = fileOf(
|
|
145
|
+
PAGE,
|
|
146
|
+
`import { fetch } from "@apps/demo/client";
|
|
147
|
+
import { ID } from "akanjs/base";
|
|
148
|
+
import { page } from "akanjs/client";
|
|
149
|
+
|
|
150
|
+
const approve = async (id: string) => await fetch.approveItem(id);
|
|
151
|
+
function Approval({ id }: { id: string }) {
|
|
152
|
+
void fetch.removeItem(id);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const unused = async () => await fetch.createItem({});
|
|
156
|
+
const onClickOnly = () => fetch.updateItem("x", {});
|
|
157
|
+
|
|
158
|
+
export default page()
|
|
159
|
+
.param("itemId", ID)
|
|
160
|
+
.render(async ({ itemId }) => {
|
|
161
|
+
await approve(itemId);
|
|
162
|
+
return <Approval id={itemId} onSelect={onClickOnly} />;
|
|
163
|
+
});
|
|
164
|
+
`,
|
|
165
|
+
);
|
|
166
|
+
expect(scan(file).map(({ line }) => line)).toEqual([5, 7]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("reads a page exported by name, a stage given by name and a call by string key", () => {
|
|
170
|
+
const legacy = fileOf(
|
|
171
|
+
PAGE,
|
|
172
|
+
`import { fetch } from "@apps/demo/client";\nconst Page = async () => {\n await fetch.approveItem("x");\n return null;\n};\nexport default Page;\n`,
|
|
173
|
+
);
|
|
174
|
+
expect(scan(legacy).map(({ line }) => line)).toEqual([3]);
|
|
175
|
+
const named = fileOf(
|
|
176
|
+
PAGE,
|
|
177
|
+
`import { fetch } from "@apps/demo/client";\nimport { page } from "akanjs/client";\nconst render = async () => {\n await fetch["approveItem"]("x");\n return null;\n};\nexport default page().render(render);\n`,
|
|
178
|
+
);
|
|
179
|
+
expect(scan(named).map(({ line, message }) => [line, message.split("()")[0]])).toEqual([
|
|
180
|
+
[4, 'fetch["approveItem"]'],
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reads the guard, the head and a layout's render too", () => {
|
|
185
|
+
const guarded = pageOf(`page()
|
|
186
|
+
.guard(async () => { await fetch.removeItem("x"); })
|
|
187
|
+
.head(async () => { await fetch.approveItem("x"); return <title>x</title>; })
|
|
188
|
+
.render(() => null)`);
|
|
189
|
+
const wrapping = pageOf(
|
|
190
|
+
`layout().render(async ({ children }) => { await fetch.createItem({}); return children; })`,
|
|
191
|
+
"apps/demo/page/item/_layout.tsx",
|
|
192
|
+
);
|
|
193
|
+
expect(scan(guarded, wrapping).map(({ file, line }) => `${file}:${line}`)).toEqual([
|
|
194
|
+
`${PAGE}:6`,
|
|
195
|
+
`${PAGE}:7`,
|
|
196
|
+
"apps/demo/page/item/_layout.tsx:5",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("reads a legacy default-exported page function", () => {
|
|
201
|
+
expect(scan(pageOf(`async function Page() { await fetch.approveItem("x"); return null; }`))).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an app load, a file outside page/ and a page with no fetch import are not read", () => {
|
|
205
|
+
expect(
|
|
206
|
+
scan(
|
|
207
|
+
pageOf(
|
|
208
|
+
`page().app("showItem", "Show the item.", async () => { await fetch.approveItem("x"); return null; }).render(() => null)`,
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
).toEqual([]);
|
|
212
|
+
expect(
|
|
213
|
+
scan(
|
|
214
|
+
pageOf(`page().render(async () => { await fetch.approveItem("x"); return null; })`, "apps/demo/ui/Item.tsx"),
|
|
215
|
+
),
|
|
216
|
+
).toEqual([]);
|
|
217
|
+
const global = fileOf(
|
|
218
|
+
PAGE,
|
|
219
|
+
`export default page().render(async () => { await fetch.approveItem("x"); return null; });`,
|
|
220
|
+
);
|
|
221
|
+
expect(scan(global)).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
import type { SignalMutationIndex } from "./signalMutationIndex";
|
|
4
|
+
|
|
5
|
+
//? A server render sends the visitor's credential with no Origin, so its writes run for whoever opens a link.
|
|
6
|
+
export class RenderMutationScanner {
|
|
7
|
+
static readonly rule = "akan.ssr.render-mutation";
|
|
8
|
+
static readonly fix =
|
|
9
|
+
"Send the write from an explicit user action, such as a button that calls a store action. Only a write that is harmless to repeat and safe for anyone to trigger may stay in the render, with `{ duringRender: true }` as the call's option.";
|
|
10
|
+
static #builders = new Set(["page", "layout", "rootLayout"]);
|
|
11
|
+
static #stages = new Set(["render", "guard", "head", "loading", "notFound", "error"]);
|
|
12
|
+
readonly #mutations: SignalMutationIndex;
|
|
13
|
+
|
|
14
|
+
constructor(mutations: SignalMutationIndex) {
|
|
15
|
+
this.#mutations = mutations;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
19
|
+
return sourceFiles
|
|
20
|
+
.filter(({ file }) => /^(apps|libs)\/[^/]+\/page\/.+(?<!\.test|\.spec)\.tsx$/.test(file))
|
|
21
|
+
.flatMap((sourceFile) => this.#scanRoute(sourceFile));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#scanRoute({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
25
|
+
const fetchNames = RenderMutationScanner.#fetchBindingsOf(sourceFile);
|
|
26
|
+
if (!fetchNames.size) return [];
|
|
27
|
+
const functions = RenderMutationScanner.#functionsOf(sourceFile);
|
|
28
|
+
const calls = new Set<ts.CallExpression>();
|
|
29
|
+
const helpers = new Set<ts.Node>();
|
|
30
|
+
const visit = (node: ts.Node, follow: boolean) => {
|
|
31
|
+
if (RenderMutationScanner.#isEventHandler(node)) return;
|
|
32
|
+
if (this.#isRenderMutation(file, node, fetchNames)) calls.add(node);
|
|
33
|
+
const helper = follow ? RenderMutationScanner.#helperOf(node, functions) : undefined;
|
|
34
|
+
if (helper) helpers.add(helper);
|
|
35
|
+
ts.forEachChild(node, (child) => visit(child, follow));
|
|
36
|
+
};
|
|
37
|
+
for (const body of RenderMutationScanner.#renderBodiesOf(sourceFile, functions)) visit(body, true);
|
|
38
|
+
for (const helper of helpers) visit(helper, false);
|
|
39
|
+
return [...calls]
|
|
40
|
+
.sort((a, b) => a.getStart(sourceFile) - b.getStart(sourceFile))
|
|
41
|
+
.map((call) => ({
|
|
42
|
+
rule: RenderMutationScanner.rule,
|
|
43
|
+
scope: "ssr",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
file,
|
|
46
|
+
line: sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)).line + 1,
|
|
47
|
+
message: `${call.expression.getText(sourceFile)}() is a mutation sent while the route renders: anyone who opens a link to this page would perform it, and the call is refused unless it passes { duringRender: true }.`,
|
|
48
|
+
fix: RenderMutationScanner.fix,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#isRenderMutation(file: string, node: ts.Node, fetchNames: Set<string>): node is ts.CallExpression {
|
|
53
|
+
if (!ts.isCallExpression(node)) return false;
|
|
54
|
+
const name = RenderMutationScanner.#endpointOf(node.expression, fetchNames);
|
|
55
|
+
if (!name || !this.#mutations.isMutation(file, name)) return false;
|
|
56
|
+
return !RenderMutationScanner.#optsIn(node, this.#mutations.policyIndexOf(file, name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static #isEventHandler(node: ts.Node) {
|
|
60
|
+
return ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && /^on[A-Z]/.test(node.name.text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #endpointOf(callee: ts.Expression, fetchNames: Set<string>) {
|
|
64
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
65
|
+
return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.name.text : null;
|
|
66
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression))
|
|
67
|
+
return RenderMutationScanner.#isFetch(callee.expression, fetchNames) ? callee.argumentExpression.text : null;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #isFetch(expression: ts.Expression, fetchNames: Set<string>) {
|
|
72
|
+
return ts.isIdentifier(expression) && fetchNames.has(expression.text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//? The runtime reads only the policy slot, and a value there this cannot evaluate (a variable, a call) may opt in.
|
|
76
|
+
static #optsIn(call: ts.CallExpression, policyIndex: number | null) {
|
|
77
|
+
if (policyIndex === null) return false;
|
|
78
|
+
const leading = call.arguments.slice(0, policyIndex + 1);
|
|
79
|
+
if (leading.some((argument) => ts.isSpreadElement(argument))) return true;
|
|
80
|
+
const policy = leading[policyIndex];
|
|
81
|
+
return policy ? RenderMutationScanner.#policyOptsIn(policy) : false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static #policyOptsIn(argument: ts.Expression): boolean {
|
|
85
|
+
const policy = RenderMutationScanner.#unwrap(argument);
|
|
86
|
+
if (!ts.isObjectLiteralExpression(policy)) return !RenderMutationScanner.#cannotBePolicy(policy);
|
|
87
|
+
return policy.properties.some((property) => {
|
|
88
|
+
if (ts.isSpreadAssignment(property)) return true;
|
|
89
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return false;
|
|
90
|
+
if (!(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) return false;
|
|
91
|
+
if (property.name.text !== "duringRender") return false;
|
|
92
|
+
if (ts.isShorthandPropertyAssignment(property)) return true;
|
|
93
|
+
const value = RenderMutationScanner.#unwrap(property.initializer);
|
|
94
|
+
return value.kind !== ts.SyntaxKind.FalseKeyword && !(ts.isIdentifier(value) && value.text === "undefined");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static #unwrap(expression: ts.Expression): ts.Expression {
|
|
99
|
+
let current = expression;
|
|
100
|
+
while (
|
|
101
|
+
ts.isParenthesizedExpression(current) ||
|
|
102
|
+
ts.isAsExpression(current) ||
|
|
103
|
+
ts.isSatisfiesExpression(current) ||
|
|
104
|
+
ts.isNonNullExpression(current) ||
|
|
105
|
+
ts.isTypeAssertionExpression(current)
|
|
106
|
+
)
|
|
107
|
+
current = current.expression;
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #cannotBePolicy(expression: ts.Expression) {
|
|
112
|
+
if (ts.isIdentifier(expression)) return expression.text === "undefined";
|
|
113
|
+
return (
|
|
114
|
+
ts.isLiteralExpression(expression) ||
|
|
115
|
+
ts.isNoSubstitutionTemplateLiteral(expression) ||
|
|
116
|
+
ts.isTemplateExpression(expression) ||
|
|
117
|
+
ts.isArrayLiteralExpression(expression) ||
|
|
118
|
+
ts.isArrowFunction(expression) ||
|
|
119
|
+
ts.isFunctionExpression(expression) ||
|
|
120
|
+
ts.isVoidExpression(expression) ||
|
|
121
|
+
ts.isPrefixUnaryExpression(expression) ||
|
|
122
|
+
[ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(expression.kind)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #fetchBindingsOf(sourceFile: ts.SourceFile) {
|
|
127
|
+
const names = new Set<string>();
|
|
128
|
+
for (const statement of sourceFile.statements) {
|
|
129
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
130
|
+
const bindings = statement.importClause?.namedBindings;
|
|
131
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
132
|
+
for (const element of bindings.elements)
|
|
133
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === "fetch")
|
|
134
|
+
names.add(element.name.text);
|
|
135
|
+
}
|
|
136
|
+
return names;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #functionsOf(sourceFile: ts.SourceFile) {
|
|
140
|
+
const functions = new Map<string, ts.Node>();
|
|
141
|
+
for (const statement of sourceFile.statements) {
|
|
142
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body)
|
|
143
|
+
functions.set(statement.name.text, statement.body);
|
|
144
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
145
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
146
|
+
const initializer = declaration.initializer && RenderMutationScanner.#unwrap(declaration.initializer);
|
|
147
|
+
if (!ts.isIdentifier(declaration.name) || !initializer) continue;
|
|
148
|
+
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
|
|
149
|
+
functions.set(declaration.name.text, initializer.body);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return functions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//? One level only: a function the stage calls, or renders as a component, runs while the route renders.
|
|
156
|
+
static #helperOf(node: ts.Node, functions: Map<string, ts.Node>) {
|
|
157
|
+
const callee = ts.isCallExpression(node)
|
|
158
|
+
? node.expression
|
|
159
|
+
: ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)
|
|
160
|
+
? node.tagName
|
|
161
|
+
: undefined;
|
|
162
|
+
return callee && ts.isIdentifier(callee) ? functions.get(callee.text) : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static #renderBodiesOf(sourceFile: ts.SourceFile, functions: Map<string, ts.Node>) {
|
|
166
|
+
const bodies: ts.Node[] = [];
|
|
167
|
+
const bodyOf = (node: ts.Node) => (ts.isIdentifier(node) ? functions.get(node.text) : node);
|
|
168
|
+
const visit = (node: ts.Node) => {
|
|
169
|
+
if (
|
|
170
|
+
ts.isCallExpression(node) &&
|
|
171
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
172
|
+
RenderMutationScanner.#stages.has(node.expression.name.text) &&
|
|
173
|
+
RenderMutationScanner.#isRouteChain(node.expression.expression)
|
|
174
|
+
)
|
|
175
|
+
for (const argument of node.arguments) {
|
|
176
|
+
const body = bodyOf(argument);
|
|
177
|
+
if (body) bodies.push(body);
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(node, visit);
|
|
180
|
+
};
|
|
181
|
+
visit(sourceFile);
|
|
182
|
+
for (const statement of sourceFile.statements) {
|
|
183
|
+
const legacy = RenderMutationScanner.#legacyPageOf(statement, functions);
|
|
184
|
+
if (legacy) bodies.push(legacy);
|
|
185
|
+
}
|
|
186
|
+
return bodies;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static #isRouteChain(expression: ts.Expression) {
|
|
190
|
+
let current = expression;
|
|
191
|
+
while (ts.isCallExpression(current)) {
|
|
192
|
+
if (ts.isIdentifier(current.expression)) return RenderMutationScanner.#builders.has(current.expression.text);
|
|
193
|
+
if (!ts.isPropertyAccessExpression(current.expression)) return false;
|
|
194
|
+
current = current.expression.expression;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
static #legacyPageOf(statement: ts.Statement, functions: Map<string, ts.Node>) {
|
|
200
|
+
if (ts.isFunctionDeclaration(statement))
|
|
201
|
+
return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
202
|
+
? statement.body
|
|
203
|
+
: undefined;
|
|
204
|
+
if (!ts.isExportAssignment(statement)) return undefined;
|
|
205
|
+
const expression = RenderMutationScanner.#unwrap(statement.expression);
|
|
206
|
+
if (ts.isIdentifier(expression)) return functions.get(expression.text);
|
|
207
|
+
return ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) ? expression.body : undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ticketSignal = fileOf(
|
|
14
|
+
"apps/demo/lib/ticket/ticket.signal.ts",
|
|
15
|
+
`
|
|
16
|
+
export class TicketSlice extends slice(srv.ticket, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
|
|
17
|
+
|
|
18
|
+
export class TicketEndpoint extends endpoint(srv.ticket.with(srv.util.security), ({ query, mutation: write, message }) => ({
|
|
19
|
+
approveTicket: write(cnst.Ticket, { guards: [User] })
|
|
20
|
+
.param("ticketId", ID)
|
|
21
|
+
.exec(async function (ticketId) {
|
|
22
|
+
return await this.ticketService.approve(ticketId);
|
|
23
|
+
}),
|
|
24
|
+
ticketCount: query(Int, { guards: [User] }).exec(async function () {
|
|
25
|
+
return 0;
|
|
26
|
+
}),
|
|
27
|
+
nudgeTicket: message(Boolean, { guards: [User] })
|
|
28
|
+
.msg("ticketId", ID)
|
|
29
|
+
.exec(function (ticketId) {
|
|
30
|
+
return !!ticketId;
|
|
31
|
+
}),
|
|
32
|
+
}))
|
|
33
|
+
{}
|
|
34
|
+
`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
describe("SignalMutationIndex", () => {
|
|
38
|
+
test("reads each endpoint's kind from its declaration, aliased builders included", () => {
|
|
39
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
40
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "approveTicket")).toBe(true);
|
|
41
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
|
|
42
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "initTicketInOrg")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("counts a socket message as a mutation, its policy after the message arguments", () => {
|
|
46
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
47
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "nudgeTicket")).toBe(true);
|
|
48
|
+
expect(index.policyIndexOf("apps/demo/page/x.tsx", "nudgeTicket")).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("counts the CRUD a model's slice generates as mutations", () => {
|
|
52
|
+
const index = new SignalMutationIndex([ticketSignal]);
|
|
53
|
+
for (const name of ["createTicket", "updateTicket", "removeTicket", "mergeTicket", "addTicketFiles"])
|
|
54
|
+
expect(index.isMutation("apps/demo/page/x.tsx", name)).toBe(true);
|
|
55
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticket")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("places each mutation's policy right after the arguments the client takes", () => {
|
|
59
|
+
const signal = fileOf(
|
|
60
|
+
"apps/demo/lib/item/item.signal.ts",
|
|
61
|
+
`export class ItemSlice extends slice(srv.item, { guards: { root: Admin, cru: User } }, () => ({})) {}
|
|
62
|
+
export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
63
|
+
noteItem: mutation(cnst.Item, { guards: [User] })
|
|
64
|
+
.param("itemId", ID)
|
|
65
|
+
.body("note", String)
|
|
66
|
+
.search("tag", String)
|
|
67
|
+
.with(Ip)
|
|
68
|
+
.exec(async function (itemId, note, tag, ip) {
|
|
69
|
+
return await this.itemService.note(itemId, note, tag, ip);
|
|
70
|
+
}),
|
|
71
|
+
touchItems: mutation(Boolean, { guards: [User] }).exec(() => true),
|
|
72
|
+
itemCount: query(Int, { guards: [User] }).param("itemId", ID).exec(() => 0),
|
|
73
|
+
})) {}`,
|
|
74
|
+
);
|
|
75
|
+
const index = new SignalMutationIndex([signal]);
|
|
76
|
+
const policyOf = (name: string) => index.policyIndexOf("apps/demo/page/x.tsx", name);
|
|
77
|
+
expect(policyOf("noteItem")).toBe(3);
|
|
78
|
+
expect(policyOf("touchItems")).toBe(0);
|
|
79
|
+
expect([policyOf("createItem"), policyOf("removeItem")]).toEqual([1, 1]);
|
|
80
|
+
expect([policyOf("updateItem"), policyOf("mergeItem"), policyOf("addItemFiles")]).toEqual([2, 2, 2]);
|
|
81
|
+
expect(policyOf("itemCount")).toBeNull();
|
|
82
|
+
expect(policyOf("unknownItem")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reads a builder given as one parameter and a block body", () => {
|
|
86
|
+
const signal = fileOf(
|
|
87
|
+
"libs/shared/lib/_util/util.signal.ts",
|
|
88
|
+
`export class UtilEndpoint extends endpoint(srv.util, (b) => {
|
|
89
|
+
return { ping: b.query(String, { guards: [Public] }).exec(() => "pong"), touch: b.mutation(Boolean, { guards: [User] }).exec(() => true) };
|
|
90
|
+
}) {}`,
|
|
91
|
+
);
|
|
92
|
+
const index = new SignalMutationIndex([signal]);
|
|
93
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "touch")).toBe(true);
|
|
94
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "ping")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("scopes an app's endpoints to that app and every lib's to all of them", () => {
|
|
98
|
+
const otherApp = fileOf(
|
|
99
|
+
"apps/other/lib/_other/other.signal.ts",
|
|
100
|
+
`export class OtherEndpoint extends endpoint(srv.other, ({ mutation }) => ({ approveTicket: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
101
|
+
);
|
|
102
|
+
const lib = fileOf(
|
|
103
|
+
"libs/shared/lib/_shared/shared.signal.ts",
|
|
104
|
+
`export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ ticketCount: mutation(Boolean, {}).exec(() => true), touchShared: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
105
|
+
);
|
|
106
|
+
const index = new SignalMutationIndex([ticketSignal, otherApp, lib]);
|
|
107
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "touchShared")).toBe(true);
|
|
108
|
+
expect(index.isMutation("apps/demo/page/x.tsx", "ticketCount")).toBe(false);
|
|
109
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "approveTicket")).toBe(false);
|
|
110
|
+
expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("ignores signal tests and specs", () => {
|
|
114
|
+
const spec = fileOf(
|
|
115
|
+
"apps/demo/lib/ticket/ticket.signal.spec.ts",
|
|
116
|
+
`endpoint(srv.ticket, ({ mutation }) => ({ fixture: mutation(Boolean, {}).exec(() => true) }));`,
|
|
117
|
+
);
|
|
118
|
+
expect(new SignalMutationIndex([spec]).isMutation("apps/demo/page/x.tsx", "fixture")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
interface EndpointShape {
|
|
5
|
+
kind: "query" | "mutation";
|
|
6
|
+
argCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class SignalMutationIndex {
|
|
10
|
+
//? As the client generates them: `update<Model>(id, data, option?)`, `add<Model>Files(files, parentId?, option?)`.
|
|
11
|
+
static #generatedArgCounts = { create: 1, update: 2, remove: 1, merge: 2 } as const;
|
|
12
|
+
static #argMethods = new Set(["param", "body", "search", "room", "msg"]);
|
|
13
|
+
readonly #shapesByOwner = new Map<string, Map<string, EndpointShape>>();
|
|
14
|
+
|
|
15
|
+
constructor(sourceFiles: SourceFileInfo[]) {
|
|
16
|
+
for (const { file, sourceFile } of sourceFiles) {
|
|
17
|
+
if (!/^(apps|libs)\/[^/]+\/lib\/.+\.signal\.ts$/.test(file)) continue;
|
|
18
|
+
const owner = file.split("/").slice(0, 2).join("/");
|
|
19
|
+
const shapes = this.#shapesByOwner.get(owner) ?? new Map<string, EndpointShape>();
|
|
20
|
+
SignalMutationIndex.#collect(sourceFile, shapes);
|
|
21
|
+
this.#shapesByOwner.set(owner, shapes);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isMutation(consumerFile: string, name: string) {
|
|
26
|
+
return this.#shapeOf(consumerFile, name)?.kind === "mutation";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a call to the mutation passes its `FetchPolicy`: the argument right after the ones it declares. */
|
|
30
|
+
policyIndexOf(consumerFile: string, name: string): number | null {
|
|
31
|
+
const shape = this.#shapeOf(consumerFile, name);
|
|
32
|
+
return shape?.kind === "mutation" ? shape.argCount : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#shapeOf(consumerFile: string, name: string) {
|
|
36
|
+
const [root, owner] = consumerFile.split("/");
|
|
37
|
+
const own = root === "apps" ? this.#shapesByOwner.get(`apps/${owner}`)?.get(name) : undefined;
|
|
38
|
+
if (own) return own;
|
|
39
|
+
for (const [key, shapes] of this.#shapesByOwner) {
|
|
40
|
+
const shape = shapes.get(name);
|
|
41
|
+
if (key.startsWith("libs/") && shape?.kind === "mutation") return shape;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static #collect(sourceFile: ts.SourceFile, kinds: Map<string, EndpointShape>) {
|
|
47
|
+
const visit = (node: ts.Node) => {
|
|
48
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
49
|
+
if (node.expression.text === "slice") SignalMutationIndex.#collectGenerated(node, kinds);
|
|
50
|
+
else if (node.expression.text === "endpoint") SignalMutationIndex.#collectEndpoints(node, kinds);
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(node, visit);
|
|
53
|
+
};
|
|
54
|
+
visit(sourceFile);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static #collectGenerated(slice: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
58
|
+
let service: ts.Expression | undefined = slice.arguments[0];
|
|
59
|
+
while (service && ts.isCallExpression(service) && ts.isPropertyAccessExpression(service.expression))
|
|
60
|
+
service = service.expression.expression;
|
|
61
|
+
if (!service || !ts.isPropertyAccessExpression(service) || !SignalMutationIndex.#isNamed(service.expression, "srv"))
|
|
62
|
+
return;
|
|
63
|
+
const model = service.name.text;
|
|
64
|
+
const capModel = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
65
|
+
for (const [verb, argCount] of Object.entries(SignalMutationIndex.#generatedArgCounts))
|
|
66
|
+
kinds.set(`${verb}${capModel}`, { kind: "mutation", argCount });
|
|
67
|
+
kinds.set(`add${capModel}Files`, { kind: "mutation", argCount: 2 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #collectEndpoints(endpoint: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
71
|
+
const builder = endpoint.arguments[1];
|
|
72
|
+
if (!builder || (!ts.isArrowFunction(builder) && !ts.isFunctionExpression(builder))) return;
|
|
73
|
+
const map = SignalMutationIndex.#returnedObjectOf(builder);
|
|
74
|
+
if (!map) return;
|
|
75
|
+
const verbOf = SignalMutationIndex.#verbReaderOf(builder.parameters[0]);
|
|
76
|
+
for (const property of map.properties) {
|
|
77
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
78
|
+
if (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name)) continue;
|
|
79
|
+
const { root, argCount } = SignalMutationIndex.#chainOf(property.initializer);
|
|
80
|
+
const verb = verbOf(root);
|
|
81
|
+
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
82
|
+
const kind = verb === "message" ? "mutation" : verb;
|
|
83
|
+
if (kind === "query" || kind === "mutation") kinds.set(property.name.text, { kind, argCount });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static #returnedObjectOf(builder: ts.ArrowFunction | ts.FunctionExpression) {
|
|
88
|
+
let body: ts.Node | undefined = builder.body;
|
|
89
|
+
if (ts.isBlock(body)) body = body.statements.find(ts.isReturnStatement)?.expression;
|
|
90
|
+
while (body && ts.isParenthesizedExpression(body)) body = body.expression;
|
|
91
|
+
return body && ts.isObjectLiteralExpression(body) ? body : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static #verbReaderOf(parameter: ts.ParameterDeclaration | undefined) {
|
|
95
|
+
const verbs = new Map<string, string>();
|
|
96
|
+
if (parameter && ts.isObjectBindingPattern(parameter.name))
|
|
97
|
+
for (const element of parameter.name.elements) {
|
|
98
|
+
const verb = element.propertyName ?? element.name;
|
|
99
|
+
if (ts.isIdentifier(verb) && ts.isIdentifier(element.name)) verbs.set(element.name.text, verb.text);
|
|
100
|
+
}
|
|
101
|
+
const holder = parameter && ts.isIdentifier(parameter.name) ? parameter.name.text : null;
|
|
102
|
+
return (callee: ts.Expression | undefined) => {
|
|
103
|
+
if (callee && ts.isIdentifier(callee)) return verbs.get(callee.text);
|
|
104
|
+
if (callee && ts.isPropertyAccessExpression(callee) && SignalMutationIndex.#isNamed(callee.expression, holder))
|
|
105
|
+
return callee.name.text;
|
|
106
|
+
return undefined;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static #isNamed(expression: ts.Expression, name: string | null) {
|
|
111
|
+
return ts.isIdentifier(expression) && expression.text === name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static #chainOf(initializer: ts.Expression) {
|
|
115
|
+
let root = initializer;
|
|
116
|
+
let argCount = 0;
|
|
117
|
+
while (
|
|
118
|
+
ts.isCallExpression(root) &&
|
|
119
|
+
ts.isPropertyAccessExpression(root.expression) &&
|
|
120
|
+
ts.isCallExpression(root.expression.expression)
|
|
121
|
+
) {
|
|
122
|
+
if (SignalMutationIndex.#argMethods.has(root.expression.name.text)) argCount += 1;
|
|
123
|
+
root = root.expression.expression;
|
|
124
|
+
}
|
|
125
|
+
return { root: ts.isCallExpression(root) ? root.expression : undefined, argCount };
|
|
126
|
+
}
|
|
127
|
+
}
|