@capybara-hq/plugin-tools 0.1.0-rc.3 → 0.1.0-rc.5

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 CHANGED
@@ -36,26 +36,33 @@ capybara-plugin release [--dry-run] [--provenance] [--out <dir>] [--auto] [--roo
36
36
  timers, no module-level mutable state), the permission manifest (hooks, UI, storage, HTTP,
37
37
  secrets, tools), packing into a valid plugin package, the tool design rules (English
38
38
  descriptions, actionable errors), the migrations (naming, idempotent, no unmarked destructive
39
- statements), the plugin tests' imports (`plugin-test-imports`: a file under `<plugin>/test` uses
39
+ statements, one file per number in a directory), the plugin tests' imports (`plugin-test-imports`: a file under `<plugin>/test` uses
40
40
  only `@capybara-hq/plugin-sdk`, `@capybara-hq/testing`, `@capybara-hq/plugin-model-fake`, the
41
41
  plugin itself and files inside the plugin, by import, package-name string or path) and the
42
42
  release metadata (`plugin-release-metadata`: a public plugin has a `description`, a `license`, an
43
43
  https `homepage` and `repository` `{ type: "git", url: <the root package.json's
44
- repository.url>, directory: <its dir> }`; `example-*` plugins are private). `--base <ref>` also fails when the diff edits, deletes or renames an applied
45
- migration. No plugin found is an error. A plugin may list `@capybara-hq/plugin-tools` (like
44
+ repository.url>, directory: <its dir> }`; `example-*` plugins are private; its `plugin.json`
45
+ has a `displayName`, and `release-description-zh` warns, without failing, when its `description`
46
+ has no `zh`). Warnings are printed under `passed with N warning(s)`. `--base <ref>` also fails when the diff edits, deletes or renames an applied
47
+ migration, or adds one numbered at or below the highest number of its directory at `<ref>`. No plugin found is an error. A plugin may list `@capybara-hq/plugin-tools` (like
46
48
  `@capybara-hq/testing`) in its devDependencies.
47
49
  - **sync-versions**: after `changeset version` (which bumps only `package.json`), copies each
48
50
  plugin's version into its `.capybara-plugin/plugin.json` and its extension manifest
49
51
  (`src/index.ts`, ` version: "x"`), which the packer requires equal.
50
- - **marketplace**: `--write` appends to `.capybara-plugin/marketplace.json` (`--file`) one npm entry
51
- per public plugin version it does not list yet (`name`, `version`, `summary`, `homepage`,
52
+ - **marketplace**: `--write` keeps one npm entry per public plugin in
53
+ `.capybara-plugin/marketplace.json` (`--file`), its current version (`name`, `version`,
54
+ `summary` from the `plugin.json` `description`, `displayName` from `plugin.json`, `homepage`,
52
55
  `permissions`, `source.npm` with the sha512 of a reproducible pack), formatted as Biome formats
53
- it; `--check` fails when an entry is missing (`marketplace-missing`) or the file does not parse
54
- (`marketplace-invalid`). A version already listed is not compared with the tree: once
56
+ it: a plugin whose version moved gets its entry replaced in place, its other versions are
57
+ dropped, a listed entry's `summary` / `displayName` are refreshed from `plugin.json`
58
+ (`marketplace-display-stale` in `--check`; source and integrity untouched), and entries of
59
+ plugins not in the tree are kept. `--check` fails when a plugin's
60
+ current version is missing or not its only entry (`marketplace-missing`) or the file does not
61
+ parse (`marketplace-invalid`). A version already listed is not compared with the tree: once
55
62
  published, its bytes are the release gate's business. `--check --base <rev>` (pull requests)
56
- also requires the entries at `<rev>` to stay unchanged and first, in order
57
- (`marketplace-append-only`), and the entries added since to match their packs
58
- (`marketplace-integrity`). Entries of versions no longer in the tree are kept.
63
+ also requires a plugin's version to only increase over the versions `<rev>` lists
64
+ (`marketplace-version-decrease`), a version `<rev>` lists to keep its integrity, and the entries
65
+ added since to match their packs (`marketplace-integrity`).
59
66
  - **release**: publishes every non-private workspace package whose version is not on npm yet:
60
67
  plugins (the packages under `plugins/`) as their plugin tgz, libraries through `pnpm pack`, with
61
68
  the dist-tag rule (prerelease `next`, backport `release-<major>.<minor>`, else `latest`), then
@@ -26,3 +26,26 @@ export declare function checkAppliedMigrations(nameStatus: string, isMigration:
26
26
  * repository; finding files are relative to `cwd` too.
27
27
  */
28
28
  export declare function diffAppliedMigrations(cwd: string, base: string, isMigration: MigrationFilePredicate): Finding[];
29
+ /** The files of a migration directory at the base (repo-relative `dir`, POSIX); `[]` when absent. */
30
+ export type BaseMigrationNames = (dir: string) => readonly string[];
31
+ /**
32
+ * PR check (rule `migration-number-behind-base`): every migration the diff adds is numbered above
33
+ * the highest number of its directory at the base. A branch that numbered its migrations before
34
+ * the base released the same or higher numbers (a branch's `0063..0068` against main's new
35
+ * `0063`) would otherwise merge with a collision or out of order: databases that already ran the
36
+ * base's files never run a lower-numbered newcomer in sequence, and a shared one ends up numbered
37
+ * differently from a fresh one.
38
+ *
39
+ * `nameStatus` is the output of `git diff --name-status -M <base>...HEAD`; the added files are the
40
+ * `A` entries and the destinations of `C` and `R` entries (`R` sources are
41
+ * {@link checkAppliedMigrations}'). `baseNames` lists a directory at the base itself (not the merge
42
+ * base: the base may have moved on since the branch started). A file that already exists at the
43
+ * base under the same name is not new to it and is skipped.
44
+ */
45
+ export declare function checkMigrationNumbers(nameStatus: string, isMigration: MigrationFilePredicate, baseNames: BaseMigrationNames): Finding[];
46
+ /**
47
+ * Runs {@link checkMigrationNumbers} on `<base>...HEAD` in `cwd`, listing each directory at
48
+ * `<base>` itself with `git ls-tree`. Paths are relative to `cwd`, as in
49
+ * {@link diffAppliedMigrations}.
50
+ */
51
+ export declare function diffMigrationNumbers(cwd: string, base: string, isMigration: MigrationFilePredicate): Finding[];
@@ -63,3 +63,89 @@ export function diffAppliedMigrations(cwd, base, isMigration) {
63
63
  const out = execFileSync("git", ["diff", "--name-status", "--no-ext-diff", "--relative", "-M", `${base}...HEAD`], { cwd, encoding: "utf8" });
64
64
  return checkAppliedMigrations(out, isMigration);
65
65
  }
66
+ const NUMBERED = /^(\d{4})_[^/]*\.sql$/;
67
+ /**
68
+ * PR check (rule `migration-number-behind-base`): every migration the diff adds is numbered above
69
+ * the highest number of its directory at the base. A branch that numbered its migrations before
70
+ * the base released the same or higher numbers (a branch's `0063..0068` against main's new
71
+ * `0063`) would otherwise merge with a collision or out of order: databases that already ran the
72
+ * base's files never run a lower-numbered newcomer in sequence, and a shared one ends up numbered
73
+ * differently from a fresh one.
74
+ *
75
+ * `nameStatus` is the output of `git diff --name-status -M <base>...HEAD`; the added files are the
76
+ * `A` entries and the destinations of `C` and `R` entries (`R` sources are
77
+ * {@link checkAppliedMigrations}'). `baseNames` lists a directory at the base itself (not the merge
78
+ * base: the base may have moved on since the branch started). A file that already exists at the
79
+ * base under the same name is not new to it and is skipped.
80
+ */
81
+ export function checkMigrationNumbers(nameStatus, isMigration, baseNames) {
82
+ const findings = [];
83
+ const highest = new Map();
84
+ const highestIn = (dir) => {
85
+ if (!highest.has(dir)) {
86
+ let top = null;
87
+ for (const name of baseNames(dir)) {
88
+ const m = NUMBERED.exec(name);
89
+ if (m?.[1] === undefined)
90
+ continue;
91
+ const n = Number(m[1]);
92
+ if (top === null || n > top.n)
93
+ top = { n, name };
94
+ }
95
+ highest.set(dir, top);
96
+ }
97
+ return highest.get(dir) ?? null;
98
+ };
99
+ for (const line of nameStatus.split("\n")) {
100
+ if (line === "")
101
+ continue;
102
+ const parts = line.split("\t");
103
+ const kind = parts[0]?.[0];
104
+ const added = kind === "A" ? parts[1] : kind === "C" || kind === "R" ? parts[2] : undefined;
105
+ if (added === undefined || !isMigration(added))
106
+ continue;
107
+ const slash = added.lastIndexOf("/");
108
+ const dir = slash < 0 ? "" : added.slice(0, slash);
109
+ const name = added.slice(slash + 1);
110
+ const m = NUMBERED.exec(name);
111
+ if (m?.[1] === undefined)
112
+ continue; // the naming rule reports it
113
+ if (baseNames(dir).includes(name))
114
+ continue;
115
+ const top = highestIn(dir);
116
+ if (top === null || Number(m[1]) > top.n)
117
+ continue;
118
+ findings.push({
119
+ file: added,
120
+ line: 1,
121
+ rule: "migration-number-behind-base",
122
+ message: `migration number ${m[1]} is not above the base's highest in ${dir || "."} (${top.name}): renumber the files this change adds to follow it`,
123
+ });
124
+ }
125
+ return findings;
126
+ }
127
+ /**
128
+ * Runs {@link checkMigrationNumbers} on `<base>...HEAD` in `cwd`, listing each directory at
129
+ * `<base>` itself with `git ls-tree`. Paths are relative to `cwd`, as in
130
+ * {@link diffAppliedMigrations}.
131
+ */
132
+ export function diffMigrationNumbers(cwd, base, isMigration) {
133
+ const out = execFileSync("git", ["diff", "--name-status", "--no-ext-diff", "--relative", "-M", `${base}...HEAD`], { cwd, encoding: "utf8" });
134
+ const listed = new Map();
135
+ const baseNames = (dir) => {
136
+ let names = listed.get(dir);
137
+ if (names === undefined) {
138
+ const tree = execFileSync("git", ["ls-tree", "--name-only", base, "--", `${dir || "."}/`], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ });
142
+ names = tree
143
+ .split("\n")
144
+ .filter((p) => p !== "")
145
+ .map((p) => p.slice(p.lastIndexOf("/") + 1));
146
+ listed.set(dir, names);
147
+ }
148
+ return names;
149
+ };
150
+ return checkMigrationNumbers(out, isMigration, baseNames);
151
+ }
@@ -7,7 +7,8 @@ export declare const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plug
7
7
  * default `plugins/*` when `<root>/plugins` exists, else the root itself when it holds
