@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.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +105 -2
- package/bin/capybara-plugin.mjs +12 -0
- package/dist/check/applied-migrations.d.ts +28 -0
- package/dist/check/applied-migrations.js +65 -0
- package/dist/check/cli.d.ts +16 -0
- package/dist/check/cli.js +69 -0
- package/dist/check/deps.d.ts +23 -0
- package/dist/check/deps.js +207 -0
- package/dist/check/index.d.ts +15 -0
- package/dist/check/index.js +14 -0
- package/dist/check/migrations.d.ts +11 -0
- package/dist/check/migrations.js +93 -0
- package/dist/check/plugin-pack.d.ts +17 -0
- package/dist/check/plugin-pack.js +56 -0
- package/dist/check/plugin-permissions.d.ts +22 -0
- package/dist/check/plugin-permissions.js +392 -0
- package/dist/check/plugin-state.d.ts +19 -0
- package/dist/check/plugin-state.js +181 -0
- package/dist/check/plugin-versions.d.ts +13 -0
- package/dist/check/plugin-versions.js +56 -0
- package/dist/check/release-metadata.d.ts +13 -0
- package/dist/check/release-metadata.js +80 -0
- package/dist/check/report.d.ts +8 -0
- package/dist/check/report.js +17 -0
- package/dist/check/run.d.ts +36 -0
- package/dist/check/run.js +60 -0
- package/dist/check/source.d.ts +53 -0
- package/dist/check/source.js +153 -0
- package/dist/check/sync-versions-cli.d.ts +10 -0
- package/dist/check/sync-versions-cli.js +44 -0
- package/dist/check/test-imports.d.ts +20 -0
- package/dist/check/test-imports.js +158 -0
- package/dist/check/tool-design.d.ts +13 -0
- package/dist/check/tool-design.js +227 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +48 -0
- package/dist/discover.d.ts +21 -0
- package/dist/discover.js +69 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/integrity.d.ts +2 -0
- package/dist/integrity.js +3 -0
- package/dist/marketplace/cli.d.ts +20 -0
- package/dist/marketplace/cli.js +80 -0
- package/dist/marketplace/file.d.ts +2 -0
- package/dist/marketplace/file.js +2 -0
- package/dist/marketplace/index.d.ts +2 -0
- package/dist/marketplace/index.js +2 -0
- package/dist/marketplace/marketplace.d.ts +57 -0
- package/dist/marketplace/marketplace.js +245 -0
- package/dist/pack/bootstrap.d.ts +25 -0
- package/dist/pack/bootstrap.js +70 -0
- package/dist/pack/cli.d.ts +20 -0
- package/dist/pack/cli.js +109 -0
- package/dist/pack/index.d.ts +4 -0
- package/dist/pack/index.js +4 -0
- package/dist/pack/npm.d.ts +66 -0
- package/dist/pack/npm.js +177 -0
- package/dist/pack/pack.d.ts +44 -0
- package/dist/pack/pack.js +518 -0
- package/dist/release/cli.d.ts +30 -0
- package/dist/release/cli.js +93 -0
- package/dist/release/index.d.ts +4 -0
- package/dist/release/index.js +4 -0
- package/dist/release/publish-plan.d.ts +68 -0
- package/dist/release/publish-plan.js +117 -0
- package/dist/release/publish.d.ts +91 -0
- package/dist/release/publish.js +274 -0
- package/dist/release/semver.d.ts +11 -0
- package/dist/release/semver.js +44 -0
- package/package.json +61 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* Release metadata of the plugins a repository publishes (`plugin-release-metadata`): a plugin that
|
|
4
|
+
* would be published (not `"private": true`) needs a `description`, a `license`, an https `homepage`
|
|
5
|
+
* and `"repository": { "type": "git", "url", "directory": <its dir relative to root> }`, where `url`
|
|
6
|
+
* is the root package.json's `repository.url` (npm provenance rejects a publish whose repository
|
|
7
|
+
* does not match the repository that built it; core's plugins name `capybara-hq/core.git`, the
|
|
8
|
+
* plugins repository's `capybara-hq/plugins.git`). A plugin that is the repository root itself
|
|
9
|
+
* has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
|
|
10
|
+
* (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
|
|
11
|
+
* `check:release-metadata`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkPluginReleaseMetadata(plugins: string[], root: string): Finding[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
const RULE = "plugin-release-metadata";
|
|
4
|
+
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
5
|
+
/** `repository.url` of a package.json (`"repository": "<url>"` or `{ url }`). */
|
|
6
|
+
function repositoryUrl(pkg) {
|
|
7
|
+
const r = pkg.repository;
|
|
8
|
+
if (typeof r === "string")
|
|
9
|
+
return r;
|
|
10
|
+
if (typeof r === "object" && r !== null && typeof r.url === "string") {
|
|
11
|
+
return r.url;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Release metadata of the plugins a repository publishes (`plugin-release-metadata`): a plugin that
|
|
17
|
+
* would be published (not `"private": true`) needs a `description`, a `license`, an https `homepage`
|
|
18
|
+
* and `"repository": { "type": "git", "url", "directory": <its dir relative to root> }`, where `url`
|
|
19
|
+
* is the root package.json's `repository.url` (npm provenance rejects a publish whose repository
|
|
20
|
+
* does not match the repository that built it; core's plugins name `capybara-hq/core.git`, the
|
|
21
|
+
* plugins repository's `capybara-hq/plugins.git`). A plugin that is the repository root itself
|
|
22
|
+
* has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
|
|
23
|
+
* (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
|
|
24
|
+
* `check:release-metadata`.
|
|
25
|
+
*/
|
|
26
|
+
export function checkPluginReleaseMetadata(plugins, root) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const rootFile = join(root, "package.json");
|
|
29
|
+
const url = existsSync(rootFile) ? repositoryUrl(readJson(rootFile)) : undefined;
|
|
30
|
+
let rootReported = false;
|
|
31
|
+
for (const dir of plugins) {
|
|
32
|
+
const file = join(dir, "package.json");
|
|
33
|
+
if (!existsSync(file))
|
|
34
|
+
continue;
|
|
35
|
+
const pkg = readJson(file);
|
|
36
|
+
if (pkg.private === true)
|
|
37
|
+
continue;
|
|
38
|
+
const rel = relative(root, dir).split("\\").join("/");
|
|
39
|
+
const label = rel === "" ? String(pkg.name ?? basename(dir)) : rel;
|
|
40
|
+
const finding = (message) => out.push({ file, line: 1, rule: RULE, message });
|
|
41
|
+
if (basename(dir).startsWith("example-")) {
|
|
42
|
+
finding(`${label} must not be published: add "private": true (example plugins stay off npm)`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof pkg.description !== "string" || pkg.description.trim() === "") {
|
|
46
|
+
finding(`${label} is published and needs a "description"`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof pkg.license !== "string")
|
|
49
|
+
finding(`${label} is published and needs a "license"`);
|
|
50
|
+
if (typeof pkg.homepage !== "string" || !pkg.homepage.startsWith("https://")) {
|
|
51
|
+
finding(`${label} is published and needs an https "homepage"`);
|
|
52
|
+
}
|
|
53
|
+
if (url === undefined) {
|
|
54
|
+
if (!rootReported) {
|
|
55
|
+
rootReported = true;
|
|
56
|
+
out.push({
|
|
57
|
+
file: rootFile,
|
|
58
|
+
line: 1,
|
|
59
|
+
rule: RULE,
|
|
60
|
+
message: 'the repository root package.json needs "repository": { "type": "git", "url": … }: ' +
|
|
61
|
+
"every published plugin's repository.url must equal it (npm provenance)",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const repo = pkg.repository;
|
|
67
|
+
const ok = typeof repo === "object" &&
|
|
68
|
+
repo !== null &&
|
|
69
|
+
repo.type === "git" &&
|
|
70
|
+
repo.url === url &&
|
|
71
|
+
(rel === "" ? repo.directory === undefined : repo.directory === rel);
|
|
72
|
+
if (!ok) {
|
|
73
|
+
const want = rel === ""
|
|
74
|
+
? `{ "type": "git", "url": "${url}" }`
|
|
75
|
+
: `{ "type": "git", "url": "${url}", "directory": "${rel}" }`;
|
|
76
|
+
finding(`${label} is published and needs "repository": ${want} (the root package.json's repository; npm provenance requires it)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
|
+
export declare const formatFinding: (f: Finding, root: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Prints the result of one check: `[name] passed`, or the finding count and one line per finding
|
|
6
|
+
* (stderr). Returns whether it passed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function printCheck(name: string, findings: Finding[], root: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
|
+
export const formatFinding = (f, root) => `${relative(root, f.file) || f.file}:${f.line} ${f.rule} ${f.message}`;
|
|
4
|
+
/**
|
|
5
|
+
* Prints the result of one check: `[name] passed`, or the finding count and one line per finding
|
|
6
|
+
* (stderr). Returns whether it passed.
|
|
7
|
+
*/
|
|
8
|
+
export function printCheck(name, findings, root) {
|
|
9
|
+
if (findings.length === 0) {
|
|
10
|
+
console.log(`[${name}] passed`);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
console.error(`[${name}] ${findings.length} finding(s)`);
|
|
14
|
+
for (const f of findings)
|
|
15
|
+
console.error(` ${formatFinding(f, root)}`);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/** What the plugin-scoped checks scan. */
|
|
3
|
+
export interface PluginCheckScope {
|
|
4
|
+
/**
|
|
5
|
+
* The repository root: its package.json's `repository.url` is the one every published plugin
|
|
6
|
+
* must name (release metadata).
|
|
7
|
+
*/
|
|
8
|
+
root: string;
|
|
9
|
+
/**
|
|
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
|
|
12
|
+
* `<plugin>/migrations`).
|
|
13
|
+
*/
|
|
14
|
+
plugins: string[];
|
|
15
|
+
/** Package directories held to the SDK's boundary (core: `packages/plugin-sdk`). */
|
|
16
|
+
sdkDirs?: string[];
|
|
17
|
+
/** More directories the tool design rules scan (core: the kernel's builtin tools). */
|
|
18
|
+
toolDesignDirs?: string[];
|
|
19
|
+
/** More migration directories (core: the kernel's migrations). */
|
|
20
|
+
migrationDirs?: string[];
|
|
21
|
+
}
|
|
22
|
+
/** A named check: `run` returns its findings. */
|
|
23
|
+
export interface NamedCheck {
|
|
24
|
+
name: string;
|
|
25
|
+
run: () => Finding[] | Promise<Finding[]>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every plugin-scoped check over `scope`, in the order `capybara-plugin check` and core's `pnpm
|
|
29
|
+
* check` run them. `checkAppliedMigrations` is not here: it needs a diff base (PR only).
|
|
30
|
+
*/
|
|
31
|
+
export declare function pluginChecks(scope: PluginCheckScope): NamedCheck[];
|
|
32
|
+
/**
|
|
33
|
+
* Runs `checks` in order and prints each result ({@link printCheck}, paths relative to `root`); a
|
|
34
|
+
* check that throws counts as failed. Returns the number of failed checks.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runChecks(checks: NamedCheck[], root: string): Promise<number>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { checkDeps } from "./deps.js";
|
|
3
|
+
import { checkMigrations } from "./migrations.js";
|
|
4
|
+
import { checkPluginPack } from "./plugin-pack.js";
|
|
5
|
+
import { checkPluginPermissions } from "./plugin-permissions.js";
|
|
6
|
+
import { checkPluginState } from "./plugin-state.js";
|
|
7
|
+
import { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
8
|
+
import { printCheck } from "./report.js";
|
|
9
|
+
import { checkPluginTestImports } from "./test-imports.js";
|
|
10
|
+
import { checkToolDesign } from "./tool-design.js";
|
|
11
|
+
/**
|
|
12
|
+
* Every plugin-scoped check over `scope`, in the order `capybara-plugin check` and core's `pnpm
|
|
13
|
+
* check` run them. `checkAppliedMigrations` is not here: it needs a diff base (PR only).
|
|
14
|
+
*/
|
|
15
|
+
export function pluginChecks(scope) {
|
|
16
|
+
const { plugins } = scope;
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
name: "Migrations",
|
|
20
|
+
run: () => checkMigrations([
|
|
21
|
+
...(scope.migrationDirs ?? []),
|
|
22
|
+
...plugins.map((p) => join(p, "migrations")),
|
|
23
|
+
]),
|
|
24
|
+
},
|
|
25
|
+
{ name: "Dependency boundary", run: () => checkDeps(plugins, scope.sdkDirs ?? []) },
|
|
26
|
+
{ name: "Extension state", run: () => checkPluginState(plugins) },
|
|
27
|
+
{ name: "Extension permissions", run: () => checkPluginPermissions(plugins) },
|
|
28
|
+
{ name: "Plugin pack", run: () => checkPluginPack(plugins) },
|
|
29
|
+
{
|
|
30
|
+
name: "Tool design",
|
|
31
|
+
run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
|
|
32
|
+
},
|
|
33
|
+
{ name: "Plugin test imports", run: () => checkPluginTestImports(plugins) },
|
|
34
|
+
{
|
|
35
|
+
name: "Plugin release metadata",
|
|
36
|
+
run: () => checkPluginReleaseMetadata(plugins, scope.root),
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runs `checks` in order and prints each result ({@link printCheck}, paths relative to `root`); a
|
|
42
|
+
* check that throws counts as failed. Returns the number of failed checks.
|
|
43
|
+
*/
|
|
44
|
+
export async function runChecks(checks, root) {
|
|
45
|
+
let failed = 0;
|
|
46
|
+
for (const c of checks) {
|
|
47
|
+
let findings;
|
|
48
|
+
try {
|
|
49
|
+
findings = await c.run();
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error(`[${c.name}] check crashed: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
|
|
53
|
+
failed++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!printCheck(c.name, findings, root))
|
|
57
|
+
failed++;
|
|
58
|
+
}
|
|
59
|
+
return failed;
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Comment } from "oxc-parser";
|
|
2
|
+
export type Finding = {
|
|
3
|
+
file: string;
|
|
4
|
+
line: number;
|
|
5
|
+
rule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
/** ESTree 节点(oxc-parser 的输出)。只声明本检查用到的公共字段。 */
|
|
9
|
+
export interface Node {
|
|
10
|
+
type: string;
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface SourceFile {
|
|
16
|
+
file: string;
|
|
17
|
+
/** 测试文件:插件根的 test/ 目录下的文件,或 *.test.* / *.spec.* 文件。 */
|
|
18
|
+
isTest: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 递归列出目录下的源码文件。node_modules 与 packer 的临时目录 `.capybara-pack-*` 在任何深度都跳过;
|
|
22
|
+
* dist 只在根目录跳过(构建产物),
|
|
23
|
+
* test / tests / __tests__ 目录只在根目录算测试(标 isTest),更深处同名目录照常当源码扫描,
|
|
24
|
+
* 否则 `src/test/impl.ts` 再 re-export 就能绕过全部检查。结果有序。
|
|
25
|
+
*/
|
|
26
|
+
export declare function listSourceFiles(dir: string): SourceFile[];
|
|
27
|
+
export interface ParsedFile {
|
|
28
|
+
file: string;
|
|
29
|
+
source: string;
|
|
30
|
+
program: Node;
|
|
31
|
+
comments: Comment[];
|
|
32
|
+
/** 偏移量 -> 1 基行号。 */
|
|
33
|
+
lineOf(offset: number): number;
|
|
34
|
+
/** 取 1 基行号的原文。 */
|
|
35
|
+
lineText(line: number): string;
|
|
36
|
+
}
|
|
37
|
+
/** 解析一个文件;语法错误时返回 Finding(rule: parse-error),检查不能因解析失败而静默放过。 */
|
|
38
|
+
export declare function parseFile(file: string): ParsedFile | Finding;
|
|
39
|
+
/** 判断 parseFile 的结果是不是解析失败。 */
|
|
40
|
+
export declare function isParseError(x: ParsedFile | Finding): x is Finding;
|
|
41
|
+
/** 深度优先遍历 AST;visit 收到节点、父节点与它在父节点上的属性名。 */
|
|
42
|
+
export declare function walk(node: unknown, visit: (n: Node, parent: Node | null, key: string) => void, parent?: Node | null, key?: string): void;
|
|
43
|
+
/** 取字符串字面量、无插值模板字符串、或它们的 `+` 拼接的值;其他表达式返回 undefined。 */
|
|
44
|
+
export declare function stringValue(n: unknown): string | undefined;
|
|
45
|
+
export interface ImportRef {
|
|
46
|
+
spec: string;
|
|
47
|
+
start: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 收集文件里所有模块引用:import / export … from / export * / import type / 动态 import() /
|
|
51
|
+
* require() / import x = require() / import("x") 类型。非字面量的动态 import 无法静态判定,被忽略。
|
|
52
|
+
*/
|
|
53
|
+
export declare function collectImports(program: Node): ImportRef[];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseSync } from "oxc-parser";
|
|
4
|
+
const SOURCE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
5
|
+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
6
|
+
/** 只在插件根目录下才有特殊含义的目录名(更深处同名目录是普通源码目录,必须扫描)。 */
|
|
7
|
+
const ROOT_TEST_DIRS = new Set(["test", "tests", "__tests__"]);
|
|
8
|
+
/**
|
|
9
|
+
* 递归列出目录下的源码文件。node_modules 与 packer 的临时目录 `.capybara-pack-*` 在任何深度都跳过;
|
|
10
|
+
* dist 只在根目录跳过(构建产物),
|
|
11
|
+
* test / tests / __tests__ 目录只在根目录算测试(标 isTest),更深处同名目录照常当源码扫描,
|
|
12
|
+
* 否则 `src/test/impl.ts` 再 re-export 就能绕过全部检查。结果有序。
|
|
13
|
+
*/
|
|
14
|
+
export function listSourceFiles(dir) {
|
|
15
|
+
if (!existsSync(dir))
|
|
16
|
+
return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
const visit = (d, depth, inTestRoot) => {
|
|
19
|
+
for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
20
|
+
const p = join(d, e.name);
|
|
21
|
+
if (e.isDirectory()) {
|
|
22
|
+
if (e.name === "node_modules")
|
|
23
|
+
continue;
|
|
24
|
+
// packer 的临时目录(旧版本建在插件目录里,崩溃后可能残留):里面是打包产物,不是插件源码
|
|
25
|
+
if (e.name.startsWith(".capybara-pack-"))
|
|
26
|
+
continue;
|
|
27
|
+
if (depth === 0 && e.name === "dist")
|
|
28
|
+
continue;
|
|
29
|
+
visit(p, depth + 1, inTestRoot || (depth === 0 && ROOT_TEST_DIRS.has(e.name)));
|
|
30
|
+
}
|
|
31
|
+
else if (e.isFile() && SOURCE_EXT.test(e.name)) {
|
|
32
|
+
out.push({ file: p, isTest: inTestRoot || TEST_FILE.test(e.name) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
visit(dir, 0, false);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** 解析一个文件;语法错误时返回 Finding(rule: parse-error),检查不能因解析失败而静默放过。 */
|
|
40
|
+
export function parseFile(file) {
|
|
41
|
+
const source = readFileSync(file, "utf8");
|
|
42
|
+
const result = parseSync(file, source);
|
|
43
|
+
const first = result.errors[0];
|
|
44
|
+
const starts = [0];
|
|
45
|
+
for (let i = 0; i < source.length; i++)
|
|
46
|
+
if (source[i] === "\n")
|
|
47
|
+
starts.push(i + 1);
|
|
48
|
+
const lineOf = (offset) => {
|
|
49
|
+
let lo = 0;
|
|
50
|
+
let hi = starts.length - 1;
|
|
51
|
+
while (lo < hi) {
|
|
52
|
+
const mid = (lo + hi + 1) >> 1;
|
|
53
|
+
if (starts[mid] <= offset)
|
|
54
|
+
lo = mid;
|
|
55
|
+
else
|
|
56
|
+
hi = mid - 1;
|
|
57
|
+
}
|
|
58
|
+
return lo + 1;
|
|
59
|
+
};
|
|
60
|
+
const lines = source.split("\n");
|
|
61
|
+
if (first) {
|
|
62
|
+
const label = first.labels?.[0];
|
|
63
|
+
return {
|
|
64
|
+
file,
|
|
65
|
+
line: label ? lineOf(label.start) : 1,
|
|
66
|
+
rule: "parse-error",
|
|
67
|
+
message: `无法解析,检查无法进行:${first.message}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
file,
|
|
72
|
+
source,
|
|
73
|
+
program: result.program,
|
|
74
|
+
comments: result.comments,
|
|
75
|
+
lineOf,
|
|
76
|
+
lineText: (n) => lines[n - 1] ?? "",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** 判断 parseFile 的结果是不是解析失败。 */
|
|
80
|
+
export function isParseError(x) {
|
|
81
|
+
return "rule" in x;
|
|
82
|
+
}
|
|
83
|
+
const isNode = (v) => typeof v === "object" && v !== null && typeof v.type === "string";
|
|
84
|
+
/** 深度优先遍历 AST;visit 收到节点、父节点与它在父节点上的属性名。 */
|
|
85
|
+
export function walk(node, visit, parent = null, key = "") {
|
|
86
|
+
if (Array.isArray(node)) {
|
|
87
|
+
for (const item of node)
|
|
88
|
+
walk(item, visit, parent, key);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!isNode(node))
|
|
92
|
+
return;
|
|
93
|
+
visit(node, parent, key);
|
|
94
|
+
for (const [k, v] of Object.entries(node)) {
|
|
95
|
+
if (k === "type" || k === "start" || k === "end")
|
|
96
|
+
continue;
|
|
97
|
+
if (typeof v === "object" && v !== null)
|
|
98
|
+
walk(v, visit, node, k);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** 取字符串字面量、无插值模板字符串、或它们的 `+` 拼接的值;其他表达式返回 undefined。 */
|
|
102
|
+
export function stringValue(n) {
|
|
103
|
+
if (!isNode(n))
|
|
104
|
+
return undefined;
|
|
105
|
+
if (n.type === "BinaryExpression" && n.operator === "+") {
|
|
106
|
+
const l = stringValue(n.left);
|
|
107
|
+
const r = stringValue(n.right);
|
|
108
|
+
return l !== undefined && r !== undefined ? l + r : undefined;
|
|
109
|
+
}
|
|
110
|
+
if (n.type === "Literal" && typeof n.value === "string")
|
|
111
|
+
return n.value;
|
|
112
|
+
if (n.type === "TemplateLiteral") {
|
|
113
|
+
const quasis = n.quasis;
|
|
114
|
+
const exprs = n.expressions;
|
|
115
|
+
if (exprs.length === 0 && quasis.length === 1)
|
|
116
|
+
return quasis[0]?.value.cooked ?? undefined;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 收集文件里所有模块引用:import / export … from / export * / import type / 动态 import() /
|
|
122
|
+
* require() / import x = require() / import("x") 类型。非字面量的动态 import 无法静态判定,被忽略。
|
|
123
|
+
*/
|
|
124
|
+
export function collectImports(program) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const add = (n, at) => {
|
|
127
|
+
const spec = stringValue(n);
|
|
128
|
+
if (spec !== undefined)
|
|
129
|
+
out.push({ spec, start: at.start });
|
|
130
|
+
};
|
|
131
|
+
walk(program, (n) => {
|
|
132
|
+
switch (n.type) {
|
|
133
|
+
case "ImportDeclaration":
|
|
134
|
+
case "ExportNamedDeclaration":
|
|
135
|
+
case "ExportAllDeclaration":
|
|
136
|
+
case "ImportExpression":
|
|
137
|
+
case "TSImportType":
|
|
138
|
+
add(n.source, n);
|
|
139
|
+
break;
|
|
140
|
+
case "TSExternalModuleReference":
|
|
141
|
+
add(n.expression, n);
|
|
142
|
+
break;
|
|
143
|
+
case "CallExpression": {
|
|
144
|
+
const callee = n.callee;
|
|
145
|
+
const args = n.arguments;
|
|
146
|
+
if (callee.type === "Identifier" && callee.name === "require")
|
|
147
|
+
add(args[0], n);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const SYNC_VERSIONS_USAGE = "capybara-plugin sync-versions [--root <dir>] [--plugins <glob>]";
|
|
2
|
+
/**
|
|
3
|
+
* `capybara-plugin sync-versions`: copies each plugin's `package.json` version into its
|
|
4
|
+
* `.capybara-plugin/plugin.json` and its extension manifest (`src/index.ts`, ` version: "x"`),
|
|
5
|
+
* which the packer requires equal. Run it right after `changeset version` (which bumps only
|
|
6
|
+
* `package.json`), before anything packs. Plugins are found as by `check` (`<root>/plugins/*`,
|
|
7
|
+
* `--plugins <glob>`, or the root itself). Relative paths resolve against `cwd`. Returns the exit
|
|
8
|
+
* code.
|
|
9
|
+
*/
|
|
10
|
+
export declare function syncVersionsCommand(args: string[], cwd: string): Promise<number>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { discoverPlugins } from "../discover.js";
|
|
4
|
+
import { syncPluginVersions } from "./plugin-versions.js";
|
|
5
|
+
export const SYNC_VERSIONS_USAGE = "capybara-plugin sync-versions [--root <dir>] [--plugins <glob>]";
|
|
6
|
+
/**
|
|
7
|
+
* `capybara-plugin sync-versions`: copies each plugin's `package.json` version into its
|
|
8
|
+
* `.capybara-plugin/plugin.json` and its extension manifest (`src/index.ts`, ` version: "x"`),
|
|
9
|
+
* which the packer requires equal. Run it right after `changeset version` (which bumps only
|
|
10
|
+
* `package.json`), before anything packs. Plugins are found as by `check` (`<root>/plugins/*`,
|
|
11
|
+
* `--plugins <glob>`, or the root itself). Relative paths resolve against `cwd`. Returns the exit
|
|
12
|
+
* code.
|
|
13
|
+
*/
|
|
14
|
+
export async function syncVersionsCommand(args, cwd) {
|
|
15
|
+
const { values } = parseArgs({
|
|
16
|
+
args,
|
|
17
|
+
options: {
|
|
18
|
+
help: { type: "boolean", short: "h", default: false },
|
|
19
|
+
root: { type: "string" },
|
|
20
|
+
plugins: { type: "string" },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (values.help) {
|
|
24
|
+
console.log(`usage: ${SYNC_VERSIONS_USAGE}`);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const root = resolve(cwd, values.root ?? ".");
|
|
28
|
+
try {
|
|
29
|
+
const plugins = discoverPlugins(root, values.plugins);
|
|
30
|
+
if (plugins.length === 0) {
|
|
31
|
+
console.error(`${relative(cwd, root) || "."}: no plugin found`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const changed = syncPluginVersions(root, plugins);
|
|
35
|
+
console.log(changed.length === 0
|
|
36
|
+
? "plugin versions already match package.json"
|
|
37
|
+
: `plugin versions synced from package.json: ${changed.join(", ")}`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Finding } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* `@capybara-hq/*` packages a plugin test may use besides the plugin itself: the SDK, the test
|
|
4
|
+
* helpers, and model-fake (the scripted model provider published from core), which a test loads by
|
|
5
|
+
* package name to drive agent runs.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PLUGIN_TEST_PACKAGES: string[];
|
|
8
|
+
/**
|
|
9
|
+
* `plugin-test-imports`: a test of a plugin (any file under `<plugin>/test`) reaches nothing outside
|
|
10
|
+
* the plugin, so it keeps working in a repository of its own (the official plugins live in
|
|
11
|
+
* `capybara-hq/plugins`; package ecosystem spec §6.2):
|
|
12
|
+
* - no import of an `@capybara-hq/*` package other than {@link PLUGIN_TEST_PACKAGES} and the plugin
|
|
13
|
+
* itself, nor such a package name written as a string (a name in a kernel `extensions` list,
|
|
14
|
+
* `require.resolve`);
|
|
15
|
+
* - no relative import or path string (`../x`) that leaves the plugin directory, and no `join` /
|
|
16
|
+
* `resolve` whose bare `..` arguments leave it (taken from the file's directory);
|
|
17
|
+
* - no repo-root path into `packages/` or `apps/` (core's layout).
|
|
18
|
+
* A kernel facility a plugin test needs is exported by `@capybara-hq/testing`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkPluginTestImports(pluginDirs: string[]): Finding[];
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { collectImports, isParseError, parseFile, stringValue, walk, } from "./source.js";
|
|
4
|
+
/**
|
|
5
|
+
* `@capybara-hq/*` packages a plugin test may use besides the plugin itself: the SDK, the test
|
|
6
|
+
* helpers, and model-fake (the scripted model provider published from core), which a test loads by
|
|
7
|
+
* package name to drive agent runs.
|
|
8
|
+
*/
|
|
9
|
+
export const PLUGIN_TEST_PACKAGES = [
|
|
10
|
+
"@capybara-hq/plugin-sdk",
|
|
11
|
+
"@capybara-hq/testing",
|
|
12
|
+
"@capybara-hq/plugin-model-fake",
|
|
13
|
+
];
|
|
14
|
+
const SOURCE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
15
|
+
/** A relative path in a string or template literal (`./x`, `../x`). */
|
|
16
|
+
const RELATIVE_STRING = /(["'`])(\.{1,2}\/[^"'`$]*)/g;
|
|
17
|
+
/** A repo-root path into core: `"packages/…"`, `"apps/…"`, or after an interpolation `${root}/apps/…`. */
|
|
18
|
+
const ROOTED_STRING = /(?:["'`]|\}\/?)((?:packages|apps)\/[a-z0-9][^"'`$]*)/g;
|
|
19
|
+
/** An `@capybara-hq/<name>` package name (with an optional subpath); group 1 is the package. */
|
|
20
|
+
const SCOPED_NAME = /^(@capybara-hq\/[^/\s]+)(?:\/\S*)?$/;
|
|
21
|
+
/** The path functions whose `..` segments are followed: `join`, `resolve`, `path.join`, `posix.resolve`… */
|
|
22
|
+
const PATH_CALL = new Set(["join", "resolve"]);
|
|
23
|
+
const lineAt = (source, offset) => source.slice(0, offset).split("\n").length;
|
|
24
|
+
function listFiles(dir) {
|
|
25
|
+
if (!existsSync(dir))
|
|
26
|
+
return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
29
|
+
if (e.name === "node_modules")
|
|
30
|
+
continue;
|
|
31
|
+
const p = join(dir, e.name);
|
|
32
|
+
if (e.isDirectory())
|
|
33
|
+
out.push(...listFiles(p));
|
|
34
|
+
else if (e.isFile() && SOURCE_EXT.test(e.name))
|
|
35
|
+
out.push(p);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const calleeName = (callee) => {
|
|
40
|
+
if (callee.type === "Identifier")
|
|
41
|
+
return callee.name;
|
|
42
|
+
if (callee.type === "MemberExpression" && !callee.computed) {
|
|
43
|
+
return callee.property.name ?? undefined;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Where a `join(...)` / `resolve(...)` call whose literal arguments hold a bare `..` segment points,
|
|
49
|
+
* when that can be told: the first argument is taken as the file's own directory when it is not a
|
|
50
|
+
* literal (`here`, `__dirname`, `import.meta.dirname`), then the literal arguments are applied in
|
|
51
|
+
* order until a non-literal one. `undefined` when no literal argument has a `..` segment.
|
|
52
|
+
*/
|
|
53
|
+
function pathCallTarget(call, file) {
|
|
54
|
+
const args = call.arguments;
|
|
55
|
+
const values = args.map((a) => stringValue(a));
|
|
56
|
+
const hasDotDot = values.some((v) => v?.split(/[\\/]/).includes("..") === true);
|
|
57
|
+
if (!hasDotDot)
|
|
58
|
+
return undefined;
|
|
59
|
+
// A first literal argument is relative to the process's directory: a `..` there leaves anything.
|
|
60
|
+
let at = values[0] === undefined ? dirname(file) : resolve(dirname(file), "/");
|
|
61
|
+
for (const [i, v] of values.entries()) {
|
|
62
|
+
if (v === undefined) {
|
|
63
|
+
if (i === 0)
|
|
64
|
+
continue;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
at = resolve(at, v);
|
|
68
|
+
}
|
|
69
|
+
return at;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `plugin-test-imports`: a test of a plugin (any file under `<plugin>/test`) reaches nothing outside
|
|
73
|
+
* the plugin, so it keeps working in a repository of its own (the official plugins live in
|
|
74
|
+
* `capybara-hq/plugins`; package ecosystem spec §6.2):
|
|
75
|
+
* - no import of an `@capybara-hq/*` package other than {@link PLUGIN_TEST_PACKAGES} and the plugin
|
|
76
|
+
* itself, nor such a package name written as a string (a name in a kernel `extensions` list,
|
|
77
|
+
* `require.resolve`);
|
|
78
|
+
* - no relative import or path string (`../x`) that leaves the plugin directory, and no `join` /
|
|
79
|
+
* `resolve` whose bare `..` arguments leave it (taken from the file's directory);
|
|
80
|
+
* - no repo-root path into `packages/` or `apps/` (core's layout).
|
|
81
|
+
* A kernel facility a plugin test needs is exported by `@capybara-hq/testing`.
|
|
82
|
+
*/
|
|
83
|
+
export function checkPluginTestImports(pluginDirs) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const pluginDir of pluginDirs) {
|
|
86
|
+
const pkgFile = join(pluginDir, "package.json");
|
|
87
|
+
const name = existsSync(pkgFile)
|
|
88
|
+
? JSON.parse(readFileSync(pkgFile, "utf8")).name
|
|
89
|
+
: undefined;
|
|
90
|
+
const own = typeof name === "string" ? name : undefined;
|
|
91
|
+
const allowed = new Set([...PLUGIN_TEST_PACKAGES, ...(own === undefined ? [] : [own])]);
|
|
92
|
+
for (const file of listFiles(join(pluginDir, "test"))) {
|
|
93
|
+
out.push(...fileFindings(file, pluginDir, allowed));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function fileFindings(file, pluginDir, allowed) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const source = readFileSync(file, "utf8");
|
|
101
|
+
const inside = (p) => !relative(pluginDir, p).startsWith("..");
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const add = (offset, what) => {
|
|
104
|
+
const line = lineAt(source, offset);
|
|
105
|
+
if (seen.has(line))
|
|
106
|
+
return;
|
|
107
|
+
seen.add(line);
|
|
108
|
+
out.push({
|
|
109
|
+
file,
|
|
110
|
+
line,
|
|
111
|
+
rule: "plugin-test-imports",
|
|
112
|
+
message: `${what}: a plugin test may use only ${[...allowed].join(", ")} and files inside its ` +
|
|
113
|
+
"plugin directory (it must keep working in its own repository); export a missing kernel " +
|
|
114
|
+
"facility from @capybara-hq/testing",
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
const parsed = parseFile(file);
|
|
118
|
+
if (isParseError(parsed)) {
|
|
119
|
+
out.push(parsed);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const imports = new Set();
|
|
123
|
+
for (const { spec, start } of collectImports(parsed.program)) {
|
|
124
|
+
imports.add(start);
|
|
125
|
+
const scoped = SCOPED_NAME.exec(spec)?.[1];
|
|
126
|
+
if (scoped !== undefined && !allowed.has(scoped))
|
|
127
|
+
add(start, `imports ${spec}`);
|
|
128
|
+
}
|
|
129
|
+
walk(parsed.program, (n) => {
|
|
130
|
+
if (n.type === "Literal" || n.type === "TemplateLiteral") {
|
|
131
|
+
const v = stringValue(n);
|
|
132
|
+
const scoped = v === undefined ? undefined : SCOPED_NAME.exec(v)?.[1];
|
|
133
|
+
if (scoped !== undefined && !allowed.has(scoped)) {
|
|
134
|
+
add(n.start, `names the package ${v}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (n.type === "CallExpression") {
|
|
138
|
+
const callee = calleeName(n.callee);
|
|
139
|
+
if (callee === undefined || !PATH_CALL.has(callee))
|
|
140
|
+
return;
|
|
141
|
+
const target = pathCallTarget(n, file);
|
|
142
|
+
if (target !== undefined && !inside(target)) {
|
|
143
|
+
add(n.start, `the ".." segments of this ${callee}() leave the plugin directory`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
for (const m of source.matchAll(RELATIVE_STRING)) {
|
|
149
|
+
const p = m[2] ?? "";
|
|
150
|
+
if (!inside(resolve(dirname(file), p))) {
|
|
151
|
+
add(m.index ?? 0, `${p} leaves the plugin directory`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const m of source.matchAll(ROOTED_STRING)) {
|
|
155
|
+
add(m.index ?? 0, `${m[1] ?? ""} is a path into core`);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|