@capybara-hq/plugin-tools 0.1.0-rc.0 → 0.1.0-rc.10
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 +26 -19
- package/bin/capybara-plugin.mjs +1 -1
- package/dist/check/applied-migrations.d.ts +23 -0
- package/dist/check/applied-migrations.js +86 -0
- package/dist/check/cli.d.ts +2 -1
- package/dist/check/cli.js +7 -3
- package/dist/check/config-forms.d.ts +13 -0
- package/dist/check/config-forms.js +71 -0
- package/dist/check/deps.d.ts +4 -2
- package/dist/check/deps.js +14 -2
- package/dist/check/index.d.ts +4 -1
- package/dist/check/index.js +4 -1
- package/dist/check/migrations.d.ts +1 -1
- package/dist/check/migrations.js +30 -1
- package/dist/check/org-override.d.ts +10 -0
- package/dist/check/org-override.js +62 -0
- package/dist/check/plugin-pack.js +2 -0
- package/dist/check/plugin-permissions.d.ts +34 -1
- package/dist/check/plugin-permissions.js +66 -6
- package/dist/check/plugin-tool-keys.d.ts +13 -0
- package/dist/check/plugin-tool-keys.js +38 -0
- package/dist/check/release-metadata.d.ts +5 -0
- package/dist/check/release-metadata.js +47 -0
- package/dist/check/report.d.ts +4 -3
- package/dist/check/report.js +13 -3
- package/dist/check/run.d.ts +3 -1
- package/dist/check/run.js +12 -1
- package/dist/check/source.d.ts +7 -0
- package/dist/check/source.js +2 -0
- package/dist/check/themes.d.ts +19 -0
- package/dist/check/themes.js +112 -0
- package/dist/check/tool-design.d.ts +1 -1
- package/dist/check/tool-design.js +3 -3
- package/dist/check/ui-slots.d.ts +7 -0
- package/dist/check/ui-slots.js +30 -0
- package/dist/cli.js +0 -2
- package/dist/marketplace/cli.d.ts +10 -7
- package/dist/marketplace/cli.js +20 -10
- package/dist/marketplace/marketplace.d.ts +46 -24
- package/dist/marketplace/marketplace.js +232 -60
- package/dist/pack/cli.d.ts +3 -10
- package/dist/pack/cli.js +3 -40
- package/dist/pack/config-schemas.d.ts +34 -0
- package/dist/pack/config-schemas.js +151 -0
- package/dist/pack/index.d.ts +2 -2
- package/dist/pack/index.js +2 -2
- package/dist/pack/pack.d.ts +8 -2
- package/dist/pack/pack.js +154 -38
- package/package.json +4 -4
- package/dist/pack/bootstrap.d.ts +0 -25
- package/dist/pack/bootstrap.js +0 -70
|
@@ -115,6 +115,7 @@ function readManifest(obj, decls) {
|
|
|
115
115
|
caps: new Set(),
|
|
116
116
|
storage: false,
|
|
117
117
|
models: false,
|
|
118
|
+
name: stringValue(prop(obj, "name")?.value ? resolve(prop(obj, "name")?.value, decls) : undefined),
|
|
118
119
|
setup: undefined,
|
|
119
120
|
};
|
|
120
121
|
for (const key of ["requires", "optional", "provides"]) {
|
|
@@ -128,7 +129,11 @@ function readManifest(obj, decls) {
|
|
|
128
129
|
m.secrets = !!secrets && resolve(secrets.value, decls).value === true;
|
|
129
130
|
const storage = prop(pe, "storage");
|
|
130
131
|
m.storage = !!storage && resolve(storage.value, decls).value === true;
|
|
131
|
-
|
|
132
|
+
// an empty list declares nothing (ctx.models is gated on at least one purpose)
|
|
133
|
+
const models = prop(pe, "models");
|
|
134
|
+
const me = models ? resolve(models.value, decls) : undefined;
|
|
135
|
+
m.models =
|
|
136
|
+
me !== undefined && !(me.type === "ArrayExpression" && me.elements.length === 0);
|
|
132
137
|
for (const t of strings(prop(pe, "tools")?.value, decls))
|
|
133
138
|
m.tools.add(t);
|
|
134
139
|
for (const h of strings(prop(pe, "hooks")?.value, decls))
|
|
@@ -165,7 +170,7 @@ function readManifest(obj, decls) {
|
|
|
165
170
|
}
|
|
166
171
|
return m;
|
|
167
172
|
}
|
|
168
|
-
function scanSetup(f, m, decls) {
|
|
173
|
+
function scanSetup(f, m, decls, onTool, onCall) {
|
|
169
174
|
const out = [];
|
|
170
175
|
/** 明确的违规:豁免注释不起作用。 */
|
|
171
176
|
const add = (at, rule, message) => {
|
|
@@ -239,6 +244,8 @@ function scanSetup(f, m, decls) {
|
|
|
239
244
|
if (obj.type !== "MemberExpression" || !isCtx(obj.object))
|
|
240
245
|
return;
|
|
241
246
|
const area = memberName(obj);
|
|
247
|
+
if (area !== undefined)
|
|
248
|
+
onCall?.(area, method);
|
|
242
249
|
if (area === "http" && method === "route") {
|
|
243
250
|
const verb = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
|
|
244
251
|
const path = args[1] ? stringValue(resolve(args[1], decls)) : undefined;
|
|
@@ -253,6 +260,13 @@ function scanSetup(f, m, decls) {
|
|
|
253
260
|
const spec = args[0] ? resolve(args[0], decls) : undefined;
|
|
254
261
|
const nameProp = spec?.type === "ObjectExpression" ? prop(spec, "name") : undefined;
|
|
255
262
|
const name = nameProp ? stringValue(resolve(nameProp.value, decls)) : undefined;
|
|
263
|
+
onTool?.({
|
|
264
|
+
file: f.file,
|
|
265
|
+
line: f.lineOf(n.start),
|
|
266
|
+
extension: m.name,
|
|
267
|
+
tool: name,
|
|
268
|
+
allowed: !!findMarker(f, n)?.reason,
|
|
269
|
+
});
|
|
256
270
|
if (name === undefined)
|
|
257
271
|
return dynamic(n, "perm-tools", "the tool name");
|
|
258
272
|
if (!m.tools.has(name)) {
|
|
@@ -302,7 +316,9 @@ function scanSetup(f, m, decls) {
|
|
|
302
316
|
}
|
|
303
317
|
}
|
|
304
318
|
else if (name === "models") {
|
|
305
|
-
|
|
319
|
+
if (!m.models) {
|
|
320
|
+
add(n, "perm-models", "ctx.models is used but not declared; declare its purposes in permissions.models in the extension manifest");
|
|
321
|
+
}
|
|
306
322
|
}
|
|
307
323
|
else if (name === "http" ||
|
|
308
324
|
name === "tools" ||
|
|
@@ -338,7 +354,7 @@ function scanSetup(f, m, decls) {
|
|
|
338
354
|
}
|
|
339
355
|
return out;
|
|
340
356
|
}
|
|
341
|
-
function scanFile(f) {
|
|
357
|
+
function scanFile(f, onTool, onCall) {
|
|
342
358
|
const decls = buildDecls(f);
|
|
343
359
|
const out = [];
|
|
344
360
|
walk(f.program, (n) => {
|
|
@@ -351,7 +367,7 @@ function scanFile(f) {
|
|
|
351
367
|
const obj = arg ? resolve(arg, decls) : undefined;
|
|
352
368
|
if (obj?.type !== "ObjectExpression")
|
|
353
369
|
return;
|
|
354
|
-
out.push(...scanSetup(f, readManifest(obj, decls), decls));
|
|
370
|
+
out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool, onCall));
|
|
355
371
|
});
|
|
356
372
|
return out;
|
|
357
373
|
}
|
|
@@ -366,7 +382,9 @@ function scanFile(f) {
|
|
|
366
382
|
* - perm-hooks: the mount point of ctx.hooks.on(mount, …) is not in permissions.hooks (the mount
|
|
367
383
|
* point must be a string literal or a same-file const, like the names below), or the options are
|
|
368
384
|
* `{ mode: "policy" }` and the mount point is not in permissions.policyHooks;
|
|
369
|
-
* - perm-
|
|
385
|
+
* - perm-models: ctx.models is used but permissions.models is not declared (the purposes it may use
|
|
386
|
+
* are checked at run time);
|
|
387
|
+
* - perm-reserved: platform access that cannot be confirmed (a destructured ctx, a computed member).
|
|
370
388
|
* 路由路径、工具名、能力 ref、界面 id 须是字符串字面量或本文件的 const;动态的写法报「无法静态确认」,
|
|
371
389
|
* 豁免:`// capybara:allow-dynamic-permission <原因>`(行尾或紧邻上一行,原因必填)。
|
|
372
390
|
* ctx 会跟踪本地别名,以及按位置传给同文件函数的参数。
|
|
@@ -390,3 +408,45 @@ export function checkPluginPermissions(pluginDirs) {
|
|
|
390
408
|
}
|
|
391
409
|
return findings;
|
|
392
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Every `ctx.tools.register({ name })` of the non-test sources under `pluginDirs`, found by the
|
|
413
|
+
* same scanner as {@link checkPluginPermissions} (so it follows ctx aliases and same-file helpers).
|
|
414
|
+
* Unparseable files are skipped here; the permissions check reports them.
|
|
415
|
+
*/
|
|
416
|
+
export function findToolRegistrations(pluginDirs) {
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const dir of pluginDirs) {
|
|
419
|
+
for (const { file, isTest } of listSourceFiles(dir)) {
|
|
420
|
+
if (isTest)
|
|
421
|
+
continue;
|
|
422
|
+
const parsed = parseFile(file);
|
|
423
|
+
if (isParseError(parsed))
|
|
424
|
+
continue;
|
|
425
|
+
scanFile(parsed, (r) => out.push(r));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
|
|
432
|
+
* under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
|
|
433
|
+
*/
|
|
434
|
+
export function findCtxCalls(pluginDir) {
|
|
435
|
+
const out = { tools: false, ui: false, hooks: false };
|
|
436
|
+
for (const { file, isTest } of listSourceFiles(pluginDir)) {
|
|
437
|
+
if (isTest)
|
|
438
|
+
continue;
|
|
439
|
+
const parsed = parseFile(file);
|
|
440
|
+
if (isParseError(parsed))
|
|
441
|
+
continue;
|
|
442
|
+
scanFile(parsed, undefined, (area, method) => {
|
|
443
|
+
if (area === "tools" && method === "register")
|
|
444
|
+
out.tools = true;
|
|
445
|
+
else if (area === "ui" && (method === "view" || method === "action"))
|
|
446
|
+
out.ui = true;
|
|
447
|
+
else if (area === "hooks" && method === "on")
|
|
448
|
+
out.hooks = true;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
export type { Finding };
|
|
3
|
+
/** The model-visible key of an extension tool is `plugin__<extension>__<tool>`; 64 characters at most. */
|
|
4
|
+
export declare const TOOL_KEY_PREFIX = "plugin__";
|
|
5
|
+
export declare const TOOL_KEY_MAX = 64;
|
|
6
|
+
/**
|
|
7
|
+
* plugin-tool-key-length: the key `plugin__<extension>__<tool>` of every `ctx.tools.register`
|
|
8
|
+
* must fit in {@link TOOL_KEY_MAX} characters (model APIs cap tool names at 64). The names must be
|
|
9
|
+
* string literals or same-file consts, as for `perm-tools`; a name that is not is reported as
|
|
10
|
+
* "cannot be confirmed statically" unless the call carries `// capybara:allow-dynamic-permission
|
|
11
|
+
* <reason>`. Scans the non-test sources under `pluginDirs`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkPluginToolKeys(pluginDirs: string[]): Finding[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { findToolRegistrations } from "./plugin-permissions.js";
|
|
2
|
+
/** The model-visible key of an extension tool is `plugin__<extension>__<tool>`; 64 characters at most. */
|
|
3
|
+
export const TOOL_KEY_PREFIX = "plugin__";
|
|
4
|
+
export const TOOL_KEY_MAX = 64;
|
|
5
|
+
/**
|
|
6
|
+
* plugin-tool-key-length: the key `plugin__<extension>__<tool>` of every `ctx.tools.register`
|
|
7
|
+
* must fit in {@link TOOL_KEY_MAX} characters (model APIs cap tool names at 64). The names must be
|
|
8
|
+
* string literals or same-file consts, as for `perm-tools`; a name that is not is reported as
|
|
9
|
+
* "cannot be confirmed statically" unless the call carries `// capybara:allow-dynamic-permission
|
|
10
|
+
* <reason>`. Scans the non-test sources under `pluginDirs`.
|
|
11
|
+
*/
|
|
12
|
+
export function checkPluginToolKeys(pluginDirs) {
|
|
13
|
+
const rule = "plugin-tool-key-length";
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const r of findToolRegistrations(pluginDirs)) {
|
|
16
|
+
if (r.extension === undefined || r.tool === undefined) {
|
|
17
|
+
if (r.allowed)
|
|
18
|
+
continue;
|
|
19
|
+
out.push({
|
|
20
|
+
file: r.file,
|
|
21
|
+
line: r.line,
|
|
22
|
+
rule,
|
|
23
|
+
message: `Cannot statically confirm the length of the tool key (${r.extension === undefined ? "extension name" : "tool name"} is not a literal). Use a string literal (or a same-file const), or add // capybara:allow-dynamic-permission <reason>`,
|
|
24
|
+
});
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const key = `${TOOL_KEY_PREFIX}${r.extension}__${r.tool}`;
|
|
28
|
+
if (key.length > TOOL_KEY_MAX) {
|
|
29
|
+
out.push({
|
|
30
|
+
file: r.file,
|
|
31
|
+
line: r.line,
|
|
32
|
+
rule,
|
|
33
|
+
message: `Tool key "${key}" is ${key.length} characters; the limit is ${TOOL_KEY_MAX} (plugin__<extension>__<tool>). Shorten the tool name to at most ${Math.max(0, TOOL_KEY_MAX - TOOL_KEY_PREFIX.length - r.extension.length - 2)} characters or the extension name`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -9,5 +9,10 @@ import type { Finding } from "./source.js";
|
|
|
9
9
|
* has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
|
|
10
10
|
* (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
|
|
11
11
|
* `check:release-metadata`.
|
|
12
|
+
* `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
|
|
13
|
+
* `displayName` (`{ zh, en }`), the name every page shows instead of the package name.
|
|
14
|
+
* `release-description-zh` (a warning, never an error, so third-party marketplaces are not
|
|
15
|
+
* blocked): a published plugin's `description` has no `zh` (`marketplace --write` copies it into
|
|
16
|
+
* the marketplace `summary`, which the Chinese UI shows).
|
|
12
17
|
*/
|
|
13
18
|
export declare function checkPluginReleaseMetadata(plugins: string[], root: string): Finding[];
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { PLUGIN_MANIFEST_FILE } from "@capybara-hq/plugin-sdk";
|
|
3
4
|
const RULE = "plugin-release-metadata";
|
|
5
|
+
const DISPLAY_NAME_RULE = "release-display-name";
|
|
6
|
+
const DESCRIPTION_ZH_RULE = "release-description-zh";
|
|
7
|
+
/**
|
|
8
|
+
* The plugin's `.capybara-plugin/plugin.json` as an object; undefined when the file is missing or
|
|
9
|
+
* not a JSON object ("Plugin pack" reports those).
|
|
10
|
+
*/
|
|
11
|
+
function readManifest(file) {
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return undefined;
|
|
14
|
+
try {
|
|
15
|
+
const m = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
16
|
+
return typeof m === "object" && m !== null && !Array.isArray(m)
|
|
17
|
+
? m
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A description without a `zh` text: a string, or an object without `zh`. */
|
|
25
|
+
const lacksZh = (d) => typeof d === "string" ||
|
|
26
|
+
(typeof d === "object" && d !== null && d.zh === undefined);
|
|
4
27
|
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
5
28
|
/** `repository.url` of a package.json (`"repository": "<url>"` or `{ url }`). */
|
|
6
29
|
function repositoryUrl(pkg) {
|
|
@@ -22,6 +45,11 @@ function repositoryUrl(pkg) {
|
|
|
22
45
|
* has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
|
|
23
46
|
* (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
|
|
24
47
|
* `check:release-metadata`.
|
|
48
|
+
* `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
|
|
49
|
+
* `displayName` (`{ zh, en }`), the name every page shows instead of the package name.
|
|
50
|
+
* `release-description-zh` (a warning, never an error, so third-party marketplaces are not
|
|
51
|
+
* blocked): a published plugin's `description` has no `zh` (`marketplace --write` copies it into
|
|
52
|
+
* the marketplace `summary`, which the Chinese UI shows).
|
|
25
53
|
*/
|
|
26
54
|
export function checkPluginReleaseMetadata(plugins, root) {
|
|
27
55
|
const out = [];
|
|
@@ -47,6 +75,25 @@ export function checkPluginReleaseMetadata(plugins, root) {
|
|
|
47
75
|
}
|
|
48
76
|
if (typeof pkg.license !== "string")
|
|
49
77
|
finding(`${label} is published and needs a "license"`);
|
|
78
|
+
const manifestFile = join(dir, PLUGIN_MANIFEST_FILE);
|
|
79
|
+
const manifest = readManifest(manifestFile);
|
|
80
|
+
if (manifest !== undefined && manifest.displayName === undefined) {
|
|
81
|
+
out.push({
|
|
82
|
+
file: manifestFile,
|
|
83
|
+
line: 1,
|
|
84
|
+
rule: DISPLAY_NAME_RULE,
|
|
85
|
+
message: `${label} is published and needs a "displayName" in ${PLUGIN_MANIFEST_FILE}: { "zh": …, "en": … }, the name pages show`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (manifest !== undefined && lacksZh(manifest.description)) {
|
|
89
|
+
out.push({
|
|
90
|
+
file: manifestFile,
|
|
91
|
+
line: 1,
|
|
92
|
+
rule: DESCRIPTION_ZH_RULE,
|
|
93
|
+
severity: "warning",
|
|
94
|
+
message: `${label} is published and its description has no zh: write "description": { "en": …, "zh": … } in ${PLUGIN_MANIFEST_FILE} (the marketplace summary the Chinese UI shows)`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
50
97
|
if (typeof pkg.homepage !== "string" || !pkg.homepage.startsWith("https://")) {
|
|
51
98
|
finding(`${label} is published and needs an https "homepage"`);
|
|
52
99
|
}
|
package/dist/check/report.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Finding } from "./source.js";
|
|
2
2
|
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
3
|
export declare const formatFinding: (f: Finding, root: string) => string;
|
|
4
4
|
/**
|
|
5
|
-
* Prints the result of one check: `[name] passed
|
|
6
|
-
*
|
|
5
|
+
* Prints the result of one check: `[name] passed` (`passed with N warning(s)` and one line per
|
|
6
|
+
* warning when it has only warnings), or the finding count and one line per finding (stderr).
|
|
7
|
+
* Returns whether it passed (warnings do not fail it).
|
|
7
8
|
*/
|
|
8
9
|
export declare function printCheck(name: string, findings: Finding[], root: string): boolean;
|
package/dist/check/report.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
|
+
import { errorsOf } from "./source.js";
|
|
2
3
|
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
4
|
export const formatFinding = (f, root) => `${relative(root, f.file) || f.file}:${f.line} ${f.rule} ${f.message}`;
|
|
4
5
|
/**
|
|
5
|
-
* Prints the result of one check: `[name] passed
|
|
6
|
-
*
|
|
6
|
+
* Prints the result of one check: `[name] passed` (`passed with N warning(s)` and one line per
|
|
7
|
+
* warning when it has only warnings), or the finding count and one line per finding (stderr).
|
|
8
|
+
* Returns whether it passed (warnings do not fail it).
|
|
7
9
|
*/
|
|
8
10
|
export function printCheck(name, findings, root) {
|
|
11
|
+
const errors = errorsOf(findings);
|
|
9
12
|
if (findings.length === 0) {
|
|
10
13
|
console.log(`[${name}] passed`);
|
|
11
14
|
return true;
|
|
12
15
|
}
|
|
13
|
-
|
|
16
|
+
if (errors.length === 0) {
|
|
17
|
+
console.error(`[${name}] passed with ${findings.length} warning(s)`);
|
|
18
|
+
for (const f of findings)
|
|
19
|
+
console.error(` ${formatFinding(f, root)}`);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const warnings = findings.length - errors.length;
|
|
23
|
+
console.error(`[${name}] ${errors.length} finding(s)${warnings > 0 ? ` and ${warnings} warning(s)` : ""}`);
|
|
14
24
|
for (const f of findings)
|
|
15
25
|
console.error(` ${formatFinding(f, root)}`);
|
|
16
26
|
return false;
|
package/dist/check/run.d.ts
CHANGED
|
@@ -8,12 +8,14 @@ export interface PluginCheckScope {
|
|
|
8
8
|
root: string;
|
|
9
9
|
/**
|
|
10
10
|
* Plugin directories: every check runs on each (dependency boundary, extension state,
|
|
11
|
-
* permissions, pack, tool design, test imports, release metadata, and the migrations in
|
|
11
|
+
* permissions, pack, config forms, tool design, test imports, release metadata, and the migrations in
|
|
12
12
|
* `<plugin>/migrations`).
|
|
13
13
|
*/
|
|
14
14
|
plugins: string[];
|
|
15
15
|
/** Package directories held to the SDK's boundary (core: `packages/plugin-sdk`). */
|
|
16
16
|
sdkDirs?: string[];
|
|
17
|
+
/** Package directories that may depend on `@capybara-hq/plugin-sdk` only (core: `packages/device`). */
|
|
18
|
+
deviceDirs?: string[];
|
|
17
19
|
/** More directories the tool design rules scan (core: the kernel's builtin tools). */
|
|
18
20
|
toolDesignDirs?: string[];
|
|
19
21
|
/** More migration directories (core: the kernel's migrations). */
|
package/dist/check/run.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { checkConfigForms } from "./config-forms.js";
|
|
2
3
|
import { checkDeps } from "./deps.js";
|
|
3
4
|
import { checkMigrations } from "./migrations.js";
|
|
5
|
+
import { checkOrgOverride } from "./org-override.js";
|
|
4
6
|
import { checkPluginPack } from "./plugin-pack.js";
|
|
5
7
|
import { checkPluginPermissions } from "./plugin-permissions.js";
|
|
6
8
|
import { checkPluginState } from "./plugin-state.js";
|
|
9
|
+
import { checkPluginToolKeys } from "./plugin-tool-keys.js";
|
|
7
10
|
import { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
8
11
|
import { printCheck } from "./report.js";
|
|
9
12
|
import { checkPluginTestImports } from "./test-imports.js";
|
|
13
|
+
import { checkThemes } from "./themes.js";
|
|
10
14
|
import { checkToolDesign } from "./tool-design.js";
|
|
11
15
|
/**
|
|
12
16
|
* Every plugin-scoped check over `scope`, in the order `capybara-plugin check` and core's `pnpm
|
|
@@ -22,14 +26,21 @@ export function pluginChecks(scope) {
|
|
|
22
26
|
...plugins.map((p) => join(p, "migrations")),
|
|
23
27
|
]),
|
|
24
28
|
},
|
|
25
|
-
{
|
|
29
|
+
{
|
|
30
|
+
name: "Dependency boundary",
|
|
31
|
+
run: () => checkDeps(plugins, scope.sdkDirs ?? [], scope.deviceDirs ?? []),
|
|
32
|
+
},
|
|
26
33
|
{ name: "Extension state", run: () => checkPluginState(plugins) },
|
|
27
34
|
{ name: "Extension permissions", run: () => checkPluginPermissions(plugins) },
|
|
28
35
|
{ name: "Plugin pack", run: () => checkPluginPack(plugins) },
|
|
36
|
+
{ name: "Config forms", run: () => checkConfigForms(plugins) },
|
|
37
|
+
{ name: "Org override", run: () => checkOrgOverride(plugins) },
|
|
38
|
+
{ name: "Themes", run: () => checkThemes(plugins) },
|
|
29
39
|
{
|
|
30
40
|
name: "Tool design",
|
|
31
41
|
run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
|
|
32
42
|
},
|
|
43
|
+
{ name: "Tool key length", run: () => checkPluginToolKeys(plugins) },
|
|
33
44
|
{ name: "Plugin test imports", run: () => checkPluginTestImports(plugins) },
|
|
34
45
|
{
|
|
35
46
|
name: "Plugin release metadata",
|
package/dist/check/source.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { type Comment } from "oxc-parser";
|
|
2
|
+
/**
|
|
3
|
+
* One finding of a check. `severity: "warning"` is printed but does not fail the check (only a
|
|
4
|
+
* few rules warn, e.g. `release-description-zh`); without it the finding is an error.
|
|
5
|
+
*/
|
|
2
6
|
export type Finding = {
|
|
3
7
|
file: string;
|
|
4
8
|
line: number;
|
|
5
9
|
rule: string;
|
|
6
10
|
message: string;
|
|
11
|
+
severity?: "warning";
|
|
7
12
|
};
|
|
13
|
+
/** The findings that fail a check (every finding that is not a warning). */
|
|
14
|
+
export declare const errorsOf: (findings: Finding[]) => Finding[];
|
|
8
15
|
/** ESTree 节点(oxc-parser 的输出)。只声明本检查用到的公共字段。 */
|
|
9
16
|
export interface Node {
|
|
10
17
|
type: string;
|
package/dist/check/source.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseSync } from "oxc-parser";
|
|
4
|
+
/** The findings that fail a check (every finding that is not a warning). */
|
|
5
|
+
export const errorsOf = (findings) => findings.filter((f) => f.severity !== "warning");
|
|
4
6
|
const SOURCE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
5
7
|
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
6
8
|
/** 只在插件根目录下才有特殊含义的目录名(更深处同名目录是普通源码目录,必须扫描)。 */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ParsedTheme } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import type { Finding } from "./source.js";
|
|
3
|
+
/** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
|
|
4
|
+
export declare function themePathsOf(dir: string): {
|
|
5
|
+
paths: string[];
|
|
6
|
+
listed: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
|
|
10
|
+
* - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
|
|
11
|
+
* `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
|
|
12
|
+
* - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
|
|
13
|
+
* - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
|
|
14
|
+
* of the distinct asset sizes over all of the plugin's themes above
|
|
15
|
+
* {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkThemes(pluginDirs: string[]): Finding[];
|
|
18
|
+
/** The asset paths a parsed theme references (fonts, logo mark and favicon). */
|
|
19
|
+
export declare function assetRefs(theme: ParsedTheme): string[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, posix } from "node:path";
|
|
3
|
+
import { PLUGIN_MANIFEST_FILE, parseThemeFile, THEME_LIMITS, themeIdOf, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
|
|
4
|
+
const stripDot = (p) => p.replace(/^\.\//, "");
|
|
5
|
+
/** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
|
|
6
|
+
export function themePathsOf(dir) {
|
|
7
|
+
let listed;
|
|
8
|
+
try {
|
|
9
|
+
const m = JSON.parse(readFileSync(join(dir, PLUGIN_MANIFEST_FILE), "utf8"));
|
|
10
|
+
if (Array.isArray(m.themes)) {
|
|
11
|
+
listed = m.themes.flatMap((t) => (typeof t?.path === "string" ? [stripDot(t.path)] : []));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// a missing or broken manifest is the pack check's business
|
|
16
|
+
}
|
|
17
|
+
if (listed !== undefined)
|
|
18
|
+
return { paths: [...new Set(listed)], listed: true };
|
|
19
|
+
const root = join(dir, "themes");
|
|
20
|
+
const scanned = existsSync(root)
|
|
21
|
+
? readdirSync(root, { withFileTypes: true })
|
|
22
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
23
|
+
.map((e) => `themes/${e.name}`)
|
|
24
|
+
.sort()
|
|
25
|
+
: [];
|
|
26
|
+
return { paths: scanned, listed: false };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
|
|
30
|
+
* - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
|
|
31
|
+
* `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
|
|
32
|
+
* - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
|
|
33
|
+
* - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
|
|
34
|
+
* of the distinct asset sizes over all of the plugin's themes above
|
|
35
|
+
* {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
|
|
36
|
+
*/
|
|
37
|
+
export function checkThemes(pluginDirs) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
for (const dir of pluginDirs) {
|
|
40
|
+
const { paths } = themePathsOf(dir);
|
|
41
|
+
if (paths.length === 0)
|
|
42
|
+
continue;
|
|
43
|
+
const manifest = join(dir, PLUGIN_MANIFEST_FILE);
|
|
44
|
+
if (paths.length > THEME_LIMITS.perPlugin) {
|
|
45
|
+
findings.push({
|
|
46
|
+
file: manifest,
|
|
47
|
+
line: 1,
|
|
48
|
+
rule: "theme-invalid",
|
|
49
|
+
message: `the plugin has ${paths.length} themes; at most ${THEME_LIMITS.perPlugin} are allowed`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const files = new Map();
|
|
53
|
+
const sizes = new Map();
|
|
54
|
+
for (const rel of paths) {
|
|
55
|
+
const file = join(dir, rel);
|
|
56
|
+
const bad = (rule, message) => findings.push({ file, line: 1, rule, message });
|
|
57
|
+
if (!existsSync(file) || !statSync(file).isFile()) {
|
|
58
|
+
bad("theme-invalid", `theme ./${rel} does not exist`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (statSync(file).size > THEME_LIMITS.fileBytes) {
|
|
62
|
+
bad("theme-invalid", `theme file is larger than ${THEME_LIMITS.fileBytes} bytes`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const r = parseThemeFile(themeIdOf(rel), readFileSync(file, "utf8"));
|
|
66
|
+
if ("errors" in r) {
|
|
67
|
+
for (const e of r.errors) {
|
|
68
|
+
bad(e.startsWith("unreadable:") ? "theme-readability" : "theme-invalid", e);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const used = assetRefs(r);
|
|
73
|
+
for (const ref of used) {
|
|
74
|
+
const key = stripDot(ref);
|
|
75
|
+
const p = posix.normalize(key);
|
|
76
|
+
if (files.has(p) || p.startsWith("..") || p.startsWith("/"))
|
|
77
|
+
continue;
|
|
78
|
+
const abs = join(dir, p);
|
|
79
|
+
if (existsSync(abs) && statSync(abs).isFile())
|
|
80
|
+
files.set(p, readFileSync(abs));
|
|
81
|
+
}
|
|
82
|
+
const { errors } = validateThemeAssets(r, files);
|
|
83
|
+
for (const e of errors)
|
|
84
|
+
bad("theme-asset", e);
|
|
85
|
+
for (const ref of used) {
|
|
86
|
+
const bytes = files.get(stripDot(ref));
|
|
87
|
+
if (bytes !== undefined)
|
|
88
|
+
sizes.set(stripDot(ref), bytes.length);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const n of sizes.values())
|
|
93
|
+
total += n;
|
|
94
|
+
if (total > THEME_LIMITS.assetsBytes) {
|
|
95
|
+
findings.push({
|
|
96
|
+
file: manifest,
|
|
97
|
+
line: 1,
|
|
98
|
+
rule: "theme-asset",
|
|
99
|
+
message: `the plugin's theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${total}); the limit covers all of the plugin's themes`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
/** The asset paths a parsed theme references (fonts, logo mark and favicon). */
|
|
106
|
+
export function assetRefs(theme) {
|
|
107
|
+
const refs = (theme.fonts ?? []).map((f) => f.file);
|
|
108
|
+
for (const ref of [theme.logo?.mark, theme.logo?.favicon])
|
|
109
|
+
if (ref !== undefined)
|
|
110
|
+
refs.push(ref);
|
|
111
|
+
return refs;
|
|
112
|
+
}
|
|
@@ -5,7 +5,7 @@ export type { Finding };
|
|
|
5
5
|
* - tool-description:`ctx.tools.register(spec, …)` 与 `BuiltinTool` 字面量(有 spec 与 run)里的 spec
|
|
6
6
|
* 必须有字符串字面量 description(可 `+` 拼接)、不少于 20 字符、不含中文(模型可见文案一律英文);
|
|
7
7
|
* - tool-error-actionable:`new ToolError(msg)` 的 msg 是字符串字面量 / 模板(或本文件内的常量、函数返回值),
|
|
8
|
-
* 且含建议动词 use / call / retry / check / ask / pass / provide 之一,让模型知道下一步做什么。
|
|
8
|
+
* 且含建议动词 use / call / retry / check / ask / pass / provide / read 之一,让模型知道下一步做什么。
|
|
9
9
|
* spec 可以直接写对象字面量,也可以引用本文件内的 const;其他写法(跨文件、动态构造)报 tool-description。
|
|
10
10
|
* 已知缺口:从别的文件导入的消息常量 / 函数不追踪;非 ToolError 类的错误不查;运行时拼出的 spec 不查。
|
|
11
11
|
* 扫描:传入目录下的非测试源码。
|
|
@@ -2,7 +2,7 @@ import { isParseError, listSourceFiles, parseFile, stringValue, walk, } from "./
|
|
|
2
2
|
const MIN_DESCRIPTION = 20;
|
|
3
3
|
const CJK = /[㐀-鿿豈- -〿-]/;
|
|
4
4
|
/** 可执行的建议动词:错误信息里至少出现其一(整词、不分大小写)。 */
|
|
5
|
-
const ACTION_VERB = /\b(?:use|call|retry|check|ask|pass|provide)\b/i;
|
|
5
|
+
const ACTION_VERB = /\b(?:use|call|retry|check|ask|pass|provide|read)\b/i;
|
|
6
6
|
const WRAPPERS = new Set([
|
|
7
7
|
"ParenthesizedExpression",
|
|
8
8
|
"TSAsExpression",
|
|
@@ -129,7 +129,7 @@ const MSG_DESC_MISSING = "工具缺少 description,或它不是字符串字面
|
|
|
129
129
|
const MSG_DESC_SHORT = `工具 description 至少 ${MIN_DESCRIPTION} 个字符:说清工具做什么、何时该用、何时不该用`;
|
|
130
130
|
const MSG_DESC_CJK = "工具 description 是模型可见文案,必须是英文,不得含中文字符";
|
|
131
131
|
const MSG_ERR_DYNAMIC = "ToolError 的消息必须是字符串字面量或模板(或指向本文件内的此类常量 / 函数),否则无法检查是否可执行";
|
|
132
|
-
const MSG_ERR_ACTION = "ToolError 的消息要让模型知道下一步怎么做:必须含建议动词之一(use / call / retry / check / ask / pass / provide)";
|
|
132
|
+
const MSG_ERR_ACTION = "ToolError 的消息要让模型知道下一步怎么做:必须含建议动词之一(use / call / retry / check / ask / pass / provide / read)";
|
|
133
133
|
/** 取一个 spec 表达式对应的对象字面量(直接写,或经本文件内的常量引用)。 */
|
|
134
134
|
function specObject(n, env) {
|
|
135
135
|
let e = unwrap(n);
|
|
@@ -204,7 +204,7 @@ function scanFile(f) {
|
|
|
204
204
|
* - tool-description:`ctx.tools.register(spec, …)` 与 `BuiltinTool` 字面量(有 spec 与 run)里的 spec
|
|
205
205
|
* 必须有字符串字面量 description(可 `+` 拼接)、不少于 20 字符、不含中文(模型可见文案一律英文);
|
|
206
206
|
* - tool-error-actionable:`new ToolError(msg)` 的 msg 是字符串字面量 / 模板(或本文件内的常量、函数返回值),
|
|
207
|
-
* 且含建议动词 use / call / retry / check / ask / pass / provide 之一,让模型知道下一步做什么。
|
|
207
|
+
* 且含建议动词 use / call / retry / check / ask / pass / provide / read 之一,让模型知道下一步做什么。
|
|
208
208
|
* spec 可以直接写对象字面量,也可以引用本文件内的 const;其他写法(跨文件、动态构造)报 tool-description。
|
|
209
209
|
* 已知缺口:从别的文件导入的消息常量 / 函数不追踪;非 ToolError 类的错误不查;运行时拼出的 spec 不查。
|
|
210
210
|
* 扫描:传入目录下的非测试源码。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* Slot problems of a manifest's `ui` (UI slots spec §3.2): a slot or version this SDK does not know
|
|
4
|
+
* (`ui-slot-unknown`, error: the host drops the contribution), an `after` that is no anchor of the
|
|
5
|
+
* slot (`ui-slot-anchor`, error) and a deprecated `place` (`ui-place-deprecated`, warning).
|
|
6
|
+
*/
|
|
7
|
+
export declare function uiSlotFindings(file: string, ui: unknown): Finding[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveUiSlots } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Slot problems of a manifest's `ui` (UI slots spec §3.2): a slot or version this SDK does not know
|
|
4
|
+
* (`ui-slot-unknown`, error: the host drops the contribution), an `after` that is no anchor of the
|
|
5
|
+
* slot (`ui-slot-anchor`, error) and a deprecated `place` (`ui-place-deprecated`, warning).
|
|
6
|
+
*/
|
|
7
|
+
export function uiSlotFindings(file, ui) {
|
|
8
|
+
if (!Array.isArray(ui))
|
|
9
|
+
return [];
|
|
10
|
+
const r = resolveUiSlots(ui);
|
|
11
|
+
const out = [];
|
|
12
|
+
const at = (rule, message, severity) => {
|
|
13
|
+
out.push({ file, line: 1, rule, message, ...(severity && { severity }) });
|
|
14
|
+
};
|
|
15
|
+
for (const d of r.dropped) {
|
|
16
|
+
at("ui-slot-unknown", `ui "${d.id}" names slot ${d.ref}, which this SDK does not know (${d.reason}); the host would drop it`);
|
|
17
|
+
}
|
|
18
|
+
for (const a of r.unknownAnchors) {
|
|
19
|
+
const c = ui.find((x) => x.id === a.id);
|
|
20
|
+
const ref = r.resolved.find((x) => x.contribution.id === a.id);
|
|
21
|
+
const anchors = ref?.slot.anchors ?? [];
|
|
22
|
+
at("ui-slot-anchor", `ui "${a.id}" has after "${a.after}", which is not an anchor of slot ${ref?.slot.id ?? c?.slot}; the anchors are ${anchors.join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
for (const id of r.deprecatedPlace) {
|
|
25
|
+
const c = ui.find((x) => x.id === id);
|
|
26
|
+
const slot = r.resolved.find((x) => x.contribution.id === id)?.slot.id;
|
|
27
|
+
at("ui-place-deprecated", `ui "${id}" uses place "${c?.place}"; write slot: "${slot}"`, "warning");
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* `capybara-plugin`: pack, check, sync the versions of and release Capybara plugins.
|
|
3
3
|
*
|
|
4
4
|
* capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]
|
|
5
|
-
* capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]
|
|
6
|
-
* [--npm-token <token>]
|
|
7
5
|
* capybara-plugin check [--root <dir>] [--plugins <glob>] [--base <ref>]
|
|
8
6
|
* capybara-plugin sync-versions [--root <dir>] [--plugins <glob>]
|
|
9
7
|
* capybara-plugin marketplace --write|--check [--base <rev>] [--root <dir>] [--file <path>]
|