8
8
  * `.capybara-plugin/plugin.json`;
9
9
  * - `--base <ref>`: also fail when `<ref>...HEAD` edits, deletes or renames a migration of a plugin
10
- * (applied migrations are immutable); for pull requests.
10
+ * (applied migrations are immutable), or adds one numbered at or below the highest number of its
11
+ * directory at `<ref>`; for pull requests.
11
12
  *
12
13
  * Finding no plugin is an error: an empty scan would pass without checking anything.
13
14
  *
package/dist/check/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, relative, resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { discoverPlugins, pluginsGlobOf } from "../discover.js";
4
- import { diffAppliedMigrations, migrationFileMatcher } from "./applied-migrations.js";
4
+ import { diffAppliedMigrations, diffMigrationNumbers, migrationFileMatcher, } from "./applied-migrations.js";
5
5
  import { pluginChecks, runChecks } from "./run.js";
6
6
  export const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plugins <glob>] [--base <ref>]";
7
7
  /**
@@ -12,7 +12,8 @@ export const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plugins <glo
12
12
  * default `plugins/*` when `<root>/plugins` exists, else the root itself when it holds
13
13
  * `.capybara-plugin/plugin.json`;
14
14
  * - `--base <ref>`: also fail when `<ref>...HEAD` edits, deletes or renames a migration of a plugin
15
- * (applied migrations are immutable); for pull requests.
15
+ * (applied migrations are immutable), or adds one numbered at or below the highest number of its
16
+ * directory at `<ref>`; for pull requests.
16
17
  *
17
18
  * Finding no plugin is an error: an empty scan would pass without checking anything.
18
19
  *
@@ -53,7 +54,10 @@ export async function checkCommand(args, cwd) {
53
54
  const isMigration = migrationFileMatcher([glob === "." ? "migrations" : `${glob}/migrations`]);
54
55
  checks.push({
55
56
  name: "Applied migrations",
56
- run: () => diffAppliedMigrations(root, base, isMigration).map((f) => ({
57
+ run: () => [
58
+ ...diffAppliedMigrations(root, base, isMigration),
59
+ ...diffMigrationNumbers(root, base, isMigration),
60
+ ].map((f) => ({
57
61
  ...f,
58
62
  file: join(root, f.file),
59
63
  })),
@@ -0,0 +1,13 @@
1
+ import type { Finding } from "./source.js";
2
+ /**
3
+ * Config forms (extension config forms spec §10): every plugin with an extension (`src/index.ts`)
4
+ * is bundled and imported the way the packer does, and its deployment and org config schemas are
5
+ * turned into the form JSON Schemas the package carries.
6
+ * - `plugin-config-ui`: a problem of their hints (`validateConfigUi` at the level, and value hints
7
+ * on secrets, which the JSON Schema drops silently); the packer refuses these too.
8
+ * - `plugin-config-labels`: a published plugin (not `"private": true`) whose rendered field has no
9
+ * label in both zh and en (secret records drawn per item by `x-ui.secrets` are skipped).
10
+ * A plugin that does not bundle or import is left to "Plugin pack", which reports it.
11
+ * Findings point at the plugin's `src/index.ts`.
12
+ */
13
+ export declare function checkConfigForms(pluginDirs: string[]): Promise<Finding[]>;
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "../pack/config-schemas.js";
4
+ import { importPluginExtension } from "../pack/pack.js";
5
+ /** A plugin's package.json, or undefined when it is missing or not a JSON object. */
6
+ function readPackageJson(file) {
7
+ if (!existsSync(file))
8
+ return undefined;
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
11
+ return typeof pkg === "object" && pkg !== null && !Array.isArray(pkg)
12
+ ? pkg
13
+ : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ /**
20
+ * Config forms (extension config forms spec §10): every plugin with an extension (`src/index.ts`)
21
+ * is bundled and imported the way the packer does, and its deployment and org config schemas are
22
+ * turned into the form JSON Schemas the package carries.
23
+ * - `plugin-config-ui`: a problem of their hints (`validateConfigUi` at the level, and value hints
24
+ * on secrets, which the JSON Schema drops silently); the packer refuses these too.
25
+ * - `plugin-config-labels`: a published plugin (not `"private": true`) whose rendered field has no
26
+ * label in both zh and en (secret records drawn per item by `x-ui.secrets` are skipped).
27
+ * A plugin that does not bundle or import is left to "Plugin pack", which reports it.
28
+ * Findings point at the plugin's `src/index.ts`.
29
+ */
30
+ export async function checkConfigForms(pluginDirs) {
31
+ const findings = [];
32
+ for (const dir of pluginDirs) {
33
+ const file = join(dir, "src/index.ts");
34
+ if (!existsSync(file))
35
+ continue;
36
+ let config;
37
+ try {
38
+ config = (await importPluginExtension(dir)).config;
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const { schemas, problems } = extensionConfigSchemas(config);
44
+ for (const p of problems) {
45
+ findings.push({
46
+ file,
47
+ line: 1,
48
+ rule: "plugin-config-ui",
49
+ message: formatConfigSchemaProblem(p),
50
+ });
51
+ }
52
+ const pkg = readPackageJson(join(dir, "package.json"));
53
+ // a missing or malformed package.json is "Plugin pack"'s finding
54
+ if (pkg === undefined || pkg.private === true)
55
+ continue;
56
+ for (const level of ["deployment", "org"]) {
57
+ const schema = schemas[level];
58
+ if (schema === undefined)
59
+ continue;
60
+ for (const p of missingConfigLabels(schema)) {
61
+ findings.push({
62
+ file,
63
+ line: 1,
64
+ rule: "plugin-config-labels",
65
+ message: `${level} config: ${p.path} ${p.message}: a published plugin labels every field it shows (ui(schema, { label: { zh, en } }))`,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ return findings;
71
+ }
@@ -1,7 +1,9 @@
1
- export { checkAppliedMigrations, diffAppliedMigrations, type MigrationFilePredicate, migrationFileMatcher, } from "./applied-migrations.js";
1
+ export { type BaseMigrationNames, checkAppliedMigrations, checkMigrationNumbers, diffAppliedMigrations, diffMigrationNumbers, type MigrationFilePredicate, migrationFileMatcher, } from "./applied-migrations.js";
2
2
  export { CHECK_USAGE, checkCommand } from "./cli.js";
3
+ export { checkConfigForms } from "./config-forms.js";
3
4
  export { checkDeps } from "./deps.js";
4
5
  export { checkMigrations, checkSql } from "./migrations.js";
6
+ export { checkOrgOverride } from "./org-override.js";
5
7
  export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
6
8
  export { checkPluginPermissions } from "./plugin-permissions.js";
7
9
  export { checkPluginState } from "./plugin-state.js";
@@ -1,7 +1,9 @@
1
- export { checkAppliedMigrations, diffAppliedMigrations, migrationFileMatcher, } from "./applied-migrations.js";
1
+ export { checkAppliedMigrations, checkMigrationNumbers, diffAppliedMigrations, diffMigrationNumbers, migrationFileMatcher, } from "./applied-migrations.js";
2
2
  export { CHECK_USAGE, checkCommand } from "./cli.js";
3
+ export { checkConfigForms } from "./config-forms.js";
3
4
  export { checkDeps } from "./deps.js";
4
5
  export { checkMigrations, checkSql } from "./migrations.js";
6
+ export { checkOrgOverride } from "./org-override.js";
5
7
  export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
6
8
  export { checkPluginPermissions } from "./plugin-permissions.js";
7
9
  export { checkPluginState } from "./plugin-state.js";
@@ -6,6 +6,6 @@ export type { Finding };
6
6
  export declare function checkSql(file: string, source: string): Finding[];
7
7
  /**
8
8
  * Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
9
- * transaction-control / do-block.
9
+ * transaction-control / do-block / migration-duplicate-number.
10
10
  */
11
11
  export declare function checkMigrations(dirs: string[]): Finding[];
@@ -64,7 +64,7 @@ export function checkSql(file, source) {
64
64
  }
65
65
  /**
66
66
  * Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
67
- * transaction-control / do-block.
67
+ * transaction-control / do-block / migration-duplicate-number.
68
68
  */
69
69
  export function checkMigrations(dirs) {
70
70
  const findings = [];
@@ -75,6 +75,7 @@ export function checkMigrations(dirs) {
75
75
  .filter((e) => e.isFile())
76
76
  .map((e) => e.name)
77
77
  .sort();
78
+ findings.push(...duplicateNumbers(dir, entries));
78
79
  for (const name of entries) {
79
80
  const file = join(dir, name);
80
81
  if (!NAME_RE.test(name)) {
@@ -91,3 +92,31 @@ export function checkMigrations(dirs) {
91
92
  }
92
93
  return findings;
93
94
  }
95
+ /**
96
+ * Two files of one directory with the same `NNNN` (rule `migration-duplicate-number`): the
97
+ * migrator runs files in name order and records them by name, so the pair runs in an order
98
+ * nobody chose, and a database that already ran one of them under that number ends up with a
99
+ * different numbering than a fresh one (as when a branch's `0063` met main's released `0063`).
100
+ * Every file after the first of a number is reported, naming the first.
101
+ */
102
+ function duplicateNumbers(dir, sortedNames) {
103
+ const first = new Map();
104
+ const findings = [];
105
+ for (const name of sortedNames) {
106
+ if (!NAME_RE.test(name))
107
+ continue;
108
+ const n = name.slice(0, 4);
109
+ const earlier = first.get(n);
110
+ if (earlier === undefined) {
111
+ first.set(n, name);
112
+ continue;
113
+ }
114
+ findings.push({
115
+ file: join(dir, name),
116
+ line: 1,
117
+ rule: "migration-duplicate-number",
118
+ message: `migration number ${n} is also used by ${earlier}: each number is used once per directory; renumber the file that is not applied anywhere yet to the next free number`,
119
+ });
120
+ }
121
+ return findings;
122
+ }
@@ -0,0 +1,10 @@
1
+ import type { Finding } from "./source.js";
2
+ /**
3
+ * `plugin-org-override-unbound` (a warning): an extension whose deployment config has an
4
+ * `orgAccess(…, "override")` field, but that registers no tool, no `ctx.ui` view or action, no
5
+ * hook and provides none of `web-search`, `web-fetch`, `directory`, `model-provider` or
6
+ * `file-preview`. An override reaches the extension only in the per-call `config` of those entry
7
+ * points, so the field can never take effect. Findings point at the plugin's `src/index.ts`.
8
+ * A plugin that does not bundle or import is left to "Plugin pack".
9
+ */
10
+ export declare function checkOrgOverride(pluginDirs: string[]): Promise<Finding[]>;
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { extensionConfigSchemas } from "../pack/config-schemas.js";
4
+ import { importPluginExtension } from "../pack/pack.js";
5
+ import { findCtxCalls } from "./plugin-permissions.js";
6
+ /** Extension points whose calls carry the org's per-call config on their own. */
7
+ const ORG_AWARE_POINTS = new Set([
8
+ "web-search",
9
+ "web-fetch",
10
+ "directory",
11
+ "model-provider",
12
+ "file-preview",
13
+ ]);
14
+ /** The top-level properties of a deployment schema marked `x-org: "override"`. */
15
+ function overrideFields(schema) {
16
+ const props = schema?.properties;
17
+ if (typeof props !== "object" || props === null)
18
+ return [];
19
+ return Object.entries(props)
20
+ .filter(([, v]) => v?.["x-org"] === "override")
21
+ .map(([k]) => k);
22
+ }
23
+ /**
24
+ * `plugin-org-override-unbound` (a warning): an extension whose deployment config has an
25
+ * `orgAccess(…, "override")` field, but that registers no tool, no `ctx.ui` view or action, no
26
+ * hook and provides none of `web-search`, `web-fetch`, `directory`, `model-provider` or
27
+ * `file-preview`. An override reaches the extension only in the per-call `config` of those entry
28
+ * points, so the field can never take effect. Findings point at the plugin's `src/index.ts`.
29
+ * A plugin that does not bundle or import is left to "Plugin pack".
30
+ */
31
+ export async function checkOrgOverride(pluginDirs) {
32
+ const findings = [];
33
+ for (const dir of pluginDirs) {
34
+ const file = join(dir, "src/index.ts");
35
+ if (!existsSync(file))
36
+ continue;
37
+ let ext;
38
+ try {
39
+ ext = await importPluginExtension(dir);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ const fields = overrideFields(extensionConfigSchemas(ext.config).schemas.deployment);
45
+ if (fields.length === 0)
46
+ continue;
47
+ const calls = findCtxCalls(dir);
48
+ if (calls.tools || calls.ui || calls.hooks)
49
+ continue;
50
+ const provided = (ext.provides ?? []).map((p) => String(p).split("@")[0] ?? "");
51
+ if (provided.some((p) => ORG_AWARE_POINTS.has(p)))
52
+ continue;
53
+ findings.push({
54
+ file,
55
+ line: 1,
56
+ rule: "plugin-org-override-unbound",
57
+ severity: "warning",
58
+ message: `deployment config field ${fields.join(", ")} allows an org override, but the extension registers no tool, ui view, action or hook and provides no org-aware extension point (${[...ORG_AWARE_POINTS].join(", ")}), so no per-call config ever reaches it`,
59
+ });
60
+ }
61
+ return findings;
62
+ }
@@ -37,3 +37,17 @@ export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
37
37
  * Unparseable files are skipped here; the permissions check reports them.
38
38
  */
39
39
  export declare function findToolRegistrations(pluginDirs: string[]): ToolRegistration[];
40
+ /** The `ctx.<area>.<method>` calls an extension's setup makes (see {@link findCtxCalls}). */
41
+ export interface CtxCalls {
42
+ /** `ctx.tools.register` is called. */
43
+ tools: boolean;
44
+ /** `ctx.ui.view` or `ctx.ui.action` is called. */
45
+ ui: boolean;
46
+ /** `ctx.hooks.on` is called. */
47
+ hooks: boolean;
48
+ }
49
+ /**
50
+ * Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
51
+ * under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
52
+ */
53
+ export declare function findCtxCalls(pluginDir: string): CtxCalls;
@@ -166,7 +166,7 @@ function readManifest(obj, decls) {
166
166
  }
167
167
  return m;
168
168
  }
169
- function scanSetup(f, m, decls, onTool) {
169
+ function scanSetup(f, m, decls, onTool, onCall) {
170
170
  const out = [];
171
171
  /** 明确的违规:豁免注释不起作用。 */
172
172
  const add = (at, rule, message) => {
@@ -240,6 +240,8 @@ function scanSetup(f, m, decls, onTool) {
240
240
  if (obj.type !== "MemberExpression" || !isCtx(obj.object))
241
241
  return;
242
242
  const area = memberName(obj);
243
+ if (area !== undefined)
244
+ onCall?.(area, method);
243
245
  if (area === "http" && method === "route") {
244
246
  const verb = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
245
247
  const path = args[1] ? stringValue(resolve(args[1], decls)) : undefined;
@@ -346,7 +348,7 @@ function scanSetup(f, m, decls, onTool) {
346
348
  }
347
349
  return out;
348
350
  }
349
- function scanFile(f, onTool) {
351
+ function scanFile(f, onTool, onCall) {
350
352
  const decls = buildDecls(f);
351
353
  const out = [];
352
354
  walk(f.program, (n) => {
@@ -359,7 +361,7 @@ function scanFile(f, onTool) {
359
361
  const obj = arg ? resolve(arg, decls) : undefined;
360
362
  if (obj?.type !== "ObjectExpression")
361
363
  return;
362
- out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool));
364
+ out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool, onCall));
363
365
  });
364
366
  return out;
365
367
  }
@@ -417,3 +419,26 @@ export function findToolRegistrations(pluginDirs) {
417
419
  }
418
420
  return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
419
421
  }
422
+ /**
423
+ * Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
424
+ * under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
425
+ */
426
+ export function findCtxCalls(pluginDir) {
427
+ const out = { tools: false, ui: false, hooks: false };
428
+ for (const { file, isTest } of listSourceFiles(pluginDir)) {
429
+ if (isTest)
430
+ continue;
431
+ const parsed = parseFile(file);
432
+ if (isParseError(parsed))
433
+ continue;
434
+ scanFile(parsed, undefined, (area, method) => {
435
+ if (area === "tools" && method === "register")
436
+ out.tools = true;
437
+ else if (area === "ui" && (method === "view" || method === "action"))
438
+ out.ui = true;
439
+ else if (area === "hooks" && method === "on")
440
+ out.hooks = true;
441
+ });
442
+ }
443
+ return out;
444
+ }
@@ -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
  }
@@ -1,8 +1,9 @@
1
- import type { Finding } from "./source.js";
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`, or the finding count and one line per finding
6
- * (stderr). Returns whether it passed.
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;