@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,410 @@
1
+ /**
2
+ * The `.gitignore` lines a notes repository needs, and the algorithm that seeds
3
+ * them without overwriting what the user already wrote.
4
+ *
5
+ * **The caller is `anc init`**, which TK-32 built; this module
6
+ * shipped before it with none, and the part worth having early was the part that
7
+ * can be wrong. Appending to a stranger's `.gitignore` has four distinct correct
8
+ * behaviours and three measured ways to corrupt a working file, and none of that
9
+ * needed a command to be gated against a scratch repository.
10
+ *
11
+ * ## What is seeded, and what deliberately is not
12
+ *
13
+ * Two entries. `/dist/` is the only generated artifact that really lands in a
14
+ * stranger's worktree — measured in a throwaway repository, one built note and
15
+ * `git add -A` stages 40 paths of which 38 are under `dist/`, and with the block
16
+ * seeded, exactly `.gitignore` and the note. `node_modules/` is one line against
17
+ * a 200 MB accidental commit, and a notes repository has no other reason for
18
+ * that directory to exist, so the entry can never hide something the user
19
+ * wanted.
20
+ *
21
+ * **The build's report is not seeded**, and its absence is the design rather
22
+ * than an omission: `scripts/write-report.ts` writes it under the git directory,
23
+ * where no ignore rule applies and none is needed. Measured, `check-ignore -v`
24
+ * on that path exits 1 reporting no rule at all and `git add -A` stages nothing
25
+ * from it. A line ignoring a file this tool never writes there is a line no gate
26
+ * can turn red and no future maintainer can safely delete.
27
+ *
28
+ * The report's own filename is deliberately not written down here, and that
29
+ * became a requirement rather than a preference when TK-32's `init` gave this
30
+ * module a caller. `tests/packaging.test.ts` forbids any module the build's
31
+ * import graph reaches from naming the report, on the reasoning that a module
32
+ * which names it is one layer from a module that prints it — and the gate fired
33
+ * on this comment the moment `bin/anc.mjs` began importing this
34
+ * file. That is the gate working: the exemption is `write-report.ts` alone, and
35
+ * an allowlist that grows every time a new module mentions the name in prose is
36
+ * not an allowlist.
37
+ *
38
+ * The build's staging workspace gets no line either: it is created inside the
39
+ * *package* root, which in a stranger's repository is under `node_modules/`,
40
+ * already covered. This repository needs its own `.anc-build-` rule
41
+ * only because this repository *is* the package.
42
+ *
43
+ * ## Root-anchored with a leading `/`, and that is not style
44
+ *
45
+ * Measured: with the bare pattern `dist`, a user's own note at `notes/dist/i.md`
46
+ * is ignored — `check-ignore` exits 0 attributing `.gitignore:1:dist` — and with
47
+ * `/dist/` it is not. A publishing tool that silently drops a directory of the
48
+ * user's notes because they named it `dist` has done the one thing it must never
49
+ * do. `node_modules/` is unanchored on purpose: a nested `node_modules` is never
50
+ * a user's note directory, and anchoring it would miss the one under a workspace
51
+ * package.
52
+ */
53
+ import { readFileSync, writeFileSync } from 'node:fs';
54
+ import { isAbsolute, join, relative } from 'node:path';
55
+ import { spawnSync } from 'node:child_process';
56
+ /**
57
+ * The seeded lines, in the order they are written.
58
+ *
59
+ * A constant in source, because "one definition" across the package boundary is
60
+ * impossible: `.gitignore` is not in `package.json`'s `files` so it is not in
61
+ * the tarball, and even when a `.gitignore` *is* packed, **npm renames it** —
62
+ * measured on a fixture whose tarball contained `package/.gitignore` and which
63
+ * installed as `node_modules/<name>/.npmignore` holding the `.gitignore` text
64
+ * and no `.gitignore`. An installed `init` cannot read its own package's copy
65
+ * under that name.
66
+ *
67
+ * Nor is this repository's own `.gitignore` a second definition to check
68
+ * against: this repository is the tool, not a notes repository. The two already
69
+ * disagree correctly — this one ignores `dist/` unanchored and needs rules for
70
+ * its packaging staging directories and tarballs besides, none of which belong
71
+ * in a stranger's notes repository.
72
+ */
73
+ export const SEEDED_ENTRIES = ['/dist/', 'node_modules/'];
74
+ /**
75
+ * The comment written above an appended block. For the human; nothing parses it.
76
+ *
77
+ * The spec's §3.1 spells this `# added by anc init`, and that
78
+ * spelling is deliberately not used. It writes this project's name into a file
79
+ * in a stranger's repository, which is the single-owner residue the same
80
+ * document's §1.1 removes from the report's own path two sections earlier —
81
+ * `publish-report/` rather than a name-carrying segment, for exactly this
82
+ * reason. The two rules cannot both be right, and the one that generalises is
83
+ * the one that survives. Nothing keys off the text: idempotence comes from
84
+ * asking git, never from searching for this string.
85
+ */
86
+ export const SEEDED_HEADER = '# added by the publish tool';
87
+ /** What `git` said, distinguishing "answered no" from "could not be asked". */
88
+ function git(cwd, args, input) {
89
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', input });
90
+ // `spawnSync` reports a missing binary as `error.code === 'ENOENT'` with
91
+ // `status === null`, which is the one genuine "cannot tell" and takes the safe
92
+ // branch at every call site.
93
+ if (result.error !== undefined || result.status === null)
94
+ return undefined;
95
+ return { status: result.status, stdout: result.stdout };
96
+ }
97
+ /**
98
+ * The probe, and why it is two calls per entry rather than the obvious one.
99
+ *
100
+ * `check-ignore -v --non-matching` reports three states in one line — a pattern
101
+ * with a file and line for covered, a pattern beginning `!` for a deliberate
102
+ * un-ignore, and the empty `::` for no rule at all. That is the reason for
103
+ * `-v --non-matching` over `-q`: measured, `-q` exits 1 for **both** the negated
104
+ * and the no-rule cases, so a quiet probe cannot tell "the user chose to track
105
+ * this" from "nobody has said anything", and would append over the first.
106
+ *
107
+ * What is not obvious, and what one probe gets wrong: **the answer depends on
108
+ * whether the path exists on disk, and differently for each spelling of the
109
+ * argument.** Measured on git 2.55.0.windows.3, with the rule `/dist/`:
110
+ *
111
+ * probe `dist` directory absent → `::` (uncovered — wrong)
112
+ * probe `dist` directory present → `.gitignore:1:/dist/` (covered)
113
+ * probe `dist/` either → `.gitignore:1:/dist/` (covered)
114
+ *
115
+ * A `dir/` pattern matches only directories, and git needs the filesystem to
116
+ * know that a slashless `dist` names one. `init` runs *before* the first build,
117
+ * so the absent case is the ordinary case rather than an edge one — a probe on
118
+ * the slashless form alone would report a correctly-ignored `dist/` as uncovered
119
+ * and append a duplicate rule to every repository it touched.
120
+ *
121
+ * So coverage is asked with the trailing slash, which is disk-independent. And
122
+ * negation is asked without it, because that is the only form that reports one:
123
+ * with the rule `!dist/`, `probe dist/` answers `::` while `probe dist` answers
124
+ * `.gitignore:1:!dist/` — when the directory exists.
125
+ *
126
+ * **The residual, stated rather than hidden:** a negation written in `dir/` form
127
+ * for a directory that does not yet exist is invisible to git — measured, both
128
+ * spellings answer with no rule. There is no `check-ignore` flag that assumes a
129
+ * path is a directory, and probing a path *inside* it answers the same way. Such
130
+ * an entry is therefore seeded as uncovered, which appends `/dist/` after the
131
+ * user's `!dist/` and, last-match-wins, reverses it. It is the one case this
132
+ * cannot get right; a caller shows the outcomes so a user sees what was written.
133
+ *
134
+ * ## `-z --stdin`, because the human-readable form is ambiguous
135
+ *
136
+ * The default `-v` output is `<file>:<line>:<pattern>\t<path>`, and **the file
137
+ * field can contain a colon**, so splitting on `:` and taking the third field is
138
+ * wrong on the most ordinary Windows configuration there is. Measured: with a
139
+ * global `core.excludesFile` at `C:/Users/<name>/.config/git/ignore` holding
140
+ * `!dist/`, the line reads
141
+ * `C:/Users/<name>/.config/git/ignore:1:!dist/<TAB>dist` and the third
142
+ * colon-separated field is `1`. A parser reading it as the pattern sees no
143
+ * leading `!`, classifies a deliberate negation as "nobody has said anything",
144
+ * and appends `/dist/` after the user's `!dist/` — silently reversing it, which
145
+ * is the exact failure this two-probe design exists to prevent.
146
+ *
147
+ * `-z` emits the same four fields NUL-separated instead, which no path can
148
+ * forge. It requires `--stdin` — measured, `-z` without it is
149
+ * `fatal: -z only makes sense with --stdin` — so the paths go in on stdin and
150
+ * the trailing empty field of each record is dropped.
151
+ */
152
+ function probe(root, paths, slash) {
153
+ const answer = git(root, ['-C', root, 'check-ignore', '-vz', '--non-matching', '--stdin'], paths.map((path) => (slash ? `${path}/` : path)).join('\0') + '\0');
154
+ // 128 has at least three meanings — not a repository, `-C` into a directory
155
+ // that does not exist, and a path argument git refuses — so it is never read
156
+ // as an answer. The caller has already established this is a work tree, which
157
+ // makes a 128 here a malformed probe rather than a missing repository.
158
+ if (answer === undefined || answer.status > 1)
159
+ return undefined;
160
+ // Four fields per record — file, line, pattern, path — then the next record.
161
+ // A trailing NUL closes the last one, so the split leaves an empty tail.
162
+ const fields = answer.stdout.split('\0');
163
+ if (fields.at(-1) === '')
164
+ fields.pop();
165
+ if (fields.length !== paths.length * 4)
166
+ return undefined;
167
+ return paths.map((_, index) => fields.slice(index * 4, index * 4 + 4));
168
+ }
169
+ /**
170
+ * Seed the two entries into `<root>/.gitignore`, writing only what is missing.
171
+ *
172
+ * Blind append and overwrite are both wrong, and so is string-matching the file
173
+ * for our entries. Each measured on git 2.55.0.windows.3:
174
+ *
175
+ * - **Overwrite destroys** rules the rest of the repository depends on, which is
176
+ * a larger blast radius than the leak it prevents.
177
+ * - **Append reverses a deliberate choice.** `.gitignore` is last-match-wins:
178
+ * with `!dist/` followed by an appended `dist/`, `dist/index.html` goes from
179
+ * not-ignored to ignored.
180
+ * - **Append corrupts a file with no trailing newline.** Measured:
181
+ * `node_modules/` written without a final `\n`, then `/dist/` appended, yields
182
+ * the single junk rule `node_modules//dist/` — verified byte by byte — after
183
+ * which *neither* path is ignored. The append broke a rule that already
184
+ * worked.
185
+ * - **Reading the file cannot answer the question.** Every one of `dist`,
186
+ * `/dist/`, a doubled-star form anchored either way, a bare `*`, a rule in
187
+ * `.git/info/exclude`, and a rule in `core.excludesFile` was measured ignoring
188
+ * `dist/index.html` while a textual search for the line `/dist/` finds
189
+ * nothing.
190
+ *
191
+ * So git is asked, from the repository root with repository-root-relative paths.
192
+ * `-C <toplevel>` matters because `check-ignore` resolves relative to cwd:
193
+ * `dist/index.html` probed from `sub/` asks about `sub/dist/index.html` and
194
+ * answers against a root-anchored rule as un-ignored.
195
+ *
196
+ * The probe arguments are paths, never patterns. A leading-slash argument is not
197
+ * a path and the call dies — measured, `check-ignore -- /dist/` exits 128 with
198
+ * `fatal: Invalid path`, and under git-bash the argument is first mangled into
199
+ * `C:/Program Files/Git/dist` by MSYS path conversion. So `dist/` is probed and
200
+ * `/dist/` is written.
201
+ *
202
+ * The ambient ignore chain is deliberately **not** neutralised here: a user with
203
+ * a global rule for `dist/` must not be forced to commit a line they do not
204
+ * need. A gate over this function is the place that neutralises it, because
205
+ * there a global rule would make the seed look unnecessary when it is not.
206
+ *
207
+ * @param root The repository root, or any directory when there is no repository.
208
+ * @throws {Error} when git is absent or a probe cannot be trusted, and when a
209
+ * re-probe after writing shows an entry still uncovered. A half-seeded run
210
+ * that reports success is how the artifact gets committed.
211
+ */
212
+ export function ensureIgnored(root) {
213
+ // Asked directly rather than inferred from `check-ignore`'s exit 128, which
214
+ // has at least three meanings. Measured: `true` in a fresh repository with no
215
+ // commits, `false` inside `.git/`, 128 for both not-a-repository and
216
+ // missing-directory.
217
+ const inside = git(root, ['-C', root, 'rev-parse', '--is-inside-work-tree']);
218
+ if (inside === undefined) {
219
+ throw new Error('git is not available, so no ignore rules could be checked — nothing was written');
220
+ }
221
+ if (inside.status !== 0 || inside.stdout.trim() !== 'true') {
222
+ // No repository: nothing can be probed, so the only available idempotence is
223
+ // a literal line match. Sound *here specifically* and nowhere else in this
224
+ // file, because with no repository there is no ignore chain to consult and
225
+ // no rule that could cover an entry other than one written literally — the
226
+ // whole argument at the head of `append` for why reading the file cannot
227
+ // answer the question rests on `check-ignore` having an answer to give.
228
+ //
229
+ // Measured before this branch deduplicated: three `init` runs in a
230
+ // directory with no repository produced three copies of the block. That is
231
+ // the *ordinary* order for a user who runs `init` first, which the seeded
232
+ // template's own "correct the moment they run `git init`" invites — so it
233
+ // is the documented path rather than an edge case.
234
+ //
235
+ // The header is deliberately not what is searched for: a user who edits or
236
+ // splits the block still gets no duplicate, which is the same property the
237
+ // probed path has for the same reason.
238
+ const existing = readLines(root);
239
+ const missing = SEEDED_ENTRIES.filter((entry) => !existing.includes(entry));
240
+ if (missing.length > 0)
241
+ append(root, missing);
242
+ return {
243
+ outcomes: SEEDED_ENTRIES.map((entry) => ({
244
+ entry,
245
+ state: missing.includes(entry) ? 'seeded' : 'covered',
246
+ })),
247
+ unprobed: true,
248
+ };
249
+ }
250
+ const top = git(root, ['-C', root, 'rev-parse', '--show-toplevel']);
251
+ if (top === undefined || top.status !== 0) {
252
+ throw new Error('the repository root could not be resolved — nothing was written');
253
+ }
254
+ const toplevel = top.stdout.trim();
255
+ const entries = entriesFor(toplevel, root);
256
+ const outcomes = classify(toplevel, entries);
257
+ // `tracked` is written as well as `seeded`, which is the finding recorded in
258
+ // `classify`: the advice alone does not survive the `git add -A` that follows
259
+ // it. `covered` and `negated` are the two that must not be written — one is
260
+ // already handled and the other is a choice the user made.
261
+ //
262
+ // The tracked case is the one place idempotence cannot come from the probe,
263
+ // and the reason is the same fact that makes the write necessary:
264
+ // `check-ignore` consults the index, so a tracked entry reports uncovered
265
+ // however many rules match it, and a second `init` would append the line
266
+ // again. Measured on this branch before the text check was added: two runs,
267
+ // two copies. So the line already being in the file is what suppresses the
268
+ // second write — the weaker test, used only where git has no answer to give.
269
+ const present = readLines(toplevel);
270
+ const missing = outcomes
271
+ .filter((outcome) => outcome.state === 'seeded' || (outcome.state === 'tracked' && !present.includes(outcome.entry)))
272
+ .map((outcome) => outcome.entry);
273
+ if (missing.length > 0)
274
+ append(toplevel, missing);
275
+ // Verify, then fail loudly. The re-probe is the same probe, so a seed that did
276
+ // not take — appended to the wrong file, or defeated by a later rule — is a
277
+ // non-zero exit rather than a success message.
278
+ //
279
+ // A `tracked` entry is exempt from the re-probe and cannot be otherwise:
280
+ // `check-ignore` consults the index, so it reports a tracked path as uncovered
281
+ // however many rules match it. Requiring coverage there would turn the write
282
+ // that fixes the user's problem into an error.
283
+ for (const outcome of classify(toplevel, entries)) {
284
+ if (outcome.state === 'seeded' && missing.includes(outcome.entry)) {
285
+ throw new Error(`${outcome.entry} was written to .gitignore and is still not ignored — ` +
286
+ `written: ${missing.join(', ')}`);
287
+ }
288
+ }
289
+ return { outcomes, unprobed: false };
290
+ }
291
+ /**
292
+ * The lines to seed, which is {@link SEEDED_ENTRIES} plus one when the content
293
+ * directory is not the repository root.
294
+ *
295
+ * **Root-anchored `/dist/` does not cover `notes/dist/`, and that is the whole
296
+ * point of the anchoring.** The two facts compose into a hole: `init --content
297
+ * notes` seeds a rule at the root, and a build whose output lands under `notes/`
298
+ * — either `--out notes/dist`, or the default `dist` for a user standing in
299
+ * `notes/` — is not covered by it. Measured before this existed: `init
300
+ * --content notes`, then a build, then `git add -A`, staged **41 files** of
301
+ * build output, and `check-ignore` exited 1 on every one of them. That is
302
+ * exactly the accident `init` exists to prevent, on the shape `--content` was
303
+ * added for.
304
+ *
305
+ * The added entry is a *path* rather than a second bare `dist/`, for the reason
306
+ * the anchoring exists: an unanchored `dist/` would ignore a user's own note
307
+ * folder called `dist` anywhere in the tree, which is the silent-data-loss
308
+ * direction this module refuses to fail in.
309
+ *
310
+ * `node_modules/` needs no equivalent because it is already unanchored, and
311
+ * deliberately: a nested `node_modules` is never a user's note directory.
312
+ */
313
+ function entriesFor(toplevel, contentDirectory) {
314
+ const step = relative(toplevel, contentDirectory).replaceAll('\\', '/');
315
+ // Empty means they are the same directory. A `..` means the content directory
316
+ // is outside the repository, where a rule in *this* repository's `.gitignore`
317
+ // governs nothing — `init` refuses that case before reaching here, and the
318
+ // check is repeated rather than assumed because this function must be right
319
+ // about the arguments it is handed.
320
+ if (step === '' || step.startsWith('..') || isAbsolute(step))
321
+ return [...SEEDED_ENTRIES];
322
+ return [...SEEDED_ENTRIES, `/${step}/dist/`];
323
+ }
324
+ /** The four states, one probe pair for all entries. */
325
+ function classify(toplevel, entries) {
326
+ // Probed without the leading `/` the written lines carry, and with the
327
+ // trailing one they do not: the argument is a path, the line is a pattern.
328
+ const paths = entries.map((entry) => entry.replace(/^\//, '').replace(/\/$/, ''));
329
+ const covered = probe(toplevel, paths, true);
330
+ const negated = probe(toplevel, paths, false);
331
+ // Both probes are checked, and `probe` itself has already verified that each
332
+ // returned exactly one record per path. An unchecked second probe is the same
333
+ // defect as an unchecked first: a short answer makes some entry's record
334
+ // `undefined`, a negation reads as "no rule", and the seeded line silently
335
+ // reverses what the user wrote.
336
+ if (covered === undefined || negated === undefined) {
337
+ throw new Error('the ignore rules could not be read — nothing was written');
338
+ }
339
+ return entries.map((entry, index) => {
340
+ // `[file, line, pattern, path]`, with the first three empty when no rule
341
+ // matched. The pattern is read as a field rather than parsed out of a
342
+ // colon-joined string, because the file field can itself contain a colon.
343
+ if (covered[index][2] !== '')
344
+ return { entry, state: 'covered' };
345
+ if (negated[index][2].startsWith('!'))
346
+ return { entry, state: 'negated' };
347
+ // Already tracked is a fourth answer and needs an *additional* action rather
348
+ // than a different one. Measured: for a tracked path `check-ignore` reports
349
+ // uncovered even with a matching rule present, because it consults the
350
+ // index — and an ignore line alone does not help, since `git add -A` still
351
+ // stages the modification.
352
+ //
353
+ // **The rule is written anyway, and an earlier version's refusal to write it
354
+ // was a measured defect.** Following the advice verbatim with no rule
355
+ // present — `git rm --cached -r dist`, then `git add -A` — puts every file
356
+ // straight back in the index, so the one state the user cannot fix
357
+ // themselves was the state this withheld the line that makes the fix stick.
358
+ // With the rule present the same two commands leave it untracked. A rule on
359
+ // a tracked path is inert until the moment it is needed and harmless
360
+ // before it, which is the cheaper side of the trade by a wide margin.
361
+ const path = paths[index];
362
+ const tracked = git(toplevel, ['-C', toplevel, 'ls-files', '--error-unmatch', '--', path]);
363
+ if (tracked !== undefined && tracked.status === 0) {
364
+ return {
365
+ entry,
366
+ state: 'tracked',
367
+ advice: `git rm --cached -r ${path} (the file stays in this repository's history)`,
368
+ };
369
+ }
370
+ return { entry, state: 'seeded' };
371
+ });
372
+ }
373
+ /**
374
+ * The file's lines, trimmed, or none when there is no file.
375
+ *
376
+ * Only the no-repository branch reads this. Everywhere else the question "is
377
+ * this entry ignored?" is git's to answer, and reading the file is measured
378
+ * wrong for it — see {@link append}'s own list.
379
+ */
380
+ function readLines(root) {
381
+ try {
382
+ return readFileSync(join(root, '.gitignore'), 'utf8')
383
+ .split('\n')
384
+ .map((line) => line.trim());
385
+ }
386
+ catch {
387
+ return [];
388
+ }
389
+ }
390
+ /**
391
+ * Append at the end, after repairing a missing trailing newline.
392
+ *
393
+ * The end is the only position that cannot change the meaning of a rule the user
394
+ * already wrote, since `.gitignore` is last-match-wins. Only the uncovered
395
+ * entries are written, so a second call is idempotent because the probe answers
396
+ * differently — not because anything searched for the header comment. A user who
397
+ * edits or splits the block still gets no duplicate.
398
+ */
399
+ function append(root, entries) {
400
+ const path = join(root, '.gitignore');
401
+ let existing;
402
+ try {
403
+ existing = readFileSync(path, 'utf8');
404
+ }
405
+ catch {
406
+ existing = '';
407
+ }
408
+ const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
409
+ writeFileSync(path, `${existing}${separator}${SEEDED_HEADER}\n${entries.join('\n')}\n`, 'utf8');
410
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Read-only row enumeration shared by the two release-policy scanners.
3
+ *
4
+ * A byte scan of SQLite is not a scan of its contents: a value larger than a page
5
+ * is stored as a chain of overflow pages with a 4-byte pointer written into the
6
+ * middle, so a marker straddling a boundary exists in the file as two fragments
7
+ * and matches nothing. Reading rows is what makes the scan see the database at
8
+ * all; reading the raw bytes *as well* is what sees a deleted row's payload in a
9
+ * free page. Both scanners need both, so both call this.
10
+ *
11
+ * The enumerator is deliberately narrow: it answers "what text does this store?"
12
+ * and "what could not be read?", and nothing else. Policy — which rule fires and
13
+ * what counts as a finding — stays in the scanners.
14
+ *
15
+ * It never opens the artifact. A file is read into memory and inspected through
16
+ * a private read-only temporary copy, so an incidental journal or shared-memory
17
+ * file SQLite might want lands in the OS temp directory and is removed with it.
18
+ */
19
+ import { gunzipSync } from 'node:zlib';
20
+ import { createRequire } from 'node:module';
21
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { suppressSqliteWarning } from "../src/lib/sqlite-warning.js";
25
+ /** The first sixteen bytes of every SQLite file. */
26
+ export const SQLITE_MAGIC = 'SQLite format 3\0';
27
+ /** The first two bytes of a gzip member. */
28
+ export const GZIP_MAGIC = [0x1f, 0x8b];
29
+ /** Whether the bytes are a gzip member. */
30
+ export function isGzip(bytes) {
31
+ return bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
32
+ }
33
+ /** A database declared as WAL cannot be deserialized as a standalone snapshot. */
34
+ export function declaresWal(bytes) {
35
+ return bytes.length > 19 && (bytes[18] === 2 || bytes[19] === 2);
36
+ }
37
+ /**
38
+ * The journal siblings of an accepted artifact.
39
+ *
40
+ * A built output is exactly one rollback-journal database; `-wal`/`-shm` mean a
41
+ * WAL database and `-journal` means a hot journal. Opening the database while
42
+ * one is present is not read-only: SQLite attempts recovery and, measured, can
43
+ * write a generated `-shm` into the directory being inspected. Both recognition
44
+ * boundaries refuse on this list before opening.
45
+ */
46
+ export const JOURNAL_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal'];
47
+ /** The journal suffix beside `path`, or `undefined` when the directory is clean. */
48
+ export function journalSidecar(path) {
49
+ return JOURNAL_SIDECAR_SUFFIXES.find((suffix) => existsSync(path + suffix));
50
+ }
51
+ /** Whether the bytes carry SQLite's file header. */
52
+ export function isSqlite(bytes) {
53
+ return (bytes.length >= SQLITE_MAGIC.length &&
54
+ Buffer.from(bytes.subarray(0, SQLITE_MAGIC.length)).toString('latin1') === SQLITE_MAGIC);
55
+ }
56
+ /** Loaded lazily so a scan of a dist/ with no database never pays for the binding. */
57
+ const loadSqlite = () => {
58
+ suppressSqliteWarning();
59
+ return createRequire(import.meta.url)('node:sqlite');
60
+ };
61
+ /**
62
+ * Every text value a database carries, as rows, read through a private copy.
63
+ *
64
+ * @throws when the file declares WAL (not an accepted artifact), when SQLite
65
+ * cannot open it, or when a private temporary copy cannot be made. Callers
66
+ * decide whether that is fatal; both scanners treat it as fail-closed.
67
+ */
68
+ export function databaseText(bytes) {
69
+ if (declaresWal(bytes)) {
70
+ throw new Error('database is not in the accepted rollback-journal format');
71
+ }
72
+ const { DatabaseSync } = loadSqlite();
73
+ const directory = mkdtempSync(join(tmpdir(), 'anc-scan-'));
74
+ try {
75
+ const temporary = join(directory, 'snapshot.sqlite');
76
+ writeFileSync(temporary, bytes);
77
+ const database = new DatabaseSync(temporary, { readOnly: true });
78
+ try {
79
+ return readOpenDatabase(database);
80
+ }
81
+ finally {
82
+ database.close();
83
+ }
84
+ }
85
+ finally {
86
+ rmSync(directory, { recursive: true, force: true });
87
+ }
88
+ }
89
+ /** The reading half of {@link databaseText}, over a handle already open. */
90
+ function readOpenDatabase(database) {
91
+ const values = [];
92
+ const unreadable = [];
93
+ let corpusRows = 0;
94
+ let vocabSequence = 0;
95
+ const orphanCandidates = [];
96
+ // Row text only, kept apart from `values` so an index's terms are not checked
97
+ // for orphanhood against themselves.
98
+ const rowText = [];
99
+ const tables = database
100
+ .prepare(`SELECT name, sql FROM sqlite_schema WHERE type = 'table' ORDER BY name`)
101
+ .all();
102
+ const virtualNames = tables
103
+ .filter(({ sql }) => /^\s*CREATE\s+VIRTUAL\s+TABLE/i.test(sql ?? ''))
104
+ .map(({ name }) => name);
105
+ // The orphan pass needs the corpus text only when there is an index to orphan
106
+ // against; without FTS5 the collection and its join are dead weight.
107
+ const hasFts5 = tables.some(({ sql }) => /\bfts5\b/i.test(sql ?? ''));
108
+ for (const { name, sql } of tables) {
109
+ const isVirtual = /^\s*CREATE\s+VIRTUAL\s+TABLE/i.test(sql ?? '');
110
+ const isShadow = virtualNames.some((owner) => name.startsWith(`${owner}_`));
111
+ let rows;
112
+ try {
113
+ rows = database.prepare(`SELECT * FROM "${name.replaceAll('"', '""')}"`).all();
114
+ }
115
+ catch {
116
+ // A table that exists and cannot be selected from is "could not look", and
117
+ // must not be spelled like "looked and found nothing".
118
+ unreadable.push(name);
119
+ continue;
120
+ }
121
+ let textValues = 0;
122
+ for (const row of rows) {
123
+ for (const value of Object.values(row)) {
124
+ if (typeof value === 'string' && value.length > 0) {
125
+ textValues += 1;
126
+ values.push(value);
127
+ if (!isShadow && hasFts5)
128
+ rowText.push(value);
129
+ }
130
+ else if (value instanceof Uint8Array && value.length > 0) {
131
+ // A BLOB is read as text: the byte pass cannot see across an
132
+ // overflow-page boundary, and a BLOB body overflows identically to a
133
+ // TEXT one. A gzip BLOB is inflated first, by the same rule the file
134
+ // loop applies; an inflate failure falls back to the raw value, which
135
+ // is still scanned.
136
+ textValues += 1;
137
+ let blob = Buffer.from(value);
138
+ if (blob.length >= 2 && blob[0] === GZIP_MAGIC[0] && blob[1] === GZIP_MAGIC[1]) {
139
+ try {
140
+ blob = Buffer.from(gunzipSync(blob));
141
+ }
142
+ catch {
143
+ // Kept as the raw bytes, and still scanned.
144
+ }
145
+ }
146
+ values.push(blob.toString('utf8'));
147
+ if (!isShadow && hasFts5)
148
+ rowText.push(blob.toString('utf8'));
149
+ }
150
+ }
151
+ }
152
+ // Only a corpus table's rows count toward coverage; SQLite's own bookkeeping
153
+ // must not be able to satisfy the vacuity guard.
154
+ if (textValues > 0 && !isShadow)
155
+ corpusRows += rows.length;
156
+ // An index's own terms, read through `fts5vocab`, are a second copy of the
157
+ // corpus that need not agree with the first. A tokenizer is lossy, so
158
+ // reading terms proves the index is enumerable, not that it is scannable.
159
+ const vocabTerms = [];
160
+ let vocabReadable = false;
161
+ if (isVirtual && /\bfts5\b/i.test(sql ?? '')) {
162
+ const view = `scan_vocab_${vocabSequence++}`;
163
+ try {
164
+ database.exec(`CREATE VIRTUAL TABLE temp."${view}" USING fts5vocab(main, "${name.replaceAll('"', '""')}", 'row')`);
165
+ for (const { term } of database.prepare(`SELECT term FROM temp."${view}"`).all()) {
166
+ if (typeof term === 'string' && term.length > 0) {
167
+ vocabTerms.push(term);
168
+ values.push(term);
169
+ }
170
+ }
171
+ vocabReadable = true;
172
+ }
173
+ catch {
174
+ vocabReadable = false;
175
+ }
176
+ finally {
177
+ try {
178
+ database.exec(`DROP TABLE IF EXISTS temp."${view}"`);
179
+ }
180
+ catch {
181
+ // Already absent because the create failed.
182
+ }
183
+ }
184
+ }
185
+ if (isVirtual && (rows.length > 0 || vocabTerms.length > 0) && textValues === 0) {
186
+ unreadable.push(name);
187
+ }
188
+ else if (isVirtual && !vocabReadable) {
189
+ unreadable.push(name);
190
+ }
191
+ else {
192
+ orphanCandidates.push({ name, terms: vocabTerms });
193
+ }
194
+ }
195
+ // A term is only an orphan if *no* table accounts for it, checked against the
196
+ // row text rather than against `values` (which carries the terms themselves).
197
+ if (hasFts5) {
198
+ const corpus = rowText.join('\n').toLowerCase();
199
+ for (const { name, terms } of orphanCandidates) {
200
+ if (terms.some((term) => !corpus.includes(term.toLowerCase())))
201
+ unreadable.push(name);
202
+ }
203
+ }
204
+ return { values, corpusRows, unreadable };
205
+ }