@codyswann/lisa 3.13.0 → 3.13.2

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 (61) hide show
  1. package/dist/core/lisa-owned-provenance.d.ts +1 -5
  2. package/dist/core/lisa-owned-provenance.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-provenance.js +27 -10
  4. package/dist/core/lisa-owned-provenance.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +7 -2
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/package.json +2 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  42. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-agy/plugin.json +1 -1
  47. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  52. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  59. package/scripts/check-conflict-markers.mjs +312 -0
  60. package/scripts/generate-lisa-owned-hash-ledger.mjs +67 -14
  61. package/scripts/plugin-routing-validate.mjs +55 -14
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic gate against leftover merge-conflict markers in tracked files
4
+ * (issue #2552).
5
+ *
6
+ * PR #2548 committed literal `<<<<<<< HEAD` conflict blocks into six generated
7
+ * parity `SKILL.md` files and passed every gate on the way in:
8
+ *
9
+ * - the parity push gate greps for the pin *value*, which was present inside
10
+ * the conflict block, so it read as current;
11
+ * - the test suite parses skill frontmatter, never the body;
12
+ * - `🧩 Plugin artifacts match source` compares the generated copies against
13
+ * `plugins/src`, and both sides were broken the *same* way, so they matched.
14
+ *
15
+ * Nothing in the loop read the bytes. This script does, and only that.
16
+ *
17
+ * Determinism guarantees (so the unit test is reproducible and CI is stable):
18
+ * - zero third-party dependencies (Node built-ins only),
19
+ * - no network access,
20
+ * - no `Date` / `Math.random`,
21
+ * - the file list comes from `git ls-files`, so the gate sees exactly what a
22
+ * push would carry — an untracked scratch file can never block a push.
23
+ *
24
+ * Detection deliberately requires the COMPLETE ordered marker triple
25
+ * (`<<<<<<<` … `=======` … `>>>>>>>`), which is what git actually writes.
26
+ * Content between the start and the separator is ignored, so the diff3 base
27
+ * marker (`|||||||`) is accepted. A lone `<<<<<<<` line is NOT reported:
28
+ * documentation that quotes one side of a conflict is legitimate content, and a
29
+ * gate that fires on files it should not read is its own outage.
30
+ *
31
+ * CLI:
32
+ * node scripts/check-conflict-markers.mjs [--root <dir>] [--json]
33
+ *
34
+ * Exit codes (mirroring the sibling parity scripts):
35
+ * 0 — no tracked file carries a conflict block.
36
+ * 1 — ≥1 tracked file carries a conflict block.
37
+ * 2 — operational/usage error: unknown flag, a flag missing its value,
38
+ * `--root` absent or not a git repository, or git being unavailable.
39
+ *
40
+ * @module scripts/check-conflict-markers
41
+ */
42
+ import { execFileSync } from "node:child_process";
43
+ import fs from "node:fs";
44
+ import path from "node:path";
45
+ import process from "node:process";
46
+ import { fileURLToPath, pathToFileURL } from "node:url";
47
+
48
+ const REPO_ROOT = path.resolve(
49
+ path.dirname(fileURLToPath(import.meta.url)),
50
+ ".."
51
+ );
52
+
53
+ /** `<<<<<<<` alone, or followed by whitespace and a label (`<<<<<<< HEAD`). */
54
+ const START_RE = /^<{7}(?:[ \t].*)?$/;
55
+
56
+ /** The separator git writes between the two sides: exactly seven `=`. */
57
+ const SEPARATOR_RE = /^={7}$/;
58
+
59
+ /** `>>>>>>>` alone, or followed by whitespace and a label. */
60
+ const END_RE = /^>{7}(?:[ \t].*)?$/;
61
+
62
+ /** Files larger than this are skipped — no source file is this big. */
63
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
64
+
65
+ /** Leading bytes probed for a NUL, which marks the file as binary. */
66
+ const BINARY_PROBE_BYTES = 8000;
67
+
68
+ /** Max bytes of `git ls-files` output (7k+ tracked paths is ~0.3 MB today). */
69
+ const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
70
+
71
+ /**
72
+ * Usage error — thrown by `parseArgs` / `listTrackedFiles` for an invalid
73
+ * invocation so `main` can distinguish it (exit 2) from a finding (exit 1).
74
+ */
75
+ export class UsageError extends Error {}
76
+
77
+ /**
78
+ * Find every complete conflict block in `content`.
79
+ *
80
+ * A block is a `<<<<<<<` line, then a `=======` line, then a `>>>>>>>` line, in
81
+ * that order. A second `<<<<<<<` abandons any partial block and starts a new
82
+ * one, matching how git nests nothing and always re-opens.
83
+ *
84
+ * @param {string} content - full text of a file.
85
+ * @returns {{ startLine: number, separatorLine: number, endLine: number }[]}
86
+ * one entry per complete block, with 1-based line numbers.
87
+ */
88
+ export function findConflictBlocks(content) {
89
+ const lines = String(content).split(/\r?\n/);
90
+ const blocks = [];
91
+ let startLine = -1;
92
+ let separatorLine = -1;
93
+ for (let index = 0; index < lines.length; index++) {
94
+ const line = lines[index];
95
+ if (START_RE.test(line)) {
96
+ startLine = index + 1;
97
+ separatorLine = -1;
98
+ continue;
99
+ }
100
+ if (startLine === -1) {
101
+ continue;
102
+ }
103
+ if (SEPARATOR_RE.test(line)) {
104
+ if (separatorLine === -1) {
105
+ separatorLine = index + 1;
106
+ }
107
+ continue;
108
+ }
109
+ if (END_RE.test(line)) {
110
+ if (separatorLine !== -1) {
111
+ blocks.push({ endLine: index + 1, separatorLine, startLine });
112
+ }
113
+ startLine = -1;
114
+ separatorLine = -1;
115
+ }
116
+ }
117
+ return blocks;
118
+ }
119
+
120
+ /**
121
+ * True iff `buffer` looks binary (a NUL byte in its leading bytes) — the same
122
+ * heuristic git uses to decide a blob is not text.
123
+ *
124
+ * @param {Buffer} buffer - the file contents.
125
+ * @returns {boolean} whether the file should be skipped as binary.
126
+ */
127
+ function isBinary(buffer) {
128
+ return (
129
+ buffer.indexOf(0, 0) !== -1 && buffer.indexOf(0, 0) < BINARY_PROBE_BYTES
130
+ );
131
+ }
132
+
133
+ /**
134
+ * List every tracked file in `root`, relative to it. Throws `UsageError` when
135
+ * git is unavailable or `root` is not a repository.
136
+ *
137
+ * @param {string} root - the repository root.
138
+ * @returns {string[]} tracked paths, relative to `root`.
139
+ */
140
+ function listTrackedFiles(root) {
141
+ let stdout;
142
+ try {
143
+ stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
144
+ encoding: "utf8",
145
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
146
+ stdio: ["ignore", "pipe", "ignore"],
147
+ });
148
+ } catch (error) {
149
+ throw new UsageError(
150
+ `could not list tracked files in ${root}: ${error.message}`
151
+ );
152
+ }
153
+ return stdout.split("\0").filter(entry => entry !== "");
154
+ }
155
+
156
+ /**
157
+ * Scan one tracked file. Missing (staged-deleted), oversized, and binary files
158
+ * are skipped rather than reported.
159
+ *
160
+ * @param {string} root - the repository root.
161
+ * @param {string} file - a tracked path relative to `root`.
162
+ * @returns {{ file: string, blocks: ReadonlyArray<Record<string, number>> } | null}
163
+ * the finding, or `null` when the file is clean or skipped.
164
+ */
165
+ function scanFile(root, file) {
166
+ const absolute = path.join(root, file);
167
+ let stat;
168
+ try {
169
+ stat = fs.statSync(absolute);
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
174
+ return null;
175
+ }
176
+ let buffer;
177
+ try {
178
+ buffer = fs.readFileSync(absolute);
179
+ } catch {
180
+ return null;
181
+ }
182
+ if (isBinary(buffer)) {
183
+ return null;
184
+ }
185
+ const blocks = findConflictBlocks(buffer.toString("utf8"));
186
+ return blocks.length === 0 ? null : { blocks, file };
187
+ }
188
+
189
+ /**
190
+ * Assemble the machine-readable report.
191
+ *
192
+ * @param {ReadonlyArray<{ file: string, blocks: ReadonlyArray<Record<string, number>> }>} results
193
+ * the conflicted files, sorted by path.
194
+ * @param {{ root: string, scanned: number }} opts - resolved options + scan size.
195
+ * @returns {Record<string, unknown>} the report object.
196
+ */
197
+ export function buildReport(results, opts) {
198
+ return {
199
+ results,
200
+ root: opts.root,
201
+ schemaVersion: 1,
202
+ summary: {
203
+ clean: opts.scanned - results.length,
204
+ conflicted: results.length,
205
+ scanned: opts.scanned,
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Render the human-readable report.
212
+ *
213
+ * @param {{ results: ReadonlyArray<{ file: string, blocks: ReadonlyArray<Record<string, number>> }>, summary: { scanned: number, conflicted: number } }} report
214
+ * the report object.
215
+ * @returns {string} the rendered report.
216
+ */
217
+ function humanReport(report) {
218
+ if (report.summary.conflicted === 0) {
219
+ return `✓ no leftover conflict markers in ${report.summary.scanned} tracked files`;
220
+ }
221
+ const lines = report.results.flatMap(result => [
222
+ `✗ ${result.file}`,
223
+ ...result.blocks.map(
224
+ block =>
225
+ ` - conflict block opens at line ${block.startLine}, separator at line ${block.separatorLine}, closes at line ${block.endLine}`
226
+ ),
227
+ ]);
228
+ return [
229
+ ...lines,
230
+ "",
231
+ `${report.summary.conflicted} of ${report.summary.scanned} tracked files carry leftover conflict markers`,
232
+ ].join("\n");
233
+ }
234
+
235
+ /**
236
+ * Parse argv into resolved options. Throws `UsageError` on a bad invocation.
237
+ *
238
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
239
+ * @returns {{ root: string, json: boolean }} options.
240
+ */
241
+ export function parseArgs(argv) {
242
+ let root = null;
243
+ let json = false;
244
+ for (let i = 0; i < argv.length; i++) {
245
+ const arg = argv[i];
246
+ if (arg === "--json") {
247
+ json = true;
248
+ } else if (arg === "--root") {
249
+ const next = argv[i + 1];
250
+ if (next === undefined || next.startsWith("--")) {
251
+ throw new UsageError("--root requires a value");
252
+ }
253
+ root = next;
254
+ i += 1;
255
+ } else {
256
+ throw new UsageError(`unknown argument: ${arg}`);
257
+ }
258
+ }
259
+ return { json, root: path.resolve(root ?? REPO_ROOT) };
260
+ }
261
+
262
+ /**
263
+ * Run the gate. Returns the process exit code (does not call `exit`).
264
+ *
265
+ * @param {readonly string[]} argv - arguments (without node/script prefix).
266
+ * @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
267
+ * injectable streams (defaults to process streams).
268
+ * @returns {number} the exit code (0 clean, 1 markers found, 2 usage error).
269
+ */
270
+ export function main(argv, io = {}) {
271
+ const out = io.stdout ?? process.stdout;
272
+ const err = io.stderr ?? process.stderr;
273
+ let opts;
274
+ let files;
275
+ try {
276
+ opts = parseArgs(argv);
277
+ if (!fs.existsSync(opts.root) || !fs.statSync(opts.root).isDirectory()) {
278
+ throw new UsageError(`--root is not a directory: ${opts.root}`);
279
+ }
280
+ files = listTrackedFiles(opts.root);
281
+ } catch (error) {
282
+ err.write(`error: ${error.message}\n`);
283
+ return 2;
284
+ }
285
+ let results;
286
+ try {
287
+ results = files
288
+ .map(file => scanFile(opts.root, file))
289
+ .filter(result => result !== null)
290
+ .sort((a, b) => a.file.localeCompare(b.file));
291
+ } catch (error) {
292
+ err.write(`error: failed to scan tracked files: ${error.message}\n`);
293
+ return 2;
294
+ }
295
+ const report = buildReport(results, {
296
+ root: opts.root,
297
+ scanned: files.length,
298
+ });
299
+ out.write(
300
+ (opts.json ? JSON.stringify(report, null, 2) : humanReport(report)) + "\n"
301
+ );
302
+ return results.length === 0 ? 0 : 1;
303
+ }
304
+
305
+ if (
306
+ process.argv[1] &&
307
+ import.meta.url === pathToFileURL(process.argv[1]).href
308
+ ) {
309
+ // exitCode (not process.exit): when stdout is a pipe, writes are async and
310
+ // process.exit() truncates the report mid-flush.
311
+ process.exitCode = main(process.argv.slice(2));
312
+ }
@@ -93,6 +93,25 @@ function lisaOwnedSources() {
93
93
  return byDestination;
94
94
  }
95
95
 
96
+ /**
97
+ * The bytes currently on disk for a tracked source.
98
+ *
99
+ * Read from the working tree rather than `HEAD:` so an author who edits a guard
100
+ * and regenerates gets *their* new hash recorded. Reading HEAD would record the
101
+ * pre-edit bytes and force a commit-then-regenerate-then-amend dance, and the
102
+ * `--check` gate would be asserting against a version nobody is shipping. In CI
103
+ * the working tree is the checked-out commit, so the two agree there.
104
+ * @param {string} source - Repo-relative source path
105
+ * @returns {Buffer|undefined} File contents, or undefined when unreadable
106
+ */
107
+ function workingCopy(source) {
108
+ try {
109
+ return readFileSync(path.join(repoRoot, source));
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
96
115
  /**
97
116
  * Hex sha256 of a blob's bytes.
98
117
  * @param {Buffer} bytes - Blob contents
@@ -112,8 +131,8 @@ function digest(bytes) {
112
131
  */
113
132
  function historicalHashes(source) {
114
133
  const hashes = [];
115
- const current = git(["show", `HEAD:${source}`]);
116
- if (current !== "") hashes.push(digest(Buffer.from(current, "binary")));
134
+ const current = workingCopy(source);
135
+ if (current !== undefined) hashes.push(digest(current));
117
136
 
118
137
  // `--name-only` is what makes rename-following actually work. Asking for the
119
138
  // commit list alone and then reading `<rev>:<source>` fails for every revision
@@ -205,22 +224,56 @@ for (const [destination, sources] of lisaOwnedSources()) {
205
224
  ledger.set(destination, [...known].sort());
206
225
  }
207
226
 
208
- const rendered = render(ledger);
209
227
  if (process.argv.includes("--check")) {
210
- let actual = "";
211
- try {
212
- actual = readFileSync(outputPath, "utf8");
213
- } catch {
214
- actual = "";
228
+ checkCurrentBytesAreRecorded();
229
+ } else {
230
+ writeFileSync(outputPath, render(ledger));
231
+ process.stdout.write(`Wrote ${ledger.size} ledger entries.\n`);
232
+ }
233
+
234
+ /**
235
+ * Assert the one property that actually protects refresh: every Lisa-owned file
236
+ * Lisa ships *right now* has its current hash recorded.
237
+ *
238
+ * Deliberately narrower than "the file equals a fresh regeneration". That
239
+ * stricter form looks safer and is not: the history walk depends on clone depth
240
+ * and on merge topology, so a byte-exact check fails whenever CI's view of
241
+ * history differs from the author's. It did exactly that here — `autoupdate`
242
+ * merged `main` into the PR branch, main carried other merged template changes,
243
+ * the walk found hashes the author's run never saw, and a correct ledger was
244
+ * reported out of date. With many PRs in flight it would mean every merge
245
+ * reddens every other open PR.
246
+ *
247
+ * Historical hashes appearing later are purely additive, and being additive they
248
+ * cannot cause harm: an extra known-good hash can only let refresh replace a
249
+ * copy that genuinely came from an older Lisa. What must never happen is the
250
+ * *current* bytes going unrecorded — a guard edited without regenerating would
251
+ * stop being recognised as Lisa's own, and refresh would silently stop
252
+ * delivering it. That is the failure this gate exists to catch, and it is
253
+ * deterministic everywhere.
254
+ */
255
+ function checkCurrentBytesAreRecorded() {
256
+ const recorded = existingLedger();
257
+ const missing = [];
258
+ for (const [destination, sources] of lisaOwnedSources()) {
259
+ const known = new Set(recorded.get(destination) ?? []);
260
+ for (const source of sources) {
261
+ const current = workingCopy(source);
262
+ if (current === undefined) continue;
263
+ if (!known.has(digest(current))) missing.push(source);
264
+ }
215
265
  }
216
- if (actual !== rendered) {
266
+ if (missing.length > 0) {
217
267
  process.stderr.write(
218
- "Lisa-owned hash ledger is out of date. Run `bun run build:lisa-owned-hash-ledger` and commit the result.\n"
268
+ `Lisa-owned hash ledger does not record the bytes currently shipped for:\n${missing
269
+ .map(source => ` ${source}`)
270
+ .join(
271
+ "\n"
272
+ )}\nRun \`bun run build:lisa-owned-hash-ledger\` and commit the result.\n`
219
273
  );
220
274
  process.exit(1);
221
275
  }
222
- process.stdout.write("Lisa-owned hash ledger is current.\n");
223
- } else {
224
- writeFileSync(outputPath, rendered);
225
- process.stdout.write(`Wrote ${ledger.size} ledger entries.\n`);
276
+ process.stdout.write(
277
+ `Lisa-owned hash ledger records every shipped artifact (${recorded.size} entries).\n`
278
+ );
226
279
  }
@@ -158,6 +158,14 @@ export function cacheMaxVersion(cacheRoot, name, marketplace) {
158
158
  * contract): a semver value must equal the cache max; `"unknown"` is valid only
159
159
  * when the cache has no semver anywhere.
160
160
  *
161
+ * A semver `upstreamVersion` with NO semver in the cache is deliberately not an
162
+ * error. It means THIS machine cannot see the plugin (fresh clone, CI runner,
163
+ * plugin uninstalled) — not that the artifact is wrong. `isVersionUnverifiable`
164
+ * reports that state separately so the caller can warn instead of blocking,
165
+ * mirroring how the drift detector treats `not-installed` (issue #2552). Every
166
+ * other gate — schema, routing, coverage, anti-patterns — still runs, so a
167
+ * cacheless machine validates strictly more than it used to.
168
+ *
161
169
  * @param {unknown} upstreamVersion - the artifact's `upstreamVersion`.
162
170
  * @param {string | null} cacheMax - resolved max semver, or null.
163
171
  * @returns {string[]} validation error messages (empty when valid).
@@ -174,9 +182,7 @@ function validateVersion(upstreamVersion, cacheMax) {
174
182
  ];
175
183
  }
176
184
  if (cacheMax === null) {
177
- return [
178
- `upstreamVersion ${upstreamVersion} but no semver in the cache to confirm`,
179
- ];
185
+ return [];
180
186
  }
181
187
  if (compareSemver(upstreamVersion, cacheMax) !== 0) {
182
188
  return [`upstreamVersion ${upstreamVersion} != cache max ${cacheMax}`];
@@ -184,6 +190,19 @@ function validateVersion(upstreamVersion, cacheMax) {
184
190
  return [];
185
191
  }
186
192
 
193
+ /**
194
+ * True iff the artifact pins a semver `upstreamVersion` that this machine has
195
+ * no cached copy to confirm against. A cache-visibility fact, not a contract
196
+ * violation — reported so the push gate can warn without blocking.
197
+ *
198
+ * @param {unknown} upstreamVersion - the artifact's `upstreamVersion`.
199
+ * @param {string | null} cacheMax - resolved max semver, or null.
200
+ * @returns {boolean} whether the version claim is unverifiable here.
201
+ */
202
+ export function isVersionUnverifiable(upstreamVersion, cacheMax) {
203
+ return cacheMax === null && isValidSemver(upstreamVersion);
204
+ }
205
+
187
206
  /**
188
207
  * Validate one component entry.
189
208
  *
@@ -430,14 +449,20 @@ function truncate(text) {
430
449
  * @param {string} routingDir - the routing directory.
431
450
  * @param {string} file - the artifact filename (`*.json`).
432
451
  * @param {string} cacheRoot - the installed-plugin cache root.
433
- * @returns {{ file: string, errors: string[] }} the per-file result.
452
+ * @returns {{ file: string, errors: string[], unverifiable: boolean }}
453
+ * the per-file result. `unverifiable` means the version claim could not be
454
+ * confirmed on this machine; it never blocks.
434
455
  */
435
456
  function validateFile(routingDir, file, cacheRoot) {
436
457
  let artifact;
437
458
  try {
438
459
  artifact = JSON.parse(fs.readFileSync(path.join(routingDir, file), "utf8"));
439
460
  } catch (error) {
440
- return { errors: [`invalid JSON: ${error.message}`], file };
461
+ return {
462
+ errors: [`invalid JSON: ${error.message}`],
463
+ file,
464
+ unverifiable: false,
465
+ };
441
466
  }
442
467
  const hasIds =
443
468
  artifact !== null &&
@@ -455,13 +480,21 @@ function validateFile(routingDir, file, cacheRoot) {
455
480
  filename: file,
456
481
  mdExists,
457
482
  });
458
- return { errors, file };
483
+ const upstreamVersion =
484
+ artifact !== null && typeof artifact === "object"
485
+ ? artifact.upstreamVersion
486
+ : undefined;
487
+ return {
488
+ errors,
489
+ file,
490
+ unverifiable: isVersionUnverifiable(upstreamVersion, cacheMax),
491
+ };
459
492
  }
460
493
 
461
494
  /**
462
495
  * Assemble the machine-readable report.
463
496
  *
464
- * @param {ReadonlyArray<{ file: string, errors: string[] }>} results - per-file results.
497
+ * @param {ReadonlyArray<{ file: string, errors: string[], unverifiable?: boolean }>} results - per-file results.
465
498
  * @param {{ cacheRoot: string, routingDir: string }} opts - resolved options.
466
499
  * @returns {Record<string, unknown>} the report object.
467
500
  */
@@ -475,6 +508,7 @@ export function buildReport(results, opts) {
475
508
  summary: {
476
509
  invalid,
477
510
  scanned: results.length,
511
+ unverifiable: results.filter(r => r.unverifiable === true).length,
478
512
  valid: results.length - invalid,
479
513
  },
480
514
  };
@@ -483,20 +517,27 @@ export function buildReport(results, opts) {
483
517
  /**
484
518
  * Render the human-readable report.
485
519
  *
486
- * @param {{ results: ReadonlyArray<{ file: string, errors: string[] }>, summary: { scanned: number, valid: number, invalid: number } }} report - report object.
520
+ * @param {{ results: ReadonlyArray<{ file: string, errors: string[], unverifiable?: boolean }>, summary: { scanned: number, valid: number, invalid: number, unverifiable: number } }} report - report object.
487
521
  * @returns {string} the rendered report.
488
522
  */
489
523
  function humanReport(report) {
490
- const lines = report.results.map(r =>
491
- r.errors.length === 0
492
- ? `✓ ${r.file}`
493
- : `✗ ${r.file}\n${r.errors.map(e => ` - ${e}`).join("\n")}`
494
- );
524
+ const lines = report.results.map(r => {
525
+ if (r.errors.length > 0) {
526
+ return `✗ ${r.file}\n${r.errors.map(e => ` - ${e}`).join("\n")}`;
527
+ }
528
+ return r.unverifiable === true
529
+ ? `⚠ ${r.file} (version unverifiable: plugin not in this machine's cache)`
530
+ : `✓ ${r.file}`;
531
+ });
495
532
  const s = report.summary;
533
+ const unverifiable =
534
+ s.unverifiable > 0
535
+ ? `, ${s.unverifiable} unverifiable on this machine`
536
+ : "";
496
537
  return [
497
538
  ...lines,
498
539
  "",
499
- `${s.valid}/${s.scanned} routing artifacts valid, ${s.invalid} invalid`,
540
+ `${s.valid}/${s.scanned} routing artifacts valid, ${s.invalid} invalid${unverifiable}`,
500
541
  ].join("\n");
501
542
  }
502
543