@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,207 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { collectImports, isParseError, listSourceFiles, parseFile, } from "./source.js";
|
|
5
|
+
const SCOPE = "@capybara-hq/";
|
|
6
|
+
const SDK = "@capybara-hq/plugin-sdk";
|
|
7
|
+
const TESTING = "@capybara-hq/testing";
|
|
8
|
+
/** The scripted model provider that stays in core: plugin tests load it to drive agent runs. */
|
|
9
|
+
const MODEL_FAKE = "@capybara-hq/plugin-model-fake";
|
|
10
|
+
/** The plugin toolchain (`capybara-plugin`): a plugin repository's devDependency. */
|
|
11
|
+
const PLUGIN_TOOLS = "@capybara-hq/plugin-tools";
|
|
12
|
+
const DEP_FIELDS = [
|
|
13
|
+
"dependencies",
|
|
14
|
+
"peerDependencies",
|
|
15
|
+
"devDependencies",
|
|
16
|
+
"optionalDependencies",
|
|
17
|
+
];
|
|
18
|
+
const SCOPED_RE = /@capybara-hq\/([A-Za-z0-9._-]+)/g;
|
|
19
|
+
const PATH_PROTOCOL_RE = /^(?:link|file|portal):/;
|
|
20
|
+
const PLUGIN_RULES = {
|
|
21
|
+
pkgRule: "plugin-imports-kernel",
|
|
22
|
+
outsideRule: "plugin-imports-outside",
|
|
23
|
+
pkgMessage: (what) => `a plugin may depend only on ${SDK} and third-party npm packages, not on ${what}; what it needs comes through ctx (extend the plugin-sdk contract if it is missing)`,
|
|
24
|
+
outsideMessage: (what) => `${what} points outside the plugin directory, which bypasses the package check and depends on kernel sources directly; a plugin may reference only files inside its own directory`,
|
|
25
|
+
allowed: (pkg, where) => pkg === SDK ||
|
|
26
|
+
((pkg === TESTING || pkg === MODEL_FAKE || pkg === PLUGIN_TOOLS) &&
|
|
27
|
+
(where === "devDependencies" || where === "test")),
|
|
28
|
+
};
|
|
29
|
+
const SDK_RULES = {
|
|
30
|
+
pkgRule: "sdk-imports-internal",
|
|
31
|
+
outsideRule: "sdk-imports-internal",
|
|
32
|
+
pkgMessage: (what) => `packages/plugin-sdk holds only types and pure functions and must not depend on any other @capybara-hq/* package (found ${what})`,
|
|
33
|
+
outsideMessage: (what) => `packages/plugin-sdk must not reference files outside its package directory (${what})`,
|
|
34
|
+
allowed: () => false,
|
|
35
|
+
};
|
|
36
|
+
/** The violation a reference string (package name / relative path / file: URL / protocol dependency value) makes, if any. */
|
|
37
|
+
function judge(value, baseDir, rootDir, rules, where) {
|
|
38
|
+
const pkgs = [...value.matchAll(SCOPED_RE)].map((m) => `${SCOPE}${m[1]}`);
|
|
39
|
+
if (pkgs.some((p) => !rules.allowed(p, where)))
|
|
40
|
+
return "pkg";
|
|
41
|
+
let path;
|
|
42
|
+
if (value.startsWith("file:///"))
|
|
43
|
+
path = fileURLToPath(value);
|
|
44
|
+
else if (PATH_PROTOCOL_RE.test(value))
|
|
45
|
+
path = resolve(baseDir, value.replace(PATH_PROTOCOL_RE, ""));
|
|
46
|
+
else if (value.startsWith(".") || isAbsolute(value))
|
|
47
|
+
path = resolve(baseDir, value);
|
|
48
|
+
if (path !== undefined && relative(resolve(rootDir), path).startsWith(".."))
|
|
49
|
+
return "outside";
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Every string leaf of a JSON subtree. */
|
|
53
|
+
function leaves(v) {
|
|
54
|
+
if (typeof v === "string")
|
|
55
|
+
return [v];
|
|
56
|
+
if (Array.isArray(v))
|
|
57
|
+
return v.flatMap(leaves);
|
|
58
|
+
if (typeof v === "object" && v !== null)
|
|
59
|
+
return Object.values(v).flatMap(leaves);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
/** Reads package.json; undefined when there is none. */
|
|
63
|
+
function readPackage(dir) {
|
|
64
|
+
const file = join(dir, "package.json");
|
|
65
|
+
if (!existsSync(file))
|
|
66
|
+
return undefined;
|
|
67
|
+
const text = readFileSync(file, "utf8");
|
|
68
|
+
return { file, lines: text.split("\n"), pkg: JSON.parse(text) };
|
|
69
|
+
}
|
|
70
|
+
function packageFindings(dir, rules) {
|
|
71
|
+
const loaded = readPackage(dir);
|
|
72
|
+
if (!loaded)
|
|
73
|
+
return [];
|
|
74
|
+
const { file, lines, pkg } = loaded;
|
|
75
|
+
const out = [];
|
|
76
|
+
const lineOf = (needle) => Math.max(lines.findIndex((l) => l.includes(`"${needle}"`)) + 1, 1);
|
|
77
|
+
const add = (kind, where, name, what) => out.push({
|
|
78
|
+
file,
|
|
79
|
+
line: lineOf(name),
|
|
80
|
+
rule: kind === "pkg" ? rules.pkgRule : rules.outsideRule,
|
|
81
|
+
message: `${name} in ${where}: ${kind === "pkg" ? rules.pkgMessage(what) : rules.outsideMessage(what)}`,
|
|
82
|
+
});
|
|
83
|
+
for (const field of DEP_FIELDS) {
|
|
84
|
+
const deps = (pkg[field] ?? {});
|
|
85
|
+
for (const [name, value] of Object.entries(deps)) {
|
|
86
|
+
// Both the name and the value: an alias `"x": "npm:@capybara-hq/kernel@*"` has no scope in its name.
|
|
87
|
+
const kind = judge(name, dir, dir, rules, field) ?? judge(String(value), dir, dir, rules, field);
|
|
88
|
+
if (kind)
|
|
89
|
+
add(kind, field, name, `${name}@${value}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const field of ["bundleDependencies", "bundledDependencies"]) {
|
|
93
|
+
const list = pkg[field];
|
|
94
|
+
if (!Array.isArray(list))
|
|
95
|
+
continue;
|
|
96
|
+
for (const name of list) {
|
|
97
|
+
if (judge(name, dir, dir, rules, field) === "pkg")
|
|
98
|
+
add("pkg", field, name, name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const field of ["imports", "exports"]) {
|
|
102
|
+
const map = pkg[field];
|
|
103
|
+
if (typeof map !== "object" || map === null)
|
|
104
|
+
continue;
|
|
105
|
+
for (const [key, v] of Object.entries(map)) {
|
|
106
|
+
for (const target of leaves(v)) {
|
|
107
|
+
const kind = judge(target, dir, dir, rules, field);
|
|
108
|
+
if (kind)
|
|
109
|
+
add(kind, field, key, target);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** Expands a `#xxx` subpath import of the sources through the package.json imports map (empty when unmapped). */
|
|
116
|
+
function resolveHashImport(spec, imports) {
|
|
117
|
+
const direct = imports[spec];
|
|
118
|
+
if (direct !== undefined)
|
|
119
|
+
return leaves(direct);
|
|
120
|
+
for (const [key, v] of Object.entries(imports)) {
|
|
121
|
+
const star = key.indexOf("*");
|
|
122
|
+
if (star === -1)
|
|
123
|
+
continue;
|
|
124
|
+
const head = key.slice(0, star);
|
|
125
|
+
const tail = key.slice(star + 1);
|
|
126
|
+
if (spec.startsWith(head) && spec.endsWith(tail) && spec.length >= head.length + tail.length) {
|
|
127
|
+
const mid = spec.slice(head.length, spec.length - tail.length);
|
|
128
|
+
return leaves(v).map((t) => t.replace("*", mid));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
function sourceFindings(dir, rules, srcDir) {
|
|
134
|
+
const out = [];
|
|
135
|
+
const loaded = readPackage(dir);
|
|
136
|
+
const imports = (loaded?.pkg.imports ?? {});
|
|
137
|
+
// Dependency aliases: a dependency without a scope whose value is an @capybara-hq/* package; importing
|
|
138
|
+
// the alias in the sources is importing that package.
|
|
139
|
+
const aliases = [];
|
|
140
|
+
for (const field of DEP_FIELDS) {
|
|
141
|
+
for (const [name, value] of Object.entries((loaded?.pkg[field] ?? {}))) {
|
|
142
|
+
if (!name.startsWith(SCOPE) && judge(String(value), dir, dir, rules, field) === "pkg") {
|
|
143
|
+
aliases.push(name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const { file, isTest } of listSourceFiles(srcDir)) {
|
|
148
|
+
const parsed = parseFile(file);
|
|
149
|
+
if (isParseError(parsed)) {
|
|
150
|
+
out.push(parsed);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const where = isTest ? "test" : "source";
|
|
154
|
+
for (const { spec, start } of collectImports(parsed.program)) {
|
|
155
|
+
const line = parsed.lineOf(start);
|
|
156
|
+
let kind = judge(spec, join(file, ".."), dir, rules, where);
|
|
157
|
+
let shown = spec;
|
|
158
|
+
if (!kind && aliases.some((a) => spec === a || spec.startsWith(`${a}/`)))
|
|
159
|
+
kind = "pkg";
|
|
160
|
+
if (!kind && spec.startsWith("#")) {
|
|
161
|
+
for (const target of resolveHashImport(spec, imports)) {
|
|
162
|
+
kind = judge(target, dir, dir, rules, where);
|
|
163
|
+
if (kind) {
|
|
164
|
+
shown = `${spec} -> ${target}`;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (kind === "pkg")
|
|
170
|
+
out.push({ file, line, rule: rules.pkgRule, message: rules.pkgMessage(shown) });
|
|
171
|
+
else if (kind === "outside") {
|
|
172
|
+
out.push({ file, line, rule: rules.outsideRule, message: rules.outsideMessage(shown) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The dependency boundary check.
|
|
180
|
+
* - pluginDirs: each plugin directory (source imports and package.json); a violation is
|
|
181
|
+
* plugin-imports-kernel (a path leaving the plugin directory: plugin-imports-outside);
|
|
182
|
+
* - sdkDirs: the packages/plugin-sdk directory; any @capybara-hq/* package or path leaving the
|
|
183
|
+
* package directory is sdk-imports-internal.
|
|
184
|
+
*
|
|
185
|
+
* Covers: import / export from / export * / import type / dynamic import("literal or concatenated
|
|
186
|
+
* literals") / require("…") / import x = require() / import("x") types; package.json dependencies /
|
|
187
|
+
* peerDependencies / devDependencies / optionalDependencies (name and value, so npm: / workspace:
|
|
188
|
+
* aliases too) / bundleDependencies / link:|file:|portal: leaving the directory / imports and exports
|
|
189
|
+
* map values; `#xxx` imports in the sources after expanding the imports map, and dependency aliases;
|
|
190
|
+
* file: URLs and absolute paths count as leaving the directory.
|
|
191
|
+
* Allowed in devDependencies and test files: @capybara-hq/testing and @capybara-hq/plugin-tools.
|
|
192
|
+
* Also allowed there: `@capybara-hq/plugin-model-fake` (plugin tests drive agent runs with it).
|
|
193
|
+
*
|
|
194
|
+
* Known gaps: non-literal dynamic specifiers (`import(x)`); indirect require through a variable,
|
|
195
|
+
* `module.require` or `createRequire(...)`; `import.meta.resolve`; paths built at run time to read
|
|
196
|
+
* files; nested package.json files and TS `paths` mappings.
|
|
197
|
+
*/
|
|
198
|
+
export function checkDeps(pluginDirs, sdkDirs = []) {
|
|
199
|
+
const findings = [];
|
|
200
|
+
for (const dir of pluginDirs) {
|
|
201
|
+
findings.push(...packageFindings(dir, PLUGIN_RULES), ...sourceFindings(dir, PLUGIN_RULES, dir));
|
|
202
|
+
}
|
|
203
|
+
for (const dir of sdkDirs) {
|
|
204
|
+
findings.push(...packageFindings(dir, SDK_RULES), ...sourceFindings(dir, SDK_RULES, join(dir, "src")));
|
|
205
|
+
}
|
|
206
|
+
return findings;
|
|
207
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { checkAppliedMigrations, diffAppliedMigrations, type MigrationFilePredicate, migrationFileMatcher, } from "./applied-migrations.js";
|
|
2
|
+
export { CHECK_USAGE, checkCommand } from "./cli.js";
|
|
3
|
+
export { checkDeps } from "./deps.js";
|
|
4
|
+
export { checkMigrations, checkSql } from "./migrations.js";
|
|
5
|
+
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
6
|
+
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
7
|
+
export { checkPluginState } from "./plugin-state.js";
|
|
8
|
+
export { syncPluginVersions } from "./plugin-versions.js";
|
|
9
|
+
export { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
10
|
+
export { formatFinding, printCheck } from "./report.js";
|
|
11
|
+
export { type NamedCheck, type PluginCheckScope, pluginChecks, runChecks } from "./run.js";
|
|
12
|
+
export type { Finding } from "./source.js";
|
|
13
|
+
export { SYNC_VERSIONS_USAGE, syncVersionsCommand } from "./sync-versions-cli.js";
|
|
14
|
+
export { checkPluginTestImports, PLUGIN_TEST_PACKAGES } from "./test-imports.js";
|
|
15
|
+
export { checkToolDesign } from "./tool-design.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { checkAppliedMigrations, diffAppliedMigrations, migrationFileMatcher, } from "./applied-migrations.js";
|
|
2
|
+
export { CHECK_USAGE, checkCommand } from "./cli.js";
|
|
3
|
+
export { checkDeps } from "./deps.js";
|
|
4
|
+
export { checkMigrations, checkSql } from "./migrations.js";
|
|
5
|
+
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
6
|
+
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
7
|
+
export { checkPluginState } from "./plugin-state.js";
|
|
8
|
+
export { syncPluginVersions } from "./plugin-versions.js";
|
|
9
|
+
export { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
10
|
+
export { formatFinding, printCheck } from "./report.js";
|
|
11
|
+
export { pluginChecks, runChecks } from "./run.js";
|
|
12
|
+
export { SYNC_VERSIONS_USAGE, syncVersionsCommand } from "./sync-versions-cli.js";
|
|
13
|
+
export { checkPluginTestImports, PLUGIN_TEST_PACKAGES } from "./test-imports.js";
|
|
14
|
+
export { checkToolDesign } from "./tool-design.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
export type { Finding };
|
|
3
|
+
/**
|
|
4
|
+
* Checks the SQL text of one migration file (not its name); `file` is only used in the findings.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkSql(file: string, source: string): Finding[];
|
|
7
|
+
/**
|
|
8
|
+
* Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
|
|
9
|
+
* transaction-control / do-block.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkMigrations(dirs: string[]): Finding[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isDestructiveBody, isDestructiveSql, splitSqlStatements } from "@capybara-hq/kernel";
|
|
4
|
+
const NAME_RE = /^\d{4}_[a-z0-9_]+\.sql$/;
|
|
5
|
+
const ALLOW_RE = /^\s*--\s*capybara:allow-destructive(?:\s+(.*\S))?\s*$/;
|
|
6
|
+
const MSG_DESTRUCTIVE = "stop reading and writing the column in this version and drop it in the next one; if it must run in this version, add -- capybara:allow-destructive <reason>";
|
|
7
|
+
const MSG_REASON = "-- capybara:allow-destructive needs a reason (it cannot be empty) saying why this version may run it directly";
|
|
8
|
+
const MSG_DO = "the statements inside a DO block cannot be reviewed statically: split it into plain idempotent statements if you can; if a DO block is needed (e.g. a RENAME COLUMN guarded by an IF EXISTS test), add -- capybara:allow-destructive <reason> right above the DO line";
|
|
9
|
+
const MSG_TX = "the migrator already wraps each file in a transaction: remove the transaction control statements. CONCURRENTLY cannot run in a transaction; use a plain CREATE INDEX IF NOT EXISTS";
|
|
10
|
+
const CREATE_RE = /^CREATE\s+(?:(?:UNIQUE|UNLOGGED|TEMP|TEMPORARY)\s+)*(TABLE|INDEX|SCHEMA|EXTENSION|SEQUENCE)\s+(?:CONCURRENTLY\s+)?(IF NOT EXISTS\b)?/i;
|
|
11
|
+
const TX_RE = /^(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK|SAVEPOINT|END|ABORT|RELEASE|PREPARE\s+TRANSACTION)\b/i;
|
|
12
|
+
const CONCURRENTLY_RE = /^(CREATE|DROP)\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/i;
|
|
13
|
+
/**
|
|
14
|
+
* Checks the SQL text of one migration file (not its name); `file` is only used in the findings.
|
|
15
|
+
*/
|
|
16
|
+
export function checkSql(file, source) {
|
|
17
|
+
const findings = [];
|
|
18
|
+
const lines = source.split("\n");
|
|
19
|
+
const add = (line, rule, message) => findings.push({ file, line, rule, message });
|
|
20
|
+
for (const st of splitSqlStatements(source)) {
|
|
21
|
+
const t = st.text;
|
|
22
|
+
const tx = TX_RE.exec(t);
|
|
23
|
+
if (tx || CONCURRENTLY_RE.test(t))
|
|
24
|
+
add(st.line, "transaction-control", MSG_TX);
|
|
25
|
+
const create = CREATE_RE.exec(t);
|
|
26
|
+
if (create && !create[2]) {
|
|
27
|
+
add(st.line, "idempotent", `CREATE ${create[1]?.toUpperCase()} needs IF NOT EXISTS`);
|
|
28
|
+
}
|
|
29
|
+
const prev = st.line >= 2 ? (lines[st.line - 2] ?? "") : "";
|
|
30
|
+
const allow = ALLOW_RE.exec(prev);
|
|
31
|
+
const isDo = /^DO\b/i.test(t);
|
|
32
|
+
if (isDo && !allow)
|
|
33
|
+
add(st.line, "do-block", MSG_DO);
|
|
34
|
+
let dropsColumn = false;
|
|
35
|
+
if (/^DROP\b/i.test(t) && !/\bIF EXISTS\b/i.test(t)) {
|
|
36
|
+
add(st.line, "idempotent", "DROP needs IF EXISTS");
|
|
37
|
+
}
|
|
38
|
+
else if (/^ALTER\s+TABLE\b/i.test(t)) {
|
|
39
|
+
for (const m of t.matchAll(/\bDROP\s+(COLUMN\s+)?(IF\s+EXISTS\s+)?(NOT\s+NULL\b|DEFAULT\b|EXPRESSION\b|IDENTITY\b|CONSTRAINT\b|\S+)/gi)) {
|
|
40
|
+
const kw = (m[3] ?? "").toUpperCase();
|
|
41
|
+
if (!m[1] && /^(NOT\s+NULL|DEFAULT|EXPRESSION|IDENTITY)$/.test(kw))
|
|
42
|
+
continue;
|
|
43
|
+
if (!m[1] && kw === "CONSTRAINT") {
|
|
44
|
+
if (!/^\s*(IF\s+EXISTS\b)/i.test(t.slice((m.index ?? 0) + m[0].length))) {
|
|
45
|
+
add(st.line, "idempotent", "DROP CONSTRAINT needs IF EXISTS");
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
dropsColumn = true;
|
|
50
|
+
if (!m[2])
|
|
51
|
+
add(st.line, "idempotent", "DROP COLUMN needs IF EXISTS");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (dropsColumn ||
|
|
55
|
+
isDestructiveSql(t) ||
|
|
56
|
+
(isDo && (allow !== null || st.bodies.some(isDestructiveBody)))) {
|
|
57
|
+
if (!allow)
|
|
58
|
+
add(st.line, "destructive", MSG_DESTRUCTIVE);
|
|
59
|
+
else if (!allow[1])
|
|
60
|
+
add(st.line, "destructive", MSG_REASON);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
|
|
67
|
+
* transaction-control / do-block.
|
|
68
|
+
*/
|
|
69
|
+
export function checkMigrations(dirs) {
|
|
70
|
+
const findings = [];
|
|
71
|
+
for (const dir of dirs) {
|
|
72
|
+
if (!existsSync(dir))
|
|
73
|
+
continue;
|
|
74
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
75
|
+
.filter((e) => e.isFile())
|
|
76
|
+
.map((e) => e.name)
|
|
77
|
+
.sort();
|
|
78
|
+
for (const name of entries) {
|
|
79
|
+
const file = join(dir, name);
|
|
80
|
+
if (!NAME_RE.test(name)) {
|
|
81
|
+
findings.push({
|
|
82
|
+
file,
|
|
83
|
+
line: 1,
|
|
84
|
+
rule: "naming",
|
|
85
|
+
message: "the file name must match NNNN_<snake_desc>.sql (four digits, lowercase letters / digits / underscores)",
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
findings.push(...checkSql(file, readFileSync(file, "utf8")));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* The default of `packages.maxBytes` (from the kernel config schema): an official plugin's package
|
|
4
|
+
* must install under the default limit.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PLUGIN_PACK_MAX_BYTES = 52428800;
|
|
7
|
+
/**
|
|
8
|
+
* Every plugin packs into a self-contained plugin package (C12): packed with {@link packPlugin}
|
|
9
|
+
* (esbuild single-file ESM + `.capybara-plugin/plugin.json`), then validated with the kernel's
|
|
10
|
+
* install-time readers: `readPluginPackage` (unpack rules, size, plugin.json, package.json, listed
|
|
11
|
+
* paths, skills, connector templates) and, for a plugin with an extension, `readExtensionPackage`
|
|
12
|
+
* (import allowlist of the entry's module graph, migrations). One finding per plugin that fails to
|
|
13
|
+
* pack or validate (rule `plugin-pack`, located at the plugin's package.json).
|
|
14
|
+
* package.json `files` lists something the package can't carry (a directory, a glob, a missing path):
|
|
15
|
+
* rule `plugin-pack-dropped`, one per entry.
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkPluginPack(pluginDirs: string[]): Promise<Finding[]>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ExtensionPackageError, PACKAGES_MAX_BYTES_DEFAULT, PluginPackageError, readExtensionPackage, readPluginPackage, } from "@capybara-hq/kernel";
|
|
5
|
+
import { SDK_VERSION } from "@capybara-hq/plugin-sdk";
|
|
6
|
+
import { packPlugin } from "../pack/pack.js";
|
|
7
|
+
/**
|
|
8
|
+
* The default of `packages.maxBytes` (from the kernel config schema): an official plugin's package
|
|
9
|
+
* must install under the default limit.
|
|
10
|
+
*/
|
|
11
|
+
export const PLUGIN_PACK_MAX_BYTES = PACKAGES_MAX_BYTES_DEFAULT;
|
|
12
|
+
/**
|
|
13
|
+
* Every plugin packs into a self-contained plugin package (C12): packed with {@link packPlugin}
|
|
14
|
+
* (esbuild single-file ESM + `.capybara-plugin/plugin.json`), then validated with the kernel's
|
|
15
|
+
* install-time readers: `readPluginPackage` (unpack rules, size, plugin.json, package.json, listed
|
|
16
|
+
* paths, skills, connector templates) and, for a plugin with an extension, `readExtensionPackage`
|
|
17
|
+
* (import allowlist of the entry's module graph, migrations). One finding per plugin that fails to
|
|
18
|
+
* pack or validate (rule `plugin-pack`, located at the plugin's package.json).
|
|
19
|
+
* package.json `files` lists something the package can't carry (a directory, a glob, a missing path):
|
|
20
|
+
* rule `plugin-pack-dropped`, one per entry.
|
|
21
|
+
*/
|
|
22
|
+
export async function checkPluginPack(pluginDirs) {
|
|
23
|
+
const out = await mkdtemp(join(tmpdir(), "check-plugin-pack-"));
|
|
24
|
+
const findings = [];
|
|
25
|
+
const opts = { maxBytes: PLUGIN_PACK_MAX_BYTES, hostSdkVersion: SDK_VERSION };
|
|
26
|
+
try {
|
|
27
|
+
for (const dir of pluginDirs) {
|
|
28
|
+
const file = join(dir, "package.json");
|
|
29
|
+
try {
|
|
30
|
+
const r = await packPlugin(dir, out);
|
|
31
|
+
for (const d of r.dropped) {
|
|
32
|
+
findings.push({
|
|
33
|
+
file,
|
|
34
|
+
line: 1,
|
|
35
|
+
rule: "plugin-pack-dropped",
|
|
36
|
+
message: `"files" entry "${d}" is not in the plugin package (only plain root files, README.md, LICENSE, migrations/, ui/, skills/ and connectors/ are shipped): remove it or make it a plain file`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const bytes = new Uint8Array(await readFile(r.file));
|
|
40
|
+
const plugin = await readPluginPackage(bytes, opts);
|
|
41
|
+
if (plugin.manifest.extension !== undefined)
|
|
42
|
+
await readExtensionPackage(bytes, opts);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
const message = e instanceof ExtensionPackageError || e instanceof PluginPackageError
|
|
46
|
+
? `the plugin package is invalid: ${e.issues.join("; ")}`
|
|
47
|
+
: `packing failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
48
|
+
findings.push({ file, line: 1, rule: "plugin-pack", message });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await rm(out, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Finding } from "./source.js";
|
|
2
|
+
export type { Finding };
|
|
3
|
+
/**
|
|
4
|
+
* 插件权限静态检查(AST):把 setup(ctx) 里对平台能力的访问与 defineExtension 清单比对。规则:
|
|
5
|
+
* - perm-secrets:用了 ctx.secrets 而 permissions.secrets 不是 true;
|
|
6
|
+
* - perm-http:ctx.http.route(method, path) 不在 permissions.http 里;
|
|
7
|
+
* - perm-tools:ctx.tools.register({ name }) 的名字不在 permissions.tools 里;
|
|
8
|
+
* - perm-extension-point: the ref of ctx.extensionPoints.get|list(ref) is not in requires / optional / provides;
|
|
9
|
+
* - perm-ui:ctx.ui.view|action(id) 的 id 不在 permissions.ui.views 里;
|
|
10
|
+
* - perm-storage:用了 ctx.storage 而 permissions.storage 不是 true;
|
|
11
|
+
* - perm-hooks: the mount point of ctx.hooks.on(mount, …) is not in permissions.hooks (the mount
|
|
12
|
+
* point must be a string literal or a same-file const, like the names below), or the options are
|
|
13
|
+
* `{ mode: "policy" }` and the mount point is not in permissions.policyHooks;
|
|
14
|
+
* - perm-reserved:用了尚未提供的 ctx.models。
|
|
15
|
+
* 路由路径、工具名、能力 ref、界面 id 须是字符串字面量或本文件的 const;动态的写法报「无法静态确认」,
|
|
16
|
+
* 豁免:`// capybara:allow-dynamic-permission <原因>`(行尾或紧邻上一行,原因必填)。
|
|
17
|
+
* ctx 会跟踪本地别名,以及按位置传给同文件函数的参数。
|
|
18
|
+
* 已知缺口(靠评审与运行时 ExtensionPermissionError 兜底):传给其他文件 / 方法的 ctx、闭包外的重新赋值、
|
|
19
|
+
* 没有 defineExtension 的辅助文件、permissions 本身是运行时拼出的。
|
|
20
|
+
* 扫描范围:传入目录下的非测试源码。
|
|
21
|
+
*/
|
|
22
|
+
export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
|