@capybara-hq/plugin-tools 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +105 -2
  4. package/bin/capybara-plugin.mjs +12 -0
  5. package/dist/check/applied-migrations.d.ts +28 -0
  6. package/dist/check/applied-migrations.js +65 -0
  7. package/dist/check/cli.d.ts +16 -0
  8. package/dist/check/cli.js +69 -0
  9. package/dist/check/deps.d.ts +23 -0
  10. package/dist/check/deps.js +207 -0
  11. package/dist/check/index.d.ts +15 -0
  12. package/dist/check/index.js +14 -0
  13. package/dist/check/migrations.d.ts +11 -0
  14. package/dist/check/migrations.js +93 -0
  15. package/dist/check/plugin-pack.d.ts +17 -0
  16. package/dist/check/plugin-pack.js +56 -0
  17. package/dist/check/plugin-permissions.d.ts +22 -0
  18. package/dist/check/plugin-permissions.js +392 -0
  19. package/dist/check/plugin-state.d.ts +19 -0
  20. package/dist/check/plugin-state.js +181 -0
  21. package/dist/check/plugin-versions.d.ts +13 -0
  22. package/dist/check/plugin-versions.js +56 -0
  23. package/dist/check/release-metadata.d.ts +13 -0
  24. package/dist/check/release-metadata.js +80 -0
  25. package/dist/check/report.d.ts +8 -0
  26. package/dist/check/report.js +17 -0
  27. package/dist/check/run.d.ts +36 -0
  28. package/dist/check/run.js +60 -0
  29. package/dist/check/source.d.ts +53 -0
  30. package/dist/check/source.js +153 -0
  31. package/dist/check/sync-versions-cli.d.ts +10 -0
  32. package/dist/check/sync-versions-cli.js +44 -0
  33. package/dist/check/test-imports.d.ts +20 -0
  34. package/dist/check/test-imports.js +158 -0
  35. package/dist/check/tool-design.d.ts +13 -0
  36. package/dist/check/tool-design.js +227 -0
  37. package/dist/cli.d.ts +1 -0
  38. package/dist/cli.js +48 -0
  39. package/dist/discover.d.ts +21 -0
  40. package/dist/discover.js +69 -0
  41. package/dist/index.d.ts +10 -0
  42. package/dist/index.js +10 -0
  43. package/dist/integrity.d.ts +2 -0
  44. package/dist/integrity.js +3 -0
  45. package/dist/marketplace/cli.d.ts +20 -0
  46. package/dist/marketplace/cli.js +80 -0
  47. package/dist/marketplace/file.d.ts +2 -0
  48. package/dist/marketplace/file.js +2 -0
  49. package/dist/marketplace/index.d.ts +2 -0
  50. package/dist/marketplace/index.js +2 -0
  51. package/dist/marketplace/marketplace.d.ts +57 -0
  52. package/dist/marketplace/marketplace.js +245 -0
  53. package/dist/pack/bootstrap.d.ts +25 -0
  54. package/dist/pack/bootstrap.js +70 -0
  55. package/dist/pack/cli.d.ts +20 -0
  56. package/dist/pack/cli.js +109 -0
  57. package/dist/pack/index.d.ts +4 -0
  58. package/dist/pack/index.js +4 -0
  59. package/dist/pack/npm.d.ts +66 -0
  60. package/dist/pack/npm.js +177 -0
  61. package/dist/pack/pack.d.ts +44 -0
  62. package/dist/pack/pack.js +518 -0
  63. package/dist/release/cli.d.ts +30 -0
  64. package/dist/release/cli.js +93 -0
  65. package/dist/release/index.d.ts +4 -0
  66. package/dist/release/index.js +4 -0
  67. package/dist/release/publish-plan.d.ts +68 -0
  68. package/dist/release/publish-plan.js +117 -0
  69. package/dist/release/publish.d.ts +91 -0
  70. package/dist/release/publish.js +274 -0
  71. package/dist/release/semver.d.ts +11 -0
  72. package/dist/release/semver.js +44 -0
  73. package/package.json +61 -3
