website-xp-phone 2.2.1 → 2.2.11

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 (69) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/Makefile +5 -1
  3. package/README.md +9 -0
  4. package/build/routes.ts +27 -10
  5. package/package.json +2 -1
  6. package/public/Click.ogg +0 -0
  7. package/public/addons/rage-volley.txt +1 -1
  8. package/public/addons/rueg-wa-druid.txt +1 -1
  9. package/public/addons/rueg-wa-mage.txt +1 -1
  10. package/public/addons/rueg-wa-paladin.txt +1 -0
  11. package/public/addons/rueg-wa-priest.txt +1 -1
  12. package/public/addons/rueg-wa-rueg.txt +1 -1
  13. package/public/addons/rueg-wa-shaman.txt +1 -1
  14. package/public/blog/random/branch.md +29 -0
  15. package/public/blog/random/google.md +25 -0
  16. package/public/checksums.txt +70 -47
  17. package/public/documents/drift-report.json +46 -148
  18. package/public/loading.css +7 -0
  19. package/scripts/asset-reference.mjs +37 -0
  20. package/scripts/mutable-assets.mjs +46 -39
  21. package/scripts/refresh-soundcloud.mjs +152 -414
  22. package/scripts/verify-policy.mjs +206 -0
  23. package/scripts/write-dist-checksums.mjs +251 -135
  24. package/src/content/addons.json +13 -17
  25. package/src/content/blog.json +5 -26
  26. package/src/content/characters.json +6 -6
  27. package/src/content/configs.json +6 -2
  28. package/src/content/index.ts +105 -12
  29. package/src/content/pages.json +10 -1
  30. package/src/content/projects/development.json +9 -1
  31. package/src/content/projects/doftiles.json +9 -1
  32. package/src/content/projects/frontend.json +9 -1
  33. package/src/content/projects/graphbrowser.json +9 -1
  34. package/src/content/projects/herbstutil.json +93 -4
  35. package/src/content/projects/lightdni-jssas-toggle.json +9 -1
  36. package/src/content/projects/pagerts.json +13 -2
  37. package/src/content/projects/rarebert.json +12 -2
  38. package/src/content/projects/webuser.json +9 -1
  39. package/src/content/schema.ts +10 -1
  40. package/src/content/sections.json +2 -3
  41. package/src/content/soundcloud.json +204 -72
  42. package/src/lib/audio.ts +14 -0
  43. package/src/lib/clippy.ts +1 -7
  44. package/src/lib/embeds.ts +1 -1
  45. package/src/lib/musicSchema.ts +1 -0
  46. package/src/main.tsx +8 -0
  47. package/src/pages/MusicPage.tsx +0 -1
  48. package/src/pages/SitemapPage.tsx +1 -1
  49. package/src/pages/WowPage.tsx +1 -1
  50. package/src/shell/App.tsx +4 -1
  51. package/src/styles/extras.css +85 -30
  52. package/src/styles/layout.css +27 -18
  53. package/src/styles/window.css +53 -50
  54. package/src/themes/pageSlide/index.ts +115 -13
  55. package/src/themes/pageSlide/pageSlide.css +99 -6
  56. package/src/window/Embed.tsx +3 -1
  57. package/src/window/MaximizedWindow.tsx +250 -0
  58. package/src/window/OkButton.tsx +6 -1
  59. package/src/window/PageWindows.tsx +3 -9
  60. package/src/window/Window.tsx +129 -53
  61. package/src/window/model.ts +22 -0
  62. package/src/window/state.tsx +54 -30
  63. package/test/asset-reference.test.js +53 -0
  64. package/test/content.test.js +60 -6
  65. package/test/mutable-assets.test.js +27 -0
  66. package/test/verify-policy.test.js +92 -0
  67. package/tsconfig.node.tsbuildinfo +1 -1
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/xp.css/README.md +10 -0
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI drift gate for the checksums pipeline.
4
+ *
5
+ * Reads the sidecar `public/documents/drift-report.json` that
6
+ * write-dist-checksums.mjs emits during the build and fails when the
7
+ * build observed genuine STABLE-invariant drift relative to the live
8
+ * deployment baseline:
9
+ *
10
+ * - a non-empty `violations` array (content drift or an artifact that
11
+ * could not be verified against the live counterpart), or
12
+ * - a corrupt/inconsistent report (`violationCount` disagreeing with
13
+ * the `violations` array, reconciled resolutions inside `violations`,
14
+ * missing fields) — the gate fails closed on those.
15
+ *
16
+ * Rebuild noise that write-dist-checksums.mjs already reconciled against
17
+ * the live deployment (`rehash-only`, `live-match`) is NOT a failure; it
18
+ * is printed for observability only.
19
+ *
20
+ * The gate runs in CI (where the remote baseline is reachable) rather
21
+ * than only locally. On pull requests it runs in advisory mode so that
22
+ * legitimate content edits under review do not block CI — pass
23
+ * `--nonblocking` (or set `VERIFY_POLICY_NONBLOCKING=1`) to downgrade
24
+ * genuine-drift findings to warnings. A missing or corrupt report is
25
+ * always a hard failure: an indefinite signal cannot gate anything.
26
+ */
27
+
28
+ import { readFile } from "node:fs/promises";
29
+ import { dirname, resolve } from "node:path";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
+
32
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
+ const driftReportPath = resolve(
34
+ projectRoot,
35
+ "public",
36
+ "documents",
37
+ "drift-report.json",
38
+ );
39
+
40
+ /** Reconciled resolutions must never appear inside `violations`. */
41
+ const RECONCILED_RESOLUTIONS = new Set(["rehash-only", "live-match"]);
42
+
43
+ function isRecord(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ /**
48
+ * Decode the drift report. Unknown shape fails closed.
49
+ * Returns `{ ok: true, report }` or `{ ok: false, issues }`.
50
+ */
51
+ export function decodeReport(value) {
52
+ if (!isRecord(value)) {
53
+ return { ok: false, issues: ["drift report is not a JSON object"] };
54
+ }
55
+
56
+ const issues = [];
57
+ const { baselineUrl, generatedAt, violationCount, violations } = value;
58
+
59
+ if (typeof baselineUrl !== "string" || baselineUrl.length === 0) {
60
+ issues.push("baselineUrl is missing or not a non-empty string");
61
+ }
62
+ if (typeof generatedAt !== "string" || generatedAt.length === 0) {
63
+ issues.push("generatedAt is missing or not a non-empty string");
64
+ }
65
+ if (
66
+ typeof violationCount !== "number" ||
67
+ !Number.isInteger(violationCount) ||
68
+ violationCount < 0
69
+ ) {
70
+ issues.push("violationCount is missing or not a non-negative integer");
71
+ }
72
+ if (!Array.isArray(violations)) {
73
+ issues.push("violations is missing or not an array");
74
+ }
75
+ if (issues.length > 0) {
76
+ return { ok: false, issues };
77
+ }
78
+
79
+ for (const [index, entry] of violations.entries()) {
80
+ if (!isRecord(entry) || typeof entry.relPath !== "string") {
81
+ issues.push(`violations[${index}] has no relPath`);
82
+ continue;
83
+ }
84
+ if (typeof entry.resolution !== "string") {
85
+ issues.push(`violations[${index}] (${entry.relPath}) has no resolution`);
86
+ }
87
+ }
88
+ if (violationCount !== violations.length) {
89
+ issues.push(
90
+ `violationCount (${violationCount}) does not match ` +
91
+ `violations length (${violations.length})`,
92
+ );
93
+ }
94
+
95
+ return issues.length === 0
96
+ ? {
97
+ ok: true,
98
+ report: { baselineUrl, generatedAt, violationCount, violations },
99
+ }
100
+ : { ok: false, issues };
101
+ }
102
+
103
+ /**
104
+ * Evaluate a decoded report. Returns `{ ok, reasons }` where `reasons`
105
+ * is empty exactly when there is no genuine drift.
106
+ */
107
+ export function evaluateReport(report) {
108
+ const reasons = [];
109
+ for (const entry of report.violations) {
110
+ if (RECONCILED_RESOLUTIONS.has(entry.resolution)) {
111
+ reasons.push(
112
+ `${entry.relPath}: reconciled resolution "${entry.resolution}" ` +
113
+ `must not appear in violations (generator regression)`,
114
+ );
115
+ continue;
116
+ }
117
+ const note = entry.note ? ` — ${entry.note}` : "";
118
+ reasons.push(
119
+ `${entry.relPath}: ${entry.status ?? "unknown"} ${entry.resolution}${note}`,
120
+ );
121
+ }
122
+ return { ok: reasons.length === 0, reasons };
123
+ }
124
+
125
+ function observabilityLines(report) {
126
+ const rehashOnly = Array.isArray(report.rehashOnly) ? report.rehashOnly : [];
127
+ const liveMatched = Array.isArray(report.liveMatched)
128
+ ? report.liveMatched
129
+ : [];
130
+ return [
131
+ `rebuild noise reconciled against live: ${rehashOnly.length} rehash-only, ` +
132
+ `${liveMatched.length} live-match (not drift)`,
133
+ ];
134
+ }
135
+
136
+ async function main() {
137
+ const nonblocking =
138
+ process.argv.includes("--nonblocking") ||
139
+ process.env.VERIFY_POLICY_NONBLOCKING === "1";
140
+
141
+ let raw;
142
+ try {
143
+ raw = await readFile(driftReportPath, "utf8");
144
+ } catch {
145
+ process.stderr.write(
146
+ `Drift policy FAIL — drift report not found at ${driftReportPath}. ` +
147
+ `The build pipeline (write-dist-checksums.mjs) must run first so ` +
148
+ `the gate consumes a definite signal.\n`,
149
+ );
150
+ process.exit(1);
151
+ }
152
+
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(raw);
156
+ } catch (error) {
157
+ process.stderr.write(
158
+ `Drift policy FAIL — drift report at ${driftReportPath} is not valid ` +
159
+ `JSON: ${error instanceof Error ? error.message : String(error)}\n`,
160
+ );
161
+ process.exit(1);
162
+ }
163
+
164
+ const decoded = decodeReport(parsed);
165
+ if (!decoded.ok) {
166
+ process.stderr.write(
167
+ `Drift policy FAIL — drift report at ${driftReportPath} failed validation:\n` +
168
+ decoded.issues.map((issue) => ` ${issue}`).join("\n") +
169
+ "\n",
170
+ );
171
+ process.exit(1);
172
+ }
173
+
174
+ const { report } = decoded;
175
+ const evaluation = evaluateReport(report);
176
+ const observability = observabilityLines(report).map((line) => ` ${line}`);
177
+
178
+ if (evaluation.ok) {
179
+ process.stdout.write(
180
+ `Drift policy PASS — ${report.violationCount} violation(s) vs ${report.baselineUrl}\n` +
181
+ `${observability.join("\n")}\n` +
182
+ `Report generated at ${report.generatedAt}\n`,
183
+ );
184
+ process.exit(0);
185
+ }
186
+
187
+ const verdict = nonblocking ? "WARN (nonblocking)" : "FAIL";
188
+ const stream = nonblocking ? process.stdout : process.stderr;
189
+ stream.write(
190
+ `Drift policy ${verdict} — ${report.violationCount} genuine drift ` +
191
+ `artifact(s) vs ${report.baselineUrl}:\n` +
192
+ evaluation.reasons.map((reason) => ` ${reason}`).join("\n") +
193
+ "\n" +
194
+ `${observability.join("\n")}\n` +
195
+ `Report generated at ${report.generatedAt}\n`,
196
+ );
197
+ process.exit(nonblocking ? 0 : 1);
198
+ }
199
+
200
+ const invokedDirectly =
201
+ process.argv[1] !== undefined &&
202
+ pathToFileURL(process.argv[1]).href === import.meta.url;
203
+
204
+ if (invokedDirectly) {
205
+ main();
206
+ }
@@ -18,14 +18,18 @@
18
18
  */
