@codyswann/lisa 4.13.1 → 4.14.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.
Files changed (115) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts +17 -0
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +601 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/lisa-owned-provenance.d.ts +30 -0
  6. package/dist/core/lisa-owned-provenance.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-provenance.js +28 -1
  8. package/dist/core/lisa-owned-provenance.js.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +13 -10
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/package.json +1 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +48 -6
  16. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +38 -2
  17. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +47 -6
  18. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +38 -2
  19. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +38 -2
  20. package/plugins/lisa/.codex-plugin/skills/lisa-setup-atlassian/SKILL.md +53 -7
  21. package/plugins/lisa/.codex-plugin/skills/lisa-setup-notion/SKILL.md +53 -7
  22. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  23. package/plugins/lisa/rules/reference/config-resolution.md +43 -6
  24. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +48 -6
  25. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +38 -2
  26. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +47 -6
  27. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +38 -2
  28. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +38 -2
  29. package/plugins/lisa/skills/lisa-setup-atlassian/SKILL.md +53 -7
  30. package/plugins/lisa/skills/lisa-setup-notion/SKILL.md +53 -7
  31. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  32. package/plugins/lisa-agy/plugin.json +1 -1
  33. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +48 -6
  34. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +38 -2
  35. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +47 -6
  36. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +38 -2
  37. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +38 -2
  38. package/plugins/lisa-agy/skills/lisa-setup-atlassian/SKILL.md +53 -7
  39. package/plugins/lisa-agy/skills/lisa-setup-notion/SKILL.md +53 -7
  40. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  41. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/rules/reference/config-resolution.md +43 -6
  48. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +48 -6
  49. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +38 -2
  50. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +47 -6
  51. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +38 -2
  52. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +38 -2
  53. package/plugins/lisa-copilot/skills/lisa-setup-atlassian/SKILL.md +53 -7
  54. package/plugins/lisa-copilot/skills/lisa-setup-notion/SKILL.md +53 -7
  55. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  56. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +43 -6
  58. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +48 -6
  59. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +38 -2
  60. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +47 -6
  61. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +38 -2
  62. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +38 -2
  63. package/plugins/lisa-cursor/skills/lisa-setup-atlassian/SKILL.md +53 -7
  64. package/plugins/lisa-cursor/skills/lisa-setup-notion/SKILL.md +53 -7
  65. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  66. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-expo-agy/plugin.json +1 -1
  69. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  89. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-rails-agy/plugin.json +1 -1
  94. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  99. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  103. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  104. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  106. package/plugins/src/base/rules/reference/config-resolution.md +43 -6
  107. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +48 -6
  108. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +38 -2
  109. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +47 -6
  110. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +38 -2
  111. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +38 -2
  112. package/plugins/src/base/skills/lisa-setup-atlassian/SKILL.md +53 -7
  113. package/plugins/src/base/skills/lisa-setup-notion/SKILL.md +53 -7
  114. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +49 -7
  115. package/scripts/generate-lisa-owned-hash-ledger.mjs +455 -64
@@ -7,7 +7,7 @@
7
7
  * past Lisa release, so overwriting is correct) from one that is *ahead* (its
8
8
  * bytes match no Lisa release, so overwriting would delete downstream work).
9
9
  *
10
- * Two properties are load-bearing:
10
+ * Three properties are load-bearing:
11
11
  *
12
12
  * - **Keyed by destination, not source.** Several stacks ship a variant of the
13
13
  * same installed path; a host has exactly one of them and refresh must accept
@@ -18,19 +18,97 @@
18
18
  * hashes, never the loss of known-good ones. Losing a hash would reclassify a
19
19
  * legitimately stale file as host-modified and stop refreshing it — failing
20
20
  * safe, but permanently.
