website-xp-phone 2.2.11 → 2.3.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.
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { run as refreshSoundcloud } from "./refresh-soundcloud.mjs";
2
+ /**
3
+ * Validates and normalizes the authored JSON content under src/content.
4
+ *
5
+ * Deliberately does NOT regenerate live-data snapshots: soundcloud.json is
6
+ * rewritten by scripts/refresh-soundcloud.mjs (network-dependent), invoked
7
+ * by the `refresh:soundcloud` script. Keeping that out of here means every
8
+ * entry point that runs this script — including a deploy-side build — can
9
+ * only validate content, never re-generate it. Deploy builds must ship the
10
+ * committed snapshot exactly as `build:local` produced it.
11
+ */
3
12
  import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
4
13
  import { dirname, resolve } from "node:path";
5
14
  import { fileURLToPath } from "node:url";
@@ -13,52 +22,64 @@ const projectRoot = resolve(__dirname, "..");
13
22
  * that `verify:policy` (which may run before `checksums`) does not act on a
14
23
  * report produced by a previous build. The checksums step rewrites it.
15
24
  */
16
- const driftReportPath = resolve(projectRoot, "public", "documents", "drift-report.json");
25
+ const driftReportPath = resolve(
26
+ projectRoot,
27
+ "public",
28
+ "documents",
29
+ "drift-report.json",
30
+ );
17
31
 
18
32
  const contentRoot = resolve(projectRoot, "src/content");
19
33
 
20
34
  /** Every structural JSON file under src/content (recursively). */
21
35
  async function listContentJson(dir = contentRoot) {
22
- const entries = await readdir(dir, { withFileTypes: true });
23
- const nested = await Promise.all(
24
- entries.map((entry) =>
25
- entry.isDirectory()
26
- ? listContentJson(resolve(dir, entry.name))
27
- : entry.name.endsWith(".json")
28
- ? [resolve(dir, entry.name)]
29
- : [],
30
- ),
31
- );
32
- return nested.flat();
36
+ const entries = await readdir(dir, { withFileTypes: true });
37
+ const nested = await Promise.all(
38
+ entries.map((entry) =>
39
+ entry.isDirectory()
40
+ ? listContentJson(resolve(dir, entry.name))
41
+ : entry.name.endsWith(".json")
42
+ ? [resolve(dir, entry.name)]
43
+ : [],
44
+ ),
45
+ );
46
+ return nested.flat();
33
47
  }
34
48
 
35
49
  async function validateLocalContent() {
36
- for (const inputPath of await listContentJson()) {
37
- const content = await readFile(inputPath, "utf8");
38
- JSON.parse(content);
50
+ for (const inputPath of await listContentJson()) {
51
+ const content = await readFile(inputPath, "utf8");
52
+ JSON.parse(content);
39
53
 
40
- // Normalize trailing newline so build outputs remain stable.
41
- await writeFile(inputPath, `${content.trim()}\n`, "utf8");
42
- process.stdout.write(`Validated local content: ${inputPath}\n`);
43
- }
54
+ // Normalize trailing newline so build outputs remain stable.
55
+ await writeFile(inputPath, `${content.trim()}\n`, "utf8");
56
+ process.stdout.write(`Validated local content: ${inputPath}\n`);
57
+ }
44
58
  }
45
59
 
46
60
  export async function run() {
47
- await unlink(driftReportPath).catch((error) => {
48
- if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
49
- throw error;
50
- }
51
- });
52
- await validateLocalContent();
53
- await refreshSoundcloud();
61
+ await unlink(driftReportPath).catch((error) => {
62
+ if (!(
63
+ error &&
64
+ typeof error === "object" &&
65
+ "code" in error &&
66
+ error.code === "ENOENT"
67
+ )) {
68
+ throw error;
69
+ }
70
+ });
71
+ await validateLocalContent();
54
72
  }
55
73
 
56
74
  const isDirectExecution =
57
- process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
75
+ process.argv[1] &&
76
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
58
77
 
59
78
  if (isDirectExecution) {
60
- run().catch((error) => {
61
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
62
- process.exit(1);
63
- });
79
+ run().catch((error) => {
80
+ process.stderr.write(
81
+ `${error instanceof Error ? error.message : String(error)}\n`,
82
+ );
83
+ process.exit(1);
84
+ });
64
85
  }
