@wxxb789/anc 0.0.1

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Exact route inventory plus tightly-owned build namespaces for the final site.
3
+ *
4
+ * Notes publish as HTML routes only. Static package assets are byte-compared to
5
+ * public/, Astro owns only flat hashed JS/CSS under _astro/, and Pagefind's
6
+ * content-addressed members must be referenced by its own metadata. Everything
7
+ * else is an unexpected publication surface and fails before copy-out.
8
+ */
9
+ import { gunzipSync } from 'node:zlib';
10
+ import { createHash } from 'node:crypto';
11
+ import { lstatSync, readFileSync, readdirSync, statSync, } from 'node:fs';
12
+ import { join, relative, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { DatabaseSync } from "../src/lib/sqlite.js";
15
+ import { loadArtifact } from "../src/lib/artifact-source.js";
16
+ import { noteRoute, WITHHELD_ROUTE } from "../src/lib/route-path.js";
17
+ import { COLLECTIONS_SEGMENT, FIXED_ROUTES, TAGS_SEGMENT, collectionFacets, tagFacets, } from "../src/lib/routes.js";
18
+ import { snapshotFileName } from "../src/lib/snapshot.js";
19
+ import { readBuildBinding } from "../src/lib/snapshot-reader.js";
20
+ import { BuildFailure } from "./write-report.js";
21
+ import { assertSnapshotRows } from "../src/lib/snapshot-contract.js";
22
+ import { isGzip, declaresWal, journalSidecar } from "./snapshot-rows.js";
23
+ import { readStagedWasm } from "./copy-wasm.js";
24
+ const DIST = fileURLToPath(new URL('../dist', import.meta.url));
25
+ const PUBLIC = fileURLToPath(new URL('../public', import.meta.url));
26
+ const GENERATED_FILES = ['_redirects', 'robots.txt', 'rss.xml', 'sitemap.xml'];
27
+ const PAGEFIND_RUNTIME = [
28
+ 'pagefind/pagefind-entry.json',
29
+ 'pagefind/pagefind-highlight.js',
30
+ 'pagefind/pagefind-worker.js',
31
+ 'pagefind/pagefind.js',
32
+ 'pagefind/pagefind-ui.css',
33
+ 'pagefind/pagefind-ui.js',
34
+ 'pagefind/pagefind-modular-ui.css',
35
+ 'pagefind/pagefind-modular-ui.js',
36
+ 'pagefind/pagefind-component-ui.css',
37
+ 'pagefind/pagefind-component-ui.js',
38
+ 'pagefind/wasm.unknown.pagefind',
39
+ ];
40
+ function posix(path) {
41
+ return path.split(sep).join('/');
42
+ }
43
+ function filesUnder(root) {
44
+ const files = [];
45
+ const pending = [root];
46
+ while (pending.length > 0) {
47
+ const directory = pending.pop();
48
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
49
+ const path = join(directory, entry.name);
50
+ if (entry.isDirectory())
51
+ pending.push(path);
52
+ else
53
+ files.push(path);
54
+ }
55
+ }
56
+ return files.map((path) => posix(relative(root, path))).sort();
57
+ }
58
+ function htmlFile(route) {
59
+ if (route === '/')
60
+ return 'index.html';
61
+ if (route.startsWith('/') && route.endsWith('.html'))
62
+ return route.slice(1);
63
+ if (!route.startsWith('/') || !route.endsWith('/')) {
64
+ throw new BuildFailure('output-inventory-route-invalid', 'output inventory could not derive one static route', 'route has no static output shape: ' + JSON.stringify(route));
65
+ }
66
+ return route.slice(1) + 'index.html';
67
+ }
68
+ function expectedHtml(artifact) {
69
+ const routes = new Set([...FIXED_ROUTES, WITHHELD_ROUTE, '/404.html']);
70
+ for (const entry of artifact.entries)
71
+ routes.add(noteRoute(entry.slug));
72
+ for (const facet of tagFacets(artifact.entries))
73
+ routes.add('/' + TAGS_SEGMENT + '/' + facet.key + '/');
74
+ for (const facet of collectionFacets(artifact.entries)) {
75
+ routes.add('/' + COLLECTIONS_SEGMENT + '/' + facet.key + '/');
76
+ }
77
+ return new Set([...routes].map(htmlFile));
78
+ }
79
+ function inflateIfGzip(bytes) {
80
+ return isGzip(bytes) ? gunzipSync(bytes) : bytes;
81
+ }
82
+ /**
83
+ * The bound snapshot member, validated against its own filename and schema.
84
+ *
85
+ * An empty set when this build has no binding — a synthetic inventory fixture,
86
+ * or a checkout that has not run the snapshot step. When a binding exists, the
87
+ * exact digest-named file must be present: a misnamed or second `site.*.sqlite`
88
+ * is an unexpected member, and a wrong digest or schema fails here rather than
89
+ * after copy-out.
90
+ */
91
+ function snapshotOutput(root, workspace) {
92
+ const binding = readBuildBinding(workspace);
93
+ if (binding === undefined)
94
+ return new Set();
95
+ const file = snapshotFileName(binding.digest);
96
+ const path = join(root, ...file.split('/'));
97
+ let bytes;
98
+ try {
99
+ bytes = readFileSync(path);
100
+ }
101
+ catch {
102
+ throw new BuildFailure('output-inventory-snapshot-missing', 'output inventory is missing its bound snapshot', file);
103
+ }
104
+ const digest = createHash('sha256').update(bytes).digest('hex');
105
+ if (digest !== binding.digest) {
106
+ throw new BuildFailure('output-inventory-snapshot-digest', 'output inventory snapshot digest does not match its bound URL', file + ': expected ' + binding.digest + ', got ' + digest);
107
+ }
108
+ // The accepted artifact is one rollback-journal file, and opening is not a
109
+ // read-only operation when SQLite believes it has journal work to do: a
110
+ // `-wal` sibling makes the driver recover, which fails on the read-only
111
+ // connection and — measured — creates a `-shm` file inside the directory
112
+ // being inspected. Refusing before the open is what keeps "scan the
113
+ // immutable artifact" a statement about bytes rather than about the driver's
114
+ // side effects. The enumerator's own WAL refusal covers the scanners; this
115
+ // covers inventory, which reads the same bytes directly.
116
+ if (declaresWal(bytes)) {
117
+ throw new BuildFailure('output-inventory-snapshot-format', 'output inventory snapshot is not in the accepted rollback-journal format', file + ': WAL header');
118
+ }
119
+ const sidecar = journalSidecar(path);
120
+ if (sidecar !== undefined) {
121
+ throw new BuildFailure('output-inventory-snapshot-format', 'output inventory snapshot carries a journal sidecar, which the accepted artifact does not', file + ': found ' + file.split('/').at(-1) + sidecar);
122
+ }
123
+ let database;
124
+ try {
125
+ database = new DatabaseSync(path, { readOnly: true });
126
+ }
127
+ catch (error) {
128
+ throw new BuildFailure('output-inventory-snapshot-unreadable', 'output inventory could not open its snapshot', file + ': ' + (error instanceof Error ? error.message : String(error)));
129
+ }
130
+ try {
131
+ assertSnapshotRows((sql) => database.prepare(sql).all());
132
+ }
133
+ catch (error) {
134
+ throw new BuildFailure('output-inventory-snapshot-schema', 'output inventory snapshot does not carry the accepted schema', file + ': ' + (error instanceof Error ? error.message : String(error)));
135
+ }
136
+ finally {
137
+ database.close();
138
+ }
139
+ return new Set([file]);
140
+ }
141
+ /**
142
+ * The bound SQLite WASM member, validated against its own filename.
143
+ *
144
+ * An empty set when this build staged no WASM (synthetic inventories without a
145
+ * browser runtime). When a binding exists, exactly the digest-named member must
146
+ * be present; a second `sqlite3*.wasm` is unexpected.
147
+ */
148
+ function wasmOutput(root, workspace) {
149
+ const binding = readStagedWasm(workspace);
150
+ if (binding === undefined)
151
+ return new Set();
152
+ for (const member of binding.members) {
153
+ const path = join(root, ...member.split('/'));
154
+ if (member.endsWith('.wasm')) {
155
+ let bytes;
156
+ try {
157
+ bytes = readFileSync(path);
158
+ }
159
+ catch {
160
+ throw new BuildFailure('output-inventory-wasm-missing', 'output inventory is missing a bound wasm member', member);
161
+ }
162
+ const digest = createHash('sha256').update(bytes).digest('hex');
163
+ if (digest !== binding.digest) {
164
+ throw new BuildFailure('output-inventory-wasm-digest', 'output inventory wasm digest does not match its bound URL', member + ': expected ' + binding.digest + ', got ' + digest);
165
+ }
166
+ }
167
+ else if (!statSync(path).isFile()) {
168
+ throw new BuildFailure('output-inventory-wasm-missing', 'output inventory is missing a bound wasm member', member);
169
+ }
170
+ }
171
+ return new Set(binding.members);
172
+ }
173
+ function pagefindFiles(root, actual) {
174
+ // These are Pagefind's fixed runtime distribution. Content-addressed members
175
+ // are added only from pagefind-entry.json and the gzip metadata it names.
176
+ const allowed = new Set(PAGEFIND_RUNTIME);
177
+ const entryPath = join(root, 'pagefind', 'pagefind-entry.json');
178
+ let value;
179
+ try {
180
+ value = JSON.parse(readFileSync(entryPath, 'utf8'));
181
+ }
182
+ catch (error) {
183
+ throw new BuildFailure('output-inventory-pagefind-unreadable', 'output inventory could not read the Pagefind manifest', error instanceof Error ? error.stack ?? error.message : String(error));
184
+ }
185
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
186
+ throw new BuildFailure('output-inventory-pagefind-invalid', 'output inventory found an invalid Pagefind manifest', 'pagefind-entry.json is not an object');
187
+ }
188
+ const languagesValue = value['languages'];
189
+ if (languagesValue === null || typeof languagesValue !== 'object' || Array.isArray(languagesValue) ||
190
+ Object.keys(languagesValue).length === 0) {
191
+ throw new BuildFailure('output-inventory-pagefind-invalid', 'output inventory found an invalid Pagefind manifest', 'pagefind-entry.json languages must be a non-empty object');
192
+ }
193
+ const metadata = [];
194
+ for (const [name, member] of Object.entries(languagesValue)) {
195
+ if (member === null || typeof member !== 'object' || Array.isArray(member)) {
196
+ throw new BuildFailure('output-inventory-pagefind-invalid', 'output inventory found an invalid Pagefind manifest', 'invalid Pagefind language member: ' + JSON.stringify(name));
197
+ }
198
+ const language = member;
199
+ const wasm = language['wasm'];
200
+ if (typeof language['hash'] !== 'string' || !/^[A-Za-z0-9_-]+$/.test(language['hash']) ||
201
+ (wasm !== null && (typeof wasm !== 'string' || !/^[A-Za-z0-9_-]+$/.test(wasm)))) {
202
+ throw new BuildFailure('output-inventory-pagefind-invalid', 'output inventory found an invalid Pagefind manifest', 'invalid Pagefind language references: ' + JSON.stringify(name));
203
+ }
204
+ const meta = 'pagefind/pagefind.' + language['hash'] + '.pf_meta';
205
+ allowed.add(meta);
206
+ // null is Pagefind's declaration that this language uses the fixed
207
+ // wasm.unknown.pagefind runtime already in PAGEFIND_RUNTIME.
208
+ if (typeof wasm === 'string')
209
+ allowed.add('pagefind/wasm.' + wasm + '.pagefind');
210
+ try {
211
+ metadata.push(inflateIfGzip(readFileSync(join(root, ...meta.split('/')))));
212
+ }
213
+ catch (error) {
214
+ throw new BuildFailure('output-inventory-pagefind-unreadable', 'output inventory could not read Pagefind metadata', meta + ': ' + (error instanceof Error ? error.stack ?? error.message : String(error)));
215
+ }
216
+ }
217
+ for (const file of actual) {
218
+ const match = /^pagefind\/(?:fragment|index)\/([A-Za-z0-9_-]+)\.(?:pf_fragment|pf_index)$/.exec(file);
219
+ if (match === null)
220
+ continue;
221
+ const reference = Buffer.from(match[1]);
222
+ if (metadata.some((bytes) => bytes.includes(reference)))
223
+ allowed.add(file);
224
+ }
225
+ return allowed;
226
+ }
227
+ /** Assert every final file belongs to one public route or one generated namespace. */
228
+ export function assertOutputInventory(root, artifact, workspace) {
229
+ let actual;
230
+ try {
231
+ actual = filesUnder(root);
232
+ }
233
+ catch (error) {
234
+ throw new BuildFailure('output-inventory-unreadable', 'output inventory could not be read', error instanceof Error ? error.stack ?? error.message : String(error));
235
+ }
236
+ let staticPublic;
237
+ try {
238
+ const generatedFiles = new Set(GENERATED_FILES);
239
+ staticPublic = filesUnder(PUBLIC).filter((file) => !generatedFiles.has(file));
240
+ if (staticPublic.length === 0)
241
+ throw new Error('package public assets are empty');
242
+ for (const file of staticPublic) {
243
+ if (!lstatSync(join(PUBLIC, ...file.split('/'))).isFile()) {
244
+ throw new Error('package public member is not a regular file: ' + file);
245
+ }
246
+ }
247
+ }
248
+ catch (error) {
249
+ throw new BuildFailure('output-inventory-source-unreadable', 'output inventory could not read package public assets', error instanceof Error ? error.stack ?? error.message : String(error));
250
+ }
251
+ const html = expectedHtml(artifact);
252
+ const exact = new Set([
253
+ ...html,
254
+ ...staticPublic,
255
+ ...GENERATED_FILES,
256
+ ...PAGEFIND_RUNTIME,
257
+ ...snapshotOutput(root, workspace),
258
+ ...wasmOutput(root, workspace),
259
+ ]);
260
+ const pagefind = pagefindFiles(root, actual);
261
+ const unexpected = [];
262
+ const altered = [];
263
+ for (const file of actual) {
264
+ const path = join(root, ...file.split('/'));
265
+ if (!lstatSync(path).isFile()) {
266
+ unexpected.push(file);
267
+ continue;
268
+ }
269
+ if (exact.has(file) || pagefind.has(file) || /^_astro\/[A-Za-z0-9._-]*[.-][A-Za-z0-9_-]{8,}\.(?:css|js)$/.test(file))
270
+ continue;
271
+ unexpected.push(file);
272
+ }
273
+ for (const file of staticPublic) {
274
+ if (!actual.includes(file))
275
+ continue;
276
+ const built = readFileSync(join(root, ...file.split('/')));
277
+ const source = readFileSync(join(PUBLIC, ...file.split('/')));
278
+ if (!built.equals(source))
279
+ altered.push(file);
280
+ }
281
+ const expected = new Set([...exact, ...pagefind]);
282
+ const actualSet = new Set(actual);
283
+ const missing = [...expected].filter((file) => !actualSet.has(file));
284
+ const uniqueMissing = [...new Set(missing)].sort();
285
+ if (unexpected.length > 0 || uniqueMissing.length > 0 || altered.length > 0) {
286
+ throw new BuildFailure('output-inventory-mismatch', 'output inventory mismatch: ' + unexpected.length + ' unexpected, ' + uniqueMissing.length +
287
+ ' missing, ' + altered.length + ' altered', JSON.stringify({ unexpected: unexpected.sort(), missing: uniqueMissing, altered: altered.sort() }));
288
+ }
289
+ return actual.length;
290
+ }
291
+ /**
292
+ * This repository's own build may print private detail. The packaged binary
293
+ * imports only assertOutputInventory and routes its BuildFailure message to the
294
+ * public stream while the detail goes to the private build report.
295
+ */
296
+ function main() {
297
+ try {
298
+ console.log('output inventory ok: ' + assertOutputInventory(DIST, loadArtifact()) + ' files');
299
+ return 0;
300
+ }
301
+ catch (error) {
302
+ console.error(error instanceof BuildFailure ? error.detail : error instanceof Error ? error.message : String(error));
303
+ return 1;
304
+ }
305
+ }
306
+ if (process.argv[1] === fileURLToPath(import.meta.url))
307
+ process.exit(main());
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The build's own diagnostic, written where `git` cannot commit it.
3
+ *
4
+ * A build drops files silently today — a slug collision, a filename that
5
+ * slugifies to nothing, an extension that is not `.md` — and the only surface
6
+ * that could say so is a stream this tool now forbids from carrying a filename.
7
+ * So the names go to a file, the counts go to the stream, and this module owns
8
+ * the split.
9
+ *
10
+ * ## The destination, and the one question that decides it
11
+ *
12
+ * `<git-dir>/publish-report/content-report.json`, resolved as
13
+ * `git rev-parse --git-path publish-report/content-report.json` against the
14
+ * directory the command was invoked in.
15
+ *
16
+ * Nothing under the git directory can enter the index by any command, so the
17
+ * "the report got committed" vector is closed structurally rather than by a
18
+ * rule anyone has to remember. Measured on git 2.55.0.windows.3, in a throwaway
19
+ * repository with no `.gitignore` at all: `git add -A` staged the note alone,
20
+ * `git add -f <the report>` exited 0 as a silent no-op with `git ls-files`
21
+ * unchanged, `git check-ignore -v` exited 1 reporting **no rule** — there is no
22
+ * pattern to credit — and `git clean -xfd` left the file in place. That last one
23
+ * is not incidental: `git clean -xfd` is what a user runs after a failed build,
24
+ * which is the moment the diagnostic is worth most, and a self-ignoring
25
+ * directory in the worktree does not survive it.
26
+ *
27
+ * The tool asks exactly one question, and every host can answer it definitively:
28
+ * **is there a git directory here?** There is deliberately no second question,
29
+ * and in particular no question about who can read the repository — a rule with
30
+ * no input about the reader cannot be wrong about the reader. A private
31
+ * repository gets the identical path and the identical contents, which trades a
32
+ * convenience a private-repository user could safely have for one code path
33
+ * exercised on every run instead of two of which one never is.
34
+ *
35
+ * The directory segment is `publish-report/`, and the neutrality is deliberate:
36
+ * this tool runs on a stranger's notes repository, and a directory carrying this
37
+ * project's name inside a user's own `.git/` is single-owner residue. The
38
+ * segment describes what is in the directory and is legible to someone who finds
39
+ * it without knowing what wrote it.
40
+ *
41
+ * ## Resolved before the chdir, and not with `--path-format=absolute`
42
+ *
43
+ * `bin/anc.mjs` changes the working directory to the package
44
+ * root partway through a build, so the destination is computed from the
45
+ * invocation cwd beside the other two directories and never after. Measured,
46
+ * `git rev-parse --git-path` returns a **cwd-relative** path in the ordinary
47
+ * case — `.git/publish-report/…` at a repository root, `../../.git/…` two levels
48
+ * down — so a path resolved after the chdir lands inside the installed package
49
+ * under `node_modules`.
50
+ *
51
+ * `path.resolve` on plain `--git-path` rather than `--path-format=absolute`:
52
+ * both work, the resolve form has no git version floor, and `rev-parse`
53
+ * **echoes unknown flags instead of failing** — measured, `git rev-parse
54
+ * --bogus-flag --git-path x` exits 0 and prints `--bogus-flag` first with
55
+ * `.git/x` second — so a version sniff on a flag silently returns garbage as a
56
+ * path. The last line is the answer.
57
+ *
58
+ * Measured correct at a repository root, two directories down, in a linked
59
+ * worktree, under `--separate-git-dir`, in a bare repository, in a repository
60
+ * with no commits, in a nested repository, and in a submodule.
61
+ */
62
+ import { createHash } from 'node:crypto';
63
+ import { mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
64
+ import { homedir } from 'node:os';
65
+ import { dirname, join, resolve } from 'node:path';
66
+ import { spawnSync } from 'node:child_process';
67
+ export const REPORT_SCHEMA_VERSION = 1;
68
+ /**
69
+ * Where a reader would look for a finding: by file, then down the file.
70
+ *
71
+ * Exported because the producer sorts its own findings before handing them over
72
+ * and this module sorts them again on the way to disk — and two copies of one
73
+ * comparator is one edit away from two different orders. `line` is compared
74
+ * numerically: as a string, 10 sorts before 9.
75
+ */
76
+ export function bySourceThenLine(a, b) {
77
+ if (a.source !== b.source)
78
+ return a.source < b.source ? -1 : 1;
79
+ return a.line - b.line;
80
+ }
81
+ /**
82
+ * An error whose message was composed under the disclosure rule, so the caller
83
+ * may print it.
84
+ *
85
+ * The boundary in `bin/anc.mjs` prints `error.message` for
86
+ * every throw in the process, which is how a `readdir` `ENOTDIR` — a string
87
+ * nobody here composed, with an absolute path already inside it — would reach a
88
+ * world-readable workflow log. Only errors this module vouches for are printed;
89
+ * everything else prints a fixed literal and puts its real message in the
90
+ * report's `failure.detail`.
91
+ *
92
+ * @param code A stable identifier from a closed set, safe on any stream.
93
+ * @param message The public half: literals, integers, and published slugs only.
94
+ * @param detail The private half, which goes to the report and nowhere else.
95
+ */
96
+ export class BuildFailure extends Error {
97
+ code;
98
+ detail;
99
+ constructor(code, message, detail = message) {
100
+ super(message);
101
+ this.name = 'BuildFailure';
102
+ this.code = code;
103
+ this.detail = detail;
104
+ }
105
+ }
106
+ /**
107
+ * Error names whose messages are composed under the disclosure rule.
108
+ *
109
+ * `ContentValidationError` earns its place rather than being grandfathered in:
110
+ * its message is a source literal for the artifact, `entries[N]` coordinates,
111
+ * a rule name from `schema.ts`'s own table, and the slug of an entry — which is
112
+ * a *published* route the build was about to serve, not the name of a file the
113
+ * build withheld. Its `source` argument used to be the resolved content
114
+ * directory, and that is the one part of it that changed.
115
+ *
116
+ * Matched by name rather than by `instanceof` so this module imports nothing
117
+ * from `src/`: `bin/` loads every build step through `await import()`, and a
118
+ * static import here would pull the schema into the report writer's own module
119
+ * graph for a type test.
120
+ */
121
+ const DISCLOSURE_CHECKED = new Set(['BuildFailure', 'ContentValidationError']);
122
+ /** Whether this error's own message is safe to print on a stream. */
123
+ export function isDisclosureChecked(error) {
124
+ return error instanceof Error && DISCLOSURE_CHECKED.has(error.name);
125
+ }
126
+ /**
127
+ * The report's `failure` for a thrown error: a stable code for the stream's
128
+ * sake, and everything else for the file's.
129
+ */
130
+ export function failureFor(error) {
131
+ if (error instanceof BuildFailure)
132
+ return { code: error.code, detail: error.detail };
133
+ if (error instanceof Error) {
134
+ return {
135
+ code: error.name === 'ContentValidationError' ? 'content-contract-violation' : 'unexpected-error',
136
+ detail: error.stack ?? error.message,
137
+ };
138
+ }
139
+ return { code: 'unexpected-error', detail: String(error) };
140
+ }
141
+ const GENERATOR = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
142
+ /**
143
+ * The line a build prints instead of the report's path.
144
+ *
145
+ * A path, printed, would carry the user's own directory onto a public log. This
146
+ * is a command instead: one literal, byte-identical on every machine, that the
147
+ * user runs to get their own path — and it is correct from any subdirectory, in
148
+ * a linked worktree, in a submodule, and under `--separate-git-dir` alike,
149
+ * because it asks the same question of git that this module asked.
150
+ */
151
+ const POINTER = 'report: cat "$(git rev-parse --git-path publish-report/content-report.json)"';
152
+ /** The same line where there is no git directory to ask. Also a fixed literal. */
153
+ const POINTER_NO_GIT = 'report: no git directory here; written under the user state directory, publish-report/<key>/';
154
+ /**
155
+ * Where a user's own state lives, for the case where there is no repository.
156
+ *
157
+ * Never `os.tmpdir()`: on Linux that is world-readable `/tmp`. Outside the
158
+ * worktree either way, so a later `git init` followed by `git add -A` cannot
159
+ * reach a report an earlier run wrote — the hazard any in-worktree fallback
160
+ * carries and cannot close, since the earlier run had no repository to consult.
161
+ *
162
+ * `||` rather than `??`, which is not a style choice and has a measured failure
163
+ * behind it: `??` treats an **empty** environment variable as a value, so
164
+ * `XDG_STATE_HOME=` — the ordinary way a shell unsets one for a single command —
165
+ * yields `''`, and `join('', 'publish-report', …)` is a *relative* path that
166
+ * `mkdirSync` then creates inside the invocation directory. That is the report
167
+ * landing in the user's worktree, one `git init && git add -A` from being
168
+ * committed, which is precisely the hazard this destination exists to close.
169
+ * `src/lib/artifact-source.ts` documents `||` for the identical reason.
170
+ */
171
+ function stateDirectory() {
172
+ if (process.platform === 'win32') {
173
+ return process.env['LOCALAPPDATA'] || join(homedir(), 'AppData', 'Local');
174
+ }
175
+ return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
176
+ }
177
+ /** Soft cap and age bound for reports that have no git directory to own them. */
178
+ export const STATE_REPORT_MAX_PROJECTS = 128;
179
+ export const STATE_REPORT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
180
+ const STATE_KEY = /^[a-f0-9]{16}$/;
181
+ /**
182
+ * Remove old no-git reports without following or touching unfamiliar names.
183
+ *
184
+ * Only this tool's 16-hex project directories are candidates. The current key
185
+ * is retained even when its previous report is old; one slot is reserved for it
186
+ * before the next write, so an ordinary run leaves at most 128 projects. Races
187
+ * between several new projects may exceed the soft cap briefly and converge on
188
+ * the next run. Cleanup is best-effort: inability to delete history must never
189
+ * replace the report for the build in progress.
190
+ */
191
+ export function pruneStateReports(root, currentKey, now = Date.now()) {
192
+ let entries;
193
+ try {
194
+ entries = readdirSync(root, { withFileTypes: true });
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ const candidates = [];
200
+ for (const entry of entries) {
201
+ if (entry.name === currentKey || !STATE_KEY.test(entry.name) || !entry.isDirectory())
202
+ continue;
203
+ const directory = join(root, entry.name);
204
+ let modified;
205
+ try {
206
+ modified = statSync(join(directory, 'content-report.json')).mtimeMs;
207
+ }
208
+ catch {
209
+ try {
210
+ modified = statSync(directory).mtimeMs;
211
+ }
212
+ catch {
213
+ continue;
214
+ }
215
+ }
216
+ candidates.push({ directory, modified });
217
+ }
218
+ const remove = (directory) => {
219
+ try {
220
+ rmSync(directory, { recursive: true, force: true });
221
+ }
222
+ catch {
223
+ // Best-effort retention only; the current report still has to be written.
224
+ }
225
+ };
226
+ const retained = candidates.filter(({ directory, modified }) => {
227
+ if (now - modified <= STATE_REPORT_MAX_AGE_MS)
228
+ return true;
229
+ remove(directory);
230
+ return false;
231
+ });
232
+ retained
233
+ .sort((left, right) => right.modified - left.modified)
234
+ .slice(Math.max(0, STATE_REPORT_MAX_PROJECTS - 1))
235
+ .forEach(({ directory }) => remove(directory));
236
+ }
237
+ /**
238
+ * The report's path for this invocation, and whether git answered.
239
+ *
240
+ * `spawnSync` surfaces a missing binary as `error.code === 'ENOENT'` with
241
+ * `status === null`, and `git rev-parse` outside a repository exits 128. Both
242
+ * take the same branch, because the conclusion is the same: there is no index,
243
+ * so no path in the worktree can be staged.
244
+ */
245
+ function destinationFor(userDirectory) {
246
+ const probe = spawnSync('git', ['rev-parse', '--git-path', 'publish-report/content-report.json'], {
247
+ cwd: userDirectory,
248
+ encoding: 'utf8',
249
+ });
250
+ const answer = probe.status === 0 ? probe.stdout.trim().split('\n').at(-1) : undefined;
251
+ if (answer !== undefined && answer !== '') {
252
+ return { path: resolve(userDirectory, answer), hasGitDirectory: true };
253
+ }
254
+ // A filesystem key, not a disclosure: it exists so two projects do not
255
+ // overwrite each other's report. It names the user's own directory on the
256
+ // user's own machine and is never interpolated into a stream.
257
+ const key = createHash('sha256').update(realpath(userDirectory)).digest('hex').slice(0, 16);
258
+ const root = join(stateDirectory(), 'publish-report');
259
+ pruneStateReports(root, key);
260
+ return {
261
+ path: join(root, key, 'content-report.json'),
262
+ hasGitDirectory: false,
263
+ };
264
+ }
265
+ function realpath(directory) {
266
+ try {
267
+ return realpathSync(directory);
268
+ }
269
+ catch {
270
+ return directory;
271
+ }
272
+ }
273
+ /**
274
+ * Open the report **before discovery starts**, and write the stub immediately.
275
+ *
276
+ * The ordering is the whole point, and it is why this is opened rather than
277
+ * written at the end: the diagnostic is most needed on the run that failed, and
278
+ * a report written only after validation cannot exist on the run that failed
279
+ * validation — which is the likeliest throw in the whole binary. Measured
280
+ * before this existed: a note whose body carried an absolute path exited 1 with
281
+ * **nothing** written anywhere.
282
+ *
283
+ * `status: "aborted"` is the only value that is true at that moment, and
284
+ * writing it first is what makes "I stopped before I could look" a state a
285
+ * reader can observe rather than an absence they must interpret.
286
+ *
287
+ * A successful run needs no third write: the stub is superseded by
288
+ * {@link OpenReport.discovered}, which already leaves `failure: null`.
289
+ */
290
+ export function openReport(userDirectory) {
291
+ const { path, hasGitDirectory } = destinationFor(userDirectory);
292
+ const report = {
293
+ version: REPORT_SCHEMA_VERSION,
294
+ generator: GENERATOR,
295
+ status: 'aborted',
296
+ counts: { discovered: 0, published: 0, dropped: 0 },
297
+ dropped: [],
298
+ links: [],
299
+ failure: null,
300
+ };
301
+ const flush = () => {
302
+ // 0700 where the tool chose the directory. On Windows Node's mode argument
303
+ // is inert and the directory reports the default — accepted, because that
304
+ // path is inside the user's own profile directory and the vector this
305
+ // destination closes is git, not local ACLs.
306
+ mkdirSync(dirname(path), hasGitDirectory ? { recursive: true } : { recursive: true, mode: 0o700 });
307
+ writeFileSync(path, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
308
+ };
309
+ flush();
310
+ return {
311
+ destination: path,
312
+ pointer: hasGitDirectory ? POINTER : POINTER_NO_GIT,
313
+ get summary() {
314
+ // Three integers and a source literal on the ordinary path. On a run that
315
+ // stopped before discovery finished the same three integers are all zero,
316
+ // and printing them unqualified would say "your directory held nothing" —
317
+ // indistinguishable from an empty content directory, and the opposite of
318
+ // what `status: "aborted"` exists to record. So the aborted run gets its
319
+ // own literal instead, and the counts are simply not claimed.
320
+ if (report.status === 'aborted')
321
+ return 'content: discovery did not finish';
322
+ const { discovered, published, dropped } = report.counts;
323
+ // Per-reason counts after the total: still integers and literals of the
324
+ // closed {@link DropReason} set, so the line stays rename-invariant, and a
325
+ // collision or an exclusion is visible on the stream without a name.
326
+ const byReason = new Map();
327
+ for (const row of report.dropped)
328
+ byReason.set(row.reason, (byReason.get(row.reason) ?? 0) + 1);
329
+ const reasons = [...byReason]
330
+ .sort(([a], [b]) => (a < b ? -1 : 1))
331
+ .map(([reason, count]) => `${count} ${reason}`);
332
+ return (`content: ${discovered} discovered, ${published} published, ${dropped} dropped` +
333
+ (reasons.length === 0 ? '' : ` (${reasons.join(', ')})`));
334
+ },
335
+ discovered(counts, dropped, links) {
336
+ report.status = 'complete';
337
+ report.counts = counts;
338
+ report.dropped = [...dropped].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
339
+ // Sorted where a reader would look, and left alone when the caller has
340
+ // nothing yet — link resolution is a second step, and a caller recording
341
+ // discovery first must not have that read as "no findings".
342
+ if (links !== undefined)
343
+ report.links = [...links].sort(bySourceThenLine);
344
+ flush();
345
+ },
346
+ failed(failure) {
347
+ report.failure = failure;
348
+ flush();
349
+ },
350
+ };
351
+ }