21
+ * - **Every digest says where it came from.** Two sources feed the ledger: the
22
+ * `git log --follow` walk in {@link historicalHashes}, and carry-forward of
23
+ * whatever is already checked in. Before CodySwannGT/lisa#3115 the two were
24
+ * indistinguishable once written, so settling "should this line be here?" for
25
+ * a single digest cost a per-digest excavation — 10 reachable revisions and
26
+ * 14 reflog entries checked, then a delete-and-regenerate to find out which
27
+ * source had produced it. `LISA_OWNED_HASH_HISTORY_DERIVED` answers that in
28
+ * one lookup, and this generator reports every digest it kept without being
29
+ * able to derive it.
30
+ *
31
+ * ## Why provenance is a second append-only set, not a label per digest
32
+ *
33
+ * Whether *this* clone can derive a digest depends on clone depth and merge
34
+ * topology — the same instability that stops the `--check` gate being a
35
+ * byte-exact regeneration check. So the recorded fact is deliberately weaker
36
+ * and stable: **some run of this generator derived this digest from history**.
37
+ * Once true it stays true, exactly like the ledger itself, so a shallower clone
38
+ * downgrades nothing and a deeper one only ever adds.
39
+ *
40
+ * It also has to survive the merge driver added in CodySwannGT/lisa#3084, which
41
+ * unions array-valued entries **line by line** at one indent. A per-digest label
42
+ * written into the entry line would make the same hash render differently on two
43
+ * branches: the union would keep both spellings, and the base check would see an
44
+ * element that vanished — a permanent conflict on the very file that driver
45
+ * exists to stop conflicting. A second block of the same shape merges pointwise
46
+ * exactly like the first one does.
47
+ * @module scripts/generate-lisa-owned-hash-ledger
21
48
  */
22
49
  import { createHash } from "node:crypto";
23
50
  import { readFileSync, writeFileSync } from "node:fs";
24
51
  import path from "node:path";
52
+ import process from "node:process";
25
53
 
26
54
  import { boundedExecFileSync, isChildTimeout } from "./lib/bounded-spawn.mjs";
55
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
56
+
57
+ /** Repository this generator runs against when no root is supplied. */
58
+ export const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, "..");
59
+
60
+ /** Repo-relative path of the generated ledger module. */
61
+ export const LEDGER_RELATIVE_PATH = "src/core/lisa-owned-hash-ledger.ts";
62
+
63
+ /** Name of the record holding every hash ever shipped at a destination. */
64
+ export const LEDGER_EXPORT = "LISA_OWNED_HASH_LEDGER";
65
+
66
+ /** Name of the record holding the digests the history walk attested. */
67
+ export const HISTORY_EXPORT = "LISA_OWNED_HASH_HISTORY_DERIVED";
68
+
69
+ /** The command that rebuilds this artifact, quoted in every diagnostic. */
70
+ const REGENERATE = "bun run build:lisa-owned-hash-ledger";
71
+
72
+ /**
73
+ * How many carried-forward digests the report names before it summarises.
74
+ *
75
+ * The count is the headline and is never truncated; the list is a courtesy. It
76
+ * runs on every commit that touches a template, and a wall of hashes is how a
77
+ * report teaches its reader to scroll past it — measured at 89 carried-forward
78
+ * digests on `main` when this was written. Anyone chasing one digest reads the
79
+ * artifact, where the answer is a single lookup; the remainder line says
80
+ * exactly how.
81
+ */
82
+ const REPORT_LIST_LIMIT = 25;
27
83
 
28
- const repoRoot = path.resolve(import.meta.dirname, "..");
29
- const outputPath = path.join(repoRoot, "src/core/lisa-owned-hash-ledger.ts");
30
84
  const COPY_OVERWRITE = "copy-overwrite";
31
85
  const LISA_NAMESPACE_PREFIX = "lisa-";
32
86
  const ENFORCEMENT_TREE = "scripts/";
33
87
 
88
+ /**
89
+ * Compare two strings the way both this generator and the merge driver sort.
90
+ *
91
+ * Spelled out rather than left to a bare `.sort()`: the default comparator
92
+ * stringifies and compares by UTF-16 code unit, which is the same answer here
93
+ * and a SonarCloud finding everywhere.
94
+ * @param {string} left - First value
95
+ * @param {string} right - Second value
96
+ * @returns {number} Negative, zero, or positive per the comparator contract
97
+ */
98
+ function byText(left, right) {
99
+ if (left === right) return 0;
100
+ return left < right ? -1 : 1;
101
+ }
102
+
103
+ /**
104
+ * A set of hashes as the sorted array the ledger renders.
105
+ * @param {Iterable<string>} hashes - Hashes in any order
106
+ * @returns {string[]} Sorted hashes
107
+ */
108
+ function sorted(hashes) {
109
+ return [...hashes].sort(byText);
110
+ }
111
+
34
112
  /**
35
113
  * Run a git command in the repository and return stdout as latin1 text.
36
114
  *
@@ -41,14 +119,15 @@ const ENFORCEMENT_TREE = "scripts/";
41
119
  * on the `ls-files` call would build a ledger that vouches for NOTHING and
42
120
  * report it as a complete one. A ledger's whole job is to vouch for bytes; one
43
121
  * assembled from silence is worse than none, because it looks authoritative.
122
+ * @param {string} root - Repository root
44
123
  * @param {readonly string[]} args - Arguments passed to git
45
124
  * @returns {string} Command stdout, or an empty string when git said nothing
46
125
  * @throws {Error} When the child was killed at its deadline
47
126
  */
