@frockbot/applet-sdk 0.0.0 → 0.3.13
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/README.md +77 -1
- package/dist/cli.mjs +1007 -0
- package/package.json +51 -5
- package/src/cli/bin.ts +128 -0
- package/src/cli/build.ts +181 -0
- package/src/cli/check.ts +134 -0
- package/src/cli/dev.ts +84 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/manifest.ts +58 -0
- package/src/cli/new.ts +74 -0
- package/src/cli/paths.ts +98 -0
- package/src/cli/runtime.ts +123 -0
- package/src/client/collections.ts +72 -0
- package/src/client/index.ts +203 -0
- package/src/client/transport.ts +334 -0
- package/src/kit/README.md +130 -0
- package/src/kit/index.tsx +427 -0
- package/src/kit/styles.ts +200 -0
- package/src/lint/index.ts +148 -0
- package/src/lint/rules.ts +394 -0
- package/src/protocol/index.ts +411 -0
- package/src/schema/index.ts +436 -0
- package/src/server/applet.ts +399 -0
- package/src/server/index.ts +53 -0
- package/src/server/session.ts +156 -0
- package/src/server/store.ts +398 -0
- package/template/README.md +37 -0
- package/template/applet.json +5 -0
- package/template/server.ts +46 -0
- package/template/ui.tsx +113 -0
- package/types/cloudflare-workers.d.ts +55 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@frockbot/applet-sdk/lint` — the flat config, the rules, and the one call
|
|
3
|
+
* `applet check` makes.
|
|
4
|
+
*
|
|
5
|
+
* A diagnostic is the SDK's whole answer to "what did I do wrong": the CLI
|
|
6
|
+
* prints `path:line:col message` and nothing else, so what a Bot must remember
|
|
7
|
+
* is the message, not a manual.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { join, relative, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { ESLint, type Linter, type Rule } from "eslint";
|
|
14
|
+
import tseslint from "typescript-eslint";
|
|
15
|
+
|
|
16
|
+
import { anchoredToToken, appletRules } from "./rules.js";
|
|
17
|
+
|
|
18
|
+
export * from "./rules.js";
|
|
19
|
+
|
|
20
|
+
export interface AppletDiagnostic {
|
|
21
|
+
/** Path relative to the Applet's directory. */
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
column: number;
|
|
25
|
+
message: string;
|
|
26
|
+
severity: "error" | "warning";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The one line the CLI prints per diagnostic. */
|
|
30
|
+
export function formatDiagnostic(diagnostic: AppletDiagnostic): string {
|
|
31
|
+
return `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.message}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const appletPlugin = {
|
|
35
|
+
rules: appletRules as unknown as Record<string, Rule.RuleModule>,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The flat config an Applet is linted with. */
|
|
39
|
+
export function appletLintConfig(): Linter.Config[] {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
files: ["**/*.ts", "**/*.tsx"],
|
|
43
|
+
languageOptions: {
|
|
44
|
+
parser: tseslint.parser as unknown as Linter.Parser,
|
|
45
|
+
parserOptions: {
|
|
46
|
+
ecmaVersion: 2023,
|
|
47
|
+
sourceType: "module",
|
|
48
|
+
ecmaFeatures: { jsx: true },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
plugins: { applet: appletPlugin },
|
|
52
|
+
rules: {
|
|
53
|
+
"applet/no-raw-colors": "error",
|
|
54
|
+
"applet/no-network": "error",
|
|
55
|
+
"applet/allowed-imports": "error",
|
|
56
|
+
"applet/tables-via-table": "error",
|
|
57
|
+
"applet/tools-via-this-tool": "error",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CSS_COLOR =
|
|
64
|
+
/#[0-9a-fA-F]{3,8}\b|\brgba?\s*\([^)]*\)|\bhsla?\s*\([^)]*\)|\bcolor-mix\s*\(/g;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ESLint does not see `.css`, and a stylesheet is the easiest place to smuggle
|
|
68
|
+
* a colour past the theme, so the same rule is applied here by hand.
|
|
69
|
+
*/
|
|
70
|
+
export function lintCssText(text: string, file: string): AppletDiagnostic[] {
|
|
71
|
+
const diagnostics: AppletDiagnostic[] = [];
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
lines.forEach((line, index) => {
|
|
74
|
+
if (line.trimStart().startsWith("/*")) return;
|
|
75
|
+
for (const match of line.matchAll(CSS_COLOR)) {
|
|
76
|
+
// Judge the declaration the literal sits in, the same unit the TSX rule
|
|
77
|
+
// judges: a literal is fine where its own value names a theme token.
|
|
78
|
+
const start = line.lastIndexOf(";", match.index) + 1;
|
|
79
|
+
const end = line.indexOf(";", match.index);
|
|
80
|
+
const declaration = line.slice(start, end === -1 ? undefined : end);
|
|
81
|
+
if (anchoredToToken(declaration)) continue;
|
|
82
|
+
diagnostics.push({
|
|
83
|
+
file,
|
|
84
|
+
line: index + 1,
|
|
85
|
+
column: match.index + 1,
|
|
86
|
+
message:
|
|
87
|
+
`"${match[0]}" is a raw colour; use a --frockbot-* theme token ` +
|
|
88
|
+
"(or a var(--frockbot-…, fallback)).",
|
|
89
|
+
severity: "error",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return diagnostics;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function sourceFiles(
|
|
97
|
+
directory: string,
|
|
98
|
+
extensions: string[],
|
|
99
|
+
): Promise<string[]> {
|
|
100
|
+
const found: string[] = [];
|
|
101
|
+
const walk = async (current: string): Promise<void> => {
|
|
102
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
103
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
104
|
+
if (entry.name.startsWith(".")) continue;
|
|
105
|
+
const path = join(current, entry.name);
|
|
106
|
+
if (entry.isDirectory()) await walk(path);
|
|
107
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) {
|
|
108
|
+
found.push(path);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
await walk(directory);
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Lint one Applet directory. Returns diagnostics; never throws on a finding. */
|
|
117
|
+
export async function lintApplet(
|
|
118
|
+
directory: string,
|
|
119
|
+
): Promise<AppletDiagnostic[]> {
|
|
120
|
+
const root = resolve(directory);
|
|
121
|
+
const eslint = new ESLint({
|
|
122
|
+
cwd: root,
|
|
123
|
+
overrideConfigFile: true,
|
|
124
|
+
overrideConfig: appletLintConfig(),
|
|
125
|
+
errorOnUnmatchedPattern: false,
|
|
126
|
+
});
|
|
127
|
+
const results = await eslint.lintFiles(
|
|
128
|
+
await sourceFiles(root, [".ts", ".tsx"]),
|
|
129
|
+
);
|
|
130
|
+
const diagnostics: AppletDiagnostic[] = [];
|
|
131
|
+
for (const result of results) {
|
|
132
|
+
for (const message of result.messages) {
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
file: relative(root, result.filePath),
|
|
135
|
+
line: message.line ?? 1,
|
|
136
|
+
column: message.column ?? 1,
|
|
137
|
+
message: `${message.message}${message.ruleId ? ` (${message.ruleId})` : ""}`,
|
|
138
|
+
severity: message.severity === 2 ? "error" : "warning",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const path of await sourceFiles(root, [".css"])) {
|
|
143
|
+
diagnostics.push(
|
|
144
|
+
...lintCssText(await readFile(path, "utf8"), relative(root, path)),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return diagnostics;
|
|
148
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five rules that keep an Applet inside the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Each one exists because of a way an Applet can look right and be wrong: a
|
|
5
|
+
* hard-coded colour that ignores the user's theme, a network call the loader
|
|
6
|
+
* would block anyway, an import that will not exist at build time, and state
|
|
7
|
+
* or tools declared in a shape the server cannot see. ADR 0022 decision 10
|
|
8
|
+
* says this set grows from observed failures — add a rule and a test here,
|
|
9
|
+
* never a paragraph in a prompt.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A syntax node, walked structurally so no parser type leaks into the rules. */
|
|
13
|
+
export interface AstNode {
|
|
14
|
+
type: string;
|
|
15
|
+
[field: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RuleContext {
|
|
19
|
+
report(descriptor: { node: AstNode; message: string }): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppletRule {
|
|
23
|
+
meta: {
|
|
24
|
+
type: "problem" | "suggestion";
|
|
25
|
+
docs: { description: string };
|
|
26
|
+
schema: [];
|
|
27
|
+
messages?: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
create(context: RuleContext): Record<string, (node: AstNode) => void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function child(node: AstNode | undefined, field: string): AstNode | undefined {
|
|
33
|
+
const value = node?.[field];
|
|
34
|
+
return value && typeof value === "object" ? (value as AstNode) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function name(node: AstNode | undefined): string | undefined {
|
|
38
|
+
if (!node) return undefined;
|
|
39
|
+
if (node.type === "Identifier" && typeof node.name === "string")
|
|
40
|
+
return node.name;
|
|
41
|
+
if (node.type === "Literal" && typeof node.value === "string")
|
|
42
|
+
return node.value;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function list(node: AstNode | undefined, field: string): AstNode[] {
|
|
47
|
+
const value = node?.[field];
|
|
48
|
+
return Array.isArray(value) ? (value as AstNode[]) : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// no-raw-colors
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const FUNCTIONAL_COLOR =
|
|
56
|
+
/#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\bcolor-mix\s*\(/;
|
|
57
|
+
const NAMED_COLORS = new Set([
|
|
58
|
+
"aqua",
|
|
59
|
+
"black",
|
|
60
|
+
"blue",
|
|
61
|
+
"brown",
|
|
62
|
+
"cyan",
|
|
63
|
+
"fuchsia",
|
|
64
|
+
"gold",
|
|
65
|
+
"gray",
|
|
66
|
+
"green",
|
|
67
|
+
"grey",
|
|
68
|
+
"indigo",
|
|
69
|
+
"lime",
|
|
70
|
+
"magenta",
|
|
71
|
+
"maroon",
|
|
72
|
+
"navy",
|
|
73
|
+
"olive",
|
|
74
|
+
"orange",
|
|
75
|
+
"pink",
|
|
76
|
+
"purple",
|
|
77
|
+
"red",
|
|
78
|
+
"silver",
|
|
79
|
+
"teal",
|
|
80
|
+
"violet",
|
|
81
|
+
"white",
|
|
82
|
+
"yellow",
|
|
83
|
+
]);
|
|
84
|
+
const COLOR_PROPERTIES = new Set([
|
|
85
|
+
"color",
|
|
86
|
+
"background",
|
|
87
|
+
"backgroundColor",
|
|
88
|
+
"borderColor",
|
|
89
|
+
"borderTopColor",
|
|
90
|
+
"borderRightColor",
|
|
91
|
+
"borderBottomColor",
|
|
92
|
+
"borderLeftColor",
|
|
93
|
+
"outlineColor",
|
|
94
|
+
"fill",
|
|
95
|
+
"stroke",
|
|
96
|
+
"caretColor",
|
|
97
|
+
"accentColor",
|
|
98
|
+
"textDecorationColor",
|
|
99
|
+
"boxShadow",
|
|
100
|
+
"textShadow",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A colour literal is fine only where the same value is anchored to a theme
|
|
105
|
+
* token: as a `var(--frockbot-x, #fallback)` fallback, or as an ingredient of a
|
|
106
|
+
* `color-mix()` over one. Anything else is a colour the User's theme cannot
|
|
107
|
+
* move.
|
|
108
|
+
*/
|
|
109
|
+
export function anchoredToToken(text: string): boolean {
|
|
110
|
+
return /var\(\s*--frockbot-[a-z-]+/.test(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const noRawColors: AppletRule = {
|
|
114
|
+
meta: {
|
|
115
|
+
type: "problem",
|
|
116
|
+
docs: {
|
|
117
|
+
description:
|
|
118
|
+
"Colours come from the nine --frockbot-* theme tokens, never from a literal",
|
|
119
|
+
},
|
|
120
|
+
schema: [],
|
|
121
|
+
},
|
|
122
|
+
create(context) {
|
|
123
|
+
const flag = (node: AstNode, text: string) => {
|
|
124
|
+
if (anchoredToToken(text)) return;
|
|
125
|
+
if (!FUNCTIONAL_COLOR.test(text)) return;
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
message:
|
|
129
|
+
"Use a --frockbot-* theme token instead of a colour literal " +
|
|
130
|
+
"(the kit's components already do).",
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
Literal(node) {
|
|
135
|
+
if (typeof node.value === "string") flag(node, node.value);
|
|
136
|
+
},
|
|
137
|
+
TemplateElement(node) {
|
|
138
|
+
const value = child(node, "value");
|
|
139
|
+
const raw = value?.raw;
|
|
140
|
+
if (typeof raw === "string") flag(node, raw);
|
|
141
|
+
},
|
|
142
|
+
Property(node) {
|
|
143
|
+
const key = name(child(node, "key"));
|
|
144
|
+
if (!key || !COLOR_PROPERTIES.has(key)) return;
|
|
145
|
+
const value = child(node, "value");
|
|
146
|
+
if (value?.type !== "Literal" || typeof value.value !== "string")
|
|
147
|
+
return;
|
|
148
|
+
const text = value.value.trim().toLowerCase();
|
|
149
|
+
if (anchoredToToken(text) || !NAMED_COLORS.has(text)) return;
|
|
150
|
+
context.report({
|
|
151
|
+
node: value,
|
|
152
|
+
message: `"${text}" is a raw colour; use a --frockbot-* theme token.`,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// no-network
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const FORBIDDEN_CONSTRUCTORS = new Set([
|
|
164
|
+
"XMLHttpRequest",
|
|
165
|
+
"WebSocket",
|
|
166
|
+
"EventSource",
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
export const noNetwork: AppletRule = {
|
|
170
|
+
meta: {
|
|
171
|
+
type: "problem",
|
|
172
|
+
docs: {
|
|
173
|
+
description:
|
|
174
|
+
"An Applet reaches the outside world through its tools, never directly",
|
|
175
|
+
},
|
|
176
|
+
schema: [],
|
|
177
|
+
},
|
|
178
|
+
create(context) {
|
|
179
|
+
const complain = (node: AstNode, what: string) =>
|
|
180
|
+
context.report({
|
|
181
|
+
node,
|
|
182
|
+
message:
|
|
183
|
+
`${what} is not available to an Applet: the loader runs it with no ` +
|
|
184
|
+
"outbound network. Add a tool on the server, or use the Applet socket.",
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
CallExpression(node) {
|
|
188
|
+
const callee = child(node, "callee");
|
|
189
|
+
if (name(callee) === "fetch") {
|
|
190
|
+
complain(node, "fetch()");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (callee?.type !== "MemberExpression") return;
|
|
194
|
+
const property = name(child(callee, "property"));
|
|
195
|
+
const object = name(child(callee, "object"));
|
|
196
|
+
if (
|
|
197
|
+
property === "fetch" &&
|
|
198
|
+
(object === "window" || object === "globalThis")
|
|
199
|
+
) {
|
|
200
|
+
complain(node, "fetch()");
|
|
201
|
+
}
|
|
202
|
+
if (property === "sendBeacon" && object === "navigator") {
|
|
203
|
+
complain(node, "navigator.sendBeacon()");
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
NewExpression(node) {
|
|
207
|
+
const callee = name(child(node, "callee"));
|
|
208
|
+
if (callee && FORBIDDEN_CONSTRUCTORS.has(callee))
|
|
209
|
+
complain(node, `new ${callee}`);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// allowed-imports
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function importAllowed(specifier: string): boolean {
|
|
220
|
+
if (specifier.startsWith(".")) return true;
|
|
221
|
+
if (specifier === "react" || specifier.startsWith("react/")) return true;
|
|
222
|
+
return (
|
|
223
|
+
specifier === "@frockbot/applet-sdk" ||
|
|
224
|
+
specifier.startsWith("@frockbot/applet-sdk/")
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const allowedImports: AppletRule = {
|
|
229
|
+
meta: {
|
|
230
|
+
type: "problem",
|
|
231
|
+
docs: {
|
|
232
|
+
description:
|
|
233
|
+
"An Applet bundles from @frockbot/applet-sdk, react, and its own files only",
|
|
234
|
+
},
|
|
235
|
+
schema: [],
|
|
236
|
+
},
|
|
237
|
+
create(context) {
|
|
238
|
+
const check = (node: AstNode, source: AstNode | undefined) => {
|
|
239
|
+
const specifier = source?.type === "Literal" ? source.value : undefined;
|
|
240
|
+
if (typeof specifier !== "string" || importAllowed(specifier)) return;
|
|
241
|
+
context.report({
|
|
242
|
+
node,
|
|
243
|
+
message:
|
|
244
|
+
`"${specifier}" cannot be imported: an Applet may import ` +
|
|
245
|
+
"@frockbot/applet-sdk/*, react, and its own relative files.",
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
ImportDeclaration: (node) => check(node, child(node, "source")),
|
|
250
|
+
ExportNamedDeclaration: (node) => check(node, child(node, "source")),
|
|
251
|
+
ExportAllDeclaration: (node) => check(node, child(node, "source")),
|
|
252
|
+
ImportExpression: (node) => check(node, child(node, "source")),
|
|
253
|
+
CallExpression(node) {
|
|
254
|
+
if (name(child(node, "callee")) !== "require") return;
|
|
255
|
+
check(node, list(node, "arguments")[0]);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Class-shape rules
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
function isAppletClassBody(node: AstNode): boolean {
|
|
266
|
+
const parent = child(node, "parent");
|
|
267
|
+
return name(child(parent, "superClass")) === "Applet";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function declaredProperty(node: AstNode, field: string): AstNode | undefined {
|
|
271
|
+
for (const member of list(node, "body")) {
|
|
272
|
+
if (member.type !== "PropertyDefinition") continue;
|
|
273
|
+
if (name(child(member, "key")) === field) return member;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const tablesViaTable: AppletRule = {
|
|
279
|
+
meta: {
|
|
280
|
+
type: "problem",
|
|
281
|
+
docs: {
|
|
282
|
+
description: "Tables are declared with table() so the SDK can derive DDL",
|
|
283
|
+
},
|
|
284
|
+
schema: [],
|
|
285
|
+
},
|
|
286
|
+
create(context) {
|
|
287
|
+
// `tables = tables` naming a `const tables = { … }` is the shape the
|
|
288
|
+
// template uses, so the rule follows one level of indirection. The lookup
|
|
289
|
+
// happens on `Program:exit` so the declaration may come after the class.
|
|
290
|
+
const objectsByName = new Map<string, AstNode>();
|
|
291
|
+
const classBodies: AstNode[] = [];
|
|
292
|
+
return {
|
|
293
|
+
VariableDeclarator(node) {
|
|
294
|
+
const identifier = name(child(node, "id"));
|
|
295
|
+
const init = child(node, "init");
|
|
296
|
+
if (identifier && init?.type === "ObjectExpression") {
|
|
297
|
+
objectsByName.set(identifier, init);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
ClassBody(node) {
|
|
301
|
+
if (isAppletClassBody(node)) classBodies.push(node);
|
|
302
|
+
},
|
|
303
|
+
"Program:exit"() {
|
|
304
|
+
for (const body of classBodies) {
|
|
305
|
+
const property = declaredProperty(body, "tables");
|
|
306
|
+
if (!property) continue;
|
|
307
|
+
const declared = child(property, "value");
|
|
308
|
+
const value =
|
|
309
|
+
declared?.type === "Identifier"
|
|
310
|
+
? objectsByName.get(name(declared) ?? "")
|
|
311
|
+
: declared;
|
|
312
|
+
if (value?.type !== "ObjectExpression") {
|
|
313
|
+
context.report({
|
|
314
|
+
node: property,
|
|
315
|
+
message:
|
|
316
|
+
"`tables` must be an object literal of table({ … }) declarations.",
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
for (const entry of list(value, "properties")) {
|
|
321
|
+
const declaration = child(entry, "value");
|
|
322
|
+
if (
|
|
323
|
+
declaration?.type === "CallExpression" &&
|
|
324
|
+
name(child(declaration, "callee")) === "table"
|
|
325
|
+
) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
context.report({
|
|
329
|
+
node: entry,
|
|
330
|
+
message:
|
|
331
|
+
"Each table must be declared with table({ … }) from the SDK.",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const toolsViaThisTool: AppletRule = {
|
|
341
|
+
meta: {
|
|
342
|
+
type: "problem",
|
|
343
|
+
docs: {
|
|
344
|
+
description:
|
|
345
|
+
"Tools are declared with this.tool() so health() can report them",
|
|
346
|
+
},
|
|
347
|
+
schema: [],
|
|
348
|
+
},
|
|
349
|
+
create(context) {
|
|
350
|
+
return {
|
|
351
|
+
ClassBody(node) {
|
|
352
|
+
if (!isAppletClassBody(node)) return;
|
|
353
|
+
const property = declaredProperty(node, "tools");
|
|
354
|
+
if (!property) return;
|
|
355
|
+
const value = child(property, "value");
|
|
356
|
+
if (value?.type !== "ObjectExpression") {
|
|
357
|
+
context.report({
|
|
358
|
+
node: property,
|
|
359
|
+
message:
|
|
360
|
+
"`tools` must be an object literal of this.tool(…) declarations.",
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
for (const entry of list(value, "properties")) {
|
|
365
|
+
const declaration = child(entry, "value");
|
|
366
|
+
const callee = child(declaration, "callee");
|
|
367
|
+
if (
|
|
368
|
+
declaration?.type === "CallExpression" &&
|
|
369
|
+
callee?.type === "MemberExpression" &&
|
|
370
|
+
child(callee, "object")?.type === "ThisExpression" &&
|
|
371
|
+
name(child(callee, "property")) === "tool"
|
|
372
|
+
) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
context.report({
|
|
376
|
+
node: entry,
|
|
377
|
+
message:
|
|
378
|
+
"Each tool must be declared with this.tool({ … }, handler).",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export const appletRules = {
|
|
387
|
+
"no-raw-colors": noRawColors,
|
|
388
|
+
"no-network": noNetwork,
|
|
389
|
+
"allowed-imports": allowedImports,
|
|
390
|
+
"tables-via-table": tablesViaTable,
|
|
391
|
+
"tools-via-this-tool": toolsViaThisTool,
|
|
392
|
+
} satisfies Record<string, AppletRule>;
|
|
393
|
+
|
|
394
|
+
export type AppletRuleName = keyof typeof appletRules;
|