@codyswann/lisa 2.125.1 → 2.126.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/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/plugin-routing-validate.mjs +538 -0
package/package.json
CHANGED
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"lodash": ">=4.18.1"
|
|
84
84
|
},
|
|
85
85
|
"name": "@codyswann/lisa",
|
|
86
|
-
"version": "2.
|
|
86
|
+
"version": "2.126.0",
|
|
87
87
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
88
88
|
"main": "dist/index.js",
|
|
89
89
|
"exports": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.126.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.126.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.126.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.126.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.126.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic validator for the plugin-parity routing artifacts written by the
|
|
4
|
+
* `analyze-plugin` skill (issue #1059, productionized in #12).
|
|
5
|
+
*
|
|
6
|
+
* It gates `parity/plugin-routing/*.json` against the analyze-plugin schema and
|
|
7
|
+
* a set of anti-pattern checks so the parity gaps fixed during review cannot
|
|
8
|
+
* silently regress. `analyze-plugin` runs it as a Definition-of-Done gate and
|
|
9
|
+
* `implement-plugin-parity` runs it pre-flight before acting on an approved
|
|
10
|
+
* artifact.
|
|
11
|
+
*
|
|
12
|
+
* Determinism guarantees (so the unit test is reproducible and CI is stable):
|
|
13
|
+
* - zero third-party dependencies (Node built-ins only),
|
|
14
|
+
* - no network access,
|
|
15
|
+
* - no `Date` / `Math.random` — the upstream version is resolved purely from
|
|
16
|
+
* the installed plugin cache (manifest `version`, else a semver dir name).
|
|
17
|
+
*
|
|
18
|
+
* The routing dir and cache root are injectable via flags so tests point at a
|
|
19
|
+
* committed fixture instead of the machine's real `~/.claude/plugins/cache`.
|
|
20
|
+
*
|
|
21
|
+
* CLI:
|
|
22
|
+
* node scripts/plugin-routing-validate.mjs [--routing-dir <dir>] [--cache-root <dir>] [--json]
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 — every artifact is valid.
|
|
26
|
+
* 1 — ≥1 artifact failed validation.
|
|
27
|
+
* 2 — operational/usage error: unknown flag, a flag missing its value, the
|
|
28
|
+
* routing dir absent, or a filesystem error during the scan.
|
|
29
|
+
*
|
|
30
|
+
* The semver primitives are shared with the drift detector to keep a single
|
|
31
|
+
* source of truth for version parsing/comparison.
|
|
32
|
+
*
|
|
33
|
+
* @module scripts/plugin-routing-validate
|
|
34
|
+
*/
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import process from "node:process";
|
|
39
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
40
|
+
import { compareSemver, isValidSemver } from "./plugin-parity-drift.mjs";
|
|
41
|
+
|
|
42
|
+
const REPO_ROOT = path.resolve(
|
|
43
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
44
|
+
".."
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
|
|
48
|
+
const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
|
|
49
|
+
|
|
50
|
+
/** The non-Claude-native agents that every routing block must cover, exactly. */
|
|
51
|
+
const AGENTS = ["agy", "codex", "copilot", "cursor"];
|
|
52
|
+
|
|
53
|
+
/** The locked routing-outcome enum. */
|
|
54
|
+
const OUTCOMES = new Set([
|
|
55
|
+
"already-native",
|
|
56
|
+
"claude-only",
|
|
57
|
+
"enable-vendor-equivalent",
|
|
58
|
+
"re-point-mcp-lsp",
|
|
59
|
+
"reimplement",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Valid component `kind` values. */
|
|
63
|
+
const KINDS = new Set(["agent", "command", "hook", "lsp", "mcp", "skill"]);
|
|
64
|
+
|
|
65
|
+
/** Valid component `classification` values. */
|
|
66
|
+
const CLASSES = new Set([
|
|
67
|
+
"claude-agent",
|
|
68
|
+
"claude-command",
|
|
69
|
+
"claude-skill",
|
|
70
|
+
"hook",
|
|
71
|
+
"lsp-server",
|
|
72
|
+
"mcp-server",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Valid artifact `status` values. */
|
|
76
|
+
const STATUSES = new Set(["approved", "proposed"]);
|
|
77
|
+
|
|
78
|
+
/** Max characters of an offending action quoted back in an error message. */
|
|
79
|
+
const QUOTE_LEN = 60;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
|
|
83
|
+
* distinguish it (exit 2) from a validation failure (exit 1).
|
|
84
|
+
*/
|
|
85
|
+
export class UsageError extends Error {}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True iff `target` is an existing directory.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} target - filesystem path.
|
|
91
|
+
* @returns {boolean} whether `target` resolves to a directory.
|
|
92
|
+
*/
|
|
93
|
+
function isDirectory(target) {
|
|
94
|
+
try {
|
|
95
|
+
return fs.statSync(target).isDirectory();
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
|
|
105
|
+
* @returns {string | null} the manifest version string, or `null`.
|
|
106
|
+
*/
|
|
107
|
+
function readManifestVersion(manifestPath) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
110
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the max semver across a plugin's cache version subdirs, mirroring the
|
|
118
|
+
* analyze-plugin Version-fallback rule: prefer each subdir's manifest `version`,
|
|
119
|
+
* else fall back to the subdir NAME when it is itself semver (some plugins ship
|
|
120
|
+
* no manifest version but a semver-named dir). Returns `null` when neither the
|
|
121
|
+
* plugin nor any semver is present.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} cacheRoot - the installed-plugin cache root.
|
|
124
|
+
* @param {string} name - plugin name.
|
|
125
|
+
* @param {string} marketplace - marketplace id.
|
|
126
|
+
* @returns {string | null} the max semver, or `null`.
|
|
127
|
+
*/
|
|
128
|
+
export function cacheMaxVersion(cacheRoot, name, marketplace) {
|
|
129
|
+
if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const dir = path.join(cacheRoot, marketplace, name);
|
|
133
|
+
if (!isDirectory(dir)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const versions = [];
|
|
137
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
138
|
+
if (!entry.isDirectory()) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const manifestVersion = readManifestVersion(
|
|
142
|
+
path.join(dir, entry.name, ".claude-plugin", "plugin.json")
|
|
143
|
+
);
|
|
144
|
+
if (manifestVersion !== null && isValidSemver(manifestVersion)) {
|
|
145
|
+
versions.push(manifestVersion);
|
|
146
|
+
} else if (isValidSemver(entry.name)) {
|
|
147
|
+
versions.push(entry.name);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (versions.length === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return versions.reduce((acc, v) => (compareSemver(v, acc) > 0 ? v : acc));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validate the `upstreamVersion` against the resolved cache max (§ version
|
|
158
|
+
* contract): a semver value must equal the cache max; `"unknown"` is valid only
|
|
159
|
+
* when the cache has no semver anywhere.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} upstreamVersion - the artifact's `upstreamVersion`.
|
|
162
|
+
* @param {string | null} cacheMax - resolved max semver, or null.
|
|
163
|
+
* @returns {string[]} validation error messages (empty when valid).
|
|
164
|
+
*/
|
|
165
|
+
function validateVersion(upstreamVersion, cacheMax) {
|
|
166
|
+
if (upstreamVersion === "unknown") {
|
|
167
|
+
return cacheMax === null
|
|
168
|
+
? []
|
|
169
|
+
: [`upstreamVersion "unknown" but the cache has semver ${cacheMax}`];
|
|
170
|
+
}
|
|
171
|
+
if (typeof upstreamVersion !== "string" || !isValidSemver(upstreamVersion)) {
|
|
172
|
+
return [
|
|
173
|
+
`upstreamVersion must be semver or "unknown" (got ${String(upstreamVersion)})`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
if (cacheMax === null) {
|
|
177
|
+
return [
|
|
178
|
+
`upstreamVersion ${upstreamVersion} but no semver in the cache to confirm`,
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
if (compareSemver(upstreamVersion, cacheMax) !== 0) {
|
|
182
|
+
return [`upstreamVersion ${upstreamVersion} != cache max ${cacheMax}`];
|
|
183
|
+
}
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Validate one component entry.
|
|
189
|
+
*
|
|
190
|
+
* @param {Record<string, unknown>} component - a `components[]` entry.
|
|
191
|
+
* @returns {string[]} validation error messages (empty when valid).
|
|
192
|
+
*/
|
|
193
|
+
function validateComponent(component) {
|
|
194
|
+
const errors = [];
|
|
195
|
+
if (component === null || typeof component !== "object") {
|
|
196
|
+
return ["component is not an object"];
|
|
197
|
+
}
|
|
198
|
+
if (!KINDS.has(component.kind)) {
|
|
199
|
+
errors.push(`component kind invalid: ${String(component.kind)}`);
|
|
200
|
+
}
|
|
201
|
+
if (!CLASSES.has(component.classification)) {
|
|
202
|
+
errors.push(
|
|
203
|
+
`component classification invalid: ${String(component.classification)}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
typeof component.id !== "string" ||
|
|
208
|
+
component.id === "" ||
|
|
209
|
+
typeof component.path !== "string" ||
|
|
210
|
+
component.path === ""
|
|
211
|
+
) {
|
|
212
|
+
errors.push("component is missing a non-empty id and path");
|
|
213
|
+
}
|
|
214
|
+
return errors;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate one agent's action list: no unparseable `@unknown` pin, no
|
|
219
|
+
* "not addressed" cop-out, and a `reimplement` with a semver upstream must carry
|
|
220
|
+
* the `synced-from` stamp.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} agent - the agent key.
|
|
223
|
+
* @param {Record<string, unknown>} entry - the routing entry.
|
|
224
|
+
* @param {string} plugin - the canonical plugin id.
|
|
225
|
+
* @param {unknown} upstreamVersion - the artifact's `upstreamVersion`.
|
|
226
|
+
* @returns {string[]} validation error messages (empty when valid).
|
|
227
|
+
*/
|
|
228
|
+
function validateActions(agent, entry, plugin, upstreamVersion) {
|
|
229
|
+
const errors = [];
|
|
230
|
+
const actions = Array.isArray(entry.actions) ? entry.actions : [];
|
|
231
|
+
for (const action of actions) {
|
|
232
|
+
const text = String(action);
|
|
233
|
+
if (/@unknown\b/.test(text)) {
|
|
234
|
+
errors.push(
|
|
235
|
+
`routing.${agent} action carries an unparseable @unknown pin: "${truncate(text)}"`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (/not\s+addressed/i.test(text)) {
|
|
239
|
+
errors.push(
|
|
240
|
+
`routing.${agent} action flags a component "not addressed" (cover it, do not flag): "${truncate(text)}"`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (entry.outcome === "reimplement" && upstreamVersion !== "unknown") {
|
|
245
|
+
const stamp = `synced-from: ${plugin}@${String(upstreamVersion)}`;
|
|
246
|
+
if (!actions.some(action => String(action).includes(stamp))) {
|
|
247
|
+
errors.push(
|
|
248
|
+
`routing.${agent} reimplement actions must include the stamp "${stamp}"`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return errors;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Validate the routing block: exactly the four agent keys, each with a valid
|
|
257
|
+
* outcome, an actions array, a non-empty rationale, and clean actions.
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} routing - the artifact's `routing` object.
|
|
260
|
+
* @param {string} plugin - the canonical plugin id.
|
|
261
|
+
* @param {unknown} upstreamVersion - the artifact's `upstreamVersion`.
|
|
262
|
+
* @returns {string[]} validation error messages (empty when valid).
|
|
263
|
+
*/
|
|
264
|
+
function validateRouting(routing, plugin, upstreamVersion) {
|
|
265
|
+
if (routing === null || typeof routing !== "object") {
|
|
266
|
+
return ["routing must be an object covering agy,codex,copilot,cursor"];
|
|
267
|
+
}
|
|
268
|
+
const errors = [];
|
|
269
|
+
const keys = Object.keys(routing).sort();
|
|
270
|
+
if (keys.join(",") !== [...AGENTS].sort().join(",")) {
|
|
271
|
+
errors.push(
|
|
272
|
+
`routing must have exactly agy,codex,copilot,cursor (got ${keys.join(",") || "none"})`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
for (const agent of AGENTS) {
|
|
276
|
+
const entry = routing[agent];
|
|
277
|
+
if (entry === null || entry === undefined || typeof entry !== "object") {
|
|
278
|
+
errors.push(`routing.${agent} is missing`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!OUTCOMES.has(entry.outcome)) {
|
|
282
|
+
errors.push(`routing.${agent} outcome invalid: ${String(entry.outcome)}`);
|
|
283
|
+
}
|
|
284
|
+
if (!Array.isArray(entry.actions)) {
|
|
285
|
+
errors.push(`routing.${agent}.actions must be an array`);
|
|
286
|
+
}
|
|
287
|
+
if (typeof entry.rationale !== "string" || entry.rationale === "") {
|
|
288
|
+
errors.push(`routing.${agent}.rationale must be a non-empty string`);
|
|
289
|
+
}
|
|
290
|
+
errors.push(...validateActions(agent, entry, plugin, upstreamVersion));
|
|
291
|
+
}
|
|
292
|
+
return errors;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Validate a parsed routing artifact against the analyze-plugin schema + the
|
|
297
|
+
* version contract + anti-pattern gates. Pure — all filesystem facts are passed
|
|
298
|
+
* in via `context`, so it is directly unit-testable.
|
|
299
|
+
*
|
|
300
|
+
* @param {unknown} artifact - the parsed JSON artifact.
|
|
301
|
+
* @param {{ filename?: string, cacheMax: string | null, mdExists: boolean }} context
|
|
302
|
+
* the resolved filesystem facts.
|
|
303
|
+
* @returns {string[]} validation error messages (empty when valid).
|
|
304
|
+
*/
|
|
305
|
+
export function validateArtifact(artifact, context) {
|
|
306
|
+
if (artifact === null || typeof artifact !== "object") {
|
|
307
|
+
return ["artifact is not a JSON object"];
|
|
308
|
+
}
|
|
309
|
+
const { cacheMax, filename, mdExists } = context;
|
|
310
|
+
const errors = [];
|
|
311
|
+
if (artifact.schemaVersion !== 1) {
|
|
312
|
+
errors.push(
|
|
313
|
+
`schemaVersion must be 1 (got ${String(artifact.schemaVersion)})`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (!STATUSES.has(artifact.status)) {
|
|
317
|
+
errors.push(
|
|
318
|
+
`status must be one of proposed|approved (got ${String(artifact.status)})`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (artifact.plugin !== `${artifact.pluginName}@${artifact.marketplace}`) {
|
|
322
|
+
errors.push("plugin must equal pluginName@marketplace");
|
|
323
|
+
}
|
|
324
|
+
if (filename !== undefined && `${artifact.plugin}.json` !== filename) {
|
|
325
|
+
errors.push(`filename must equal <plugin>.json (file is ${filename})`);
|
|
326
|
+
}
|
|
327
|
+
errors.push(...validateVersion(artifact.upstreamVersion, cacheMax));
|
|
328
|
+
if (!Array.isArray(artifact.components) || artifact.components.length === 0) {
|
|
329
|
+
errors.push("components must be a non-empty array");
|
|
330
|
+
} else {
|
|
331
|
+
for (const component of artifact.components) {
|
|
332
|
+
errors.push(...validateComponent(component));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
errors.push(
|
|
336
|
+
...validateRouting(
|
|
337
|
+
artifact.routing,
|
|
338
|
+
artifact.plugin,
|
|
339
|
+
artifact.upstreamVersion
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
if (mdExists === false) {
|
|
343
|
+
errors.push("paired .md companion file is missing");
|
|
344
|
+
}
|
|
345
|
+
return errors;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Truncate a string for inclusion in an error message.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} text - the source string.
|
|
352
|
+
* @returns {string} `text` clipped to QUOTE_LEN characters.
|
|
353
|
+
*/
|
|
354
|
+
function truncate(text) {
|
|
355
|
+
return text.length > QUOTE_LEN ? `${text.slice(0, QUOTE_LEN)}…` : text;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validate a single artifact file: parse it, resolve its cache max + paired-md
|
|
360
|
+
* existence, and return the per-file result.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} routingDir - the routing directory.
|
|
363
|
+
* @param {string} file - the artifact filename (`*.json`).
|
|
364
|
+
* @param {string} cacheRoot - the installed-plugin cache root.
|
|
365
|
+
* @returns {{ file: string, errors: string[] }} the per-file result.
|
|
366
|
+
*/
|
|
367
|
+
function validateFile(routingDir, file, cacheRoot) {
|
|
368
|
+
let artifact;
|
|
369
|
+
try {
|
|
370
|
+
artifact = JSON.parse(fs.readFileSync(path.join(routingDir, file), "utf8"));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return { errors: [`invalid JSON: ${error.message}`], file };
|
|
373
|
+
}
|
|
374
|
+
const hasIds =
|
|
375
|
+
artifact !== null &&
|
|
376
|
+
typeof artifact === "object" &&
|
|
377
|
+
typeof artifact.pluginName === "string" &&
|
|
378
|
+
typeof artifact.marketplace === "string";
|
|
379
|
+
const cacheMax = hasIds
|
|
380
|
+
? cacheMaxVersion(cacheRoot, artifact.pluginName, artifact.marketplace)
|
|
381
|
+
: null;
|
|
382
|
+
const mdExists = fs.existsSync(
|
|
383
|
+
path.join(routingDir, file.replace(/\.json$/, ".md"))
|
|
384
|
+
);
|
|
385
|
+
const errors = validateArtifact(artifact, {
|
|
386
|
+
cacheMax,
|
|
387
|
+
filename: file,
|
|
388
|
+
mdExists,
|
|
389
|
+
});
|
|
390
|
+
return { errors, file };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Assemble the machine-readable report.
|
|
395
|
+
*
|
|
396
|
+
* @param {ReadonlyArray<{ file: string, errors: string[] }>} results - per-file results.
|
|
397
|
+
* @param {{ cacheRoot: string, routingDir: string }} opts - resolved options.
|
|
398
|
+
* @returns {Record<string, unknown>} the report object.
|
|
399
|
+
*/
|
|
400
|
+
export function buildReport(results, opts) {
|
|
401
|
+
const invalid = results.filter(r => r.errors.length > 0).length;
|
|
402
|
+
return {
|
|
403
|
+
cacheRoot: opts.cacheRoot,
|
|
404
|
+
results,
|
|
405
|
+
routingDir: opts.routingDir,
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
summary: {
|
|
408
|
+
invalid,
|
|
409
|
+
scanned: results.length,
|
|
410
|
+
valid: results.length - invalid,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Render the human-readable report.
|
|
417
|
+
*
|
|
418
|
+
* @param {{ results: ReadonlyArray<{ file: string, errors: string[] }>, summary: { scanned: number, valid: number, invalid: number } }} report - report object.
|
|
419
|
+
* @returns {string} the rendered report.
|
|
420
|
+
*/
|
|
421
|
+
function humanReport(report) {
|
|
422
|
+
const lines = report.results.map(r =>
|
|
423
|
+
r.errors.length === 0
|
|
424
|
+
? `✓ ${r.file}`
|
|
425
|
+
: `✗ ${r.file}\n${r.errors.map(e => ` - ${e}`).join("\n")}`
|
|
426
|
+
);
|
|
427
|
+
const s = report.summary;
|
|
428
|
+
return [
|
|
429
|
+
...lines,
|
|
430
|
+
"",
|
|
431
|
+
`${s.valid}/${s.scanned} routing artifacts valid, ${s.invalid} invalid`,
|
|
432
|
+
].join("\n");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Render a report to the chosen stream.
|
|
437
|
+
*
|
|
438
|
+
* @param {{ write(s: string): void }} out - the output stream.
|
|
439
|
+
* @param {Record<string, unknown>} report - the report object.
|
|
440
|
+
* @param {boolean} json - whether to emit JSON instead of the human report.
|
|
441
|
+
* @returns {void}
|
|
442
|
+
*/
|
|
443
|
+
function emitReport(out, report, json) {
|
|
444
|
+
out.write(
|
|
445
|
+
(json ? JSON.stringify(report, null, 2) : humanReport(report)) + "\n"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Parse argv into resolved options. Throws `UsageError` on a bad invocation.
|
|
451
|
+
*
|
|
452
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
453
|
+
* @returns {{ routingDir: string, cacheRoot: string, json: boolean }} options.
|
|
454
|
+
*/
|
|
455
|
+
export function parseArgs(argv) {
|
|
456
|
+
let routingDir = null;
|
|
457
|
+
let cacheRoot = null;
|
|
458
|
+
let json = false;
|
|
459
|
+
for (let i = 0; i < argv.length; i++) {
|
|
460
|
+
const arg = argv[i];
|
|
461
|
+
if (arg === "--json") {
|
|
462
|
+
json = true;
|
|
463
|
+
} else if (arg === "--routing-dir") {
|
|
464
|
+
const next = argv[i + 1];
|
|
465
|
+
if (next === undefined || next.startsWith("--")) {
|
|
466
|
+
throw new UsageError("--routing-dir requires a value");
|
|
467
|
+
}
|
|
468
|
+
routingDir = next;
|
|
469
|
+
i += 1;
|
|
470
|
+
} else if (arg === "--cache-root") {
|
|
471
|
+
const next = argv[i + 1];
|
|
472
|
+
if (next === undefined || next.startsWith("--")) {
|
|
473
|
+
throw new UsageError("--cache-root requires a value");
|
|
474
|
+
}
|
|
475
|
+
cacheRoot = next;
|
|
476
|
+
i += 1;
|
|
477
|
+
} else {
|
|
478
|
+
throw new UsageError(`unknown argument: ${arg}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const resolvedCache =
|
|
482
|
+
cacheRoot ??
|
|
483
|
+
process.env.CLAUDE_PLUGIN_CACHE ??
|
|
484
|
+
path.join(os.homedir(), ".claude", "plugins", "cache");
|
|
485
|
+
const resolvedRouting =
|
|
486
|
+
routingDir ?? path.join(REPO_ROOT, "parity", "plugin-routing");
|
|
487
|
+
return {
|
|
488
|
+
cacheRoot: path.resolve(resolvedCache),
|
|
489
|
+
json,
|
|
490
|
+
routingDir: path.resolve(resolvedRouting),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Run the validator. Returns the process exit code (does not call `exit`).
|
|
496
|
+
*
|
|
497
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
498
|
+
* @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
|
|
499
|
+
* injectable streams (defaults to process streams).
|
|
500
|
+
* @returns {number} the exit code (0 valid, 1 invalid, 2 usage error).
|
|
501
|
+
*/
|
|
502
|
+
export function main(argv, io = {}) {
|
|
503
|
+
const out = io.stdout ?? process.stdout;
|
|
504
|
+
const err = io.stderr ?? process.stderr;
|
|
505
|
+
let opts;
|
|
506
|
+
try {
|
|
507
|
+
opts = parseArgs(argv);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
err.write(`error: ${error.message}\n`);
|
|
510
|
+
return 2;
|
|
511
|
+
}
|
|
512
|
+
if (!isDirectory(opts.routingDir)) {
|
|
513
|
+
err.write(`error: --routing-dir is not a directory: ${opts.routingDir}\n`);
|
|
514
|
+
return 2;
|
|
515
|
+
}
|
|
516
|
+
let results;
|
|
517
|
+
try {
|
|
518
|
+
const files = fs
|
|
519
|
+
.readdirSync(opts.routingDir)
|
|
520
|
+
.filter(f => f.endsWith(".json"))
|
|
521
|
+
.sort();
|
|
522
|
+
results = files.map(file =>
|
|
523
|
+
validateFile(opts.routingDir, file, opts.cacheRoot)
|
|
524
|
+
);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
err.write(`error: failed to validate routing dir: ${error.message}\n`);
|
|
527
|
+
return 2;
|
|
528
|
+
}
|
|
529
|
+
emitReport(out, buildReport(results, opts), opts.json);
|
|
530
|
+
return results.every(r => r.errors.length === 0) ? 0 : 1;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (
|
|
534
|
+
process.argv[1] &&
|
|
535
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
536
|
+
) {
|
|
537
|
+
process.exit(main(process.argv.slice(2)));
|
|
538
|
+
}
|