19
19
  import { createHash } from "node:crypto";
20
20
  import {
21
- copyFile,
22
- mkdir,
23
- readdir,
24
- readFile,
25
- writeFile,
21
+ copyFile,
22
+ mkdir,
23
+ readdir,
24
+ readFile,
25
+ writeFile,
26
26
  } from "node:fs/promises";
27
27
  import { dirname, relative, resolve } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
+ import {
30
+ canonicalizeAssetRefs,
31
+ isCanonicalizablePath,
32
+ } from "./asset-reference.mjs";
29
33
  import { isMutablePath, MUTABLE_PATH_SET } from "./mutable-assets.mjs";
30
34
 
31
35
  const __filename = fileURLToPath(import.meta.url);
@@ -46,30 +50,100 @@ const driftReportPath = resolve(publicRoot, "documents", "drift-report.json");
46
50
 
47
51
  /** Where the previous deployment's manifest lives. */
48
52
  const REMOTE_CHECKSUMS_URL =
49
- process.env.CHECKSUMS_URL || "https://akinevz.com/checksums.txt";
53
+ process.env.CHECKSUMS_URL || "https://akinevz.com/checksums.txt";
54
+
55
+ /** Directory of the remote manifest; built artifacts are fetched here. */
56
+ const REMOTE_BASE_URL = REMOTE_CHECKSUMS_URL.replace(/checksums\.txt$/, "");
50
57
 