@@ -43,7 +43,7 @@ const SOUND_CLOUD_OWNERS = ["akinevz", "akinevz2"];
43
43
 
44
44
  /** User-Agent sent for every outbound HTTP request. */
45
45
  const USER_AGENT =
46
- "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0";
46
+ "Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0";
47
47
 
48
48
  /** Fetch timeout (ms) for every HTTP request. */
49
49
  const FETCH_TIMEOUT_MS = 15_000;
@@ -63,17 +63,17 @@ const getProfileUrl = (owner) => `https://soundcloud.com/${owner}`;
63
63
  * (callers decide how to interpret it); throws only on network failure.
64
64
  */
65
65
  const fetchWithTimeout = async (url, options = {}) => {
66
- const controller = new AbortController();
67
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
68
- try {
69
- return await fetch(url, {
70
- ...options,
71
- headers: { "User-Agent": USER_AGENT, ...options.headers },
72
- signal: controller.signal,
73
- });
74
- } finally {
75
- clearTimeout(timer);
76
- }
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
68
+ try {
69
+ return await fetch(url, {
70
+ ...options,
71
+ headers: { "User-Agent": USER_AGENT, ...options.headers },
72
+ signal: controller.signal,
73
+ });
74
+ } finally {
75
+ clearTimeout(timer);
76
+ }
77
77
  };
78
78
 
79
79
  /**
@@ -82,21 +82,23 @@ const fetchWithTimeout = async (url, options = {}) => {
82
82
  * JSON. Throws with the HTTP status on definitive failure.
83
83
  */
84
84
  const fetchApiJson = async (url, attempt = 1) => {
85
- try {
86
- const response = await fetchWithTimeout(url, {
87
- headers: { Accept: "application/json" },
88
- });
89
- if (!response.ok) {
90
- throw new Error(`HTTP ${response.status} for ${new URL(url).pathname}`);
91
- }
92
- return await response.json();
93
- } catch (error) {
94
- if (attempt < API_RETRIES) {
95
- await new Promise((resolveSleep) => setTimeout(resolveSleep, 1_000 * attempt));
96
- return fetchApiJson(url, attempt + 1);
97
- }
98
- throw error;
85
+ try {
86
+ const response = await fetchWithTimeout(url, {
87
+ headers: { Accept: "application/json" },
88
+ });
89
+ if (!response.ok) {
90
+ throw new Error(`HTTP ${response.status} for ${new URL(url).pathname}`);
91
+ }
92
+ return await response.json();
93
+ } catch (error) {
94
+ if (attempt < API_RETRIES) {
95
+ await new Promise((resolveSleep) =>
96
+ setTimeout(resolveSleep, 1_000 * attempt),
97
+ );
98
+ return fetchApiJson(url, attempt + 1);
99
99
  }
100
+ throw error;
101
+ }
100
102
  };
101
103
 
102
104
  /**
@@ -105,46 +107,53 @@ const fetchApiJson = async (url, attempt = 1) => {
105
107
  * they must never be hard-coded. Throws if no bundle yields one.
106
108
  */
107
109
  const resolveClientId = async () => {
108
- const profileResponse = await fetchWithTimeout(getProfileUrl(SOUND_CLOUD_OWNERS[0]));
109
- if (!profileResponse.ok) {
110
- throw new Error(`Could not fetch profile HTML: HTTP ${profileResponse.status}`);
111
- }
112
- const html = await profileResponse.text();
110
+ const profileResponse = await fetchWithTimeout(
111
+ getProfileUrl(SOUND_CLOUD_OWNERS[0]),
112
+ );
113
+ if (!profileResponse.ok) {
114
+ throw new Error(
115
+ `Could not fetch profile HTML: HTTP ${profileResponse.status}`,
116
+ );
117
+ }
118
+ const html = await profileResponse.text();
113
119
 
114
- const assetUrls = [
115
- ...new Set(
116
- [...html.matchAll(/https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9._-]+\.js/g)].map(
117
- (match) => match[0],
118
- ),
120
+ const assetUrls = [
121
+ ...new Set(
122
+ [
123
+ ...html.matchAll(
124
+ /https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9._-]+\.js/g,
119
125
  ),
120
- ];
121
- if (assetUrls.length === 0) {
122
- throw new Error("No SoundCloud asset bundles found in profile HTML");
123
- }
126
+ ].map((match) => match[0]),
127
+ ),
128
+ ];
129
+ if (assetUrls.length === 0) {
130
+ throw new Error("No SoundCloud asset bundles found in profile HTML");
131
+ }
124
132
 
125
- for (const assetUrl of assetUrls) {
126
- const assetResponse = await fetchWithTimeout(assetUrl);
127
- if (!assetResponse.ok) continue;
128
- const js = await assetResponse.text();
129
- const match = js.match(/client_id:"([a-zA-Z0-9]{20,40})"/);
130
- if (match) return match[1];
131
- }
133
+ for (const assetUrl of assetUrls) {
134
+ const assetResponse = await fetchWithTimeout(assetUrl);
135
+ if (!assetResponse.ok) continue;
136
+ const js = await assetResponse.text();
137
+ const match = js.match(/client_id:"([a-zA-Z0-9]{20,40})"/);
138
+ if (match) return match[1];
139
+ }
132
140
 
133
- throw new Error("No client_id found in any SoundCloud asset bundle");
141
+ throw new Error("No client_id found in any SoundCloud asset bundle");
134
142
  };
