@codyswann/lisa 4.4.7 → 4.4.9

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 (63) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +6 -3
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  8. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  9. package/plugins/lisa-agy/plugin.json +1 -1
  10. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  61. package/scripts/lib/plugin-cache-resolution.mjs +325 -0
  62. package/scripts/plugin-parity-drift.mjs +20 -253
  63. package/scripts/plugin-routing-validate.mjs +32 -68
@@ -43,6 +43,13 @@ import process from "node:process";
43
43
  import { fileURLToPath } from "node:url";
44
44
 
45
45
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
46
+ import {
47
+ compareSemver,
48
+ isDirectory,
49
+ isValidSemver,
50
+ resolveCurrentVersion,
51
+ TOKEN_RE,
52
+ } from "./lib/plugin-cache-resolution.mjs";
46
53
 
47
54
  // Literals named once — each was repeated enough times that a typo in one
48
55
  // copy would diverge silently.
@@ -54,128 +61,26 @@ const REPO_ROOT = path.resolve(
54
61
  );
55
62
 
56
63
  /**
57
- * Semver 2.0.0 grammar, one clause per name. Build metadata (`+...`) is
58
- * accepted but ignored in comparison; prerelease (`-...`) is accepted and sorts
59
- * below its release.
60
- *
61
- * Assembled from fragments rather than written as one literal because the
62
- * literal was unreadable this is the semver.org grammar verbatim, and the
63
- * composed `.source` is byte-identical to the literal it replaced.
64
+ * Semver comparison, cache resolution, and the orphan filter all live in one
65
+ * module now, shared with `plugin-routing-validate.mjs`.
66
+ *
67
+ * They used to live here, and the validator walked the cache itself while
68
+ * importing only the semver helpers from this file so the two looked like one
69
+ * implementation and were not. When the orphan defect was fixed here, the fix
70
+ * did not reach there (CodySwannGT/lisa#3093).
71
+ *
72
+ * Re-exported rather than merely imported: `resolveCurrentVersion`,
73
+ * `compareSemver` and `isValidSemver` are this module's public surface and have
74
+ * callers. A re-export is one implementation with two names; the thing being
75
+ * removed is one name with two implementations.
64
76
  */