@@ -0,0 +1,392 @@
1
+ import { isParseError, listSourceFiles, parseFile, stringValue, walk, } from "./source.js";
2
+ const MARKER = "capybara:allow-dynamic-permission";
3
+ const WRAPPERS = new Set([
4
+ "ParenthesizedExpression",
5
+ "TSAsExpression",
6
+ "TSSatisfiesExpression",
7
+ "TSNonNullExpression",
8
+ "TSTypeAssertion",
9
+ ]);
10
+ function unwrap(n) {
11
+ let cur = n;
12
+ while (WRAPPERS.has(cur.type))
13
+ cur = cur.expression;
14
+ return cur;
15
+ }
16
+ const propName = (p) => {
17
+ if (p.type !== "Property" || p.computed)
18
+ return undefined;
19
+ const k = p.key;
20
+ if (k.type === "Identifier")
21
+ return k.name;
22
+ if (k.type === "Literal" && typeof k.value === "string")
23
+ return k.value;
24
+ return undefined;
25
+ };
26
+ function prop(obj, name) {
27
+ for (const p of obj.properties)
28
+ if (propName(p) === name)
29
+ return p;
30
+ return undefined;
31
+ }
32
+ /** `x.name` / `x["name"]` 的属性名;动态属性返回 undefined。 */
33
+ function memberName(m) {
34
+ const p = m.property;
35
+ if (!m.computed)
36
+ return p.type === "Identifier" ? p.name : undefined;
37
+ return stringValue(p);
38
+ }
39
+ const isFn = (n) => n.type === "ArrowFunctionExpression" ||
40
+ n.type === "FunctionExpression" ||
41
+ n.type === "FunctionDeclaration";
42
+ /** 文件级环境:同文件的 const 与函数,用于把标识符追到定义处。 */
43
+ function buildDecls(f) {
44
+ const decls = new Map();
45
+ walk(f.program, (n) => {
46
+ if (n.type === "VariableDeclarator") {
47
+ const id = n.id;
48
+ if (id.type === "Identifier" && n.init)
49
+ decls.set(id.name, n.init);
50
+ }
51
+ else if (n.type === "FunctionDeclaration") {
52
+ const id = n.id;
53
+ if (id)
54
+ decls.set(id.name, n);
55
+ }
56
+ });
57
+ return decls;
58
+ }
59
+ /** 追到字面量节点:直接写,或经本文件的 const 引用。 */
60
+ function resolve(n, decls) {
61
+ let e = unwrap(n);
62
+ for (let i = 0; i < 5 && e.type === "Identifier"; i++) {
63
+ const init = decls.get(e.name);
64
+ if (!init)
65
+ break;
66
+ e = unwrap(init);
67
+ }
68
+ return e;
69
+ }
70
+ /** 找行尾或紧邻上一行(独占一行)的豁免注释。 */
71
+ function findMarker(f, at) {
72
+ const first = f.lineOf(at.start);
73
+ const last = f.lineOf(at.end);
74
+ for (const c of f.comments) {
75
+ if (c.type !== "Line")
76
+ continue;
77
+ const m = new RegExp(`^\\s*${MARKER}(?:\\s+(.*\\S))?\\s*$`).exec(c.value);
78
+ if (!m)
79
+ continue;
80
+ const line = f.lineOf(c.start);
81
+ const ownLine = f.lineText(line).trimStart().startsWith("//");
82
+ if (line === first || line === last || (line === first - 1 && ownLine)) {
83
+ return { reason: m[1] ?? "" };
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+ const strings = (n, decls) => {
89
+ if (!n)
90
+ return [];
91
+ const e = resolve(n, decls);
92
+ if (e.type !== "ArrayExpression")
93
+ return [];
94
+ return e.elements.flatMap((x) => {
95
+ const v = x ? stringValue(resolve(x, decls)) : undefined;
96
+ return v === undefined ? [] : [v];
97
+ });
98
+ };
99
+ /** Whether hook options are statically `{ mode: "policy" }` (object literal or same-file const). */
100
+ function policyMode(opts, decls) {
101
+ const o = opts ? resolve(opts, decls) : undefined;
102
+ if (o?.type !== "ObjectExpression")
103
+ return false;
104
+ const mode = prop(o, "mode")?.value;
105
+ return mode !== undefined && stringValue(resolve(mode, decls)) === "policy";
106
+ }
107
+ function readManifest(obj, decls) {
108
+ const m = {
109
+ secrets: false,
110
+ http: new Set(),
111
+ tools: new Set(),
112
+ uiViews: new Set(),
113
+ hooks: new Set(),
114
+ policyHooks: new Set(),
115
+ caps: new Set(),
116
+ storage: false,
117
+ models: false,
118
+ setup: undefined,
119
+ };
120
+ for (const key of ["requires", "optional", "provides"]) {
121
+ for (const s of strings(prop(obj, key)?.value, decls))
122
+ m.caps.add(s);
123
+ }
124
+ const perm = prop(obj, "permissions");
125
+ const pe = perm ? resolve(perm.value, decls) : undefined;
126
+ if (pe?.type === "ObjectExpression") {
127
+ const secrets = prop(pe, "secrets");
128
+ m.secrets = !!secrets && resolve(secrets.value, decls).value === true;
129
+ const storage = prop(pe, "storage");
130
+ m.storage = !!storage && resolve(storage.value, decls).value === true;
131
+ m.models = !!prop(pe, "models");
132
+ for (const t of strings(prop(pe, "tools")?.value, decls))
133
+ m.tools.add(t);
134
+ for (const h of strings(prop(pe, "hooks")?.value, decls))
135
+ m.hooks.add(h);
136
+ for (const h of strings(prop(pe, "policyHooks")?.value, decls)) {
137
+ m.policyHooks.add(h);
138
+ }
139
+ const ui = prop(pe, "ui");
140
+ const ue = ui ? resolve(ui.value, decls) : undefined;
141
+ if (ue?.type === "ObjectExpression") {
142
+ for (const v of strings(prop(ue, "views")?.value, decls)) {
143
+ m.uiViews.add(v);
144
+ }
145
+ }
146
+ const http = prop(pe, "http");
147
+ const arr = http ? resolve(http.value, decls) : undefined;
148
+ if (arr?.type === "ArrayExpression") {
149
+ for (const el of arr.elements) {
150
+ const o = el ? resolve(el, decls) : undefined;
151
+ if (o?.type !== "ObjectExpression")
152
+ continue;
153
+ const method = stringValue(prop(o, "method")?.value);
154
+ const path = stringValue(prop(o, "path")?.value);
155
+ if (method !== undefined && path !== undefined)
156
+ m.http.add(`${method} ${path}`);
157
+ }
158
+ }
159
+ }
160
+ const setup = prop(obj, "setup");
161
+ if (setup) {
162
+ const v = resolve(setup.value, decls);
163
+ if (isFn(v))
164
+ m.setup = v;
165
+ }
166
+ return m;
167
+ }
168
+ function scanSetup(f, m, decls) {
169
+ const out = [];
170
+ /** 明确的违规:豁免注释不起作用。 */
171
+ const add = (at, rule, message) => {
172
+ out.push({ file: f.file, line: f.lineOf(at.start), rule, message });
173
+ };
174
+ /** 「无法静态确认」:只有这类可以用豁免注释放行(原因必填)。 */
175
+ const dynamic = (at, rule, what) => {
176
+ const marker = findMarker(f, at);
177
+ if (marker?.reason)
178
+ return;
179
+ add(at, rule, marker
180
+ ? `// ${MARKER} must be followed by a reason (not empty)`
181
+ : `Cannot statically confirm ${what}. Use a string literal (or a same-file const), or add // ${MARKER} <reason>`);
182
+ };
183
+ let first = m.setup?.params?.[0];
184
+ if (first?.type === "AssignmentPattern")
185
+ first = first.left;
186
+ if (!m.setup || !first)
187
+ return out;
188
+ const queue = [];
189
+ const seen = new Set();
190
+ const enqueue = (fn, param) => {
191
+ if (param?.type !== "Identifier")
192
+ return;
193
+ const key = `${fn.start}|${param.name}`;
194
+ if (seen.has(key))
195
+ return;
196
+ seen.add(key);
197
+ queue.push({ fn, names: new Set([param.name]) });
198
+ };
199
+ enqueue(m.setup, first);
200
+ if (first.type === "ObjectPattern") {
201
+ dynamic(first, "perm-reserved", "platform access through a destructured setup parameter");
202
+ }
203
+ while (queue.length > 0) {
204
+ const { fn, names } = queue.shift();
205
+ const isCtx = (n) => {
206
+ const e = n ? unwrap(n) : undefined;
207
+ return e?.type === "Identifier" && names.has(e.name);
208
+ };
209
+ // 先收集本地别名:const c = ctx
210
+ walk(fn.body, (n) => {
211
+ if (n.type === "VariableDeclarator" && n.init && isCtx(n.init)) {
212
+ const id = n.id;
213
+ if (id.type === "Identifier")
214
+ names.add(id.name);
215
+ else {
216
+ dynamic(n, "perm-reserved", "platform access through a destructured ctx");
217
+ }
218
+ }
219
+ });
220
+ walk(fn.body, (n, parent) => {
221
+ if (n.type === "CallExpression") {
222
+ const callee = unwrap(n.callee);
223
+ const args = n.arguments;
224
+ // 把 ctx 传给同文件函数:继续跟踪
225
+ if (callee.type === "Identifier") {
226
+ const target = decls.get(callee.name);
227
+ const t = target ? unwrap(target) : undefined;
228
+ if (t && isFn(t)) {
229
+ args.forEach((a, i) => {
230
+ if (isCtx(a))
231
+ enqueue(t, t.params[i]);
232
+ });
233
+ }
234
+ }
235
+ if (callee.type !== "MemberExpression")
236
+ return;
237
+ const method = memberName(callee);
238
+ const obj = unwrap(callee.object);
239
+ if (obj.type !== "MemberExpression" || !isCtx(obj.object))
240
+ return;
241
+ const area = memberName(obj);
242
+ if (area === "http" && method === "route") {
243
+ const verb = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
244
+ const path = args[1] ? stringValue(resolve(args[1], decls)) : undefined;
245
+ if (verb === undefined || path === undefined) {
246
+ return dynamic(n, "perm-http", "the route method and path");
247
+ }
248
+ if (!m.http.has(`${verb} ${path}`)) {
249
+ add(n, "perm-http", `Route "${verb} ${path}" is not declared; add it to permissions.http in the extension manifest`);
250
+ }
251
+ }
252
+ else if (area === "tools" && method === "register") {
253
+ const spec = args[0] ? resolve(args[0], decls) : undefined;
254
+ const nameProp = spec?.type === "ObjectExpression" ? prop(spec, "name") : undefined;
255
+ const name = nameProp ? stringValue(resolve(nameProp.value, decls)) : undefined;
256
+ if (name === undefined)
257
+ return dynamic(n, "perm-tools", "the tool name");
258
+ if (!m.tools.has(name)) {
259
+ add(n, "perm-tools", `Tool "${name}" is not declared; add it to permissions.tools in the extension manifest`);
260
+ }
261
+ }
262
+ else if (area === "ui" && (method === "view" || method === "action")) {
263
+ const id = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
264
+ if (id === undefined)
265
+ return dynamic(n, "perm-ui", `the ctx.ui.${method} id`);
266
+ if (!m.uiViews.has(id)) {
267
+ add(n, "perm-ui", `UI view "${id}" is not declared; add it to permissions.ui.views (and ui) in the extension manifest`);
268
+ }
269
+ }
270
+ else if (area === "hooks" && method === "on") {
271
+ const mount = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
272
+ if (mount === undefined)
273
+ return dynamic(n, "perm-hooks", "the hook mount point");
274
+ if (!m.hooks.has(mount)) {
275
+ add(n, "perm-hooks", `Hook "${mount}" is not declared; add it to permissions.hooks in the extension manifest`);
276
+ }
277
+ else if (policyMode(args[2], decls) && !m.policyHooks.has(mount)) {
278
+ // a non-literal mode is left to the runtime check (ExtensionPermissionError)
279
+ add(n, "perm-hooks", `Policy hook "${mount}" is not declared; add it to permissions.policyHooks in the extension manifest`);
280
+ }
281
+ }
282
+ else if (area === "extensionPoints" && (method === "get" || method === "list")) {
283
+ const ref = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
284
+ if (ref === undefined)
285
+ return dynamic(n, "perm-extension-point", "the extension point ref");
286
+ if (!m.caps.has(ref)) {
287
+ add(n, "perm-extension-point", `Extension point "${ref}" is not declared; add it to requires or optional in the extension manifest`);
288
+ }
289
+ }
290
+ }
291
+ else if (n.type === "MemberExpression" && isCtx(n.object)) {
292
+ const name = memberName(n);
293
+ const unconfirmed = (rule, what) => dynamic(n, rule, what);
294
+ if (name === undefined)
295
+ return unconfirmed("perm-reserved", "a computed ctx member");
296
+ if (name === "secrets" && !m.secrets) {
297
+ add(n, "perm-secrets", "ctx.secrets is used but not declared; set permissions.secrets to true in the extension manifest");
298
+ }
299
+ else if (name === "storage") {
300
+ if (!m.storage) {
301
+ add(n, "perm-storage", "ctx.storage is used but not declared; set permissions.storage to true in the extension manifest");
302
+ }
303
+ }
304
+ else if (name === "models") {
305
+ add(n, "perm-reserved", "ctx.models is reserved and not provided in this release; remove the access");
306
+ }
307
+ else if (name === "http" ||
308
+ name === "tools" ||
309
+ name === "extensionPoints" ||
310
+ name === "ui" ||
311
+ name === "hooks") {
312
+ // 只有 ctx.http.route / ctx.tools.register / ctx.extensionPoints.get|list /
313
+ // ctx.ui.view|action|invalidate|verifyEmbedToken 能被确认,其余写法无法确认
314
+ // (likewise only ctx.hooks.on is confirmable)
315
+ const rule = name === "http"
316
+ ? "perm-http"
317
+ : name === "tools"
318
+ ? "perm-tools"
319
+ : name === "ui"
320
+ ? "perm-ui"
321
+ : name === "hooks"
322
+ ? "perm-hooks"
323
+ : "perm-extension-point";
324
+ const outer = parent;
325
+ const ok = outer?.type === "MemberExpression" &&
326
+ outer.object === n &&
327
+ ((name === "http" && memberName(outer) === "route") ||
328
+ (name === "tools" && memberName(outer) === "register") ||
329
+ (name === "hooks" && memberName(outer) === "on") ||
330
+ (name === "extensionPoints" && ["get", "list"].includes(memberName(outer) ?? "")) ||
331
+ (name === "ui" &&
332
+ ["view", "action", "invalidate", "verifyEmbedToken"].includes(memberName(outer) ?? "")));
333
+ if (!ok)
334
+ unconfirmed(rule, `how ctx.${name} is used`);
335
+ }
336
+ }
337
+ });
338
+ }
339
+ return out;
340
+ }
341
+ function scanFile(f) {
342
+ const decls = buildDecls(f);
343
+ const out = [];
344
+ walk(f.program, (n) => {
345
+ if (n.type !== "CallExpression")
346
+ return;
347
+ const callee = unwrap(n.callee);
348
+ if (callee.type !== "Identifier" || callee.name !== "defineExtension")
349
+ return;
350
+ const arg = n.arguments[0];
351
+ const obj = arg ? resolve(arg, decls) : undefined;
352
+ if (obj?.type !== "ObjectExpression")
353
+ return;
354
+ out.push(...scanSetup(f, readManifest(obj, decls), decls));
355
+ });
356
+ return out;
357
+ }
358
+ /**
359
+ * 插件权限静态检查(AST):把 setup(ctx) 里对平台能力的访问与 defineExtension 清单比对。规则:
360
+ * - perm-secrets:用了 ctx.secrets 而 permissions.secrets 不是 true;
361
+ * - perm-http:ctx.http.route(method, path) 不在 permissions.http 里;
362
+ * - perm-tools:ctx.tools.register({ name }) 的名字不在 permissions.tools 里;
363
+ * - perm-extension-point: the ref of ctx.extensionPoints.get|list(ref) is not in requires / optional / provides;
364
+ * - perm-ui:ctx.ui.view|action(id) 的 id 不在 permissions.ui.views 里;
365
+ * - perm-storage:用了 ctx.storage 而 permissions.storage 不是 true;
366
+ * - perm-hooks: the mount point of ctx.hooks.on(mount, …) is not in permissions.hooks (the mount
367
+ * point must be a string literal or a same-file const, like the names below), or the options are
368
+ * `{ mode: "policy" }` and the mount point is not in permissions.policyHooks;
369
+ * - perm-reserved:用了尚未提供的 ctx.models。
370
+ * 路由路径、工具名、能力 ref、界面 id 须是字符串字面量或本文件的 const;动态的写法报「无法静态确认」,
371
+ * 豁免:`// capybara:allow-dynamic-permission <原因>`(行尾或紧邻上一行,原因必填)。
372
+ * ctx 会跟踪本地别名,以及按位置传给同文件函数的参数。
373
+ * 已知缺口(靠评审与运行时 ExtensionPermissionError 兜底):传给其他文件 / 方法的 ctx、闭包外的重新赋值、
374
+ * 没有 defineExtension 的辅助文件、permissions 本身是运行时拼出的。
375
+ * 扫描范围:传入目录下的非测试源码。
376
+ */
377
+ export function checkPluginPermissions(pluginDirs) {
378
+ const findings = [];
379
+ for (const dir of pluginDirs) {
380
+ for (const { file, isTest } of listSourceFiles(dir)) {
381
+ if (isTest)
382
+ continue;
383
+ const parsed = parseFile(file);
384
+ if (isParseError(parsed)) {
385
+ findings.push(parsed);
386
+ continue;
387
+ }
388
+ findings.push(...scanFile(parsed).sort((a, b) => a.line - b.line));
389
+ }
390
+ }
391
+ return findings;
392
+ }
@@ -0,0 +1,19 @@
1
+ import { type Finding } from "./source.js";
2
+ export type { Finding };
3
+ /**
4
+ * 插件状态检查(AST)。规则:
5
+ * - plugin-timer:setTimeout / setInterval / setImmediate(含 globalThis.x、别名、timers 模块导入);
6
+ * - plugin-module-state:模块顶层 let / var,或 const 初始化为 new Map/Set/WeakMap/WeakSet 或数组字面量。
7
+ * 豁免:`// capybara:allow-module-state <原因>`(行尾或紧邻上一行,原因必填)。
8
+ * 定时器识别:标识符引用(含别名赋值)、`x.setTimeout` / `x["setTimeout"]`、解构键
9
+ * `{ setTimeout: st }`、任何恰好等于定时器名的字符串字面量(含字面量之间的 `+` 拼接,如
10
+ * `g["set" + "Timeout"]`)、timers 模块导入(import/require/动态 import)。
11
+ * 扫描范围:插件目录下所有源码,node_modules 任何深度都跳过;dist 与 test/tests/__tests__ 只在插件根跳过
12
+ * (更深处同名目录照常扫描,否则 `src/test/impl.ts` 就能绕过);*.test.* / *.spec.* 不扫。
13
+ *
14
+ * 已知缺口(M0 不做,靠评审与双副本测试兜底):顶层对象字面量(`const x = {}`)、`Array.from(...)` /
15
+ * `Object.create(...)` 等工厂调用、`export default new Map()`、IIFE 闭包里的状态、namespace 内的 let、
16
+ * 类静态可变字段、块内 `var` 提升、`queueMicrotask` / `process.nextTick` 递归循环、
17
+ * 含非字面量成分的运行时拼接(变量、函数调用、带插值的模板)与非字面量动态 import。
18
+ */
19
+ export declare function checkPluginState(pluginDirs: string[]): Finding[];
@@ -0,0 +1,181 @@
1
+ import { collectImports, isParseError, listSourceFiles, parseFile, stringValue, walk, } from "./source.js";
2
+ const TIMERS = new Set(["setTimeout", "setInterval", "setImmediate"]);
3
+ const TIMER_MODULES = new Set(["timers", "node:timers", "timers/promises", "node:timers/promises"]);
4
+ const STATE_CTORS = new Set(["Map", "Set", "WeakMap", "WeakSet"]);
5
+ const MARKER = "capybara:allow-module-state";
6
+ const FIX = "改用 ctx.kv(带 TTL 的共享状态)/ ctx.queue(延迟任务)/ ctx.bus(跨副本通知)";
7
+ const MSG_TIMER = `插件不得使用定时器:定时器只存在于创建它的那个进程,多副本下别的实例停不掉也看不到它。${FIX}`;
8
+ const MSG_STATE = `模块顶层的可变状态只存在于当前进程,多副本下各实例各存一份。${FIX};确属只读常量请加 // ${MARKER} <原因>`;
9
+ const MSG_REASON = `// ${MARKER} 后必须写明原因(不能为空)`;
10
+ const WRAPPERS = new Set([
11
+ "ParenthesizedExpression",
12
+ "TSAsExpression",
13
+ "TSSatisfiesExpression",
14
+ "TSNonNullExpression",
15
+ "TSTypeAssertion",
16
+ ]);
17
+ function unwrap(n) {
18
+ let cur = n;
19
+ while (WRAPPERS.has(cur.type))
20
+ cur = cur.expression;
21
+ return cur;
22
+ }
23
+ /** 初始化表达式是否是 new Map/Set/WeakMap/WeakSet(...)(含 new globalThis.Map)或数组字面量。 */
24
+ function isStateInit(init) {
25
+ if (!init)
26
+ return false;
27
+ const e = unwrap(init);
28
+ if (e.type === "ArrayExpression")
29
+ return true;
30
+ if (e.type !== "NewExpression")
31
+ return false;
32
+ const callee = unwrap(e.callee);
33
+ if (callee.type === "Identifier")
34
+ return STATE_CTORS.has(callee.name);
35
+ if (callee.type === "MemberExpression" && !callee.computed) {
36
+ return STATE_CTORS.has(callee.property.name);
37
+ }
38
+ return false;
39
+ }
40
+ /** 找声明上的豁免标记:声明首行/末行的行尾注释,或独占一行、紧邻声明上方的注释。 */
41
+ function findMarker(f, decl) {
42
+ const first = f.lineOf(decl.start);
43
+ const last = f.lineOf(decl.end);
44
+ for (const c of f.comments) {
45
+ if (c.type !== "Line")
46
+ continue;
47
+ const m = new RegExp(`^\\s*${MARKER}(?:\\s+(.*\\S))?\\s*$`).exec(c.value);
48
+ if (!m)
49
+ continue;
50
+ const line = f.lineOf(c.start);
51
+ const ownLine = f.lineText(line).trimStart().startsWith("//");
52
+ if (line === first || line === last || (line === first - 1 && ownLine)) {
53
+ return { reason: m[1] ?? "" };
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ function moduleStateFindings(f) {
59
+ const out = [];
60
+ const body = f.program.body ?? [];
61
+ for (const stmt of body) {
62
+ const decl = stmt.type === "ExportNamedDeclaration" && stmt.declaration
63
+ ? stmt.declaration
64
+ : stmt;
65
+ if (decl.type !== "VariableDeclaration")
66
+ continue;
67
+ const kind = decl.kind;
68
+ for (const d of decl.declarations) {
69
+ const flagged = kind === "let" ||
70
+ kind === "var" ||
71
+ (kind === "const" && isStateInit(d.init));
72
+ if (!flagged)
73
+ continue;
74
+ const marker = findMarker(f, stmt);
75
+ const line = f.lineOf(d.start);
76
+ if (marker?.reason)
77
+ continue;
78
+ out.push({
79
+ file: f.file,
80
+ line,
81
+ rule: "plugin-module-state",
82
+ message: marker ? MSG_REASON : MSG_STATE,
83
+ });
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ function timerFindings(f) {
89
+ const out = [];
90
+ const add = (n) => out.push({ file: f.file, line: f.lineOf(n.start), rule: "plugin-timer", message: MSG_TIMER });
91
+ for (const { spec, start } of collectImports(f.program)) {
92
+ if (TIMER_MODULES.has(spec)) {
93
+ out.push({ file: f.file, line: f.lineOf(start), rule: "plugin-timer", message: MSG_TIMER });
94
+ }
95
+ }
96
+ walk(f.program, (n, parent, key) => {
97
+ if (n.type === "Identifier" && TIMERS.has(n.name)) {
98
+ if (!parent)
99
+ return;
100
+ const p = parent;
101
+ if (p.type === "MemberExpression" && key === "property" && !p.computed)
102
+ return;
103
+ if ((p.type === "Property" ||
104
+ p.type === "PropertyDefinition" ||
105
+ p.type === "MethodDefinition") &&
106
+ key === "key" &&
107
+ !p.computed) {
108
+ return;
109
+ }
110
+ if (p.type.startsWith("TS"))
111
+ return;
112
+ if (/^(Import|Export)/.test(p.type))
113
+ return; // 从 timers 导入的写法由上面的 import 检查报
114
+ add(n);
115
+ }
116
+ else if (n.type === "ObjectPattern") {
117
+ // const { setTimeout: st } = globalThis:键名指向定时器,改名后就认不出了。
118
+ for (const prop of n.properties) {
119
+ const k = prop.type === "Property" ? prop.key : undefined;
120
+ if (k && !prop.computed && k.type === "Identifier" && TIMERS.has(k.name))
121
+ add(k);
122
+ }
123
+ }
124
+ else if (n.type === "Literal" ||
125
+ n.type === "TemplateLiteral" ||
126
+ (n.type === "BinaryExpression" && n.operator === "+")) {
127
+ // 任何恰好等于定时器名的字符串(Reflect.get(globalThis, "setInterval")、globalThis["setTimeout"]),
128
+ // 含字符串字面量 / 无插值模板的 `+` 拼接(globalThis["set" + "Immediate"]),由 stringValue 求值。
129
+ // 刻意保守:误报用豁免之外的办法解决——换个写法。
130
+ const v = stringValue(n);
131
+ if (v !== undefined && TIMERS.has(v))
132
+ add(n);
133
+ }
134
+ else if (n.type === "MemberExpression" && !n.computed) {
135
+ const prop = n.property;
136
+ if (prop.type === "Identifier" && TIMERS.has(prop.name))
137
+ add(n);
138
+ }
139
+ });
140
+ return out;
141
+ }
142
+ /**
143
+ * 插件状态检查(AST)。规则:
144
+ * - plugin-timer:setTimeout / setInterval / setImmediate(含 globalThis.x、别名、timers 模块导入);
145
+ * - plugin-module-state:模块顶层 let / var,或 const 初始化为 new Map/Set/WeakMap/WeakSet 或数组字面量。
146
+ * 豁免:`// capybara:allow-module-state <原因>`(行尾或紧邻上一行,原因必填)。
147
+ * 定时器识别:标识符引用(含别名赋值)、`x.setTimeout` / `x["setTimeout"]`、解构键
148
+ * `{ setTimeout: st }`、任何恰好等于定时器名的字符串字面量(含字面量之间的 `+` 拼接,如
149
+ * `g["set" + "Timeout"]`)、timers 模块导入(import/require/动态 import)。
150
+ * 扫描范围:插件目录下所有源码,node_modules 任何深度都跳过;dist 与 test/tests/__tests__ 只在插件根跳过
151
+ * (更深处同名目录照常扫描,否则 `src/test/impl.ts` 就能绕过);*.test.* / *.spec.* 不扫。
152
+ *
153
+ * 已知缺口(M0 不做,靠评审与双副本测试兜底):顶层对象字面量(`const x = {}`)、`Array.from(...)` /
154
+ * `Object.create(...)` 等工厂调用、`export default new Map()`、IIFE 闭包里的状态、namespace 内的 let、
155
+ * 类静态可变字段、块内 `var` 提升、`queueMicrotask` / `process.nextTick` 递归循环、
156
+ * 含非字面量成分的运行时拼接(变量、函数调用、带插值的模板)与非字面量动态 import。
157
+ */
158
+ export function checkPluginState(pluginDirs) {
159
+ const findings = [];
160
+ for (const dir of pluginDirs) {
161
+ for (const { file, isTest } of listSourceFiles(dir)) {
162
+ if (isTest)
163
+ continue;
164
+ const parsed = parseFile(file);
165
+ if (isParseError(parsed)) {
166
+ findings.push(parsed);
167
+ continue;
168
+ }
169
+ const all = [...moduleStateFindings(parsed), ...timerFindings(parsed)];
170
+ all.sort((a, b) => a.line - b.line);
171
+ const seen = new Set();
172
+ for (const x of all) {
173
+ const k = `${x.line}|${x.rule}`;
174
+ if (!seen.has(k))
175
+ findings.push(x);
176
+ seen.add(k);
177
+ }
178
+ }
179
+ }
180
+ return findings;
181
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `changeset version` bumps only a plugin's `package.json`, but the packer (and the SDK's
3
+ * package validation) requires the same version in `.capybara-plugin/plugin.json` and in the
4
+ * extension manifest (`src/index.ts`, `version: "x"`). `pnpm version-packages` (what the Version
5
+ * Packages PR runs) calls this right after `changeset version`, before the bootstrap pins are
6
+ * rewritten (`check:bootstrap --write`), since the packed bytes include the version.
7
+ *
8
+ * `pluginDirs` defaults to the plugins {@link discoverPlugins} finds under `root`. Returns the files
9
+ * it changed, relative to `root` (POSIX). Throws when an extension's `src/index.ts`
10
+ * does not have exactly one ` version: "..."` line, so a reformatted manifest fails loudly
11
+ * instead of being left stale.
12
+ */
13
+ export declare function syncPluginVersions(root: string, pluginDirs?: string[]): string[];
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { discoverPlugins } from "../discover.js";
4
+ /** The one top-level `version: "x"` line of an extension's `defineExtension({...})` manifest. */
5
+ const MANIFEST_VERSION_RE = /^( {2}version: ")([^"]+)(",)$/gm;
6
+ /**
7
+ * `changeset version` bumps only a plugin's `package.json`, but the packer (and the SDK's
8
+ * package validation) requires the same version in `.capybara-plugin/plugin.json` and in the
9
+ * extension manifest (`src/index.ts`, `version: "x"`). `pnpm version-packages` (what the Version
10
+ * Packages PR runs) calls this right after `changeset version`, before the bootstrap pins are
11
+ * rewritten (`check:bootstrap --write`), since the packed bytes include the version.
12
+ *
13
+ * `pluginDirs` defaults to the plugins {@link discoverPlugins} finds under `root`. Returns the files
14
+ * it changed, relative to `root` (POSIX). Throws when an extension's `src/index.ts`
15
+ * does not have exactly one ` version: "..."` line, so a reformatted manifest fails loudly
16
+ * instead of being left stale.
17
+ */
18
+ export function syncPluginVersions(root, pluginDirs = discoverPlugins(root)) {
19
+ const changed = [];
20
+ const rel = (p) => relative(root, p).split("\\").join("/");
21
+ for (const dir of pluginDirs) {
22
+ const pkgFile = join(dir, "package.json");
23
+ if (!existsSync(pkgFile))
24
+ continue;
25
+ const version = JSON.parse(readFileSync(pkgFile, "utf8")).version;
26
+ if (typeof version !== "string")
27
+ continue;
28
+ const manifestFile = join(dir, ".capybara-plugin/plugin.json");
29
+ const manifestRel = rel(manifestFile);
30
+ if (existsSync(manifestFile)) {
31
+ const text = readFileSync(manifestFile, "utf8");
32
+ const json = JSON.parse(text);
33
+ if (json.version !== version) {
34
+ json.version = version;
35
+ writeFileSync(manifestFile, `${JSON.stringify(json, null, 2)}\n`);
36
+ changed.push(manifestRel);
37
+ }
38
+ }
39
+ const srcFile = join(dir, "src/index.ts");
40
+ const srcRel = rel(srcFile);
41
+ if (existsSync(srcFile)) {
42
+ const text = readFileSync(srcFile, "utf8");
43
+ const matches = [...text.matchAll(MANIFEST_VERSION_RE)];
44
+ if (matches.length !== 1) {
45
+ throw new Error(`${srcRel}: expected exactly one top-level \` version: "..."\` line in the extension manifest, found ${matches.length}`);
46
+ }
47
+ if (matches[0]?.[2] !== version) {
48
+ writeFileSync(srcFile, text.replace(MANIFEST_VERSION_RE, (_m, pre, _old, post) => {
49
+ return `${pre}${version}${post}`;
50
+ }));
51
+ changed.push(srcRel);
52
+ }
53
+ }
54
+ }
55
+ return changed;
56
+ }