@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.
- package/dist/core/lisa-owned-provenance.d.ts +1 -5
- package/dist/core/lisa-owned-provenance.d.ts.map +1 -1
- package/dist/core/lisa-owned-provenance.js +27 -10
- package/dist/core/lisa-owned-provenance.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +7 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +2 -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-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-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/check-conflict-markers.mjs +312 -0
- package/scripts/generate-lisa-owned-hash-ledger.mjs +67 -14
- 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 =
|
|
116
|
-
if (current !==
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
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 (
|
|
266
|
+
if (missing.length > 0) {
|
|
217
267
|
process.stderr.write(
|
|
218
|
-
|
|
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(
|
|
223
|
-
|
|
224
|
-
|
|
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[] }}
|
|
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 {
|
|
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
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|