@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.
- package/dist/core/lisa-owned-hash-ledger.d.ts +17 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +601 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-owned-provenance.d.ts +30 -0
- package/dist/core/lisa-owned-provenance.d.ts.map +1 -1
- package/dist/core/lisa-owned-provenance.js +28 -1
- 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 +13 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- package/plugins/lisa/rules/reference/config-resolution.md +43 -6
- package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/lisa/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/lisa/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/lisa/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/lisa/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/lisa-agy/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/lisa-agy/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- 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-copilot/rules/reference/config-resolution.md +43 -6
- package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/lisa-copilot/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/lisa-copilot/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +43 -6
- package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/lisa-cursor/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/lisa-cursor/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- 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/plugins/src/base/rules/reference/config-resolution.md +43 -6
- package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +48 -6
- package/plugins/src/base/skills/lisa-jam-access/SKILL.md +38 -2
- package/plugins/src/base/skills/lisa-notion-access/SKILL.md +47 -6
- package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +38 -2
- package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +38 -2
- package/plugins/src/base/skills/lisa-setup-atlassian/SKILL.md +53 -7
- package/plugins/src/base/skills/lisa-setup-notion/SKILL.md +53 -7
- package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +49 -7
- 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
|
-
*
|
|
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:
|
|
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(
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
298
|
+
return parsed;
|
|
196
299
|
}
|
|
197
300
|
|
|
198
301
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
+
return 1;
|
|
298
664
|
}
|
|
299
|
-
|
|
300
|
-
`Lisa-owned hash ledger records every shipped artifact (${
|
|
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
|
}
|