65
- const SEMVER_NUMERIC = "0|[1-9]\\d*";
66
- const SEMVER_PRERELEASE_ID = `(?:${SEMVER_NUMERIC}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
67
- const SEMVER_PRERELEASE = `(?:-(${SEMVER_PRERELEASE_ID}(?:\\.${SEMVER_PRERELEASE_ID})*))?`;
68
- const SEMVER_BUILD = "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
69
- const SEMVER_RE = new RegExp(
70
- `^(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})\\.(${SEMVER_NUMERIC})${SEMVER_PRERELEASE}${SEMVER_BUILD}$`
71
- );
72
-
73
- /** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
74
- const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
75
-
77
+ export { compareSemver, isValidSemver, resolveCurrentVersion };
76
78
  /**
77
79
  * Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
78
80
  * distinguish it (exit 2) from a drift result (exit 1).
79
81
  */
80
82
  export class UsageError extends Error {}
81
83
 
82
- /**
83
- * True iff `value` is a valid semver 2.0.0 string.
84
- *
85
- * @param {unknown} value - candidate version string.
86
- * @returns {boolean} whether `value` parses as semver.
87
- */
88
- export function isValidSemver(value) {
89
- if (typeof value !== "string") {
90
- return false;
91
- }
92
- return SEMVER_RE.test(value);
93
- }
94
-
95
- /**
96
- * Split a semver string into its numeric `[major, minor, patch]` core and the
97
- * raw prerelease string (build metadata stripped).
98
- *
99
- * @param {string} version - a valid semver string.
100
- * @returns {{ core: readonly number[], prerelease: string }} parsed parts.
101
- */
102
- function splitSemver(version) {
103
- const withoutBuild = version.split("+", 1)[0];
104
- const dashIndex = withoutBuild.indexOf("-");
105
- const coreStr =
106
- dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex);
107
- const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1);
108
- const core = coreStr.split(".").map(part => Number.parseInt(part, 10));
109
- return { core, prerelease };
110
- }
111
-
112
- /**
113
- * Compare two prerelease strings per semver precedence rules.
114
- *
115
- * @param {string} a - first prerelease (may be empty = "is a release").
116
- * @param {string} b - second prerelease (may be empty = "is a release").
117
- * @returns {number} -1, 0, or 1.
118
- */
119
- function comparePrerelease(a, b) {
120
- if (a === b) {
121
- return 0;
122
- }
123
- if (a === "") {
124
- return 1; // a is a full release; it outranks any prerelease b.
125
- }
126
- if (b === "") {
127
- return -1;
128
- }
129
- const aIds = a.split(".");
130
- const bIds = b.split(".");
131
- for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
132
- const ai = aIds[i];
133
- const bi = bIds[i];
134
- if (ai === undefined) {
135
- return -1; // shorter set of identifiers has lower precedence.
136
- }
137
- if (bi === undefined) {
138
- return 1;
139
- }
140
- const aNum = /^\d+$/.test(ai);
141
- const bNum = /^\d+$/.test(bi);
142
- if (aNum && bNum) {
143
- const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
144
- if (diff !== 0) {
145
- return diff < 0 ? -1 : 1;
146
- }
147
- continue;
148
- }
149
- if (aNum !== bNum) {
150
- return aNum ? -1 : 1; // numeric identifiers rank below alphanumeric.
151
- }
152
- if (ai !== bi) {
153
- return ai < bi ? -1 : 1;
154
- }
155
- }
156
- return 0;
157
- }
158
-
159
- /**
160
- * Compare two semver strings. Build metadata is ignored; a prerelease sorts
161
- * below its associated release.
162
- *
163
- * @param {string} a - first valid semver string.
164
- * @param {string} b - second valid semver string.
165
- * @returns {number} -1 if a < b, 0 if equal precedence, 1 if a > b.
166
- */
167
- export function compareSemver(a, b) {
168
- const pa = splitSemver(a);
169
- const pb = splitSemver(b);
170
- for (let i = 0; i < 3; i++) {
171
- const diff = pa.core[i] - pb.core[i];
172
- if (diff !== 0) {
173
- return diff < 0 ? -1 : 1;
174
- }
175
- }
176
- return comparePrerelease(pa.prerelease, pb.prerelease);
177
- }
178
-
179
84
  /**
180
85
  * Parse a `synced-from` value of the form `name@marketplace@version`.
181
86
  *
@@ -249,144 +154,6 @@ export function parseFrontmatter(content) {
249
154
  return result;
250
155
  }
251
156
 
252
- /**
253
- * True iff `target` is an existing directory.
254
- *
255
- * @param {string} target - filesystem path.
256
- * @returns {boolean} whether `target` resolves to a directory.
257
- */
258
- function isDirectory(target) {
259
- try {
260
- return fs.statSync(target).isDirectory();
261
- } catch {
262
- return false;
263
- }
264
- }
265
-
266
- /**
267
- * Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
268
- *
269
- * @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
270
- * @returns {string | null} the manifest version string, or `null`.
271
- */
272
- function readManifestVersion(manifestPath) {
273
- try {
274
- const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
275
- return typeof parsed.version === "string" ? parsed.version : null;
276
- } catch {
277
- return null;
278
- }
279
- }
280
-
281
- /**
282
- * Whether a cached version directory has been ORPHANED — the plugin manager's
283
- * marker for a version that is no longer installed or served.
284
- *
285
- * The cache is append-mostly: uninstalling a plugin does not delete its version
286
- * directories, it stamps each one with `.orphaned_at`. So the directories on
287
- * disk are a record of every version ever fetched, not of what is installed
288
- * now, and reading them as the latter is how this script came to compare a pin
289
- * against ten-day-old leftovers.
290
- *
291
- * @param {string} versionDir - absolute path to one cached version directory.
292
- * @returns {boolean} true when the directory carries an orphan marker.
293
- */
294
- function isOrphanedVersion(versionDir) {
295
- return fs.existsSync(path.join(versionDir, ".orphaned_at"));
296
- }
297
-
298
- /**
299
- * Resolve the current upstream version of `name@marketplace` purely from the
300
- * cache tree: the MAX valid semver across the immediate version subdirs that
301
- * are still LIVE, read from each subdir's `.claude-plugin/plugin.json`
302
- * `version` field. Non-semver dirs (`unknown`, git hashes) are skipped because
303
- * the manifest version is what counts, and orphaned dirs are skipped because
304
- * they are not installed.
305
- *
306
- * Orphans are filtered BEFORE the max, and the order is load-bearing.
307
- * "Filter, then take the max" and "take the max, then check whether it is
308
- * orphaned" are different functions, and they disagree exactly when the newest
309
- * live version is older than an orphan:
310
- *
311
- * live 1.0.6 + orphaned 2.0.4
312
- * filter-then-max -> 1.0.6 (correct: 1.0.6 IS installed)
313
- * max-then-check -> not-installed (wrong)
314
- *
315
- * When nothing live remains, this reports `not-installed` — a status this
316
- * script already has and already handles — rather than manufacturing a current
317
- * version out of orphans. That is `core/apply-receipt`'s principle: an
318
- * unresolvable state reports unresolvable, not half-understood.
319
- *
320
- * The failure this closes was not theoretical. Every one of the ten cached
321
- * `safety-net` versions was orphaned in a single sweep, and the resulting
322
- * manufactured comparison blocked every push from the checkout — in one
323
- * direction, and then, after a pin was moved to satisfy it, in the other.
324
- * A defect that produces two opposite plausible remedies is one where the
325
- * remedy is in neither direction.
326
- *
327
- * @param {string} cacheRoot - the installed-plugin cache root.
328
- * @param {string} name - plugin name.
329
- * @param {string} marketplace - marketplace id.
330
- * @returns {{ status: "ok" | "not-installed" | "unresolved", version: string | null }}
331
- * the resolution outcome.
332
- */
333
- export function resolveCurrentVersion(cacheRoot, name, marketplace) {
334
- // Defense-in-depth path-traversal guard: only single-token names/marketplaces
335
- // (no `.`, `/`, `..`) can map to a cache subdir. parseSyncedFrom already
336
- // enforces this, but resolveCurrentVersion is a public export that no longer
337
- // co-locates with its validating caller.
338
- if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
339
- return { status: NOT_INSTALLED, version: null };
340
- }
341
- const dir = path.join(cacheRoot, marketplace, name);
342
- if (!isDirectory(dir)) {
343
- return { status: NOT_INSTALLED, version: null };
344
- }
345
- const versions = [];
346
- // Counted separately from `versions`, because "nothing is installed" and
347
- // "something is installed but I cannot read its version" are different
348
- // answers and must not collapse into one. A live directory with an
349
- // unparseable manifest is `unresolved`; no live directory at all is
350
- // `not-installed`.
351
- let liveDirs = 0;
352
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
353
- if (!entry.isDirectory()) {
354
- continue;
355
- }
356
- const versionDir = path.join(dir, entry.name);
357
- // Before the manifest is even read: an orphaned directory is not an
358
- // installed version, whatever its manifest claims.
359
- if (isOrphanedVersion(versionDir)) {
360
- continue;
361
- }
362
- liveDirs += 1;
363
- const manifest = path.join(versionDir, ".claude-plugin", "plugin.json");
364
- const version = readManifestVersion(manifest);
365
- if (version !== null && isValidSemver(version)) {
366
- versions.push(version);
367
- }
368
- }
369
- if (versions.length === 0) {
370
- // No live directory at all means the plugin is not installed — a known,
371
- // answerable state, and the one the whole orphan filter exists to reach.
372
- // But a live directory whose manifest would not parse is still INSTALLED
373
- // and merely unreadable, which is what `unresolved` has always meant.
374
- // Collapsing the two would answer "I cannot read this version" with "this
375
- // is not here", and an operator would go looking for the wrong thing.
376
- return liveDirs === 0
377
- ? { status: NOT_INSTALLED, version: null }
378
- : { status: "unresolved", version: null };
379
- }
380
- // `versions` is non-empty (guarded above), so seeding with the first element
381
- // is exactly what the no-seed form did — and it cannot throw if that guard is
382
- // ever moved.
383
- const max = versions.reduce(
384
- (acc, v) => (compareSemver(v, acc) > 0 ? v : acc),
385
- versions[0]
386
- );
387
- return { status: "ok", version: max };
388
- }
389
-
390
157
  /**
391
158
  * Classify a synced skill by comparing its pinned version to the resolved
392
159
  * current version (§3.3).
@@ -39,16 +39,18 @@ import process from "node:process";
39
39
  import { fileURLToPath } from "node:url";
40
40
 
41
41
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
42
- import { compareSemver, isValidSemver } from "./plugin-parity-drift.mjs";
42
+ import {
43
+ compareSemver,
44
+ isDirectory,
45
+ isValidSemver,
46
+ resolveCurrentVersion,
47
+ } from "./lib/plugin-cache-resolution.mjs";
43
48
 
44
49
  const REPO_ROOT = path.resolve(
45
50
  path.dirname(fileURLToPath(import.meta.url)),
46
51
  ".."
47
52
  );
48
53
 
49
- /** A plugin name / marketplace token: `1*(ALPHA / DIGIT / "-" / "_")`. */
50
- const TOKEN_RE = /^[A-Za-z0-9_-]+$/;
51
-
52
54
  /** The non-Claude-native agents that every routing block must cover, exactly. */
53
55
  const AGENTS = ["agy", "codex", "copilot", "cursor"];
54
56
 
@@ -87,78 +89,40 @@ const QUOTE_LEN = 60;
87
89
  export class UsageError extends Error {}
88
90
 
89
91
  /**
90
- * True iff `target` is an existing directory.
92
+ * The max semver across a plugin's LIVE cache version subdirs, or `null`.
91
93
  *
92
- * @param {string} target - filesystem path.
93
- * @returns {boolean} whether `target` resolves to a directory.
94
- */
95
- function isDirectory(target) {
96
- try {
97
- return fs.statSync(target).isDirectory();
98
- } catch {
99
- return false;
100
- }
101
- }
102
-
103
- /**
104
- * Read a plugin manifest's `version` field, or `null` if unreadable / invalid.
94
+ * This used to walk the cache itself while importing only the semver helpers
95
+ * from the drift detector, which made the two scripts look like one resolver
96
+ * and behave like two. They disagreed about orphans: the detector was fixed to
97
+ * skip `.orphaned_at` directories (CodySwannGT/lisa#3085) and this walk was
98
+ * not, so it went on resolving a "current" version out of directories that were
99
+ * uninstalled ten days earlier — and the import argued the fix was already
100
+ * applied here (CodySwannGT/lisa#3093).
105
101
  *
106
- * @param {string} manifestPath - path to a `.claude-plugin/plugin.json`.
107
- * @returns {string | null} the manifest version string, or `null`.
108
- */
109
- function readManifestVersion(manifestPath) {
110
- try {
111
- const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
112
- return typeof parsed.version === "string" ? parsed.version : null;
113
- } catch {
114
- return null;
115
- }
116
- }
117
-
118
- /**
119
- * Resolve the max semver across a plugin's cache version subdirs, mirroring the
120
- * analyze-plugin Version-fallback rule: prefer each subdir's manifest `version`,
121
- * else fall back to the subdir NAME when it is itself semver (some plugins ship
122
- * no manifest version but a semver-named dir). Returns `null` when neither the
123
- * plugin nor any semver is present.
102
+ * It now calls the one resolver. `dirNameFallback` is this caller's single
103
+ * genuine difference, and it is named rather than implied: a semver DIRECTORY
104
+ * NAME stands in when a live version's manifest carries no usable `version`,
105
+ * which the drift detector deliberately does not do.
106
+ *
107
+ * ## Why both non-`ok` statuses collapse to `null` here
108
+ *
109
+ * The resolver distinguishes `not-installed` (no live directory at all) from
110
+ * `unresolved` (a live directory whose manifest will not parse), and that
111
+ * distinction is load-bearing in the detector's report. This caller has one
112
+ * question — "is there a version to compare against?" — and both answers are
113
+ * "no". The collapse is deliberate and local; the states stay distinct in the
114
+ * resolver so the detector can keep telling them apart.
124
115
  *
125
116
  * @param {string} cacheRoot - the installed-plugin cache root.
126
117
  * @param {string} name - plugin name.
127
118
  * @param {string} marketplace - marketplace id.
128
- * @returns {string | null} the max semver, or `null`.
119
+ * @returns {string | null} the max live semver, or `null`.
129
120
  */
130
121
  export function cacheMaxVersion(cacheRoot, name, marketplace) {
131
- if (!TOKEN_RE.test(name) || !TOKEN_RE.test(marketplace)) {
132
- return null;
133
- }
134
- const dir = path.join(cacheRoot, marketplace, name);
135
- if (!isDirectory(dir)) {
136
- return null;
137
- }
138
- const versions = [];
139
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
140
- if (!entry.isDirectory()) {
141
- continue;
142
- }
143
- const manifestVersion = readManifestVersion(
144
- path.join(dir, entry.name, ".claude-plugin", "plugin.json")
145
- );
146
- if (manifestVersion !== null && isValidSemver(manifestVersion)) {
147
- versions.push(manifestVersion);
148
- } else if (isValidSemver(entry.name)) {
149
- versions.push(entry.name);
150
- }
151
- }
152
- if (versions.length === 0) {
153
- return null;
154
- }
155
- // `versions` is non-empty (guarded above), so seeding with the first element
156
- // is exactly what the no-seed form did — and it cannot throw if that guard is
157
- // ever moved.
158
- return versions.reduce(
159
- (acc, v) => (compareSemver(v, acc) > 0 ? v : acc),
160
- versions[0]
161
- );
122
+ const resolved = resolveCurrentVersion(cacheRoot, name, marketplace, {
123
+ dirNameFallback: true,
124
+ });
125
+ return resolved.status === "ok" ? resolved.version : null;
162
126
  }
163
127
 
164
128
  /**