48
- function git(args) {
127
+ function git(root, args) {
49
128
  try {
50
129
  return boundedExecFileSync("git", args, {
51
- cwd: repoRoot,
130
+ cwd: root,
52
131
  encoding: "buffer",
53
132
  maxBuffer: 64 * 1024 * 1024,
54
133
  stdio: ["ignore", "pipe", "ignore"],
@@ -79,7 +158,7 @@ function git(args) {
79
158
  * @param {string} destination - Repo-relative destination path
80
159
  * @returns {boolean} True when Lisa owns the file outright
81
160
  */
82
- function isLisaOwned(destination) {
161
+ export function isLisaOwned(destination) {
83
162
  return (
84
163
  destination.startsWith(ENFORCEMENT_TREE) ||
85
164
  destination
@@ -90,12 +169,13 @@ function isLisaOwned(destination) {
90
169
 
91
170
  /**
92
171
  * Every tracked copy-overwrite source that installs a Lisa-owned file.
172
+ * @param {string} root - Repository root
93
173
  * @returns {Map<string, string[]>} Destination path to the source paths producing it
94
174
  */
95
- function lisaOwnedSources() {
175
+ function lisaOwnedSources(root) {
96
176
  const byDestination = new Map();
97
177
  const marker = `/${COPY_OVERWRITE}/`;
98
- for (const tracked of git(["ls-files"]).split("\n")) {
178
+ for (const tracked of git(root, ["ls-files"]).split("\n")) {
99
179
  const at = tracked.indexOf(marker);
100
180
  if (at === -1) continue;
101
181
  const destination = tracked.slice(at + marker.length);
@@ -115,12 +195,13 @@ function lisaOwnedSources() {
115
195
  * pre-edit bytes and force a commit-then-regenerate-then-amend dance, and the
116
196
  * `--check` gate would be asserting against a version nobody is shipping. In CI
117
197
  * the working tree is the checked-out commit, so the two agree there.
198
+ * @param {string} root - Repository root
118
199
  * @param {string} source - Repo-relative source path
119
200
  * @returns {Buffer|undefined} File contents, or undefined when unreadable
120
201
  */
121
- function workingCopy(source) {
202
+ function workingCopy(root, source) {
122
203
  try {
123
- return readFileSync(path.join(repoRoot, source));
204
+ return readFileSync(path.join(root, source));
124
205
  } catch {
125
206
  return undefined;
126
207
  }
@@ -140,19 +221,24 @@ function digest(bytes) {
140
221
  *
141
222
  * `--follow` keeps a renamed guard's earlier hashes, which is exactly the case
142
223
  * where a host is most likely to be running an older copy.
224
+ *
225
+ * Everything this returns is *derived*: the working tree and this clone's own
226
+ * history are both things the current checkout can show a reader on demand.
227
+ * That is what makes it the right feed for the history-derived record.
228
+ * @param {string} root - Repository root
143
229
  * @param {string} source - Repo-relative source path
144
230
  * @returns {string[]} Hashes of every historical revision plus the checked-in copy
145
231
  */
146
- function historicalHashes(source) {
232
+ function historicalHashes(root, source) {
147
233
  const hashes = [];
148
- const current = workingCopy(source);
234
+ const current = workingCopy(root, source);
149
235
  if (current !== undefined) hashes.push(digest(current));
150
236
 
151
237
  // `--name-only` is what makes rename-following actually work. Asking for the
152
238
  // commit list alone and then reading `<rev>:<source>` fails for every revision
153
239
  // before a rename, because the file did not live at that path yet — the very
154
240
  // revisions a long-installed host is most likely to be running.
155
- const log = git([
241
+ const log = git(root, [
156
242
  "log",
157
243
  "--format=%H",
158
244
  "--follow",
@@ -167,50 +253,107 @@ function historicalHashes(source) {
167
253
  continue;
168
254
  }
169
255
  if (line === "" || revision === "") continue;
170
- const blob = git(["show", `${revision}:${line}`]);
256
+ const blob = git(root, ["show", `${revision}:${line}`]);
171
257
  if (blob !== "") hashes.push(digest(Buffer.from(blob, "binary")));
172
258
  }
173
259
  return hashes;
174
260
  }
175
261
 
176
262
  /**
177
- * Hashes already recorded in the checked-in ledger, so none is ever lost.
178
- * @returns {Map<string, string[]>} Destination path to previously recorded hashes
263
+ * The source text of one exported record, or undefined when it is not there.
264
+ *
265
+ * Sliced by export rather than matched across the whole file, because the two
266
+ * records share a shape: a file-wide regex would fold the second block's entries
267
+ * over the first block's under the same key, and the ledger would silently
268
+ * become whichever block happened to be rendered last.
269
+ * @param {string} text - Whole module source
270
+ * @param {string} name - Exported constant name
271
+ * @returns {string|undefined} The declaration's source, when present
179
272
  */
180
- function existingLedger() {
181
- const previous = new Map();
182
- let source = "";
183
- try {
184
- source = readFileSync(outputPath, "utf8");
185
- } catch {
186
- return previous;
187
- }
273
+ function sectionSource(text, name) {
274
+ const start = text.indexOf(`export const ${name}`);
275
+ if (start === -1) return undefined;
276
+ const end = text.indexOf("\n});", start);
277
+ if (end === -1) return undefined;
278
+ return text.slice(start, end);
279
+ }
280
+
281
+ /**
282
+ * Parse one `destination -> hashes` record out of the module source.
283
+ * @param {string} text - Whole module source
284
+ * @param {string} name - Exported constant name
285
+ * @returns {Map<string, string[]>|undefined} Recorded hashes, when the block exists
286
+ */
287
+ export function parseSection(text, name) {
288
+ const source = sectionSource(text, name);
289
+ if (source === undefined) return undefined;
290
+ const parsed = new Map();
188
291
  const entry = /"([^"]+)": Object\.freeze\(\[([^\]]*)\]\)/g;
189
292
  for (const match of source.matchAll(entry)) {
190
- previous.set(
293
+ parsed.set(
191
294
  match[1],
192
295
  [...match[2].matchAll(/"([0-9a-f]{64})"/g)].map(hash => hash[1])
193
296
  );
194
297
  }
195
- return previous;
298
+ return parsed;
196
299
  }
197
300
 
198
301
  /**
199
- * Render the ledger module.
200
- * @param {Map<string, string[]>} ledger - Destination path to known-good hashes
201
- * @returns {string} TypeScript module source
302
+ * Read the checked-in ledger and split it into its two records.
303
+ *
304
+ * `present` is deliberately not folded into "the maps came back empty". Those
305
+ * are the same value to a caller that only looks at the maps, and the difference
306
+ * decides whether a check is entitled to report all-clear at all.
307
+ * @param {string} root - Repository root
308
+ * @returns {{present: boolean, ledger: (Map<string, string[]>|undefined), historyDerived: (Map<string, string[]>|undefined)}} Parsed records
202
309
  */
203
- function render(ledger) {
204
- const entries = [...ledger.entries()]
205
- .sort(([left], [right]) => (left < right ? -1 : 1))
206
- // Indentation matches what Prettier produces. `lint-staged` reformats every
207
- // staged file, so a generator whose output Prettier would reindent leaves
208
- // `--check` failing immediately after the commit that regenerated it.
310
+ export function readLedgerSections(root) {
311
+ let text = "";
312
+ try {
313
+ text = readFileSync(path.join(root, LEDGER_RELATIVE_PATH), "utf8");
314
+ } catch {
315
+ return { present: false, ledger: undefined, historyDerived: undefined };
316
+ }
317
+ return {
318
+ present: true,
319
+ ledger: parseSection(text, LEDGER_EXPORT),
320
+ historyDerived: parseSection(text, HISTORY_EXPORT),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Render one record's `destination: Object.freeze([...])` entries.
326
+ *
327
+ * Indentation matches what Prettier produces. `lint-staged` reformats every
328
+ * staged file, so a generator whose output Prettier would reindent leaves
329
+ * `--check` failing immediately after the commit that regenerated it.
330
+ *
331
+ * A destination with no hashes is dropped rather than rendered as an empty
332
+ * array: an empty entry parses back as a destination the ledger vouches for
333
+ * nothing at, which reads exactly like a destination whose hashes were lost.
334
+ * @param {Map<string, string[]>} entries - Destination path to hashes
335
+ * @returns {string} Rendered entry lines
336
+ */
337
+ function renderEntries(entries) {
338
+ return [...entries.entries()]
339
+ .filter(([, hashes]) => hashes.length > 0)
340
+ .sort(([left], [right]) => byText(left, right))
209
341
  .map(([destination, hashes]) => {
210
342
  const rendered = hashes.map(hash => ` "${hash}",`).join("\n");
211
343
  return ` "${destination}": Object.freeze([\n${rendered}\n ]),`;
212
344
  })
213
345
  .join("\n");
346
+ }
347
+
348
+ /**
349
+ * Render the ledger module.
350
+ * @param {Map<string, string[]>} ledger - Destination path to known-good hashes
351
+ * @param {Map<string, string[]>} historyDerived - Destination path to history-attested hashes
352
+ * @returns {string} TypeScript module source
353
+ */
354
+ export function render(ledger, historyDerived) {
355
+ const entries = renderEntries(ledger);
356
+ const attested = renderEntries(historyDerived);
214
357
  // The ledger grows by one line per published hash, so it crosses the
215
358
  // 300-line max-lines budget once enough artifacts accumulate - which it
216
359
  // now has, at 42 destinations. A length limit is a readability rule for
@@ -229,29 +372,235 @@ function render(ledger) {
229
372
  * refuses rather than risk deleting a stronger guard. See
230
373
  * \`src/core/lisa-owned-provenance.ts\`.
231
374
  */
232
- export const LISA_OWNED_HASH_LEDGER: Readonly<
375
+ export const ${LEDGER_EXPORT}: Readonly<
233
376
  Record<string, readonly string[]>
234
377
  > = Object.freeze({
235
378
  ${entries}
236
379
  });
380
+
381
+ /**
382
+ * The subset of the ledger some run of the generator derived from git history.
383
+ *
384
+ * Provenance, in one lookup (CodySwannGT/lisa#3115). A digest recorded here was
385
+ * produced by the \`git log --follow\` walk, or by the bytes in the working tree,
386
+ * at the time it was recorded. A digest in the ledger and NOT here was **carried
387
+ * forward** from an earlier checked-in ledger, and no run of the generator has
388
+ * been able to derive it since.
389
+ *
390
+ * Carried-forward digests are kept, never pruned. The history walk depends on
391
+ * clone depth and merge topology, so "this clone cannot see it" is not evidence
392
+ * that Lisa never shipped it, and dropping one would permanently stop refresh
393
+ * recognising a genuinely older host copy. They are *reported* instead — by
394
+ * \`${REGENERATE}\` and by its \`--check\` mode — so the question is answerable
395
+ * without archaeology.
396
+ */
397
+ export const ${HISTORY_EXPORT}: Readonly<
398
+ Record<string, readonly string[]>
399
+ > = Object.freeze({
400
+ ${attested}
401
+ });
237
402
  /* eslint-enable max-lines -- end generated append-only hash ledger */
238
403
  `;
239
404
  }
240
405
 
241
- const ledger = new Map(existingLedger());
242
- for (const [destination, sources] of lisaOwnedSources()) {
243
- const known = new Set(ledger.get(destination) ?? []);
244
- for (const source of sources) {
245
- for (const hash of historicalHashes(source)) known.add(hash);
406
+ /**
407
+ * Split a ledger into digests history attests and digests only carried forward.
408
+ *
409
+ * `orphans` are digests recorded as history-derived that the ledger itself does
410
+ * not record. Generation cannot produce one — the ledger is a superset of the
411
+ * history-derived record by construction — so an orphan means the file was
412
+ * hand-edited, and a provenance record vouching for digests the ledger does not
413
+ * hold is worse than no provenance at all.
414
+ * @param {Map<string, string[]>} ledger - Every recorded digest per destination
415
+ * @param {Map<string, string[]>} historyDerived - History-attested digests per destination
416
+ * @returns {{digests: number, carriedForward: {destination: string, hash: string}[], orphans: {destination: string, hash: string}[]}} Classification
417
+ */
418
+ export function classifyProvenance(ledger, historyDerived) {
419
+ const carriedForward = [];
420
+ const orphans = [];
421
+ let digests = 0;
422
+ for (const [destination, hashes] of ledger) {
423
+ const derived = new Set(historyDerived.get(destination) ?? []);
424
+ digests += hashes.length;
425
+ for (const hash of hashes) {
426
+ if (!derived.has(hash)) carriedForward.push({ destination, hash });
427
+ }
428
+ }
429
+ for (const [destination, hashes] of historyDerived) {
430
+ const recorded = new Set(ledger.get(destination) ?? []);
431
+ for (const hash of hashes) {
432
+ if (!recorded.has(hash)) orphans.push({ destination, hash });
433
+ }
434
+ }
435
+ return { digests, carriedForward, orphans };
436
+ }
437
+
438
+ /**
439
+ * One ` <destination> <hash>` line per digest.
440
+ * @param {{destination: string, hash: string}[]} digests - Digests to list
441
+ * @returns {string} Indented lines
442
+ */
443
+ function formatDigests(digests) {
444
+ return digests
445
+ .map(({ destination, hash }) => ` ${destination} ${hash}`)
446
+ .join("\n");
447
+ }
448
+
449
+ /**
450
+ * The provenance report, or the reason this run may not report anything.
451
+ *
452
+ * An inspection that saw nothing and a ledger with nothing wrong produce the
453
+ * same silence, so every way of seeing nothing is a failure here: an unreadable
454
+ * ledger, a missing provenance record, no sources to inspect, no digests
455
+ * recorded. Reporting all-clear from any of those is the failure mode this
456
+ * whole gate exists to remove, one level up.
457
+ * @param {{ledgerPresent: boolean, ledger: (Map<string, string[]>|undefined), historyDerived: (Map<string, string[]>|undefined), sourceCount: number}} inspection - What the run managed to read
458
+ * @returns {{ok: true, digests: number, carriedForward: {destination: string, hash: string}[]}|{ok: false, reason: string}} Report or refusal
459
+ */
460
+ export function provenanceVerdict(inspection) {
461
+ const { ledgerPresent, ledger, historyDerived, sourceCount } = inspection;
462
+ if (!ledgerPresent || ledger === undefined) {
463
+ return {
464
+ ok: false,
465
+ reason: `could not read ${LEDGER_RELATIVE_PATH}; refusing to report on a ledger it never saw`,
466
+ };
467
+ }
468
+ if (historyDerived === undefined) {
469
+ return {
470
+ ok: false,
471
+ reason: `${LEDGER_RELATIVE_PATH} records no digest provenance (no ${HISTORY_EXPORT})`,
472
+ };
473
+ }
474
+ if (sourceCount === 0) {
475
+ return {
476
+ ok: false,
477
+ reason:
478
+ "found no Lisa-owned copy-overwrite sources; a run that inspected nothing cannot report all-clear",
479
+ };
480
+ }
481
+ const { digests, carriedForward, orphans } = classifyProvenance(
482
+ ledger,
483
+ historyDerived
484
+ );
485
+ if (digests === 0) {
486
+ return {
487
+ ok: false,
488
+ reason: `${LEDGER_RELATIVE_PATH} records zero digests; a run that inspected nothing cannot report all-clear`,
489
+ };
490
+ }
491
+ if (orphans.length > 0) {
492
+ return {
493
+ ok: false,
494
+ reason: `${HISTORY_EXPORT} vouches for digests ${LEDGER_EXPORT} does not record:\n${formatDigests(orphans)}`,
495
+ };
496
+ }
497
+ return { ok: true, digests, carriedForward };
498
+ }
499
+
500
+ /**
501
+ * The provenance summary both modes print.
502
+ *
503
+ * The carried-forward list is a REPORT, not a finding: it names what the run
504
+ * kept without being able to derive it, which is the cheap answer to the
505
+ * question that previously cost a per-digest excavation. It never removes
506
+ * anything and never fails a run.
507
+ * @param {{digests: number, carriedForward: {destination: string, hash: string}[]}} report - A successful verdict
508
+ * @returns {string} Summary text, newline-terminated
509
+ */
510
+ export function formatProvenanceReport(report) {
511
+ const carried = report.carriedForward.length;
512
+ const summary = `Digest provenance: ${report.digests - carried} of ${report.digests} derived from this repository's history, ${carried} carried forward only.\n`;
513
+ if (carried === 0) return summary;
514
+ const listed = report.carriedForward.slice(0, REPORT_LIST_LIMIT);
515
+ const remainder =
516
+ carried > listed.length
517
+ ? ` ... and ${carried - listed.length} more: every digest in ${LEDGER_EXPORT} that ${HISTORY_EXPORT} does not list for the same destination.\n`
518
+ : "";
519
+ return `${summary}Carried forward and KEPT — no run has derived these from history, which is expected under a shallower clone and is not on its own evidence of a stray entry:\n${formatDigests(
520
+ listed
521
+ )}\n${remainder}`;
522
+ }
523
+
524
+ /**
525
+ * Union the history walk and the checked-in ledger into the artifact to write.
526
+ *
527
+ * Both records are accumulators. The history-derived record is unioned forward
528
+ * exactly like the ledger, so a shallow clone that derives less than a deep one
529
+ * downgrades no digest's provenance — it simply adds nothing.
530
+ * @param {string} root - Repository root
531
+ * @returns {{ledger: Map<string, string[]>, historyDerived: Map<string, string[]>, sourceCount: number, ledgerPresent: boolean}} The ledger to render
532
+ */
533
+ export function buildLedger(root) {
534
+ const existing = readLedgerSections(root);
535
+ const sources = lisaOwnedSources(root);
536
+ const historyDerived = new Map(existing.historyDerived ?? []);
537
+ for (const [destination, paths] of sources) {
538
+ const known = new Set(historyDerived.get(destination) ?? []);
539
+ for (const source of paths) {
540
+ for (const hash of historicalHashes(root, source)) known.add(hash);
541
+ }
542
+ historyDerived.set(destination, sorted(known));
543
+ }
544
+ const ledger = new Map(existing.ledger ?? []);
545
+ for (const [destination, hashes] of historyDerived) {
546
+ const known = new Set(ledger.get(destination) ?? []);
547
+ for (const hash of hashes) known.add(hash);
548
+ ledger.set(destination, sorted(known));
549
+ }
550
+ return {
551
+ ledger,
552
+ historyDerived,
553
+ sourceCount: sources.size,
554
+ ledgerPresent: existing.present,
555
+ };
556
+ }
557
+
558
+ /**
559
+ * Regenerate the ledger and report what it kept without being able to derive.
560
+ * @param {string} root - Repository root
561
+ * @param {(message: string) => void} out - Sink for the report
562
+ * @param {(message: string) => void} error - Sink for diagnostics
563
+ * @returns {number} Process exit code
564
+ */
565
+ export function runGenerate(root, out, error) {
566
+ const built = buildLedger(root);
567
+ const verdict = provenanceVerdict({
568
+ ledgerPresent: true,
569
+ ledger: built.ledger,
570
+ historyDerived: built.historyDerived,
571
+ sourceCount: built.sourceCount,
572
+ });
573
+ if (!verdict.ok) {
574
+ error(`Refusing to write the Lisa-owned hash ledger: ${verdict.reason}.\n`);
575
+ return 1;
246
576
  }
247
- ledger.set(destination, [...known].sort());
577
+ writeFileSync(
578
+ path.join(root, LEDGER_RELATIVE_PATH),
579
+ render(built.ledger, built.historyDerived)
580
+ );
581
+ out(`Wrote ${built.ledger.size} ledger entries.\n`);
582
+ out(formatProvenanceReport(verdict));
583
+ return 0;
248
584
  }
249
585
 
250
- if (process.argv.includes("--check")) {
251
- checkCurrentBytesAreRecorded();
252
- } else {
253
- writeFileSync(outputPath, render(ledger));
254
- process.stdout.write(`Wrote ${ledger.size} ledger entries.\n`);
586
+ /**
587
+ * Sources whose current bytes are absent from a record.
588
+ * @param {string} root - Repository root
589
+ * @param {Map<string, string[]>} sources - Destination to source paths
590
+ * @param {Map<string, string[]>} recorded - Destination to recorded hashes
591
+ * @returns {string[]} Source paths whose shipped bytes are unrecorded
592
+ */
593
+ function missingCurrentBytes(root, sources, recorded) {
594
+ const missing = [];
595
+ for (const [destination, paths] of sources) {
596
+ const known = new Set(recorded.get(destination) ?? []);
597
+ for (const source of paths) {
598
+ const current = workingCopy(root, source);
599
+ if (current === undefined) continue;
600
+ if (!known.has(digest(current))) missing.push(source);
601
+ }
602
+ }
603
+ return missing;
255
604
  }
256
605
 
257
606
  /**
@@ -274,29 +623,71 @@ if (process.argv.includes("--check")) {
274
623
  * stop being recognised as Lisa's own, and refresh would silently stop
275
624
  * delivering it. That is the failure this gate exists to catch, and it is
276
625
  * deterministic everywhere.
626
+ *
627
+ * Asserting the same property against the provenance record costs nothing extra
628
+ * and is equally deterministic — shipped bytes are read from the working tree,
629
+ * never walked — and it is what stops provenance going quietly stale while the
630
+ * ledger stays current.
631
+ * @param {string} root - Repository root
632
+ * @param {(message: string) => void} out - Sink for the report
633
+ * @param {(message: string) => void} error - Sink for diagnostics
634
+ * @returns {number} Process exit code
277
635
  */
278
- function checkCurrentBytesAreRecorded() {
279
- const recorded = existingLedger();
280
- const missing = [];
281
- for (const [destination, sources] of lisaOwnedSources()) {
282
- const known = new Set(recorded.get(destination) ?? []);
283
- for (const source of sources) {
284
- const current = workingCopy(source);
285
- if (current === undefined) continue;
286
- if (!known.has(digest(current))) missing.push(source);
287
- }
636
+ export function runCheck(root, out, error) {
637
+ const existing = readLedgerSections(root);
638
+ const sources = lisaOwnedSources(root);
639
+ const verdict = provenanceVerdict({
640
+ ledgerPresent: existing.present,
641
+ ledger: existing.ledger,
642
+ historyDerived: existing.historyDerived,
643
+ sourceCount: sources.size,
644
+ });
645
+ if (!verdict.ok) {
646
+ error(
647
+ `Lisa-owned hash ledger check could not run: ${verdict.reason}.\nRun \`${REGENERATE}\` and commit the result.\n`
648
+ );
649
+ return 1;
288
650
  }
651
+ const missing = [
652
+ ...new Set([
653
+ ...missingCurrentBytes(root, sources, existing.ledger),
654
+ ...missingCurrentBytes(root, sources, existing.historyDerived),
655
+ ]),
656
+ ];
289
657
  if (missing.length > 0) {
290
- process.stderr.write(
658
+ error(
291
659
  `Lisa-owned hash ledger does not record the bytes currently shipped for:\n${missing
292
660
  .map(source => ` ${source}`)
293
- .join(
294
- "\n"
295
- )}\nRun \`bun run build:lisa-owned-hash-ledger\` and commit the result.\n`
661
+ .join("\n")}\nRun \`${REGENERATE}\` and commit the result.\n`
296
662
  );
297
- process.exit(1);
663
+ return 1;
298
664
  }
299
- process.stdout.write(
300
- `Lisa-owned hash ledger records every shipped artifact (${recorded.size} entries).\n`
665
+ out(
666
+ `Lisa-owned hash ledger records every shipped artifact (${existing.ledger.size} entries).\n`
301
667
  );
668
+ out(formatProvenanceReport(verdict));
669
+ return 0;
670
+ }
671
+
672
+ /**
673
+ * CLI entry point.
674
+ * @param {readonly string[]} argv - Arguments after the script name
675
+ * @param {string} [root] - Repository root
676
+ * @param {(message: string) => void} [out] - Sink for reports
677
+ * @param {(message: string) => void} [error] - Sink for diagnostics
678
+ * @returns {number} Process exit code
679
+ */
680
+ export function runLedgerCli(
681
+ argv,
682
+ root = DEFAULT_REPO_ROOT,
683
+ out = message => process.stdout.write(message),
684
+ error = message => process.stderr.write(message)
685
+ ) {
686
+ return argv.includes("--check")
687
+ ? runCheck(root, out, error)
688
+ : runGenerate(root, out, error);
689
+ }
690
+
691
+ if (invokedAsScript(import.meta.url)) {
692
+ process.exit(runLedgerCli(process.argv.slice(2)));
302
693
  }