135
143
 
136
144
  /**
137
- * Resolve an owner handle to its numeric SoundCloud user id.
145
+ * Resolve an owner handle to its public api-v2 user object (id plus
146
+ * profile metadata such as the public track_count).
138
147
  */
139
- const resolveUserId = async (owner, clientId) => {
140
- const resolveUrl = new URL("https://api-v2.soundcloud.com/resolve");
141
- resolveUrl.searchParams.set("url", getProfileUrl(owner));
142
- resolveUrl.searchParams.set("client_id", clientId);
143
- const user = await fetchApiJson(resolveUrl.toString());
144
- if (typeof user?.id !== "number") {
145
- throw new Error(`Could not resolve user id for ${owner}`);
146
- }
147
- return user.id;
148
+ const resolveUser = async (owner, clientId) => {
149
+ const resolveUrl = new URL("https://api-v2.soundcloud.com/resolve");
150
+ resolveUrl.searchParams.set("url", getProfileUrl(owner));
151
+ resolveUrl.searchParams.set("client_id", clientId);
152
+ const user = await fetchApiJson(resolveUrl.toString());
153
+ if (typeof user?.id !== "number") {
154
+ throw new Error(`Could not resolve user id for ${owner}`);
155
+ }
156
+ return user;
148
157
  };
149
158
 
150
159
  /**
@@ -153,26 +162,27 @@ const resolveUserId = async (owner, clientId) => {
153
162
  * re-appended on every page or page 2+ returns HTTP 401.
154
163
  */
155
164
  const fetchAllTracks = async (owner, userId, clientId) => {
156
- const tracks = [];
157
- let href = `https://api-v2.soundcloud.com/users/${userId}/tracks?client_id=${clientId}&limit=200&representation=full`;
165
+ const tracks = [];
166
+ let href = `https://api-v2.soundcloud.com/users/${userId}/tracks?client_id=${clientId}&limit=200&representation=full`;
158
167
 
159
- while (href) {
160
- const page = await fetchApiJson(href);
161
- const collection = Array.isArray(page?.collection) ? page.collection : [];
162
- for (const track of collection) {
163
- if (typeof track?.permalink_url !== "string") continue;
164
- tracks.push(track);
165
- }
166
- const nextHref = typeof page?.next_href === "string" ? page.next_href : null;
167
- href = nextHref
168
- ? `${nextHref}${nextHref.includes("?") ? "&" : "?"}client_id=${clientId}`
169
- : null;
168
+ while (href) {
169
+ const page = await fetchApiJson(href);
170
+ const collection = Array.isArray(page?.collection) ? page.collection : [];
171
+ for (const track of collection) {
172
+ if (typeof track?.permalink_url !== "string") continue;
173
+ tracks.push(track);
170
174
  }
175
+ const nextHref =
176
+ typeof page?.next_href === "string" ? page.next_href : null;
177
+ href = nextHref
178
+ ? `${nextHref}${nextHref.includes("?") ? "&" : "?"}client_id=${clientId}`
179
+ : null;
180
+ }
171
181
 
172
- if (tracks.length === 0) {
173
- throw new Error(`Discography for ${owner} came back empty`);
174
- }
175
- return tracks;
182
+ if (tracks.length === 0) {
183
+ throw new Error(`Discography for ${owner} came back empty`);
184
+ }
185
+ return tracks;
176
186
  };
177
187
 
178
188
  /**
@@ -180,87 +190,116 @@ const fetchAllTracks = async (owner, userId, clientId) => {
180
190
  * URL slug if the title is missing.
181
191
  */
182
192
  const toSnapshotTrack = (owner, track) => {
183
- const url = track.permalink_url;
184
- const path = `/${url.replace(/^https?:\/\/(www\.)?soundcloud\.com\//, "")}`;
185
- return {
186
- owner,
187
- path,
188
- title:
189
- typeof track.title === "string" && track.title.length > 0
190
- ? track.title
191
- : path.split("/").pop(),
192
- url,
193
- };
193
+ const url = track.permalink_url;
194
+ const path = `/${url.replace(/^https?:\/\/(www\.)?soundcloud\.com\//, "")}`;
195
+ return {
196
+ owner,
197
+ path,
198
+ title:
199
+ typeof track.title === "string" && track.title.length > 0
200
+ ? track.title
201
+ : path.split("/").pop(),
202
+ url,
203
+ };
194
204
  };
195
205
 
196
206
  /**
197
207
  * Fetch the complete discography for one owner. Returns the snapshot-shaped
198
208
  * track list in API order (newest first).
199
209
  */
210
+ /**
211
+ * Fetch the complete discography for one owner. Returns the snapshot-shaped
212
+ * track list in API order (newest first).
213
+ *
214
+ * Integrity: the fetched list must match the profile's PUBLIC track_count —
215
+ * the number any anonymous visitor (and this unauthenticated snapshot) can
216
+ * see. A mismatch means the list came back truncated or the API lagged a
217
+ * fresh upload; retry once, then fail loudly rather than write a silently
218
+ * incomplete discography. (Tracks the owner sees but the public does not —
219
+ * still processing or private — never inflate the public count, so they do
220
+ * not trip this check.)
221
+ */
200
222
  const fetchOwnerDiscography = async (owner, clientId) => {
201
- const userId = await resolveUserId(owner, clientId);
202
- const apiTracks = await fetchAllTracks(owner, userId, clientId);
203
- return apiTracks.map((track) => toSnapshotTrack(owner, track));
223
+ const user = await resolveUser(owner, clientId);
224
+ let apiTracks = await fetchAllTracks(owner, user.id, clientId);
225
+ const publicCount =
226
+ typeof user.track_count === "number" ? user.track_count : null;
227
+ if (publicCount !== null && apiTracks.length !== publicCount) {
228
+ apiTracks = await fetchAllTracks(owner, user.id, clientId);
229
+ if (apiTracks.length !== publicCount) {
230
+ throw new Error(
231
+ `Discography for ${owner} returned ${apiTracks.length} track(s) ` +
232
+ `but the public profile counts ${publicCount} — refusing to ` +
233
+ `write a truncated snapshot`,
234
+ );
235
+ }
236
+ }
237
+ return apiTracks.map((track) => toSnapshotTrack(owner, track));
204
238
  };
205
239
 
206
240
  export const run = async () => {
207
- const clientId = await resolveClientId();
208
- process.stdout.write(`Resolved SoundCloud client_id: ${clientId}\n`);
241
+ const clientId = await resolveClientId();
242
+ process.stdout.write(`Resolved SoundCloud client_id: ${clientId}\n`);
209
243
 
210
- const discographies = new Map();
211
- for (const owner of SOUND_CLOUD_OWNERS) {
212
- discographies.set(owner, await fetchOwnerDiscography(owner, clientId));
213
- }
244
+ const discographies = new Map();
245
+ for (const owner of SOUND_CLOUD_OWNERS) {
246
+ discographies.set(owner, await fetchOwnerDiscography(owner, clientId));
247
+ }
214
248
 
215
- // Flat track list in per-owner API order (newest first). The music page
216
- // filters this array by `owner`, so per-owner relative order is what the
217
- // UI displays.
218
- const tracks = SOUND_CLOUD_OWNERS.flatMap((owner) => discographies.get(owner) ?? []);
219
- const trackCount = tracks.length;
249
+ // Flat track list in per-owner API order (newest first). The music page
250
+ // filters this array by `owner`, so per-owner relative order is what the
251
+ // UI displays.
252
+ const tracks = SOUND_CLOUD_OWNERS.flatMap(
253
+ (owner) => discographies.get(owner) ?? [],
254
+ );
255
+ const trackCount = tracks.length;
220
256
 
221
- // Per-owner sections. The component groups the discography by `owner`,
222
- // so emitting a `profiles` array ensures every owner gets its own
223
- // section — not just the first one.
224
- const profiles = SOUND_CLOUD_OWNERS.map((owner) => {
225
- const ownerTracks = discographies.get(owner) ?? [];
226
- return {
227
- owner,
228
- source: getProfileUrl(owner),
229
- profileImageUrl: null,
230
- trackCount: ownerTracks.length,
231
- tracks: ownerTracks,
232
- };
233
- });
234
-
235
- const result = {
236
- source: getProfileUrl(SOUND_CLOUD_OWNERS[0]),
237
- generatedAt: new Date().toISOString(),
238
- asOfUploadingTrackCount: trackCount,
239
- trackCount,
240
- tracks,
241
- profiles,
257
+ // Per-owner sections. The component groups the discography by `owner`,
258
+ // so emitting a `profiles` array ensures every owner gets its own
259
+ // section — not just the first one.
260
+ const profiles = SOUND_CLOUD_OWNERS.map((owner) => {
261
+ const ownerTracks = discographies.get(owner) ?? [];
262
+ return {
263
+ owner,
264
+ source: getProfileUrl(owner),
265
+ profileImageUrl: null,
266
+ trackCount: ownerTracks.length,
267
+ tracks: ownerTracks,
242
268
  };
269
+ });
243
270
 
244
- await writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`, "utf8");
271
+ const result = {
272
+ source: getProfileUrl(SOUND_CLOUD_OWNERS[0]),
273
+ generatedAt: new Date().toISOString(),
274
+ asOfUploadingTrackCount: trackCount,
275
+ trackCount,
276
+ tracks,
277
+ profiles,
278
+ };
245
279
 
246
- process.stdout.write(
247
- `Refreshed ${result.tracks.length} SoundCloud track(s) into ${outputFile}\n` +
248
- profiles
249
- .map((profile) => ` @${profile.owner}: ${profile.trackCount} track(s)\n`)
250
- .join(""),
251
- );
280
+ await writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`, "utf8");
252
281
 
253
- return result;
282
+ process.stdout.write(
283
+ `Refreshed ${result.tracks.length} SoundCloud track(s) into ${outputFile}\n` +
284
+ profiles
285
+ .map(
286
+ (profile) => ` @${profile.owner}: ${profile.trackCount} track(s)\n`,
287
+ )
288
+ .join(""),
289
+ );
290
+
291
+ return result;
254
292
  };
255
293
 
256
294
  const isDirectExecution =
257
- process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
295
+ process.argv[1] &&
296
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
258
297
 
259
298
  if (isDirectExecution) {
260
- run().catch((error) => {
261
- process.stderr.write(
262
- `${error instanceof Error ? error.message : String(error)}\n`,
263
- );
264
- process.exit(1);
265
- });
299
+ run().catch((error) => {
300
+ process.stderr.write(
301
+ `${error instanceof Error ? error.message : String(error)}\n`,
302
+ );
303
+ process.exit(1);
304
+ });
266
305
  }
@@ -39,6 +39,7 @@ const distRoot = resolve(projectRoot, "dist");
39
39
  const publicRoot = resolve(projectRoot, "public");
40
40
  const distChecksumsPath = resolve(distRoot, "checksums.txt");
41
41
  const publicChecksumsPath = resolve(publicRoot, "checksums.txt");
42
+ const generatedManifestPath = resolve(distRoot, ".generated.json");
42
43
 
43
44
  /**
44
45
  * Sidecar written next to `checksums.txt` capturing any STABLE-invariant
@@ -191,9 +192,48 @@ async function readRemoteChecksums() {
191
192
  }
192
193
  }
193
194
 
195
+ /**
196
+ * Read the generated-artifact manifest that routeSkeletonsPlugin emits
197
+ * (`dist/.generated.json`): every webapp-generated path it lists is
198
+ * auto-marked mutable, so new routes and project pages never need a
199
+ * hand-maintained mutable entry.
200
+ *
201
+ * Fail-loud policy: an existing but corrupt manifest throws (a build
202
+ * tooling regression must not silently downgrade the gate); a missing
203
+ * manifest only notes it — pages then fall back to the denylist in
204
+ * mutable-assets.mjs and the live-reconcile in the drift gate.
205
+ */
206
+ async function readGeneratedPaths() {
207
+ let raw;
208
+ try {
209
+ raw = await readFile(generatedManifestPath, "utf8");
210
+ } catch {
211
+ process.stdout.write(
212
+ `Note: no generated-artifact manifest at dist/.generated.json — ` +
213
+ `generated pages are not auto-marked mutable (run vite build first)\n`,
214
+ );
215
+ return new Set();
216
+ }
217
+
218
+ const parsed = JSON.parse(raw);
219
+ const list =
220
+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
221
+ ? parsed.generated
222
+ : null;
223
+ if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) {
224
+ throw new Error(
225
+ `corrupt generated-artifact manifest: ` +
226
+ `${relative(projectRoot, generatedManifestPath)} must be ` +
227
+ `{ "generated": string[] }`,
228
+ );
229
+ }
230
+ return new Set(list);
231
+ }
232
+
194
233
  async function run() {
195
234
  const files = await walk(distRoot);
196
235
  const existing = await readRemoteChecksums();
236
+ const generatedPaths = await readGeneratedPaths();
197
237
  const rows = [];
198
238
  let index = 0;
199
239
  const driftViolations = [];
@@ -202,8 +242,9 @@ async function run() {
202
242
  const liveCounterparts = new Map();
203
243
 
204
244
  for (const filePath of files.sort()) {
205
- // Skip the manifest file itself so it doesn't appear in its own diff.
206
- if (filePath === distChecksumsPath) {
245
+ // Skip the manifest file itself and the generated-artifact manifest so
246
+ // neither appears in its own diff.
247
+ if (filePath === distChecksumsPath || filePath === generatedManifestPath) {
207
248
  continue;
208
249
  }
209
250
 
@@ -216,11 +257,13 @@ async function run() {
216
257
  : previous === digest
217
258
  ? "STABLE"
218
259
  : "CHANGED";
219
- const mutable = isMutablePath(relPath);
260
+ const generated = generatedPaths.has(relPath);
261
+ const mutable = generated || isMutablePath(relPath);
262
+ const tag = generated ? " (generated)" : mutable ? " (mutable)" : "";
220
263
  index += 1;
221
264
 
222
265
  process.stdout.write(
223
- `${String(index).padStart(3, "0")}. [${status}] ${relPath}${mutable ? " (mutable)" : ""}\n` +
266
+ `${String(index).padStart(3, "0")}. [${status}] ${relPath}${tag}\n` +
224
267
  ` prev: ${previous ?? "(none)"}\n` +
225
268
  ` next: ${digest}\n`,
226
269
  );
@@ -266,6 +309,7 @@ async function run() {
266
309
  baselineUrl: REMOTE_CHECKSUMS_URL,
267
310
  generatedAt: new Date().toISOString(),
268
311
  mutablePaths: [...MUTABLE_PATH_SET],
312
+ generatedPaths: [...generatedPaths].sort(),
269
313
  violationCount: driftViolations.length,
270
314
  violations: driftViolations,
271
315
  rehashOnlyCount: rehashOnly.length,