@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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- 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
|
+
}
|