51
58
  async function walk(dirPath) {
52
- const entries = await readdir(dirPath, { withFileTypes: true });
53
- const files = [];
54
-
55
- for (const entry of entries) {
56
- const fullPath = resolve(dirPath, entry.name);
57
- if (entry.isDirectory()) {
58
- files.push(...(await walk(fullPath)));
59
- continue;
60
- }
61
-
62
- if (entry.isFile()) {
63
- files.push(fullPath);
64
- }
59
+ const entries = await readdir(dirPath, { withFileTypes: true });
60
+ const files = [];
61
+
62
+ for (const entry of entries) {
63
+ const fullPath = resolve(dirPath, entry.name);
64
+ if (entry.isDirectory()) {
65
+ files.push(...(await walk(fullPath)));
66
+ continue;
65
67
  }
66
68
 
67
- return files;
69
+ if (entry.isFile()) {
70
+ files.push(fullPath);
71
+ }
72
+ }
73
+
74
+ return files;
75
+ }
76
+
77
+ async function sha256Text(text) {
78
+ return createHash("sha256").update(text).digest("hex");
79
+ }
80
+
81
+ /**
82
+ * Fetch the previously deployed counterpart of a built artifact.
83
+ *
84
+ * Returns null when the remote is unreachable or the file does not exist
85
+ * there yet — the caller then treats the artifact as unverifiable.
86
+ */
87
+ async function fetchLiveCounterpart(relPath, cache) {
88
+ if (cache.has(relPath)) {
89
+ return cache.get(relPath);
90
+ }
91
+
92
+ let live = null;
93
+ if (typeof fetch === "function") {
94
+ try {
95
+ const response = await fetch(`${REMOTE_BASE_URL}${encodeURI(relPath)}`, {
96
+ cache: "no-store",
97
+ headers: { Accept: "text/plain" },
98
+ });
99
+ if (response.ok) {
100
+ live = await response.text();
101
+ }
102
+ } catch {
103
+ live = null;
104
+ }
105
+ }
106
+ cache.set(relPath, live);
107
+ return live;
68
108
  }
69
109
 
70
- async function sha256File(filePath) {
71
- const content = await readFile(filePath, "utf8");
72
- return createHash("sha256").update(content).digest("hex");
110
+ /**
111
+ * Decide whether a non-STABLE non-mutable artifact actually drifted.
112
+ *
113
+ * Vite rebuilds every HTML entry page whenever any bundle's content hash
114
+ * changes, so raw byte comparison reports false drift on unchanged pages.
115
+ * Two false-positive classes are resolved against the live deployment:
116
+ *
117
+ * - "live-match": the artifact is byte-identical to the live file but
118
+ * the baseline manifest predates it (baseline staleness).
119
+ * - "rehash-only": the page differs only in the content-hashed asset
120
+ * references Vite re-injects at build time.
121
+ *
122
+ * Anything else — including an unfetchable counterpart — is treated as
123
+ * real drift, the loud failure mode.
124
+ */
125
+ function classifyDrift({ relPath, localText, liveText }) {
126
+ const localCanonical = isCanonicalizablePath(relPath)
127
+ ? canonicalizeAssetRefs(localText)
128
+ : localText;
129
+
130
+ if (liveText === null) {
131
+ return {
132
+ drift: true,
133
+ resolution: "unverifiable",
134
+ note: "no counterpart at the baseline URL",
135
+ };
136
+ }
137
+
138
+ if (localText === liveText) {
139
+ return { drift: false, resolution: "live-match" };
140
+ }
141
+
142
+ if (localCanonical === canonicalizeAssetRefs(liveText)) {
143
+ return { drift: false, resolution: "rehash-only" };
144
+ }
145
+
146
+ return { drift: true, resolution: "content-drift" };
73
147
  }
74
148
 
75
149
  /**
@@ -78,142 +152,184 @@ async function sha256File(filePath) {
78
152
  * Two or more spaces separate the digest from the path.
79
153
  */
80
154
  function parseChecksumsText(text) {
81
- const entries = new Map();
82
-
83
- for (const line of text.split(/\r?\n/)) {
84
- const trimmed = line.trim();
85
- if (!trimmed) {
86
- continue;
87
- }
155
+ const entries = new Map();
88
156
 
89
- const [digest, relPath] = trimmed.split(/\s{2,}/);
90
- if (!digest || !relPath) {
91
- continue;
92
- }
157
+ for (const line of text.split(/\r?\n/)) {
158
+ const trimmed = line.trim();
159
+ if (!trimmed) {
160
+ continue;
161
+ }
93
162
 
94
- entries.set(relPath, digest);
163
+ const [digest, relPath] = trimmed.split(/\s{2,}/);
164
+ if (!digest || !relPath) {
165
+ continue;
95
166
  }
96
167
 
97
- return entries;
168
+ entries.set(relPath, digest);
169
+ }
170
+
171
+ return entries;
98
172
  }
99
173
 
100
174
  async function readRemoteChecksums() {
101
- if (typeof fetch !== "function") {
102
- return new Map();
103
- }
175
+ if (typeof fetch !== "function") {
176
+ return new Map();
177
+ }
104
178
 
105
- try {
106
- const response = await fetch(REMOTE_CHECKSUMS_URL, {
107
- cache: "no-store",
108
- headers: { Accept: "text/plain" },
109
- });
110
- if (!response.ok) {
111
- return new Map();
112
- }
113
- const text = await response.text();
114
- return parseChecksumsText(text);
115
- } catch {
116
- return new Map();
179
+ try {
180
+ const response = await fetch(REMOTE_CHECKSUMS_URL, {
181
+ cache: "no-store",
182
+ headers: { Accept: "text/plain" },
183
+ });
184
+ if (!response.ok) {
185
+ return new Map();
117
186
  }
187
+ const text = await response.text();
188
+ return parseChecksumsText(text);
189
+ } catch {
190
+ return new Map();
191
+ }
118
192
  }
119
193
 
120
194
  async function run() {
121
- const files = await walk(distRoot);
122
- const existing = await readRemoteChecksums();
123
- const rows = [];
124
- let index = 0;
125
- const driftViolations = [];
126
-
127
- for (const filePath of files.sort()) {
128
- // Skip the manifest file itself so it doesn't appear in its own diff.
129
- if (filePath === distChecksumsPath) {
130
- continue;
131
- }
132
-
133
- const digest = await sha256File(filePath);
134
- const relPath = relative(distRoot, filePath).replace(/\\/g, "/");
135
- const previous = existing.get(relPath);
136
- const status = !previous
137
- ? "NEW"
138
- : previous === digest
139
- ? "STABLE"
140
- : "CHANGED";
141
- const mutable = isMutablePath(relPath);
142
- index += 1;
143
-
144
- process.stdout.write(
145
- `${String(index).padStart(3, "0")}. [${status}] ${relPath}${mutable ? " (mutable)" : ""}\n` +
146
- ` prev: ${previous ?? "(none)"}\n` +
147
- ` next: ${digest}\n`,
148
- );
149
-
150
- // Drift gate: a non-mutable artifact MUST be STABLE relative to the
151
- // remote baseline. A mutable artifact may be STABLE, CHANGED, or NEW.
152
- if (!mutable && status !== "STABLE") {
153
- driftViolations.push({
154
- relPath,
155
- status,
156
- prev: previous ?? null,
157
- next: digest,
158
- });
159
- }
160
-
161
- rows.push(`${digest} ${relPath}`);
195
+ const files = await walk(distRoot);
196
+ const existing = await readRemoteChecksums();
197
+ const rows = [];
198
+ let index = 0;
199
+ const driftViolations = [];
200
+ const rehashOnly = [];
201
+ const liveMatched = [];
202
+ const liveCounterparts = new Map();
203
+
204
+ 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) {
207
+ continue;
162
208
  }
163
209
 
164
- // Always emit a drift report so verify-policy can consume a definite
165
- // signal rather than relying on file presence.
166
- const driftReport = JSON.stringify(
167
- {
168
- baselineUrl: REMOTE_CHECKSUMS_URL,
169
- generatedAt: new Date().toISOString(),
170
- mutablePaths: [...MUTABLE_PATH_SET],
171
- violationCount: driftViolations.length,
172
- violations: driftViolations,
173
- },
174
- null,
175
- 2,
210
+ const localText = await readFile(filePath, "utf8");
211
+ const digest = await sha256Text(localText);
212
+ const relPath = relative(distRoot, filePath).replace(/\\/g, "/");
213
+ const previous = existing.get(relPath);
214
+ const status = !previous
215
+ ? "NEW"
216
+ : previous === digest
217
+ ? "STABLE"
218
+ : "CHANGED";
219
+ const mutable = isMutablePath(relPath);
220
+ index += 1;
221
+
222
+ process.stdout.write(
223
+ `${String(index).padStart(3, "0")}. [${status}] ${relPath}${mutable ? " (mutable)" : ""}\n` +
224
+ ` prev: ${previous ?? "(none)"}\n` +
225
+ ` next: ${digest}\n`,
176
226
  );
177
- await writeFile(driftReportPath, `${driftReport}\n`, "utf8");
178
-
179
- if (driftViolations.length > 0) {
180
- const lines = driftViolations.map(
181
- (v) => ` ${v.relPath}: ${v.status} (prev=${v.prev ?? "(none)"})`,
182
- );
183
- process.stderr.write(
184
- `Drift gate failed — ${driftViolations.length} non-mutable artifact(s) changed ` +
185
- `vs ${REMOTE_CHECKSUMS_URL}:\n${lines.join("\n")}\n`,
186
- );
187
- }
188
227
 
189
- const manifest = `${rows.join("\n")}\n`;
228
+ // Drift gate: a non-mutable artifact MUST be STABLE relative to the
229
+ // remote baseline. A mutable artifact may be STABLE, CHANGED, or NEW.
230
+ //
231
+ // Rebuild noise is resolved against the live deployment before it is
232
+ // counted as a violation: Vite re-injects content-hashed asset refs
233
+ // into every HTML entry page, and the baseline manifest can lag the
234
+ // live site, so raw byte comparison alone reports false drift.
235
+ if (!mutable && status !== "STABLE") {
236
+ const liveText = await fetchLiveCounterpart(relPath, liveCounterparts);
237
+ const drift = classifyDrift({ relPath, localText, liveText });
190
238
 
191
- // Write the deployed copy (`dist/checksums.txt`) and the source copy
192
- // (`public/checksums.txt`) so the next git commit and the next Render
193
- // deploy both carry the new manifest.
194
- await mkdir(distRoot, { recursive: true });
195
- await mkdir(publicRoot, { recursive: true });
196
- await writeFile(distChecksumsPath, manifest, "utf8");
197
- await writeFile(publicChecksumsPath, manifest, "utf8");
239
+ if (drift.drift) {
240
+ driftViolations.push({
241
+ relPath,
242
+ status,
243
+ prev: previous ?? null,
244
+ next: digest,
245
+ resolution: drift.resolution,
246
+ ...(drift.note ? { note: drift.note } : {}),
247
+ });
248
+ } else if (drift.resolution === "rehash-only") {
249
+ rehashOnly.push(relPath);
250
+ } else {
251
+ liveMatched.push(relPath);
252
+ }
253
+
254
+ process.stdout.write(` drift: ${drift.resolution}\n`);
255
+ }
198
256
 
199
- // Belt-and-braces: if Vite ever changes how it copies `public/`, the
200
- // `dist/` copy is the authoritative served artifact, so a final copy
201
- // keeps the two in sync even if `public/checksums.txt` was deleted
202
- // before the build ran.
203
- await copyFile(publicChecksumsPath, distChecksumsPath).catch(() => { });
257
+ rows.push(`${digest} ${relPath}`);
258
+ }
204
259
 
260
+ // Always emit a drift report so verify-policy can consume a definite
261
+ // signal rather than relying on file presence. `violations` contains
262
+ // only genuine content drift; rebuild noise that was reconciled against
263
+ // the live deployment is recorded separately for observability.
264
+ const driftReport = JSON.stringify(
265
+ {
266
+ baselineUrl: REMOTE_CHECKSUMS_URL,
267
+ generatedAt: new Date().toISOString(),
268
+ mutablePaths: [...MUTABLE_PATH_SET],
269
+ violationCount: driftViolations.length,
270
+ violations: driftViolations,
271
+ rehashOnlyCount: rehashOnly.length,
272
+ rehashOnly,
273
+ liveMatchCount: liveMatched.length,
274
+ liveMatched,
275
+ },
276
+ null,
277
+ 2,
278
+ );
279
+ await writeFile(driftReportPath, `${driftReport}\n`, "utf8");
280
+
281
+ if (rehashOnly.length > 0) {
205
282
  process.stdout.write(
206
- `Wrote ${rows.length} checksums to ${distChecksumsPath}\n` +
207
- ` and to ${publicChecksumsPath}\n` +
208
- `Baseline: ${REMOTE_CHECKSUMS_URL}\n`,
283
+ `Drift gate: ${rehashOnly.length} rebuilt artifact(s) differ only in ` +
284
+ `Vite's injected asset hashes (rehash-only, not drift)\n`,
209
285
  );
210
- }
286
+ }
287
+ if (liveMatched.length > 0) {
288
+ process.stdout.write(
289
+ `Drift gate: ${liveMatched.length} artifact(s) match the live site but ` +
290
+ `are missing from the baseline manifest (live-match, not drift)\n`,
291
+ );
292
+ }
211
293
 
212
- run().catch((error) => {
294
+ if (driftViolations.length > 0) {
295
+ const lines = driftViolations.map((v) => {
296
+ const note = v.note ? ` — ${v.note}` : "";
297
+ return ` ${v.relPath}: ${v.status} ${v.resolution}${note} (prev=${v.prev ?? "(none)"})`;
298
+ });
213
299
  process.stderr.write(
214
- `${error instanceof Error ? error.message : String(error)}\n`,
300
+ `Drift gate failed — ${driftViolations.length} non-mutable artifact(s) changed ` +
301
+ `vs ${REMOTE_CHECKSUMS_URL}:\n${lines.join("\n")}\n`,
215
302
  );
216
- process.exit(1);
303
+ }
304
+
305
+ const manifest = `${rows.join("\n")}\n`;
306
+
307
+ // Write the deployed copy (`dist/checksums.txt`) and the source copy
308
+ // (`public/checksums.txt`) so the next git commit and the next Render
309
+ // deploy both carry the new manifest.
310
+ await mkdir(distRoot, { recursive: true });
311
+ await mkdir(publicRoot, { recursive: true });
312
+ await writeFile(distChecksumsPath, manifest, "utf8");
313
+ await writeFile(publicChecksumsPath, manifest, "utf8");
314
+
315
+ // Belt-and-braces: if Vite ever changes how it copies `public/`, the
316
+ // `dist/` copy is the authoritative served artifact, so a final copy
317
+ // keeps the two in sync even if `public/checksums.txt` was deleted
318
+ // before the build ran.
319
+ await copyFile(publicChecksumsPath, distChecksumsPath).catch(() => {});
320
+
321
+ process.stdout.write(
322
+ `Wrote ${rows.length} checksums to ${distChecksumsPath}\n` +
323
+ ` and to ${publicChecksumsPath}\n` +
324
+ `Baseline: ${REMOTE_CHECKSUMS_URL}\n`,
325
+ );
326
+ }
327
+
328
+ run().catch((error) => {
329
+ process.stderr.write(
330
+ `${error instanceof Error ? error.message : String(error)}\n`,
331
+ );
332
+ process.exit(1);
217
333
  });
218
334
 
219
335
  console.log("https://akinevz.dev/ was here");