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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +26 -19
  2. package/bin/capybara-plugin.mjs +1 -1
  3. package/dist/check/applied-migrations.d.ts +23 -0
  4. package/dist/check/applied-migrations.js +86 -0
  5. package/dist/check/cli.d.ts +2 -1
  6. package/dist/check/cli.js +7 -3
  7. package/dist/check/config-forms.d.ts +13 -0
  8. package/dist/check/config-forms.js +71 -0
  9. package/dist/check/deps.d.ts +4 -2
  10. package/dist/check/deps.js +14 -2
  11. package/dist/check/index.d.ts +4 -1
  12. package/dist/check/index.js +4 -1
  13. package/dist/check/migrations.d.ts +1 -1
  14. package/dist/check/migrations.js +30 -1
  15. package/dist/check/org-override.d.ts +10 -0
  16. package/dist/check/org-override.js +62 -0
  17. package/dist/check/plugin-pack.js +2 -0
  18. package/dist/check/plugin-permissions.d.ts +34 -1
  19. package/dist/check/plugin-permissions.js +66 -6
  20. package/dist/check/plugin-tool-keys.d.ts +13 -0
  21. package/dist/check/plugin-tool-keys.js +38 -0
  22. package/dist/check/release-metadata.d.ts +5 -0
  23. package/dist/check/release-metadata.js +47 -0
  24. package/dist/check/report.d.ts +4 -3
  25. package/dist/check/report.js +13 -3
  26. package/dist/check/run.d.ts +3 -1
  27. package/dist/check/run.js +12 -1
  28. package/dist/check/source.d.ts +7 -0
  29. package/dist/check/source.js +2 -0
  30. package/dist/check/themes.d.ts +19 -0
  31. package/dist/check/themes.js +112 -0
  32. package/dist/check/tool-design.d.ts +1 -1
  33. package/dist/check/tool-design.js +3 -3
  34. package/dist/check/ui-slots.d.ts +7 -0
  35. package/dist/check/ui-slots.js +30 -0
  36. package/dist/cli.js +0 -2
  37. package/dist/marketplace/cli.d.ts +10 -7
  38. package/dist/marketplace/cli.js +20 -10
  39. package/dist/marketplace/marketplace.d.ts +46 -24
  40. package/dist/marketplace/marketplace.js +232 -60
  41. package/dist/pack/cli.d.ts +3 -10
  42. package/dist/pack/cli.js +3 -40
  43. package/dist/pack/config-schemas.d.ts +34 -0
  44. package/dist/pack/config-schemas.js +151 -0
  45. package/dist/pack/index.d.ts +2 -2
  46. package/dist/pack/index.js +2 -2
  47. package/dist/pack/pack.d.ts +8 -2
  48. package/dist/pack/pack.js +154 -38
  49. package/package.json +4 -4
  50. package/dist/pack/bootstrap.d.ts +0 -25
  51. package/dist/pack/bootstrap.js +0 -70
package/README.md CHANGED
@@ -14,8 +14,6 @@ npm install --save-dev @capybara-hq/plugin-tools
14
14
 
15
15
  ```bash
16
16
  capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]
17
- capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]
18
- [--npm-token <token>]
19
17
  capybara-plugin check [--root <dir>] [--plugins <glob>] [--base <ref>]
20
18
  capybara-plugin sync-versions [--root <dir>] [--plugins <glob>]
21
19
  capybara-plugin marketplace --write|--check [--base <rev>] [--root <dir>] [--file <path>]
@@ -30,38 +28,41 @@ capybara-plugin release [--dry-run] [--provenance] [--out <dir>] [--auto] [--roo
30
28
  manifest written into `plugin.json`, LICENSE / NOTICE and the bundled dependencies' notices
31
29
  included. A plugin with an extension gets `"main"` and `"exports": { ".": "./dist/index.mjs" }`,
32
30
  so once installed it resolves by package name (a test loads `@capybara-hq/plugin-model-fake`
33
- from npm). `--all <root>` packs every plugin of a repository; `--bootstrap` packs the entries of
34
- a bootstrap manifest (workspace entries from the repository, npm entries downloaded from
35
- `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` or the public registry, with
36
- `--npm-token` / `CAPYBARA_PACKAGES__NPM_TOKEN` sent to the registry origin only, tarballs capped
37
- at 50 MiB) and checks them against their pinned sha512 and their `package.json` name and version.
38
- Output: `artifacts/plugins`.
31
+ from npm). `--all <root>` packs every plugin of a repository. Output: `artifacts/plugins`. (The
32
+ bootstrap plugins a kernel pins are no longer packed here: a starting kernel downloads them.)
39
33
  - **check**: every plugin check, on the plugins under `<root>/plugins/*` (or `--plugins <glob>`, or
40
34
  the root itself when it is a plugin): the dependency boundary (a plugin depends only on
41
35
  `@capybara-hq/plugin-sdk` and third-party packages), extension state under multiple replicas (no
42
36
  timers, no module-level mutable state), the permission manifest (hooks, UI, storage, HTTP,
43
37
  secrets, tools), packing into a valid plugin package, the tool design rules (English
44
38
  descriptions, actionable errors), the migrations (naming, idempotent, no unmarked destructive
45
- 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
46
40
  only `@capybara-hq/plugin-sdk`, `@capybara-hq/testing`, `@capybara-hq/plugin-model-fake`, the
47
41
  plugin itself and files inside the plugin, by import, package-name string or path) and the
48
42
  release metadata (`plugin-release-metadata`: a public plugin has a `description`, a `license`, an
49
43
  https `homepage` and `repository` `{ type: "git", url: <the root package.json's
50
- repository.url>, directory: <its dir> }`; `example-*` plugins are private). `--base <ref>` also fails when the diff edits, deletes or renames an applied
51
- 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
52
48
  `@capybara-hq/testing`) in its devDependencies.
53
49
  - **sync-versions**: after `changeset version` (which bumps only `package.json`), copies each
54
50
  plugin's version into its `.capybara-plugin/plugin.json` and its extension manifest
55
51
  (`src/index.ts`, ` version: "x"`), which the packer requires equal.
56
- - **marketplace**: `--write` appends to `.capybara-plugin/marketplace.json` (`--file`) one npm entry
57
- 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`,
58
55
  `permissions`, `source.npm` with the sha512 of a reproducible pack), formatted as Biome formats
59
- it; `--check` fails when an entry is missing (`marketplace-missing`) or the file does not parse
60
- (`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
61
62
  published, its bytes are the release gate's business. `--check --base <rev>` (pull requests)
62
- also requires the entries at `<rev>` to stay unchanged and first, in order
63
- (`marketplace-append-only`), and the entries added since to match their packs
64
- (`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`).
65
66
  - **release**: publishes every non-private workspace package whose version is not on npm yet:
66
67
  plugins (the packages under `plugins/`) as their plugin tgz, libraries through `pnpm pack`, with
67
68
  the dist-tag rule (prerelease `next`, backport `release-<major>.<minor>`, else `latest`), then
@@ -93,9 +94,15 @@ export default defineConfig({
93
94
  });
94
95
  ```
95
96
 
97
+ Dual-replica tests (`createDualReplicaHarness`) need nothing more: each replica is a child
98
+ process outside vitest, and `@capybara-hq/testing` runs it under its own tsx when an extension
99
+ entry is a TypeScript file (the plugin under test by path, `fakeExtensionEntry(...)`). Do not set
100
+ `NODE_OPTIONS: "--import tsx"` in the vitest config: it reaches every worker and child process
101
+ and hides packaging bugs.
102
+
96
103
  ## Library
97
104
 
98
- The same code as functions: `packPlugin`, `packBootstrap`, `pluginChecks` / `runChecks` and each
105
+ The same code as functions: `packPlugin`, `downloadPinnedNpmPackage`, `pluginChecks` / `runChecks` and each
99
106
  check (`checkDeps`, `checkPluginState`, `checkPluginPermissions`, `checkPluginPack`,
100
107
  `checkToolDesign`, `checkMigrations`, `checkAppliedMigrations`, `checkPluginTestImports`,
101
108
  `checkPluginReleaseMetadata`), `syncPluginVersions` (after `changeset version`),
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // The `capybara-plugin` bin: runs the compiled CLI (dist/cli.js, built by `tsc -b`). In this
3
- // repository, the root scripts (pnpm pack-plugin, pack-plugins, pack-bootstrap, release) run the
3
+ // repository, the root scripts (pnpm pack-plugin, pack-plugins, release) run the
4
4
  // TypeScript sources through tsx instead.
5
5
  import { existsSync } from "node:fs";
6
6
 
@@ -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
+ }
@@ -5,7 +5,9 @@ export type { Finding };
5
5
  * - pluginDirs: each plugin directory (source imports and package.json); a violation is
6
6
  * plugin-imports-kernel (a path leaving the plugin directory: plugin-imports-outside);
7
7
  * - sdkDirs: the packages/plugin-sdk directory; any @capybara-hq/* package or path leaving the
8
- * package directory is sdk-imports-internal.
8
+ * package directory is sdk-imports-internal;
9
+ * - deviceDirs: the packages/device directory (source, tests and package.json); any @capybara-hq/*
10
+ * package other than plugin-sdk or path leaving the package directory is device-imports-internal.
9
11
  *
10
12
  * Covers: import / export from / export * / import type / dynamic import("literal or concatenated
11
13
  * literals") / require("…") / import x = require() / import("x") types; package.json dependencies /
@@ -20,4 +22,4 @@ export type { Finding };
20
22
  * `module.require` or `createRequire(...)`; `import.meta.resolve`; paths built at run time to read
21
23
  * files; nested package.json files and TS `paths` mappings.
22
24
  */
23
- export declare function checkDeps(pluginDirs: string[], sdkDirs?: string[]): Finding[];
25
+ export declare function checkDeps(pluginDirs: string[], sdkDirs?: string[], deviceDirs?: string[]): Finding[];
@@ -33,6 +33,13 @@ const SDK_RULES = {
33
33
  outsideMessage: (what) => `packages/plugin-sdk must not reference files outside its package directory (${what})`,
34
34
  allowed: () => false,
35
35
  };
36
+ const DEVICE_RULES = {
37
+ pkgRule: "device-imports-internal",
38
+ outsideRule: "device-imports-internal",
39
+ pkgMessage: (what) => `packages/device runs on a person's machine and may depend only on ${SDK} and third-party npm packages, not on ${what}; the kernel is reached over HTTP and WebSocket only`,
40
+ outsideMessage: (what) => `packages/device must not reference files outside its package directory (${what})`,
41
+ allowed: (pkg) => pkg === SDK,
42
+ };
36
43
  /** The violation a reference string (package name / relative path / file: URL / protocol dependency value) makes, if any. */
37
44
  function judge(value, baseDir, rootDir, rules, where) {
38
45
  const pkgs = [...value.matchAll(SCOPED_RE)].map((m) => `${SCOPE}${m[1]}`);
@@ -180,7 +187,9 @@ function sourceFindings(dir, rules, srcDir) {
180
187
  * - pluginDirs: each plugin directory (source imports and package.json); a violation is
181
188
  * plugin-imports-kernel (a path leaving the plugin directory: plugin-imports-outside);
182
189
  * - sdkDirs: the packages/plugin-sdk directory; any @capybara-hq/* package or path leaving the
183
- * package directory is sdk-imports-internal.
190
+ * package directory is sdk-imports-internal;
191
+ * - deviceDirs: the packages/device directory (source, tests and package.json); any @capybara-hq/*
192
+ * package other than plugin-sdk or path leaving the package directory is device-imports-internal.
184
193
  *
185
194
  * Covers: import / export from / export * / import type / dynamic import("literal or concatenated
186
195
  * literals") / require("…") / import x = require() / import("x") types; package.json dependencies /
@@ -195,7 +204,7 @@ function sourceFindings(dir, rules, srcDir) {
195
204
  * `module.require` or `createRequire(...)`; `import.meta.resolve`; paths built at run time to read
196
205
  * files; nested package.json files and TS `paths` mappings.
197
206
  */
198
- export function checkDeps(pluginDirs, sdkDirs = []) {
207
+ export function checkDeps(pluginDirs, sdkDirs = [], deviceDirs = []) {
199
208
  const findings = [];
200
209
  for (const dir of pluginDirs) {
201
210
  findings.push(...packageFindings(dir, PLUGIN_RULES), ...sourceFindings(dir, PLUGIN_RULES, dir));
@@ -203,5 +212,8 @@ export function checkDeps(pluginDirs, sdkDirs = []) {
203
212
  for (const dir of sdkDirs) {
204
213
  findings.push(...packageFindings(dir, SDK_RULES), ...sourceFindings(dir, SDK_RULES, join(dir, "src")));
205
214
  }
215
+ for (const dir of deviceDirs) {
216
+ findings.push(...packageFindings(dir, DEVICE_RULES), ...sourceFindings(dir, DEVICE_RULES, dir));
217
+ }
206
218
  return findings;
207
219
  }
@@ -1,10 +1,13 @@
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";
10
+ export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
8
11
  export { syncPluginVersions } from "./plugin-versions.js";
9
12
  export { checkPluginReleaseMetadata } from "./release-metadata.js";
10
13
  export { formatFinding, printCheck } from "./report.js";
@@ -1,10 +1,13 @@
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";
10
+ export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
8
11
  export { syncPluginVersions } from "./plugin-versions.js";
9
12
  export { checkPluginReleaseMetadata } from "./release-metadata.js";
10
13
  export { formatFinding, printCheck } from "./report.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
+ }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { ExtensionPackageError, PACKAGES_MAX_BYTES_DEFAULT, PluginPackageError, readExtensionPackage, readPluginPackage, } from "@capybara-hq/kernel";
5
5
  import { SDK_VERSION } from "@capybara-hq/plugin-sdk";
6
6
  import { packPlugin } from "../pack/pack.js";
7
+ import { uiSlotFindings } from "./ui-slots.js";
7
8
  /**
8
9
  * The default of `packages.maxBytes` (from the kernel config schema): an official plugin's package
9
10
  * must install under the default limit.
@@ -38,6 +39,7 @@ export async function checkPluginPack(pluginDirs) {
38
39
  }
39
40
  const bytes = new Uint8Array(await readFile(r.file));
40
41
  const plugin = await readPluginPackage(bytes, opts);
42
+ findings.push(...uiSlotFindings(file, plugin.manifest.extension?.manifest.ui));
41
43
  if (plugin.manifest.extension !== undefined)
42
44
  await readExtensionPackage(bytes, opts);
43
45
  }
@@ -1,5 +1,16 @@
1
1
  import { type Finding } from "./source.js";
2
2
  export type { Finding };
3
+ /** A `ctx.tools.register({ name })` call found in an extension's setup. */
4
+ export interface ToolRegistration {
5
+ file: string;
6
+ line: number;
7
+ /** The extension name of the enclosing defineExtension (undefined: not a literal). */
8
+ extension: string | undefined;
9
+ /** The tool name (undefined: not a literal or same-file const). */
10
+ tool: string | undefined;
11
+ /** The call carries a `capybara:allow-dynamic-permission <reason>` marker. */
12
+ allowed: boolean;
13
+ }
3
14
  /**
4
15
  * 插件权限静态检查(AST):把 setup(ctx) 里对平台能力的访问与 defineExtension 清单比对。规则:
5
16
  * - perm-secrets:用了 ctx.secrets 而 permissions.secrets 不是 true;
@@ -11,7 +22,9 @@ export type { Finding };
11
22
  * - perm-hooks: the mount point of ctx.hooks.on(mount, …) is not in permissions.hooks (the mount
12
23
  * point must be a string literal or a same-file const, like the names below), or the options are
13
24
  * `{ mode: "policy" }` and the mount point is not in permissions.policyHooks;
14
- * - perm-reserved:用了尚未提供的 ctx.models。
25
+ * - perm-models: ctx.models is used but permissions.models is not declared (the purposes it may use
26
+ * are checked at run time);
27
+ * - perm-reserved: platform access that cannot be confirmed (a destructured ctx, a computed member).
15
28
  * 路由路径、工具名、能力 ref、界面 id 须是字符串字面量或本文件的 const;动态的写法报「无法静态确认」,
16
29
  * 豁免:`// capybara:allow-dynamic-permission <原因>`(行尾或紧邻上一行,原因必填)。
17
30
  * ctx 会跟踪本地别名,以及按位置传给同文件函数的参数。
@@ -20,3 +33,23 @@ export type { Finding };
20
33
  * 扫描范围:传入目录下的非测试源码。
21
34
  */
22
35
  export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
36
+ /**
37
+ * Every `ctx.tools.register({ name })` of the non-test sources under `pluginDirs`, found by the
38
+ * same scanner as {@link checkPluginPermissions} (so it follows ctx aliases and same-file helpers).
39
+ * Unparseable files are skipped here; the permissions check reports them.
40
+ */
41
+ export declare function findToolRegistrations(pluginDirs: string[]): ToolRegistration[];
42
+ /** The `ctx.<area>.<method>` calls an extension's setup makes (see {@link findCtxCalls}). */
43
+ export interface CtxCalls {
44
+ /** `ctx.tools.register` is called. */
45
+ tools: boolean;
46
+ /** `ctx.ui.view` or `ctx.ui.action` is called. */
47
+ ui: boolean;
48
+ /** `ctx.hooks.on` is called. */
49
+ hooks: boolean;
50
+ }
51
+ /**
52
+ * Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
53
+ * under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
54
+ */
55
+ export declare function findCtxCalls(pluginDir: string): CtxCalls;